diff --git a/.github/workflows/security-scans.yaml b/.github/workflows/security-scans.yaml index 459f3da47..f9774e493 100644 --- a/.github/workflows/security-scans.yaml +++ b/.github/workflows/security-scans.yaml @@ -40,6 +40,11 @@ jobs: fail-on-severity: moderate deny-licenses: GPL-3.0, AGPL-3.0 comment-summary-in-pr: never + # CVE-2026-45829 / GHSA-f4j7-r4q5-qw2c (chromadb, transitive via crewai): no patched + # version exists (affected <= 1.5.9, crewai pins chromadb < 1.2). The vulnerability + # requires running a chromadb HTTP server with trust_remote_code=true, which the + # github_agent demo does not do. See demo/agents/github_agent/pyproject.toml. + allow-ghsas: GHSA-f4j7-r4q5-qw2c shellcheck: name: Shell Script Lint diff --git a/aiac/.gitignore b/aiac/.gitignore new file mode 100644 index 000000000..314fa2ad4 --- /dev/null +++ b/aiac/.gitignore @@ -0,0 +1,12 @@ +# AIAC working artefacts (regenerated; not source of truth) +docs/issues/ +docs/handoffs/ +docs/gh-issues/ + +# PDP policy writer integration-test scratch output (test/pdp/policy/generate_rego.py) +test/pdp/policy/rego_out/ + +# policy-pipeline integration-test generated Rego (test/integration/test_policy_pipeline.py); +# regenerated per run, left on disk for eyeballing — not source of truth. probe.rego is a +# committed fixture and is not under rego_out/, so it stays tracked. +test/integration/rego_out/ diff --git a/aiac/CLAUDE.md b/aiac/CLAUDE.md new file mode 100644 index 000000000..6561461a2 --- /dev/null +++ b/aiac/CLAUDE.md @@ -0,0 +1,113 @@ +# AIAC Codebase Guide + +All paths below are relative to `kagenti-extensions/aiac/`. + +## Requirements / PRD docs + +`docs/specs/PRD.md` — master PRD. +`docs/specs/components/` — per-component specs. + +For current file list, `ls docs/specs/` and `ls docs/specs/components/`. + +## Requirements directory — link-following policy + +When a document under `docs/specs/` contains a markdown link to another file, use the AskUserQuestion tool to ask before reading it — present "Yes" and "No" as clickable options. If the user picks Yes, read the file normally. If No, treat the link as a label and continue without reading it. + +## Issue tracking + +Issues are tracked as local markdown files under `docs/issues/`, not on GitHub. +Never use `gh` commands to create, update, or list issues — always read/write the local files directly. + +`docs/issues/implementation-plan.md` — overall implementation plan. +For current issue list, `ls` the subdirectories under `docs/issues/`. + +## Issue tracking — codebase inspection policy + +When working on an issue would benefit from inspecting the relevant source code, use the AskUserQuestion tool to ask before doing so — present "Yes" and "No" as clickable options. If the user picks Yes, inspect the codebase normally. If No, work from the issue description and existing context only. + +## Handoffs + +Per-task handoff documents live under `docs/handoffs/` — one markdown file per task, numeric-prefixed (e.g. `01-update-issues.md`, `02-update-source-and-tests.md`). When asked to generate a handoff, write it here (not a scratch/temp path). Each handoff must be self-contained — background, task, exact files, and acceptance criteria — so a fresh session can execute it without the originating conversation. + +## Source code + +`src/aiac/` — Python package root (`__init__.py` is empty). + +Key stable structure: +- `idp/` — IdP configuration service and models +- `pdp/` — PDP policy writer service and library +- `agent/` — the AIAC Agent layer (rebuilt on the SPM/APM model): + - `agent/controller/` — FastAPI Controller (`routes.py` + Dockerfile); `/apply/*` routes dispatch to the UC sub-agents and make the single `compute_and_apply` (PCE) call + - `agent/uc/` — use-case sub-agents: `onboarding/` (provision + policy_builder + orchestrator), `policy_update/` (build/rebuild), `role_update/` + - `agent/policy_rules_builder/` — PRB: `build_role_rules` / `build_scope_rules` emit `list[PolicyRule]` + - `agent/shared/` — shared helpers (`roles.py`, e.g. `flatten_role`) + - `agent/onboarding.old/` — archived prior implementation (built on the superseded `ProposedDiff` model); not part of the active build +- `policy/` — the two-layer policy stack (all implemented): + - `policy/model/` — `PolicyRule`, `ServicePolicyModel` (SPM), `AgentPolicyModel` (APM), `PolicyModel` + - `policy/store/` — Policy Store service + library (SPM CRUD) + - `policy/computation/` — Policy Computation Engine (`compute_and_apply`, SPM-based) + +For current file list, `ls` or `find` under `src/aiac/`. + +## Tests + +`test/` — mirrors `src/aiac/` structure. For current file list, `ls` under `test/`. + +**Unit test command:** + +```bash +.venv/bin/pytest test/ -m "not integration" +``` + +The whole `test/` tree (including `test/policy/`) collects and runs green. The Policy +Computation Engine (`aiac.policy.computation.engine`) was migrated to the SPM store surface in +**Wave 3 / Handoff 05**, so the earlier PCE-chain collection failures (which required ignoring +`test/policy/computation`, `test/agent/controller/test_routes.py`, and +`test/integration/test_policy_pipeline.py`) are resolved — no `--ignore` flags are needed. + +Use `ls test/` to discover current test directories. + +**Integration tests** (`-m integration`) need live config — Keycloak + admin creds + an LLM +endpoint (`opa` on PATH for the policy-pipeline suite). Those variables live in +`test/integration/.env` (gitignored): `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`, `KEYCLOAK_URL`, +`KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD`. Source it before running: + +```bash +set -a; . test/integration/.env; set +a +.venv/bin/pytest test/integration/ -m integration +``` + +**Smoke test** (requires live service at `AIAC_PDP_CONFIG_URL`, default `http://127.0.0.1:7071`): + +```bash +.venv/bin/python test/idp/configuration/show_keycloak_data.py +``` + +Exercises all `Configuration` methods — run `ls test/idp/configuration/` to see current coverage. + +## Python environment + +Virtual environment: `kagenti-extensions/aiac/.venv` + +Activate: `source kagenti-extensions/aiac/.venv/bin/activate` +Run directly: `kagenti-extensions/aiac/.venv/bin/python` / `kagenti-extensions/aiac/.venv/bin/pytest` + +Always use this venv for any Python execution, test runs, or dependency checks. + +## Kubernetes & builds + +Config: `k8s/`, `pyproject.toml`, `pyrightconfig.json` + +Docker images: + +| Image | Dockerfile location | +|-------|-------------------| +| `aiac-agent` | `src/aiac/agent/controller/Dockerfile` (build context `src/`) | +| `aiac-pdp-config` | `src/aiac/idp/service/configuration/keycloak/Dockerfile` | +| `aiac-pdp-policy-opa` | `src/aiac/pdp/service/policy/opa/Dockerfile` | +| `aiac-policy-store` | `src/aiac/policy/store/service/Dockerfile` | +| `aiac-rag-ingest` | `rag-ingest/` (separate directory) | + +## External references + +- [Kagenti Developer Guide](https://github.com/kagenti/kagenti/blob/main/docs/dev-guide.md) — upstream Kagenti dev guide: per-persona workflows (agent, tool, extensions developers, MCP gateway operators), Git/PR process, pre-commit hooks, feature flags, local Kagenti UI v2 development (React frontend + FastAPI backend, building/deploying images to Kubernetes), and HyperShift-based testing on ephemeral OpenShift clusters (cluster lifecycle, cost management, troubleshooting). diff --git a/aiac/demo/agents/github_agent/.dockerignore b/aiac/demo/agents/github_agent/.dockerignore new file mode 100644 index 000000000..287f001bc --- /dev/null +++ b/aiac/demo/agents/github_agent/.dockerignore @@ -0,0 +1,7 @@ +.venv + +# Secrets — never bake into the image +.env + +# `expect` scripts +test_startup.exp diff --git a/aiac/demo/agents/github_agent/.env.template b/aiac/demo/agents/github_agent/.env.template new file mode 100644 index 000000000..65b53af09 --- /dev/null +++ b/aiac/demo/agents/github_agent/.env.template @@ -0,0 +1,28 @@ +# Github Agent - configuration template +# Copy to .env and fill in your values. + +# LLM configuration +TASK_MODEL_ID=ollama/ibm/granite4:latest +LLM_API_BASE=http://host.docker.internal:11434 +LLM_API_KEY=my_api_key +MODEL_TEMPERATURE=0 + +# MCP Tool endpoint +MCP_URL=http://github-tool-mcp:9090/mcp + +# Agent service +PORT=8000 +LOG_LEVEL=INFO + +# Optional: static GitHub PAT passed to MCP as Bearer token. +# If unset, the inbound Authorization header (AuthBridge path) is forwarded instead. +GITHUB_TOKEN= + +# Optional: override the URL advertised in the agent card +AGENT_ENDPOINT= + +# Optional: override the curated tool allow-list (comma-separated tool names) +ENABLED_TOOLS= + +# Optional: expected issuer of inbound JWTs (informational) +ISSUER= diff --git a/aiac/demo/agents/github_agent/Dockerfile b/aiac/demo/agents/github_agent/Dockerfile new file mode 100644 index 000000000..04c77f077 --- /dev/null +++ b/aiac/demo/agents/github_agent/Dockerfile @@ -0,0 +1,15 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +ARG RELEASE_VERSION="main" + +WORKDIR /app +COPY . . +RUN uv sync --no-cache --locked --link-mode copy + +ENV PRODUCTION_MODE=True \ + HOME=/app \ + RELEASE_VERSION=${RELEASE_VERSION} + +RUN chown -R 1001:1001 /app +USER 1001 + +CMD ["uv", "run", "--no-sync", "server"] diff --git a/aiac/demo/agents/github_agent/README.md b/aiac/demo/agents/github_agent/README.md new file mode 100644 index 000000000..3eaf180bc --- /dev/null +++ b/aiac/demo/agents/github_agent/README.md @@ -0,0 +1,113 @@ +# github-agent + +An autonomous A2A agent that acts on a user's behalf against GitHub **source repositories** and an **issue/PR tracker**, using the [`github-tool-mcp`](https://github.com/kagenti/kagenti-extensions) MCP server. + +This agent implements the canonical `github-agent` used by the AIAC policy-pipeline integration test — the two skills match the policy scenario's `source_operations` and `issue_operations` roles. + +## Skills + +| Skill id | Name | Description | +|---|---|---| +| `source_operations` | Source repository operations | Browse and search code; read, create, and modify repository file contents, branches, and commits. | +| `issue_operations` | Issue & PR tracker operations | Read, search, create, and update issues, comments, sub-issues, and pull requests. | + +## Prerequisite: `github-tool-mcp` (production tool) + +This agent connects to **`github-tool-mcp:9090/mcp`** — the production 44-tool MCP server — at `MCP_URL`. +Deploy it before starting the agent: + +``` +authbridge/demos/github-issue/k8s/github-tool-deployment.yaml +``` + +> **Not the same as `demo/tools/github_tool/`.** +> `demo/tools/github_tool/` is a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`, +> `issues-write`) deployed as Service `github-tool` for **UC-1 onboarding discovery** only. +> The agent never connects to it — it connects to the production `github-tool-mcp` server which +> exposes the 44-tool GitHub API federation. + +## Configuration + +All settings are read from environment variables (or a `.env` file). Copy one of the presets: + +| Preset | Description | +|---|---| +| `.env.ollama` | Default — local Ollama (ibm/granite4) | +| `.env.openai` | OpenAI gpt-4o-mini | +| `.env.claude` | Anthropic Claude Sonnet | +| `.env.template` | Documented placeholder for all vars | + +### Variables + +| Variable | Description | Default | +|---|---|---| +| `TASK_MODEL_ID` | litellm model id | `ollama/ibm/granite4:latest` | +| `LLM_API_BASE` | OpenAI-compatible base URL | `http://host.docker.internal:11434` | +| `LLM_API_KEY` | LLM API key | `my_api_key` | +| `MODEL_TEMPERATURE` | Sampling temperature | `0` | +| `EXTRA_HEADERS` | Extra LLM headers (JSON) | `{}` | +| `MCP_URL` | MCP tool endpoint | `http://github-tool-mcp:9090/mcp` | +| `MCP_TIMEOUT` | MCP connect timeout (s) | `600` | +| `ENABLED_TOOLS` | Override the curated tool allow-list (comma-separated) | (unset → default set) | +| `PORT` | A2A listen port | `8000` | +| `LOG_LEVEL` | Log level | `INFO` | +| `GITHUB_TOKEN` | Static Bearer to MCP (else inbound passthrough) | (unset) | +| `ISSUER` | Expected `iss` of inbound JWTs (informational) | (unset) | +| `AGENT_ENDPOINT` | Override the URL advertised in the card | (unset) | + +## Running locally + +```bash +cd aiac/demo/agents/github_agent +cp .env.ollama .env # or another preset +uv sync +uv run server +# In another terminal: +curl -s localhost:8000/.well-known/agent-card.json | python3 -m json.tool +``` + +Optionally, run `expect -f test_startup.exp` instead to check startup automatically. + +## Deploying to Kagenti (Kind cluster) + +Prerequisites: a running Kagenti cluster (Keycloak realm `kagenti`, namespace `team1`) with `github-tool-mcp` already deployed. + +1. **Build the image:** + ```bash + cd aiac/demo/agents/github_agent + podman build -t github-agent:latest . + # or: docker build -t github-agent:latest . + ``` + +2. **Load into the Kind cluster:** + ```bash + kind load docker-image github-agent:latest --name kagenti + ``` + +3. **Apply manifests:** + ```bash + kubectl apply -f k8s/configmaps.yaml + kubectl apply -f k8s/github-agent-deployment.yaml + ``` + +4. **Confirm AuthBridge injection:** + ```bash + kubectl get pod -n team1 -l app.kubernetes.io/name=github-agent -o jsonpath='{.items[0].spec.containers[*].name}' + ``` + You should see the `authbridge-proxy` (or `envoy-proxy`) sidecar alongside `agent`. + +5. **Port-forward and send a message:** + ```bash + kubectl port-forward svc/github-agent 8080:8080 -n team1 & + # Send an A2A message/send request: + curl -s http://localhost:8080/.well-known/agent-card.json | python3 -m json.tool + ``` + +## Architecture + +``` +A2A client ──(JSON-RPC /)──► github-agent (:8000) + │ CrewAI: prereq extract → researcher + └──(streamable-http, MCP_URL)──► github-tool-mcp:9090/mcp ──► GitHub + (AuthBridge sidecar: inbound JWT validation; outbound RFC-8693 token exchange for MCP_URL host) +``` diff --git a/aiac/demo/agents/github_agent/a2a_agent.py b/aiac/demo/agents/github_agent/a2a_agent.py new file mode 100644 index 000000000..bb7703a90 --- /dev/null +++ b/aiac/demo/agents/github_agent/a2a_agent.py @@ -0,0 +1,242 @@ +""" +Module for A2A Agent. +""" + +import asyncio +import logging +import os +import sys +import traceback + +import uvicorn +from crewai_tools import MCPServerAdapter +from crewai_tools.adapters.tool_collection import ToolCollection + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events.event_queue import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes +from a2a.server.tasks import InMemoryTaskStore, TaskUpdater +from a2a.helpers import new_task_from_user_message, new_text_part +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + AgentSkill, + TaskState, + SecurityScheme, + HTTPAuthSecurityScheme, +) +from starlette.applications import Starlette + +from github_agent.config import settings, Settings +from github_agent.event import Event +from github_agent.tools import select_enabled_tools + +logger = logging.getLogger(__name__) +logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format="%(levelname)s: %(message)s") + + +def get_agent_card(host: str, port: int): + """Returns the Agent Card for the Github Agent.""" + capabilities = AgentCapabilities(streaming=True) + + skill_source = AgentSkill( + id="source_operations", + name="Source repository operations", + description="Browse and search code; read, create, and modify repository file contents, branches, and commits.", + tags=["git", "github", "source", "repositories", "files", "branches", "commits"], + examples=[ + "Show the README of kagenti/kagenti", + "List the branches of owner/repo", + "Create a branch and commit a fix to owner/repo", + ], + ) + + skill_issue = AgentSkill( + id="issue_operations", + name="Issue & PR tracker operations", + description="Read, search, create, and update issues, comments, sub-issues, and pull requests.", + tags=["git", "github", "issues", "pull-requests"], + examples=[ + "List open issues in kubernetes/kubernetes", + "Open an issue in owner/repo titled ...", + "Summarise PR #42 in owner/repo", + ], + ) + + if settings.AGENT_ENDPOINT: + url = settings.AGENT_ENDPOINT.rstrip("/") + "/" + else: + url = f"http://{host}:{port}/" + + return AgentCard( + name="Github agent", + description=( + "Autonomous Agent acting on a user's behalf against source repositories and an issue tracker. " + "It inspects and changes repository source contents and reads, creates, and updates issues and their threads." + ), + supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")], + version="1.0.0", + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=capabilities, + skills=[skill_source, skill_issue], + security_schemes={ + "Bearer": SecurityScheme( + http_auth_security_scheme=HTTPAuthSecurityScheme( + scheme="bearer", bearer_format="JWT", description="OAuth 2.0 JWT token" + ) + ) + }, + ) + + +class A2AEvent(Event): + """ + A class to handle events for A2A Agent. + + Attributes: + task_updater (TaskUpdater): The task updater instance. + """ + + def __init__(self, task_updater: TaskUpdater): + """ + Initializes the A2AEvent instance. + + Args: + task_updater (TaskUpdater): The task updater instance. + """ + self.task_updater = task_updater + + async def emit_event(self, message: str, final: bool = False) -> None: + """ + Emits an event with the given message. + + Args: + message (str): The event message. + final (bool): Whether the event is final. Defaults to False. + """ + logger.info("Emitting event %s", message) + + if final: + parts = [new_text_part(message)] + await self.task_updater.add_artifact(parts) + await self.task_updater.complete() + else: + await self.task_updater.update_status( + TaskState.TASK_STATE_WORKING, + self.task_updater.new_agent_message([new_text_part(message)]), + ) + + +class GithubExecutor(AgentExecutor): + """ + A class to handle execution for A2A Agent. + """ + + async def _run_agent(self, messages: dict, settings: Settings, event_emitter: Event, toolkit: ToolCollection): + from github_agent.main import GithubAgent + + github_agent = GithubAgent( + config=settings, + eventer=event_emitter, + mcp_toolkit=toolkit, + ) + result = await github_agent.execute(messages) + await event_emitter.emit_event(result, True) + + async def execute(self, context: RequestContext, event_queue: EventQueue): + """ + Executes the task. + + Args: + context (RequestContext): The request context. + event_queue (EventQueue): The event queue instance. + + Returns: + None + """ + # If GITHUB_TOKEN is set, pass it as Bearer header to MCP. + # If not set, assume AuthBridge handles auth transparently (envoy injects tokens). + headers = {} + if settings.GITHUB_TOKEN: + headers["Authorization"] = f"Bearer {settings.GITHUB_TOKEN}" + elif context.call_context and (context.call_context.state or {}).get("headers", {}).get("authorization"): + headers["Authorization"] = context.call_context.state["headers"]["authorization"] + else: + logging.warning( + "No GITHUB_TOKEN or inbound Authorization header; outbound requests will be unauthenticated" + ) + + user_input = [context.get_user_input()] + task = context.current_task + if not task: + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + task_updater = TaskUpdater(event_queue, task.id, task.context_id) + event_emitter = A2AEvent(task_updater) + messages = [] + for message in user_input: + messages.append( + { + "role": "User", + "content": message, + } + ) + + # Hook up MCP tools + try: + if settings.MCP_URL: + logging.info("Connecting to MCP server at %s", settings.MCP_URL) + + server_params = { + "url": settings.MCP_URL, + "transport": "streamable-http", + "headers": headers, + } + adapter = MCPServerAdapter(server_params, connect_timeout=settings.MCP_TIMEOUT) + # MCPServerAdapter.__enter__/__exit__ perform blocking MCP I/O; + # run them off the event loop so we don't stall other async tasks. + mcp_tools = await asyncio.to_thread(adapter.__enter__) + try: + curated_tools = select_enabled_tools(mcp_tools, settings) + if not curated_tools: + raise RuntimeError( + "No enabled tools found from the GitHub MCP server. " + "Check the ENABLED_TOOLS setting and ensure the server is reachable." + ) + await self._run_agent(messages, settings, event_emitter, curated_tools) + finally: + await asyncio.to_thread(adapter.__exit__, None, None, None) + else: + await self._run_agent(messages, settings, event_emitter, None) + + except Exception as e: + traceback.print_exc() + await event_emitter.emit_event( + f"I'm sorry I was unable to fulfill your request. I encountered the following exception: {str(e)}", True + ) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """ + Not implemented + """ + raise Exception("cancel not supported") + + +def run(): + """ + Runs the A2A Agent application. + """ + agent_card = get_agent_card(host="0.0.0.0", port=settings.SERVICE_PORT) + request_handler = DefaultRequestHandler( + agent_executor=GithubExecutor(), + task_store=InMemoryTaskStore(), + agent_card=agent_card, + ) + routes = create_jsonrpc_routes(request_handler, rpc_url="/", enable_v0_3_compat=True) + routes += create_agent_card_routes(agent_card) + routes += create_agent_card_routes(agent_card, card_url="/.well-known/agent.json") + app = Starlette(routes=routes) + uvicorn.run(app, host="0.0.0.0", port=settings.SERVICE_PORT) diff --git a/aiac/demo/agents/github_agent/github_agent/__init__.py b/aiac/demo/agents/github_agent/github_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/demo/agents/github_agent/github_agent/agents.py b/aiac/demo/agents/github_agent/github_agent/agents.py new file mode 100644 index 000000000..fa2ba63c7 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/agents.py @@ -0,0 +1,79 @@ +from crewai import Agent, Crew, Process, Task +from github_agent.config import Settings +from github_agent.llm import CrewLLM +from github_agent.prompts import TOOL_CALL_PROMPT, INFO_PARSER_PROMPT + + +class GithubAgents: + def __init__(self, config: Settings, github_tools): + self.llm = CrewLLM(config) + + ################### + # Pre-requisite extractor (no tools) + ################### + self.prereq_identifier = Agent( + role="Pre-requisite Extractor", + goal="Extract information about GitHub artifacts from the user's query", + backstory=INFO_PARSER_PROMPT, + verbose=True, + llm=self.llm.llm, + ) + + self.prereq_identifier_task = Task( + description="User query: {request}", + agent=self.prereq_identifier, + expected_output=( + 'A JSON object with keys "owner", "repo", "ref", "path", "numbers". ' + 'Example: {"owner": "kagenti", "repo": "kagenti", "ref": null, "path": null, "numbers": null}' + ), + ) + + self.prereq_id_crew = Crew( + agents=[self.prereq_identifier], + tasks=[self.prereq_identifier_task], + process=Process.sequential, + verbose=True, + ) + + ################### + # GitHub operations researcher + ################### + self.github_researcher = Agent( + role="GitHub Operations Analyst", + goal=( + "Answer the user's query using MCP tools. " + "Prefer read-only operations. Be explicit about repo owner/name and filters." + ), + backstory=TOOL_CALL_PROMPT, + tools=github_tools, + verbose=True, + llm=self.llm.llm, + inject_date=True, + max_iter=6, + max_retry_limit=3, + respect_context_window=True, + ) + + self.github_query_task = Task( + description=( + "Use GitHub MCP tools to answer the user's query.\n" + "User query: {request}\n" + "Repository owner: {owner}\n" + "Repository name: {repo}\n" + "Branch/tag/sha: {ref}\n" + "File path: {path}\n" + "Issue/PR numbers: {numbers}" + ), + agent=self.github_researcher, + expected_output=( + "A well formatted, detailed report directly answering the user's query, " + "citing the tool output to support the answer." + ), + ) + + self.crew = Crew( + agents=[self.github_researcher], + tasks=[self.github_query_task], + process=Process.sequential, + verbose=True, + ) diff --git a/aiac/demo/agents/github_agent/github_agent/config.py b/aiac/demo/agents/github_agent/github_agent/config.py new file mode 100644 index 000000000..75accc315 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/config.py @@ -0,0 +1,66 @@ +import json +import os +from pydantic_settings import BaseSettings +from pydantic import model_validator +from pydantic import Field +from typing import Literal, Optional + + +class Settings(BaseSettings): + LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( + os.getenv("LOG_LEVEL", "INFO"), + description="Application log level", + ) + TASK_MODEL_ID: str = Field( + os.getenv("TASK_MODEL_ID", "ollama/ibm/granite4:latest"), + description="The ID of the task model", + ) + LLM_API_BASE: str = Field( + os.getenv("LLM_API_BASE", "http://host.docker.internal:11434"), + description="The URL for OpenAI API", + ) + LLM_API_KEY: str = Field(os.getenv("LLM_API_KEY", "my_api_key"), description="The key for OpenAI API") + EXTRA_HEADERS: dict = Field({}, description="Extra headers for the OpenAI API") + MODEL_TEMPERATURE: float = Field( + os.getenv("MODEL_TEMPERATURE", 0), + description="The temperature for the model", + ge=0, + ) + MCP_URL: str = Field( + os.getenv("MCP_URL", "http://github-tool-mcp:9090/mcp"), + description="Endpoint for the MCP server", + ) + MCP_TIMEOUT: int = Field(os.getenv("MCP_TIMEOUT", 600), description="Timeout in seconds for MCP server connection") + ENABLED_TOOLS: Optional[str] = Field( + os.getenv("ENABLED_TOOLS", None), + description="Comma-separated list of tool names to enable (overrides the default curated allow-list)", + ) + + # auth variables for token validation + ISSUER: Optional[str] = Field(os.getenv("ISSUER", None), description="The issuer for incoming JWT tokens") + AGENT_ENDPOINT: Optional[str] = Field( + os.getenv("AGENT_ENDPOINT", None), + description="Override the URL advertised in the agent card", + ) + SERVICE_PORT: int = Field(os.getenv("PORT", 8000), description="Port on which the service will run.") + GITHUB_TOKEN: Optional[str] = Field( + os.getenv("GITHUB_TOKEN", None), + description="If not using agent with authorization, the default Github token to use", + ) + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + @model_validator(mode="after") + def validate_extra_headers(self) -> "Settings": + if os.getenv("EXTRA_HEADERS"): + try: + self.EXTRA_HEADERS = json.loads(os.getenv("EXTRA_HEADERS")) + except json.JSONDecodeError: + raise ValueError("EXTRA_HEADERS must be a valid JSON string") + + return self + + +settings = Settings() # type: ignore[call-arg] diff --git a/aiac/demo/agents/github_agent/github_agent/data_types.py b/aiac/demo/agents/github_agent/github_agent/data_types.py new file mode 100644 index 000000000..d97d6065d --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/data_types.py @@ -0,0 +1,25 @@ +import json +from pydantic import BaseModel, Field, field_validator + + +class GithubQueryInfo(BaseModel): + owner: str | None = Field(None, description="The repository owner or organization.") + repo: str | None = Field(None, description="The repository name.") + ref: str | None = Field(None, description="Branch, tag, or sha if named.") + path: str | None = Field(None, description="File path if named.") + numbers: list[int] | None = Field( + None, description="Issue or PR numbers mentioned by the user." + ) + + @field_validator("numbers", mode="before") + @classmethod + def coerce_string_to_list(cls, v): + """Small LLMs often serialize arrays as strings in tool call args.""" + if isinstance(v, str): + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return parsed + except (json.JSONDecodeError, ValueError): + pass + return v diff --git a/aiac/demo/agents/github_agent/github_agent/event.py b/aiac/demo/agents/github_agent/github_agent/event.py new file mode 100644 index 000000000..511156326 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/event.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + + +class Event(ABC): + @abstractmethod + async def emit_event(self, message: str, final: bool = False) -> None: + """Emit Event""" + pass diff --git a/aiac/demo/agents/github_agent/github_agent/llm.py b/aiac/demo/agents/github_agent/github_agent/llm.py new file mode 100644 index 000000000..5a9e380ef --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/llm.py @@ -0,0 +1,22 @@ +from crewai import LLM +from github_agent.config import Settings + + +class CrewLLM: + def __init__(self, config: Settings): + kwargs = {} + if config.EXTRA_HEADERS: + kwargs["extra_headers"] = config.EXTRA_HEADERS + + # For Ollama models, pass num_ctx to set the context window size. + # Ollama defaults to 2048 tokens which is too small for agent workflows. + if config.TASK_MODEL_ID.startswith(("ollama/", "ollama_chat/")): + kwargs["num_ctx"] = 8192 + + self.llm = LLM( + model=config.TASK_MODEL_ID, + base_url=config.LLM_API_BASE, + api_key=config.LLM_API_KEY, + temperature=config.MODEL_TEMPERATURE, + **kwargs, + ) diff --git a/aiac/demo/agents/github_agent/github_agent/main.py b/aiac/demo/agents/github_agent/github_agent/main.py new file mode 100644 index 000000000..7f9d54df2 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/main.py @@ -0,0 +1,113 @@ +import json +import logging +import re +import sys + +from crewai_tools.adapters.tool_collection import ToolCollection + +from github_agent.agents import GithubAgents +from github_agent.config import Settings, settings +from github_agent.data_types import GithubQueryInfo +from github_agent.event import Event + +logger = logging.getLogger(__name__) +logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format="%(levelname)s: %(message)s") + + +def _parse_prereq_from_raw(raw: str) -> GithubQueryInfo: + """Parse GithubQueryInfo from raw LLM text when instructor/pydantic parsing fails. + + Some Ollama models don't produce structured tool calls that crewai's instructor + integration expects. This fallback extracts JSON from the raw text output. + """ + # Only matches flat JSON (no nested braces). Sufficient for the current + # GithubQueryInfo schema; revisit if the model gains nested fields. + json_match = re.search(r"\{[^{}]*\}", raw) + if json_match: + try: + data = json.loads(json_match.group()) + return GithubQueryInfo(**data) + except (json.JSONDecodeError, ValueError): + pass + + logger.warning("Could not parse prereq JSON from raw output: %s", raw) + return GithubQueryInfo() + + +class GithubAgent: + def __init__( + self, + config: Settings, + eventer: Event = None, + mcp_toolkit: ToolCollection = None, + logger=None, + ): + self.agents = GithubAgents(config, mcp_toolkit) + self.eventer = eventer + self.logger = logger or logging.getLogger(__name__) + + async def _send_event(self, message: str, final: bool = False): + logger.info(message) + if self.eventer: + await self.eventer.emit_event(message, final) + else: + logger.warning("No event handler registered") + + def extract_user_input(self, body): + content = body[-1]["content"] + latest_content = "" + + if isinstance(content, str): + latest_content = content + else: + for item in content: + if item["type"] == "text": + latest_content += item["text"] + else: + self.logger.warning(f"Ignoring content with type {item['type']}") + + return latest_content + + async def _get_prereq_output(self, query: str) -> GithubQueryInfo: + """Run the prereq crew and extract GithubQueryInfo from raw text output. + + We avoid using crewai's output_pydantic because it relies on instructor's + tool-call-based parsing, which fails with Ollama models that don't produce + structured tool calls. Instead, we ask the LLM for JSON and parse it ourselves. + """ + try: + await self.agents.prereq_id_crew.kickoff_async( + inputs={"request": query, "repo": "", "owner": "", "ref": "", "path": "", "numbers": []} + ) + raw = self.agents.prereq_identifier_task.output.raw + self.logger.info(f"Prereq raw output: {raw}") + return _parse_prereq_from_raw(raw) + except Exception as e: + self.logger.warning(f"Prereq crew failed: {e}") + return GithubQueryInfo() + + async def execute(self, user_input): + query = self.extract_user_input(user_input) + await self._send_event("Evaluating requirements...") + prereq = await self._get_prereq_output(query) + + # Validation gates — return helpful message without tool call when unmet + if prereq.numbers: + if not prereq.owner or not prereq.repo: + return "When supplying issue or PR numbers, you must provide both a repository name and owner." + if prereq.repo: + if not prereq.owner: + return "When supplying a repository name, you must also provide an owner of the repo." + + await self._send_event("Searching GitHub...") + await self.agents.crew.kickoff_async( + inputs={ + "request": query, + "owner": prereq.owner or "", + "repo": prereq.repo or "", + "ref": prereq.ref or "", + "path": prereq.path or "", + "numbers": prereq.numbers or [], + } + ) + return self.agents.github_query_task.output.raw diff --git a/aiac/demo/agents/github_agent/github_agent/prompts.py b/aiac/demo/agents/github_agent/github_agent/prompts.py new file mode 100644 index 000000000..88dca7925 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/prompts.py @@ -0,0 +1,127 @@ +TOOL_CALL_PROMPT = """ +You are a GitHub operations analyst with access to MCP tools that can read and write GitHub repositories, issues, and pull requests. +You will receive instructions in the following format: + User query: The original query/instruction from the user + Repository owner: The github owner they are referring to, if present + Repository name: The github repo they are referring to, if present + Branch/tag/sha: The git ref they are referring to, if present + File path: The file path they are referring to, if present + Issue/PR numbers: Any issue or PR numbers they are referring to, if present + +YOUR JOB +1. Decide whether the user's request can be fulfilled with a tool from the catalog. +2. When a tool is required, emit **only** a single tool call in the exact format below. +3. Use the exact owner, repo, ref, path, and numbers as written by the user as input to the tools, without modification. +4. After tool results arrive, produce the final answer grounded strictly in those results. + +## *MODES* +──────────────────────────────────────────────────────── +⚙️ TOOL CALL PHASE + +- If a tool is required, you MUST output EXACTLY the following four lines in this order: + 1) Thought: + 2) Action: + 3) Action Input: + 4) Observation: + +- After the Observation is provided by the system, output: + Thought: I now know the final answer + Final Answer: + +STRICT RULES FOR TOOL CALLS +- Output NOTHING except those exact lines when calling a tool. No code fences, no XML, no extra braces. +- The JSON after "Action Input:" MUST be valid and single-line. No trailing commas, no extra closing braces. +- Use ONLY properties defined in the tool schema (required + optional). Exact key names and value types. +- Use only one tool per call. +- Always copy owner/organization names, repository names, file paths, ref names, and issue/PR numbers exactly as provided by the user or upstream context. Do not truncate, split, normalize, or otherwise modify these identifiers. +- When a tool call succeeds, do not immediately call another tool just to reformat or summarize the same results. + +CORRECT EXAMPLE +Thought: The user provided owner and repo; list_issues fits. +Action: list_issues +Action Input: {"owner":"kagenti","repo":"kagenti"} +Observation: + +──────────────────────────────────────────────────────── +🧩 FINAL ANSWER PHASE + +After tool results arrive: +- Synthesize the returned data to produce a human-readable answer grounded only in the tool output. +- Summarize or aggregate long lists instead of echoing raw JSON. +- Clearly cite or reference the tool results. +- If a tool failed or inputs were missing, say so explicitly. + + Thought: I now know the final answer + Final Answer: + +──────────────────────────────────────────────────────── +TOOL SELECTION GUIDELINES + +Choose the right tool based on the user's intent: + +**SOURCE OPERATIONS** (files, branches, commits, code) +- `get_file_contents` — retrieve the content of a file at an optional ref; requires owner, repo, path +- `list_branches` — list branches; requires owner, repo +- `get_commit` — get details of a specific commit; requires owner, repo, sha +- `list_commits` — list commits on a branch; requires owner, repo; optional ref +- `search_code` — search for code across repositories; use when no specific repo is given or searching across repos +- `create_or_update_file` — create or update a file; requires owner, repo, path, message, content +- `delete_file` — delete a file; requires owner, repo, path, message, sha +- `push_files` — push multiple files in one commit; requires owner, repo, branch, files, message +- `create_branch` — create a new branch; requires owner, repo, branch name + +**ISSUE & PR OPERATIONS** (issues, pull requests, comments) +- `issue_read` — get details of a specific issue; requires owner, repo, issue_number +- `list_issues` — list issues; requires owner and repo; optional state, labels, etc. +- `search_issues` — search issues across repos or within a repo; use when no specific repo is given +- `list_issue_types` — enumerate available issue types for an organization; requires org +- `pull_request_read` — get details of a specific PR; requires owner, repo, pull_number +- `list_pull_requests` — list PRs in a repo; requires owner and repo +- `issue_write` — create or update an issue; requires owner, repo +- `add_issue_comment` — add a comment to an issue; requires owner, repo, issue_number, body +- `sub_issue_write` — create or update a sub-issue; requires owner, repo, issue_number +- `create_pull_request` — create a pull request; requires owner, repo, title, head, base +- `update_pull_request` — update an existing PR; requires owner, repo, pull_number + +Decision rules: +- For source content: use `get_file_contents` when path is given, `list_branches` for branches, `search_code` for broad code search. +- For issues: prefer `list_issues` when owner+repo are known; use `search_issues` for broad searches. +- For PRs: prefer `list_pull_requests` when owner+repo are known; use `pull_request_read` when PR number is given. +- Never infer missing identifiers. +- Never call a tool when you do not have all required parameters. +- When owner/repo/ref/path/issue identifiers are provided, reuse them verbatim. + +Examples: +- "Show README of kagenti/kagenti" → get_file_contents (owner=kagenti, repo=kagenti, path=README.md) +- "List branches of owner/repo" → list_branches +- "Open issues in kagenti/agent-examples" → list_issues +- "Find issues mentioning timeout across all repos" → search_issues +- "Sub-issues under #134 in openai/triton" → sub_issue_write / issue_read with issue_number +- "PR #42 in owner/repo" → pull_request_read + +Carefully inspect the user's request for filters such as labels, date ranges, keywords, state (open/closed), etc. Use available optional parameters where appropriate. +""" + +INFO_PARSER_PROMPT = """ +You are an analyst that will extract out information from a user's instruction/query to determine the following information, if it exists: +- Github owner or organization +- Github repository +- Branch, tag, or sha (ref) if explicitly named +- File path if explicitly named +- Issue or PR number(s) + +Extraction Rules: +- Copy owner/organization names, repository names, ref names, file paths, and issue/PR identifiers exactly as the user typed them. Preserve casing, punctuation, spacing, diacritics, and hyphenation; never rewrite, normalize, or translate these strings. +- Only return values that are explicitly present in the user request. If any item is missing, output None for that field. +- Do not infer or guess missing identifiers. If you are unsure about any value, leave it as None. + +Output format: a JSON object with keys "owner", "repo", "ref", "path", "numbers". +Example: {"owner": "kagenti", "repo": "kagenti", "ref": null, "path": null, "numbers": null} + +Examples: +- "summarize open issues across the foo organization" → {"owner": "foo", "repo": null, "ref": null, "path": null, "numbers": null} +- "kagenti/agent-examples" → {"owner": "kagenti", "repo": "agent-examples", "ref": null, "path": null, "numbers": null} +- "Show README of kagenti/kagenti on branch main" → {"owner": "kagenti", "repo": "kagenti", "ref": "main", "path": "README.md", "numbers": null} +- "How long has issue 2 in modelcontextprotocol/servers been open?" → {"owner": "modelcontextprotocol", "repo": "servers", "ref": null, "path": null, "numbers": [2]} +- "Review PR #87 for CoolOrg/Next-Gen-Repo" → {"owner": "CoolOrg", "repo": "Next-Gen-Repo", "ref": null, "path": null, "numbers": [87]} +""" diff --git a/aiac/demo/agents/github_agent/github_agent/tools.py b/aiac/demo/agents/github_agent/github_agent/tools.py new file mode 100644 index 000000000..db8c0b7f3 --- /dev/null +++ b/aiac/demo/agents/github_agent/github_agent/tools.py @@ -0,0 +1,42 @@ +import logging + +logger = logging.getLogger(__name__) + +# Module constants grouped by skill/scope per spec §5: +SOURCE_READ = ["get_file_contents", "list_branches", "get_commit", "list_commits", "search_code"] +SOURCE_WRITE = ["create_or_update_file", "delete_file", "push_files", "create_branch"] +ISSUE_READ = ["issue_read", "list_issues", "search_issues", "list_issue_types", + "pull_request_read", "list_pull_requests"] +ISSUE_WRITE = ["issue_write", "add_issue_comment", "sub_issue_write", + "create_pull_request", "update_pull_request"] +DEFAULT_ENABLED_TOOLS = SOURCE_READ + SOURCE_WRITE + ISSUE_READ + ISSUE_WRITE + +# Named-but-excluded (documented; re-enable via ENABLED_TOOLS): +EXCLUDED = ["get_me", "get_team_members", "get_teams", "run_secret_scanning", + "create_repository", "fork_repository", "list_tags", "get_tag", "get_label"] + + +def enabled_tool_names(settings) -> list[str]: + """Return the list of enabled tool names from settings or the default curated list.""" + if settings.ENABLED_TOOLS: + return [name.strip() for name in settings.ENABLED_TOOLS.split(",")] + return DEFAULT_ENABLED_TOOLS + + +def select_enabled_tools(mcp_tools, settings) -> list: + """Filter mcp_tools keeping only items whose .name is in the enabled set. + + Logs dropped tool names at DEBUG level. + Caller raises RuntimeError if the result is empty — not this function's job. + """ + allowed = set(enabled_tool_names(settings)) + kept = [] + dropped = [] + for tool in mcp_tools: + if tool.name in allowed: + kept.append(tool) + else: + dropped.append(tool.name) + if dropped: + logger.debug("Dropping MCP tools not in enabled set: %s", dropped) + return kept diff --git a/aiac/demo/agents/github_agent/k8s/configmaps.yaml b/aiac/demo/agents/github_agent/k8s/configmaps.yaml new file mode 100644 index 000000000..362bcfb70 --- /dev/null +++ b/aiac/demo/agents/github_agent/k8s/configmaps.yaml @@ -0,0 +1,65 @@ +# Demo-specific ConfigMap overrides for GitHub Issue Agent + AuthBridge +# +# The Kagenti installer already creates correct defaults for authbridge-config, +# spiffe-helper-config, and envoy-config (with the kagenti realm). This file +# overrides: +# - authbridge-config: Keycloak connection, inbound validation, and outbound exchange settings +# - authproxy-routes: per-target token exchange routes +# +# Authbridge defaults to "passthrough" for outbound traffic, meaning +# requests to unknown hosts (LLM APIs, telemetry, etc.) pass through without +# token exchange. Only hosts listed in authproxy-routes get token exchange. +# +# Usage: +# kubectl apply -f configmaps.yaml +# +# Note: These manifests are configured for namespace "team1" (installer-provided +# and enrolled for AuthBridge sidecar injection) and the installer's +# "kagenti" Keycloak realm (set via KEYCLOAK_REALM below). + +--- +# authbridge-config ConfigMap - Unified config for both client-registration and envoy-proxy +# +# This ConfigMap controls: +# 1. Client registration: KEYCLOAK_URL, KEYCLOAK_REALM for Keycloak client setup +# 2. INBOUND validation: Validates JWT tokens on incoming requests to the agent +# 3. OUTBOUND exchange: Exchanges tokens when the agent calls the GitHub tool +# +# KEYCLOAK_URL and KEYCLOAK_REALM are included because kubectl apply replaces +# the entire ConfigMap. +# TOKEN_URL and ISSUER are auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM. +apiVersion: v1 +kind: ConfigMap +metadata: + name: authbridge-config + namespace: team1 +data: + KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" + KEYCLOAK_REALM: "kagenti" + # TOKEN_URL: Auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM. Only set explicitly + # when the token endpoint differs from the standard Keycloak path. + # TOKEN_URL: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" + # ISSUER: Set explicitly because the internal KEYCLOAK_URL differs from the frontend URL. + ISSUER: "http://keycloak.localtest.me:8080/realms/kagenti" + # Audience validation is automatic — uses CLIENT_ID from /shared/client-id.txt + # (written by client-registration or operator). No manual configuration needed. + +--- +# authproxy-routes ConfigMap - Per-target token exchange routes +# +# Defines which outbound hosts should get token exchange. All other hosts +# pass through unchanged (the default outbound policy is "passthrough"). +# This allows the agent to call LLM APIs, telemetry endpoints, and other +# services without interference from the token exchange proxy. +# +# Update the host pattern if the github-tool service name or namespace changes. +apiVersion: v1 +kind: ConfigMap +metadata: + name: authproxy-routes + namespace: team1 +data: + routes.yaml: | + - host: "github-tool-mcp" + target_audience: "github-tool" + token_scopes: "openid github-tool-aud github-full-access" diff --git a/aiac/demo/agents/github_agent/k8s/github-agent-deployment.yaml b/aiac/demo/agents/github_agent/k8s/github-agent-deployment.yaml new file mode 100644 index 000000000..42a588e2d --- /dev/null +++ b/aiac/demo/agents/github_agent/k8s/github-agent-deployment.yaml @@ -0,0 +1,184 @@ +# GitHub Agent Deployment with AuthBridge +# +# This deployment uses the kagenti-operator webhook to automatically inject +# a single combined AuthBridge sidecar (post-kagenti-extensions#411). The exact +# container shape depends on the resolved AuthBridge mode: +# - proxy-sidecar (default): one container "authbridge-proxy" from the +# "authbridge" image. spiffe-helper is bundled inside; gated per-workload +# by SPIRE_ENABLED. +# - envoy-sidecar: a "proxy-init" init container (iptables) plus one +# container "envoy-proxy" from the "authbridge-envoy" image (Envoy + +# ext_proc + bundled spiffe-helper). +# Either way the sidecar handles inbound JWT validation, outbound token +# exchange (HTTP), and HTTPS passthrough. +# +# Keycloak client registration is operator-managed (no in-pod sidecar); +# the operator creates a kagenti-keycloak-client-credentials- Secret +# and the webhook mounts it at /shared/client-{id,secret}.txt. +# +# The agent container: +# - Runs the A2A GitHub Agent on port 8000 +# - Calls the GitHub MCP tool via HTTP (MCP_URL below) +# - Token exchange to the tool is handled transparently by the AuthBridge sidecar +# +# Labels (on Pod template): +# kagenti.io/inject: enabled - Enables AuthBridge sidecar injection +# kagenti.io/spire: enabled - Enables SPIRE-based identity (set to "disabled" if no SPIRE) +# +# kagenti.io/type is applied automatically by the operator when the +# AgentRuntime CR (at the end of this file) is created. +# +# Prerequisites (NOT created by this file — must exist before applying): +# 1. github-tool Deployment + Service named "github-tool-mcp" (the MCP tool server) +# 2. github-tool-secrets Secret (GitHub token used by the tool) +# 3. authbridge-config ConfigMap (apply configmaps.yaml first) +# 4. authproxy-routes ConfigMap (apply configmaps.yaml first) +# +# Invariant: MCP_URL host ("github-tool-mcp") == authproxy-routes host entry +# ("github-tool-mcp") == the Kubernetes Service name for the tool server. +# All three must match or token exchange will not fire for tool calls. +# +# Usage: +# kubectl apply -f configmaps.yaml +# kubectl apply -f github-agent-deployment.yaml + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: github-agent + namespace: team1 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: github-agent + namespace: team1 + labels: + app.kubernetes.io/name: github-agent + protocol.kagenti.io/a2a: "" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: github-agent + template: + metadata: + labels: + app.kubernetes.io/name: github-agent + kagenti.io/inject: enabled + kagenti.io/spire: enabled + protocol.kagenti.io/a2a: "" + spec: + serviceAccountName: github-agent + containers: + - name: agent + image: localhost/github-agent:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + name: http + env: + # LLM configuration - Ollama (local LLM) + # Matches upstream .env.ollama from agent-examples repo + - name: TASK_MODEL_ID + value: "ollama/ibm/granite4:latest" + # Ollama API base URL. Required by litellm (used by crewai >=1.10). + # For Docker Desktop / Kind: http://host.docker.internal:11434 + # For in-cluster Ollama: http://ollama.ollama.svc:11434 + - name: LLM_API_BASE + value: "http://host.docker.internal:11434" + - name: OLLAMA_API_BASE + value: "http://host.docker.internal:11434" + - name: LLM_API_KEY + value: "ollama" + - name: MODEL_TEMPERATURE + value: "0" + # For OpenAI (uncomment and set your key): + # - name: TASK_MODEL_ID + # value: "gpt-4.1-nano" + # - name: LLM_API_KEY + # valueFrom: + # secretKeyRef: + # name: openai-secret + # key: apikey + # - name: OPENAI_API_KEY + # valueFrom: + # secretKeyRef: + # name: openai-secret + # key: apikey + + # Agent service settings + # PORT tells the agent where to listen for A2A traffic. + # The kagenti operator may override this via proxy-sidecar + # port-stealing to relocate the agent to a free port. + - name: PORT + value: "8000" + - name: LOG_LEVEL + value: "DEBUG" + + # MCP Tool endpoint - the GitHub tool service + # AuthBridge will exchange the token when the agent calls this URL. + # This hostname MUST match the authproxy-routes host entry and the + # Kubernetes Service name for the tool (see invariant in header above). + - name: MCP_URL + value: "http://github-tool-mcp:9090/mcp" + + # JWKS URI for validating incoming tokens from Keycloak + - name: JWKS_URI + value: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/certs" + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - name: shared-data + mountPath: /shared + volumes: + - name: shared-data + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: github-agent + namespace: team1 +spec: + selector: + app.kubernetes.io/name: github-agent + ports: + # Port 8001 is the agent's direct port after authbridge port-stealing (8000→8001). + # Listed first so getServicePort() picks it up for AgentCard fetching. + - name: agent + port: 8001 + targetPort: 8001 + protocol: TCP + # Port 8080 is the public/proxy port (authbridge reverse proxy on 8000). + - name: proxy + port: 8080 + targetPort: 8000 + protocol: TCP + type: ClusterIP + +--- +# AgentRuntime triggers operator-managed Keycloak client registration +# (creates kagenti-keycloak-client-credentials- Secret with +# client-id.txt and client-secret.txt that the authbridge sidecar +# mounts at /shared/). The operator also applies kagenti.io/type=agent +# to the Deployment and Pod template, enabling webhook sidecar injection. +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentRuntime +metadata: + name: github-agent + namespace: team1 +spec: + type: agent + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: github-agent diff --git a/aiac/demo/agents/github_agent/pyproject.toml b/aiac/demo/agents/github_agent/pyproject.toml new file mode 100644 index 000000000..b97188052 --- /dev/null +++ b/aiac/demo/agents/github_agent/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "github-agent" +version = "0.1.0" +requires-python = ">=3.11,<3.13" +description = "Github Agent module" +dependencies = [ + "python-dotenv>=1.2.2", + "a2a-sdk>=1.1.0,<2", + # We use 1.6.1 instead of the latest because + # crewai requires openai>=2.30.0 + # but litellm requires openai==2.24.0 + "crewai[litellm]>=1.6.1", + "litellm>=1.90.2", # Indirect, prevents CVE-2026-42271 + "crewai-tools[mcp]>=1.6.1", + "urllib3>=2.7.0", # Indirect; prevents CVE-2025-66418 + "python-multipart>=0.0.32", # Indirect; prevents CVE-2026-24486 + "cryptography>=48.0.0,<49", # Indirect; prevents CVE-2026-26007 + "pyasn1>=0.6.3", # Indirect; prevents CVE-2026-30922 + "starlette>=1.3.1", # Indirect; prevents CVE-2025-62727 + "pillow>=12.3.0", # Indirect; prevents CVE-2026-40192 + "lxml>=6.1.1", # Indirect; prevents CVE-2026-41066 + "orjson>=3.11.6", # Indirect; prevents CVE-2025-67221 + "json-repair>=0.60.1", # Indirect (via crewai); prevents GHSA-xf7x-x43h-rpqh (DoS) + # CVE-2026-45829 (chromadb): no fixed version published; crewai pins chromadb<1.2. + # Vulnerability requires running a chromadb HTTP server with trust_remote_code=true, + # which this agent does not do. +] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[project.scripts] +server = "a2a_agent:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +testpaths = ["test"] diff --git a/aiac/demo/agents/github_agent/test/test_agent_card.py b/aiac/demo/agents/github_agent/test/test_agent_card.py new file mode 100644 index 000000000..eb90f1cf6 --- /dev/null +++ b/aiac/demo/agents/github_agent/test/test_agent_card.py @@ -0,0 +1,95 @@ +import os +import json +import pytest +from starlette.testclient import TestClient +from starlette.applications import Starlette +from a2a.server.routes import create_agent_card_routes + +from a2a_agent import get_agent_card + + +@pytest.fixture +def card(): + return get_agent_card("localhost", 8000) + + +def test_card_name_and_version(card): + assert card.name == "Github agent" + assert card.version == "1.0.0" + + +def test_card_description(card): + assert card.description == ( + "Autonomous Agent acting on a user's behalf against source repositories and an issue tracker. " + "It inspects and changes repository source contents and reads, creates, and updates issues and their threads." + ) + + +def test_card_two_skills(card): + assert len(card.skills) == 2 + skill_ids = {s.id for s in card.skills} + assert skill_ids == {"source_operations", "issue_operations"} + + +def test_skill_tags_and_examples(card): + skills_by_id = {s.id: s for s in card.skills} + + source = skills_by_id["source_operations"] + assert "github" in source.tags + assert len(source.examples) >= 1 + + issue = skills_by_id["issue_operations"] + assert "github" in issue.tags + assert len(issue.examples) >= 1 + + +def test_bearer_security_scheme(card): + assert "Bearer" in card.security_schemes + assert card.security_schemes["Bearer"].http_auth_security_scheme.scheme == "bearer" + + +def test_agent_interface_jsonrpc(card): + assert card.supported_interfaces[0].protocol_binding == "JSONRPC" + + +def test_agent_endpoint_override(): + import a2a_agent + + original = a2a_agent.settings.AGENT_ENDPOINT + a2a_agent.settings.AGENT_ENDPOINT = "http://custom.host:9999" + try: + card = get_agent_card("localhost", 8000) + assert card.supported_interfaces[0].url == "http://custom.host:9999/" + finally: + a2a_agent.settings.AGENT_ENDPOINT = original + + +def test_capabilities_streaming(card): + assert card.capabilities.streaming is True + + +def test_input_output_modes(card): + assert list(card.default_input_modes) == ["text"] + assert list(card.default_output_modes) == ["text"] + + +def test_well_known_agent_card_route(card): + routes = create_agent_card_routes(card) + app = Starlette(routes=routes) + client = TestClient(app) + response = client.get("/.well-known/agent-card.json") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Github agent" + assert len(data["skills"]) == 2 + + +def test_well_known_agent_json_route(card): + routes = create_agent_card_routes(card, card_url="/.well-known/agent.json") + app = Starlette(routes=routes) + client = TestClient(app) + response = client.get("/.well-known/agent.json") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Github agent" + assert len(data["skills"]) == 2 diff --git a/aiac/demo/agents/github_agent/test/test_prereq.py b/aiac/demo/agents/github_agent/test/test_prereq.py new file mode 100644 index 000000000..a152b2936 --- /dev/null +++ b/aiac/demo/agents/github_agent/test/test_prereq.py @@ -0,0 +1,92 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from github_agent.data_types import GithubQueryInfo +from github_agent.main import GithubAgent, _parse_prereq_from_raw +from github_agent.config import Settings + + +def make_agent(prereq_raw="", researcher_raw="done"): + config = Settings() # type: ignore + agent = GithubAgent(config=config) + agent.agents.prereq_identifier_task = MagicMock() + agent.agents.prereq_identifier_task.output = MagicMock(raw=prereq_raw) + agent.agents.prereq_id_crew = MagicMock() + agent.agents.prereq_id_crew.kickoff_async = AsyncMock(return_value=None) + agent.agents.github_query_task = MagicMock() + agent.agents.github_query_task.output = MagicMock(raw=researcher_raw) + agent.agents.crew = MagicMock() + agent.agents.crew.kickoff_async = AsyncMock(return_value=None) + return agent + + +def test_github_query_info_parses_flat_json(): + info = GithubQueryInfo(owner="kagenti", repo="my-repo", ref="main", path="README.md", numbers=[1, 2]) + assert info.owner == "kagenti" + assert info.repo == "my-repo" + assert info.ref == "main" + assert info.path == "README.md" + assert info.numbers == [1, 2] + + +def test_numbers_string_coercion(): + info = GithubQueryInfo(numbers="[1, 2]") + assert info.numbers == [1, 2] + + +def test_parse_prereq_from_raw_valid_json(): + raw = '{"owner": "foo", "repo": "bar", "ref": null, "path": null, "numbers": null}' + result = _parse_prereq_from_raw(raw) + assert result.owner == "foo" + assert result.repo == "bar" + assert result.ref is None + assert result.numbers is None + + +def test_parse_prereq_from_raw_unparseable(): + result = _parse_prereq_from_raw("not json at all") + assert result.owner is None + assert result.repo is None + assert result.ref is None + assert result.path is None + assert result.numbers is None + + +@pytest.mark.anyio +async def test_gate_numbers_without_owner_repo(): + prereq_raw = '{"owner": null, "repo": null, "ref": null, "path": null, "numbers": [42]}' + agent = make_agent(prereq_raw=prereq_raw) + result = await agent.execute([{"role": "User", "content": "Issue 42"}]) + assert "must provide both" in result.lower() + agent.agents.crew.kickoff_async.assert_not_called() + + +@pytest.mark.anyio +async def test_gate_repo_without_owner(): + prereq_raw = '{"owner": null, "repo": "myrepo", "ref": null, "path": null, "numbers": null}' + agent = make_agent(prereq_raw=prereq_raw) + result = await agent.execute([{"role": "User", "content": "list issues in myrepo"}]) + assert "must also provide an owner" in result.lower() + agent.agents.crew.kickoff_async.assert_not_called() + + +@pytest.mark.anyio +async def test_happy_path_researcher_called(): + prereq_raw = '{"owner": "kagenti", "repo": "myrepo", "ref": null, "path": null, "numbers": null}' + agent = make_agent(prereq_raw=prereq_raw, researcher_raw="done") + result = await agent.execute([{"role": "User", "content": "list issues in kagenti/myrepo"}]) + agent.agents.crew.kickoff_async.assert_called_once() + assert result == "done" + + +@pytest.mark.anyio +async def test_happy_path_researcher_inputs(): + prereq_raw = '{"owner": "kagenti", "repo": "myrepo", "ref": null, "path": null, "numbers": null}' + agent = make_agent(prereq_raw=prereq_raw) + await agent.execute([{"role": "User", "content": "list issues in kagenti/myrepo"}]) + call_kwargs = agent.agents.crew.kickoff_async.call_args + inputs = call_kwargs.kwargs.get("inputs") or call_kwargs.args[0] if call_kwargs.args else {} + if not inputs: + inputs = call_kwargs[1].get("inputs", {}) + assert inputs.get("owner") == "kagenti" + assert inputs.get("repo") == "myrepo" diff --git a/aiac/demo/agents/github_agent/test/test_tools.py b/aiac/demo/agents/github_agent/test/test_tools.py new file mode 100644 index 000000000..fc42f2172 --- /dev/null +++ b/aiac/demo/agents/github_agent/test/test_tools.py @@ -0,0 +1,110 @@ +import pytest +from github_agent.tools import ( + DEFAULT_ENABLED_TOOLS, + EXCLUDED, + SOURCE_READ, + SOURCE_WRITE, + ISSUE_READ, + ISSUE_WRITE, + enabled_tool_names, + select_enabled_tools, +) + +# Extra tool names that appear in the full 44-tool github-tool catalog but are not in the +# enabled set — used to build a representative stub list of the expected catalog size. +_EXTRA_CATALOG_TOOLS = [ + "get_me", "get_team_members", "get_teams", "run_secret_scanning", + "create_repository", "fork_repository", "list_tags", "get_tag", "get_label", + "create_release", "list_releases", "get_release", "get_repository", + "list_repositories", "watch_repository", "unwatch_repository", + "get_file_metadata", "list_directory", "get_tree", + "create_gist", "list_gists", + "get_discussion", "list_discussions", +] + + +class FakeTool: + def __init__(self, name): + self.name = name + + +class FakeSettings: + def __init__(self, enabled_tools=None): + self.ENABLED_TOOLS = enabled_tools + + +def test_select_keeps_enabled_tools(): + all_tools = [FakeTool(n) for n in DEFAULT_ENABLED_TOOLS + EXCLUDED] + settings = FakeSettings() + result = select_enabled_tools(all_tools, settings) + result_names = {t.name for t in result} + for name in DEFAULT_ENABLED_TOOLS: + assert name in result_names, f"{name} should be in result" + for name in EXCLUDED: + assert name not in result_names, f"{name} should not be in result" + + +def test_select_with_override(): + all_tools = [FakeTool(n) for n in DEFAULT_ENABLED_TOOLS + EXCLUDED] + settings = FakeSettings(enabled_tools="issue_read,list_issues") + result = select_enabled_tools(all_tools, settings) + result_names = [t.name for t in result] + assert result_names == ["issue_read", "list_issues"] + + +def test_whitespace_tolerance(): + all_tools = [FakeTool(n) for n in DEFAULT_ENABLED_TOOLS + EXCLUDED] + settings = FakeSettings(enabled_tools=" issue_read , list_issues ") + result = select_enabled_tools(all_tools, settings) + result_names = [t.name for t in result] + assert result_names == ["issue_read", "list_issues"] + + +def test_default_tools_no_duplicates(): + assert len(DEFAULT_ENABLED_TOOLS) == len(set(DEFAULT_ENABLED_TOOLS)) + + +def test_default_tools_union_of_groups(): + assert set(DEFAULT_ENABLED_TOOLS) == set(SOURCE_READ + SOURCE_WRITE + ISSUE_READ + ISSUE_WRITE) + + +def test_source_vs_issue_membership(): + default_set = set(DEFAULT_ENABLED_TOOLS) + for name in SOURCE_READ: + assert name in default_set + for name in SOURCE_WRITE: + assert name in default_set + for name in ISSUE_READ: + assert name in default_set + for name in ISSUE_WRITE: + assert name in default_set + + +def test_excluded_tools_absent_by_default(): + default_set = set(DEFAULT_ENABLED_TOOLS) + for name in EXCLUDED: + assert name not in default_set, f"{name} should not appear in DEFAULT_ENABLED_TOOLS" + + +def test_stub_44_tool_catalog_filtered_to_default(): + """Simulate the full github-tool catalog (~44 tools); default set filters it to exactly DEFAULT_ENABLED_TOOLS.""" + catalog = DEFAULT_ENABLED_TOOLS + _EXTRA_CATALOG_TOOLS + # Pad to 44 entries if the catalog is shorter (adds unused dummy names) + while len(catalog) < 44: + catalog = catalog + [f"_dummy_{len(catalog)}"] + tools = [FakeTool(n) for n in catalog[:44]] + result = select_enabled_tools(tools, FakeSettings()) + result_names = {t.name for t in result} + assert result_names == set(DEFAULT_ENABLED_TOOLS) + assert len(result) == len(DEFAULT_ENABLED_TOOLS) + + +def test_executor_raises_runtime_error_on_empty_selection(): + """GithubExecutor raises RuntimeError when select_enabled_tools returns an empty list.""" + from a2a_agent import GithubExecutor + import inspect + + # Verify the RuntimeError guard is present in the execute source + src = inspect.getsource(GithubExecutor.execute) + assert "RuntimeError" in src + assert "tools found" in src.lower() or "enabled tools" in src.lower() diff --git a/aiac/demo/agents/github_agent/test_startup.exp b/aiac/demo/agents/github_agent/test_startup.exp new file mode 100644 index 000000000..881ef4a5c --- /dev/null +++ b/aiac/demo/agents/github_agent/test_startup.exp @@ -0,0 +1,23 @@ +# This script tests if the server has dependencies and reaches the point of starting its server. + +# Run with `expect -f test_startup.exp` + +set timeout 120 + +# This is similar to the Dockerfile CMD +set child_pid [spawn env HOST=localhost PORT=8001 uv run --locked server] + +expect { + "Uvicorn running on" { + # Unlike the other servers, this one opens a child process. + # Without the explicit `pkill -P` it would leave a `uv` process running. + puts "Spawned pid was $child_pid" + exec pkill -P $child_pid + puts "Success"; exit 0 + } + timeout { + exec pkill -P $child_pid + puts "Timed out waiting for startup"; exit 1 + } + eof { puts "Process exited early"; exit 2 } +} diff --git a/aiac/demo/agents/github_agent/uv.lock b/aiac/demo/agents/github_agent/uv.lock new file mode 100644 index 000000000..321afbc09 --- /dev/null +++ b/aiac/demo/agents/github_agent/uv.lock @@ -0,0 +1,3123 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.13" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "a2a-sdk" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "culsans" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/ea/3a5b160cfd51c67759b08748051094d9365ceff18127633d0021950c9860/a2a_sdk-1.1.0-py3-none-any.whl", hash = "sha256:d7f5846caf18033d8bf3108b11ec827dd8dd32f867c98848ede0e39474be93be", size = 241886, upload-time = "2026-05-29T09:34:41.484Z" }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, +] + +[[package]] +name = "aiologic" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/d3/2d310b1b839014034dba0cba685e492df8a5c7ad32c19cab7e979eed6554/aiologic-0.17.1-py3-none-any.whl", hash = "sha256:c66b319830fedb7ca3d2b2125fa6f5b653f89418c2a27ea76f259ec5f00943c0", size = 161331, upload-time = "2026-06-27T20:41:31.877Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" }, +] + +[[package]] +name = "build" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/21/6ec54248b4d0d51f12f3ca4aa77a128077d747a5db86cb5a2fcd9aedecbd/build-1.5.1.tar.gz", hash = "sha256:94e17f1db803ab22f46049376c44c8437c52090f0dfdf1adc43df56542d644fb", size = 112439, upload-time = "2026-07-09T06:21:59.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/f7/2c3f99ff9282f1c1ec9f6298f2c03034658a0901eeacb3b3501cb488574c/build-1.5.1-py3-none-any.whl", hash = "sha256:f1a58fe2e5af5b0238a07b9e70207492c79ddebbdb1ad954fc86d62a56be3e0d", size = 31195, upload-time = "2026-07-09T06:21:58.551Z" }, +] + +[[package]] +name = "cel-python" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-re2" }, + { name = "jmespath" }, + { name = "lark" }, + { name = "pendulum" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/4e/f821948a5bbd7a98a218720f831a62216f79a98e43b13d9ab2f98e37c5f8/cel_python-0.5.0.tar.gz", hash = "sha256:3eb0a619e8df0f338d0430cda01427a742e77e3c433a1c7c3ebd409cd804c45a", size = 13364027, upload-time = "2026-01-31T19:07:13.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/f8/38812adc3f787c2c2e8ba56f524185ed379656c10b40347a32796ba61c08/cel_python-0.5.0-py3-none-any.whl", hash = "sha256:d0f85008b89655c2bb18d797d2fa3f96f2ed80f4a3b43b0e8138c6646581e5f6", size = 84950, upload-time = "2026-01-31T19:07:11.821Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "chromadb" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "build" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "mmh3" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "posthog" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pypika" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/48/11851dddeadad6abe36ee071fedc99b5bdd2c324df3afa8cb952ae02798b/chromadb-1.1.1.tar.gz", hash = "sha256:ebfce0122753e306a76f1e291d4ddaebe5f01b5979b97ae0bc80b1d4024ff223", size = 1338109, upload-time = "2025-10-05T02:49:14.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/59/0d881a9b7eb63d8d2446cf67fcbb53fb8ae34991759d2b6024a067e90a9a/chromadb-1.1.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27fe0e25ef0f83fb09c30355ab084fe6f246808a7ea29e8c19e85cf45785b90d", size = 19175479, upload-time = "2025-10-05T02:49:12.525Z" }, + { url = "https://files.pythonhosted.org/packages/94/4f/5a9fa317c84c98e70af48f74b00aa25589626c03a0428b4381b2095f3d73/chromadb-1.1.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:95aed58869683f12e7dcbf68b039fe5f576dbe9d1b86b8f4d014c9d077ccafd2", size = 18267188, upload-time = "2025-10-05T02:49:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/45/1a/02defe2f1c8d1daedb084bbe85f5b6083510a3ba192ed57797a3649a4310/chromadb-1.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06776dad41389a00e7d63d936c3a15c179d502becaf99f75745ee11b062c9b6a", size = 18855754, upload-time = "2025-10-05T02:49:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80be82717e5dc19839af24558494811b6f2af2b261a8f21c51b872193b09/chromadb-1.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba0096a7f5e975875ead23a91c0d41d977fbd3767f60d3305a011b0ace7afd3", size = 19893681, upload-time = "2025-10-05T02:49:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6e/956e62975305a4e31daf6114a73b3b0683a8f36f8d70b20aabd466770edb/chromadb-1.1.1-cp39-abi3-win_amd64.whl", hash = "sha256:a77aa026a73a18181fd89bbbdb86191c9a82fd42aa0b549ff18d8cae56394c8b", size = 19844042, upload-time = "2025-10-05T02:49:16.925Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "crewai" +version = "1.15.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiosqlite" }, + { name = "appdirs" }, + { name = "cel-python" }, + { name = "chromadb" }, + { name = "click" }, + { name = "crewai-cli" }, + { name = "crewai-core" }, + { name = "httpx" }, + { name = "instructor" }, + { name = "json-repair" }, + { name = "json5" }, + { name = "jsonref" }, + { name = "lancedb" }, + { name = "mcp" }, + { name = "openai" }, + { name = "openpyxl" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "pdfplumber" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tokenizers" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/7bc19e31e203d979329da261677bc4f1c2fa4808a5878cb3f62e87f33093/crewai-1.15.8.tar.gz", hash = "sha256:f99532849e77db9af237ffd836fec7e368495d1bb86aec3eca192a9dc404666f", size = 7818988, upload-time = "2026-07-28T15:07:04.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/e3/bbf060f477e21618d0fd6fedb8be29dc630a16aa21a87ccb6a9ed5cabcda/crewai-1.15.8-py3-none-any.whl", hash = "sha256:81d32fcacfeecadf2be4adb33ca844b39bba2797a39f88754c8ca51c2a21e3c6", size = 1090246, upload-time = "2026-07-28T15:07:01.879Z" }, +] + +[package.optional-dependencies] +litellm = [ + { name = "litellm" }, +] + +[[package]] +name = "crewai-cli" +version = "1.15.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "certifi" }, + { name = "click" }, + { name = "crewai-core" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "textual" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/9e/e81aeef487e9eee1d7011d0033b1532e270bd68cb62816f33e6a7e3c793b/crewai_cli-1.15.8.tar.gz", hash = "sha256:716eb189d29bf92c36570c9d9cf6221a80b907f65578714c8b5be3e31f4d537b", size = 220296, upload-time = "2026-07-28T15:07:08.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/df/c5887b7d69ea5cff8721544f331c4c075d4f5cd6ff0790093aac2a399549/crewai_cli-1.15.8-py3-none-any.whl", hash = "sha256:1d8144d035d6457d18a45ee1448c158b18c4ebd8992486fb706331f310ce1ceb", size = 189838, upload-time = "2026-07-28T15:07:06.421Z" }, +] + +[[package]] +name = "crewai-core" +version = "1.15.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "rich" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/20/597bf2a16004dd0dd8e25a99dc0cc38b3caa1e23000bef803e0e8e50a573/crewai_core-1.15.8.tar.gz", hash = "sha256:64e570b5e4d80caba439f2c87f9370cb9e8014a4824c62543bd33c3807f53b1b", size = 23434, upload-time = "2026-07-28T15:07:10.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/7e/82401b9ae7e881c3a983737dba61d65b85f47764116fe655eda2820f2638/crewai_core-1.15.8-py3-none-any.whl", hash = "sha256:ba2de39170470fe2579c11e8909471169b23c16a221d1bae5e1afdc195682baa", size = 30975, upload-time = "2026-07-28T15:07:09.226Z" }, +] + +[[package]] +name = "crewai-tools" +version = "1.15.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "crewai" }, + { name = "pymupdf" }, + { name = "python-docx" }, + { name = "pytube" }, + { name = "requests" }, + { name = "tiktoken" }, + { name = "youtube-transcript-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/9e/41f5c4ba57376dd6482d34836cab0bd788f98985b0631c161ea2fda82648/crewai_tools-1.15.8.tar.gz", hash = "sha256:bb500e45af3a97ed3e92efaa0ebe900cfecbcdf608c453b646265c36db8aea1a", size = 910843, upload-time = "2026-07-28T15:07:15.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5d/761bf4ee2d36ca4c11bc9ecff9615f6bb4c070be68c3b8d3ea54f2edc0c5/crewai_tools-1.15.8-py3-none-any.whl", hash = "sha256:b22a0af459f6a5dbd0cc80a122720bdd970e6290284d039870c7b1950fd08730", size = 820630, upload-time = "2026-07-28T15:07:13.913Z" }, +] + +[package.optional-dependencies] +mcp = [ + { name = "mcp" }, + { name = "mcpadapt", version = "0.1.19", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "mcpadapt", version = "0.1.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +] + +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "github-agent" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "a2a-sdk" }, + { name = "crewai", extra = ["litellm"] }, + { name = "crewai-tools", extra = ["mcp"] }, + { name = "cryptography" }, + { name = "json-repair" }, + { name = "litellm" }, + { name = "lxml" }, + { name = "orjson" }, + { name = "pillow" }, + { name = "pyasn1" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "starlette" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "a2a-sdk", specifier = ">=1.1.0,<2" }, + { name = "crewai", extras = ["litellm"], specifier = ">=1.6.1" }, + { name = "crewai-tools", extras = ["mcp"], specifier = ">=1.6.1" }, + { name = "cryptography", specifier = ">=48.0.0,<49" }, + { name = "json-repair", specifier = ">=0.60.1" }, + { name = "litellm", specifier = ">=1.90.2" }, + { name = "lxml", specifier = ">=6.1.1" }, + { name = "orjson", specifier = ">=3.11.6" }, + { name = "pillow", specifier = ">=12.3.0" }, + { name = "pyasn1", specifier = ">=0.6.3" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "starlette", specifier = ">=1.3.1" }, + { name = "urllib3", specifier = ">=2.7.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + +[[package]] +name = "google-api-core" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, +] + +[[package]] +name = "google-auth" +version = "2.55.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, +] + +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.82.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, + { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, + { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, + { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/8f/999e4dda11c6187c78f090eac00895a47e11a0049308f07579bcb7aa3aa2/huggingface_hub-1.23.0.tar.gz", hash = "sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88", size = 919163, upload-time = "2026-07-09T14:49:32.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/ce/13b2ba57838b8db1e6bd033c1b21ce0b9f6153b87d4e4939f77074e41eb0/huggingface_hub-1.23.0-py3-none-any.whl", hash = "sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2", size = 770336, upload-time = "2026-07-09T14:49:30.597Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "instructor" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "docstring-parser" }, + { name = "jinja2" }, + { name = "jiter" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/24/f6b28e83b3194c6223ed7c6eed5724687f6ecd378ec2ff24044f0cbf1f09/instructor-1.15.4.tar.gz", hash = "sha256:ea2280c3678d0f6891c4d826104f95624b680e69877113a6345b1d7c9027ba0f", size = 70049678, upload-time = "2026-06-28T07:36:43.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/8d/f668a30fff4d25b36533355e23aeb0b5724df4628eb974124ed64b7bcf8d/instructor-1.15.4-py3-none-any.whl", hash = "sha256:00e0ecda80fd9746fb6d082d3f9641e193adb1d8849f0775f91519a82aeff968", size = 252522, upload-time = "2026-06-28T07:36:36.863Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "json-repair" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/d69888cb4ffde30e80db1e6c32caaadd2f984a80067d5ea72c2cb3f61c3f/json_repair-0.60.1.tar.gz", hash = "sha256:841661cdd2df507c9a4e189097f38ca6bc372e06d4b4e36d72e590f68176c290", size = 49451, upload-time = "2026-06-03T17:28:44.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049", size = 48045, upload-time = "2026-06-03T17:28:43.038Z" }, +] + +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + +[[package]] +name = "json5" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bbe62f3d0c05a689c711cff57b2e3ac3d3e526380adb7c781989f075115c/json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559", size = 48202, upload-time = "2024-11-26T19:56:37.823Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049, upload-time = "2024-11-26T19:56:36.649Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/57/8b538af5076bc3372949d76f70ba3449bdfe52f9e6488170fa5d4f7cbe70/kubernetes-36.0.2.tar.gz", hash = "sha256:03551fcb49cae1f708f63624041e37403545b7aaed10cbf54e2b01a37a5438e3", size = 2336738, upload-time = "2026-06-01T18:20:30.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/5c160dbdef7123f8cc97fd8ece7e0198627a426a2a49614845e9086feb8d/kubernetes-36.0.2-py2.py3-none-any.whl", hash = "sha256:faf9b5241b58de0c4a5069f2a0ffc8ac06fece7215156cd3d3ba081a78a858b6", size = 4617568, upload-time = "2026-06-01T18:20:28.737Z" }, +] + +[[package]] +name = "lance-namespace" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace-urllib3-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637, upload-time = "2026-07-01T07:42:41.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501, upload-time = "2026-07-01T07:42:39.372Z" }, +] + +[[package]] +name = "lance-namespace-urllib3-client" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805, upload-time = "2026-07-01T07:42:42.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586, upload-time = "2026-07-01T07:42:40.503Z" }, +] + +[[package]] +name = "lancedb" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "lance-namespace" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/1577778ad57dba0c55dc13d87230583e14541c82562483ecf8bb2f8e8a00/lancedb-0.30.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be2a9a43a65c330ccfd08115afb26106cd8d16788522fe7693d3a1f4e01ad321", size = 41959907, upload-time = "2026-03-16T23:03:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/8c2a04ce499a2a97d1a0de2b7e84fa8166f988a9a495e1ada860110489c2/lancedb-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be6a4ba2a1799a426cbf2ba5ea2559a7389a569e9a31f2409d531ceb59d42f35", size = 43873070, upload-time = "2026-03-16T23:11:01.352Z" }, + { url = "https://files.pythonhosted.org/packages/16/68/e01bf7837454a5ce9e2f6773905e07b09a949bc88136c0773c8166ed7729/lancedb-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a967ec05f9930770aeb077bc5579769b1bedf559fcd03a592d9644084625918", size = 46891197, upload-time = "2026-03-16T23:14:39.18Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/9085ad17abd98f3a180d7860df3190b2d76f99f533c76d7c7494cec4139d/lancedb-0.30.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:05c66f40f7d4f6f24208e786c40f84b87b1b8e55505305849dd3fed3b78431a3", size = 43877660, upload-time = "2026-03-16T23:11:00.837Z" }, + { url = "https://files.pythonhosted.org/packages/ea/69/504ee25c57c3f23c80276b5b7b5e4c0f98a5197a7e9e51d3c50500d2b53a/lancedb-0.30.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bdcd27d98554ed11b6f345b14d1307b0e2332d5654767e9ee2e23d9b2d6513d1", size = 46932144, upload-time = "2026-03-16T23:15:00.474Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/d5550f22023e672af1945394f7a06a578fcab2980ecc6666acef3428a771/lancedb-0.30.0-cp39-abi3-win_amd64.whl", hash = "sha256:4751ff0446b90be4d4dccfe05f6c105f403a05f3b8531ab99eedc1c656aca950", size = 51121310, upload-time = "2026-03-16T23:43:23.89Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "litellm" +version = "1.92.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/ea/5522be7e0dbcaa7c3fddfd2834f387c6e8eab47c0cc65f2a74657c815af7/litellm-1.92.0.tar.gz", hash = "sha256:773adf5503ee1793289689c899394a83df8122993760d9acd782e32aa798db9d", size = 15098143, upload-time = "2026-07-12T01:15:59.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/40/401dfb16c88f22fcb285eafc074cb995047feb24b98bcf47e9552207837c/litellm-1.92.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f0ec8327aacc7914cc16310a1a3cc14340f1140b0a761403c5a4a98b01684373", size = 19292358, upload-time = "2026-07-12T01:15:43.628Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/f4d671762611a88ff7818e891094984202c7502e2984eed0e440a11332bd/litellm-1.92.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b4b65395c5d7b15fa24e08a13871c0617f6d156c46cee0eb920f3a5954e50adf", size = 19290890, upload-time = "2026-07-12T01:15:46.237Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/8061907b67aa04b7cdf2a41cbc4f8aebfbc722c909a7e4b2aea25def7053/litellm-1.92.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c7b2849591a35f7039dc7386a81a8e68fccb5ac2fecaa5122192c1172556b29", size = 19291180, upload-time = "2026-07-12T01:15:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6c/dc9b0b580ee8af9117abd729d1de25d74fae487a0029ca77049a09f3ad33/litellm-1.92.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f17499155366afd1eaaeb6fd0c7b55cbd2239dcd72f2fe1f8071a969cc402fae", size = 19289559, upload-time = "2026-07-12T01:15:51.376Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[package.optional-dependencies] +ws = [ + { name = "websockets" }, +] + +[[package]] +name = "mcpadapt" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "jsonref", marker = "python_full_version < '3.12'" }, + { name = "mcp", extra = ["ws"], marker = "python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version < '3.12'" }, + { name = "python-dotenv", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/28/64fc666fa5d86bb1b048c167975d4ea19210f9f8571b64b26563739774ac/mcpadapt-0.1.19.tar.gz", hash = "sha256:dfab84fc75cc84a49a40bd61079773b1faf840227b74b82c71a7755b9c1957c5", size = 4227721, upload-time = "2025-10-16T07:11:56.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/21/703a79103273b5dd268457ffb94dc8b7d6efcc7fe54413e9723cf2caa8c9/mcpadapt-0.1.19-py3-none-any.whl", hash = "sha256:052e91dea8b6f530770d6fd45a1640a8c34816d18d060918dc752c5221083525", size = 19454, upload-time = "2025-10-16T07:11:55.487Z" }, +] + +[[package]] +name = "mcpadapt" +version = "0.1.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "jsonref", marker = "python_full_version >= '3.12'" }, + { name = "mcp", extra = ["ws"], marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/71/1bbbe157e55d30ab4a74fa878f6942cc0586e9820f03e03451a3d2297e9b/mcpadapt-0.1.20.tar.gz", hash = "sha256:4047c0da61e481dd0673a48936a427da9e6547c6cf0d580ff4e4761dcf058ed1", size = 4203656, upload-time = "2025-10-24T15:35:02.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/d8/5b6c8cf2070d765904fcb9066f8d7956cb9d399807d86c7fb7f7503b80bf/mcpadapt-0.1.20-py3-none-any.whl", hash = "sha256:117a661eb536dfb0b2a73e5730c2f5ad4e611263e014fb1cebaaff9e78a18f78", size = 19481, upload-time = "2025-10-24T15:35:00.159Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1f/a2117aa3f144fce88774efa37440d0ca72d0c9144854dfc0961f2b04c6fc/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb083321af8a236a84c7c140a7f4cecbfa2a987a18c07c78db471c20cd390ef", size = 16419330, upload-time = "2026-06-15T22:42:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/74bb804170ceb622fda9111df31a07b3024f7491472256d3a90b5391a4d2/onnxruntime-1.27.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4f7b0e90d2d212e2c2deaa6c8291616183ab815d3ec558ea12d3ac8b26d36f4", size = 18636930, upload-time = "2026-06-15T22:43:01.584Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8f/5b8e2b85e81735696887175dbaf6409f215683f5ca9d4928fbb038211d32/onnxruntime-1.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff050e4f6bf7f12918fa14dcb047c0b02e295f35e86d42532552be4b3d54e977", size = 13356110, upload-time = "2026-06-15T22:43:32.172Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/4f568de678126b6a371a93862f015a82138359decd97fcac61fc84b5b774/onnxruntime-1.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:75fbc1e1fb43a39a856c8209c544cca7817b5de7ac16b15b1bdf55d1cc67b9df", size = 13098635, upload-time = "2026-06-15T22:43:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/dd3a524ed93a820dff1af902d0412957ab12499953333e9daa01af5bc480/onnxruntime-1.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a14c2ce45312def86b77aea651f46565e45960cf5f0721bfdff449165086ab76", size = 18433506, upload-time = "2026-06-15T22:43:47.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/c3b6b17745a1997d784dadc9bd88d713d2e6721139a5a0e885b28cfb79b1/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fddce0539a4898c7bef35b052ffd37935b2190e35488eab99ce91887743ea1", size = 16438140, upload-time = "2026-06-15T22:42:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/81/24dd9b31b0fb912ee19ca53ac1c9764bfd79d58a2ccef564eb693be831a5/onnxruntime-1.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c65a7438632d55dfbc8a02ee60bd6cf7dd9d1ba05a43d4b851452f32338e194", size = 18658316, upload-time = "2026-06-15T22:43:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/8ec9db1a4d126bb8b758992beb40d1249df171917d75f44a327eb5f20dda/onnxruntime-1.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:20c321cf187ba496e648acf6b4cf90b4d398b0d17c2a77fdaeba365b908cc1c1", size = 13358769, upload-time = "2026-06-15T22:43:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/fdad359dfcba7e7cd8815569b304a596531d4efa77a75d77f8b4981891a2/onnxruntime-1.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:d0d1f68868e2ef30ef70998ba9bbbc5c305e9b17041e3936751c1b8aa6aade06", size = 13104440, upload-time = "2026-06-15T22:43:22.893Z" }, +] + +[[package]] +name = "openai" +version = "2.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/56/6f450312ba05a27d7713b73857c1a25100dbda04fbc1331b13fb227a607d/pdfplumber-0.11.10.tar.gz", hash = "sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57", size = 102892, upload-time = "2026-06-15T03:31:31.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9a/07d658e1e7fad860f1c541ab941348125dbdab773be3a0afaf32361866c7/pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580", size = 60047, upload-time = "2026-06-15T03:31:29.702Z" }, +] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/27/a4be6ec12161b503dd036f8d7cc57f8626170ae31bb298038be9af0001ce/pendulum-3.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5d775cc608c909ad415c8e789c84a9f120bb6a794c4215b2d8d910893cf0ec6a", size = 337923, upload-time = "2026-01-30T11:20:51.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/2a214e18355ec2a6ce3f683a97eecdb6050866ff3a6cf165d411450aeb1b/pendulum-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8de794a7f665aebc8c1ba4dd4b05ab8fe1a36ce9c0498366adf1d1edd79b2686", size = 327379, upload-time = "2026-01-30T11:20:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/01/7392e58ebc1d9e70b987dc8bb0c89710b47ac8125067efe7aa4c420b616f/pendulum-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bac7df7696e1c942e17c0556b3a7bcdd1d7aa5b24faee7620cb071e754a0622", size = 340115, upload-time = "2026-01-30T11:20:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/80de84c5ca1a3e4f7f3b75090c9b61b6dbb6d095e302ee592cebbaf0bbfb/pendulum-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0f6a8a04475d9cba26ce701e7d66d266fd97227f2f5f499270eba04be1c7e9", size = 373969, upload-time = "2026-01-30T11:20:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/f7b4c1818927ab394a2a0a9b7011f360a0a75839a22678833c5bc0a84183/pendulum-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c352c63c1ff05f2198409b28498d7158547a8be23e1fbd4aa2cf5402fb239b55", size = 379058, upload-time = "2026-01-30T11:20:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/36/94/9947cf710620afcc68751683f2f8de88d902505e7c13c0349d7e9d362f97/pendulum-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de8c1ad1d1aa7d4ceae341528bab35a0f8c88a5aa63f2f5d84e16b517d1b32c2", size = 348403, upload-time = "2026-01-30T11:20:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/0e6ba0bb00fa57907af2a3fca8643bded5dba1e87072d50673776a0d6ed2/pendulum-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1ba955511c12fec2252038b0c866c25c0c30b720bf74d3023710f121e42b1498", size = 517457, upload-time = "2026-01-30T11:21:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fe/dae5fbfe67bd41d943def0ad8f1e7f6988aa8e527255e433cd7c494f9ad5/pendulum-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4115bf364a2ec6d5ddc476751ceaa4164a04f2c15589f0d29aa210ddb784b15d", size = 561103, upload-time = "2026-01-30T11:21:03.924Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a0/8f646160b98abfc19152505af19bd643a4279ec2bdbe0959f16b7025fc6b/pendulum-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:4151a903356413fdd9549de0997b708fb95a214ed97803ffb479ffd834088378", size = 260595, upload-time = "2026-01-30T11:21:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/79/01/feead7af9ded7a13f2d798fb6573e70f469113eafcd8cc8f59671584ca3e/pendulum-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:acfdee9ddc56053cb7c8c075afbfde0857322d09e56a56195b9cd127fae87e4c", size = 255382, upload-time = "2026-01-30T11:21:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0d/d5ac8468a1b40f09a62d6e91654088de432367907579dd161c0fb1bdf222/pendulum-3.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9585594d32faa71efa5a78f576f1ee4f79e9c5340d7c6f0cd6c5dfe725effaaa", size = 338760, upload-time = "2026-01-30T11:22:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/7fa8c8be6caac8e0be78fbe7668df571f44820ed779cb3736fab645fcba8/pendulum-3.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26401e2de77c437e8f3b6160c08c6c5d45518d906f8f9b48fd7cb5aa0f4e2aff", size = 328333, upload-time = "2026-01-30T11:22:13.811Z" }, + { url = "https://files.pythonhosted.org/packages/ad/78/73a1031b7d1bf7986e8e655cea3f018164b3470aecfea25a4074e77dda73/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637e65af042f383a2764a886aa28ccc6f853bf7a142df18e41c720542934c13b", size = 340841, upload-time = "2026-01-30T11:22:15.278Z" }, + { url = "https://files.pythonhosted.org/packages/49/40/4e36e9074e92b0164c088b9ada3c02bfea386d83e24fa98b30fe9b6e61a8/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e46c28f4d067233c4a4c42748f4ffa641d9289c09e0e81488beb6d4b3fab51", size = 348959, upload-time = "2026-01-30T11:22:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/8bf7fcb91b526e1efe17d047faa845709b88800fff915ff848ff26054293/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:71d46bcc86269f97bfd8c5f1475d55e717696a0a010b1871023605ca94624031", size = 518102, upload-time = "2026-01-30T11:22:18.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b0/a36c468d2d0dec62ddea7c5e4177e93abb12f48ac90f09f24d0581c5189f/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5cd956d4176afc7bfe8a91bf3f771b46ff8d326f6c5bf778eb5010eb742ebba6", size = 561884, upload-time = "2026-01-30T11:22:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4d/dad105261898907bf806cabca53d3878529a9fa2c0d5d7f95f2035246fc2/pendulum-3.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:39ef129d7b90aab49708645867abdd207b714ba7bff12dae549975b0aca09716", size = 261236, upload-time = "2026-01-30T11:22:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/f8/969e6f280201b40b31bcb62843c619f343dcc351dff83a5891530c9dd60e/portalocker-2.7.0.tar.gz", hash = "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51", size = 20183, upload-time = "2023-01-18T23:36:14.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/df/d4f711d168524f5aebd7fb30969eaa31e3048cf8979688cde3b08f6e5eb8/portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983", size = 15502, upload-time = "2023-01-18T23:36:12.849Z" }, +] + +[[package]] +name = "posthog" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/44/767757fd2cdd4a60d7e4440d9f7b491d6131103d313638d2c03e06c268fb/proto_plus-1.28.1.tar.gz", hash = "sha256:832e68e7fe064cf90ab153b6e5eb935b27891bb89aaeb68b115e9b702f6cb168", size = 57166, upload-time = "2026-07-08T17:04:02.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/34/2f2b57dbfd145b995a29847a16b0903fce5ef6ad3c7aad740a609c5d3678/proto_plus-1.28.1-py3-none-any.whl", hash = "sha256:6660f5f1970874bdcfc3088b435188a36a37bd3596668f7d726417c4ae8cfbed", size = 50408, upload-time = "2026-07-08T17:03:34.532Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/98/ae2b5acf9876dbeffa6f320776242c52caab062df55c8ac5501ed2679e74/pyarrow-25.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2e3b6544e26e393fe2cd530f523e36c1c8d3c345bbbb60cca3fd866be8322517", size = 35939080, upload-time = "2026-07-10T08:26:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/3de2a968edbd496c86cb8b932cdbee2d4b08c4a28e9884a15e5c705a646b/pyarrow-25.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:b724d127783b4c19f088fcdfc844cbc318809246a30307bcabd5ed02045e890e", size = 37633420, upload-time = "2026-07-10T08:26:10.354Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/8399243a4ce080426ec37db18d5e29148b7ec960a8a8c7f9059a7bf6ef0a/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:244f98a595f70fa4fd35faa7508c4ae67e14a173397a4b3b49d2b3c360fb0062", size = 46861050, upload-time = "2026-07-10T08:26:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/72d704b02bc5fc6d06954d76a0208c1e79cad3ab370f6d6a91ffe5078870/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0222f0071d13313962a88d21bf28b80d355ac39d81bfa6ff3fe00eeaf748e4be", size = 50056458, upload-time = "2026-07-10T08:26:23.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3c31a60b6403d63cad2e0f829096f5fc5763a129ead4207a5d4690b96448/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b58726f118c079f9d4ed7e904975d4f15fd69d0741ba511a4e2dcaa4ef16354f", size = 49957793, upload-time = "2026-07-10T08:26:30.232Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/8f8a019061f9863a831915329264372a87ed25eaf9109ce56eb0e84012c5/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a2c887cb3883e241b70201688db34133b6dfadd04f03c8f9213df53770c18e", size = 53100544, upload-time = "2026-07-10T08:26:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/738071e95c5ddad7b3dfc12f569ffa992db89d7d7b4a95258fd184191249/pyarrow-25.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:161649d60a7a46c613a19fd795763ea8a88c36ba997dd99d9bc66e6794ee36e8", size = 27848311, upload-time = "2026-07-10T08:26:41.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pybase64" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" }, + { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" }, + { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" }, + { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" }, + { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" }, + { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" }, + { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" }, + { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" }, + { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pymupdf" +version = "1.26.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/d6/09b28f027b510838559f7748807192149c419b30cb90e6d5f0cf916dc9dc/pymupdf-1.26.7.tar.gz", hash = "sha256:71add8bdc8eb1aaa207c69a13400693f06ad9b927bea976f5d5ab9df0bb489c3", size = 84327033, upload-time = "2025-12-11T21:48:50.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/35/cd74cea1787b2247702ef8522186bdef32e9cb30a099e6bb864627ef6045/pymupdf-1.26.7-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:07085718dfdae5ab83b05eb5eb397f863bcc538fe05135318a01ea353e7a1353", size = 23179369, upload-time = "2025-12-11T21:47:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/448b6172927c829c6a3fba80078d7b0a016ebbe2c9ee528821f5ea21677a/pymupdf-1.26.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:31aa9c8377ea1eea02934b92f4dcf79fb2abba0bf41f8a46d64c3e31546a3c02", size = 22470101, upload-time = "2025-12-11T21:47:37.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/47af26f3ac76be7ac3dd4d6cc7ee105948a8355d774e5ca39857bf91c11c/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e419b609996434a14a80fa060adec72c434a1cca6a511ec54db9841bc5d51b3c", size = 23502486, upload-time = "2025-12-12T09:51:25.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/3de1714d734ff949be1e90a22375d0598d3540b22ae73eb85c2d7d1f36a9/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69dfc78f206a96e5b3ac22741263ebab945fdf51f0dbe7c5757c3511b23d9d72", size = 24115727, upload-time = "2025-12-11T21:47:51.274Z" }, + { url = "https://files.pythonhosted.org/packages/62/9b/f86224847949577a523be2207315ae0fd3155b5d909cd66c274d095349a3/pymupdf-1.26.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1d5106f46e1ca0d64d46bd51892372a4f82076bdc14a9678d33d630702abca36", size = 24324386, upload-time = "2025-12-12T14:58:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/a117d39092ca645fde8b903f4a941d9aa75b370a67b4f1f435f56393dc5a/pymupdf-1.26.7-cp310-abi3-win32.whl", hash = "sha256:7c9645b6f5452629c747690190350213d3e5bbdb6b2eca227d82702b327f6eee", size = 17203888, upload-time = "2025-12-12T13:59:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c3/d0047678146c294469c33bae167c8ace337deafb736b0bf97b9bc481aa65/pymupdf-1.26.7-cp310-abi3-win_amd64.whl", hash = "sha256:425b1befe40d41b72eb0fe211711c7ae334db5eb60307e9dd09066ed060cceba", size = 18405952, upload-time = "2025-12-11T21:48:02.947Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/b9/a887db9950379fe619f6921ade84730346e6adf008b197951ddcc42dfd1a/pypdfium2-5.11.0.tar.gz", hash = "sha256:0733749ac253c615953a5e75d4322b9f1de8c0d90137ec8dbcef403f3e57b5d9", size = 276614, upload-time = "2026-06-29T11:11:40.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/09/0aec4588adaa7eb4e42da127a57a5dd3a3997ff32be62af93f8afeb4668b/pypdfium2-5.11.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:115bc68ab2e85c9ee46f97a4c1794f1d7205c8b763fc5c16d9893eaa54608627", size = 3388299, upload-time = "2026-06-29T11:11:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/7a/53/9e349a311cb0e7cef5dd0824a6f96c9b161da5e5e67a3db030d38e624fe9/pypdfium2-5.11.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:4396d0848b79134fcdf141a4e3dfe6719efe1162309a02876b440025cfd80720", size = 2843597, upload-time = "2026-06-29T11:11:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/d2/13/13571dc7f1d11a4e4bde6ab8961318b04ff70bdc2aea5e7f88be5ef53167/pypdfium2-5.11.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:73fe55dd258f02332bc0a34128ddc2994fd610e664d1f2f7d78dd9e2570f15ee", size = 3586638, upload-time = "2026-06-29T11:11:04.264Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/9865e2822e97f6e4bed3b0e4838bd88444c62df6ebe0ccae352027133120/pypdfium2-5.11.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:badb4f4df98e25fb1b58301911c98fa18da60de98b0223a6ffa91717be068b96", size = 3649576, upload-time = "2026-06-29T11:11:06.011Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bb/dcd0ba152626c30a67bff43974b728c6bdd9a0d34af54cfa875489e46ce2/pypdfium2-5.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fbff7c3e3141f24f3a25cedddbb6c6d17baa95f83c7b7ea40d6866956df3a43", size = 3648292, upload-time = "2026-06-29T11:11:07.767Z" }, + { url = "https://files.pythonhosted.org/packages/65/62/39b40afda909bd65b212e9dbf32e7c6a2da4205ff64576965a49ba689c47/pypdfium2-5.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:faeda49c171be254b5028b7d17e54306c585374210d3e7ea4962a36774d5b76a", size = 3380060, upload-time = "2026-06-29T11:11:09.571Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cf/999b793ca17d153765dc3656e7f4cc90c4bf411b9262f15b8e7e31af164f/pypdfium2-5.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b63f6f1e67d05d2bc36f55c35ede5cd18ded8886a2fcf68f320d188ee7934348", size = 3778180, upload-time = "2026-06-29T11:11:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/73/6c/54fd2b487eed6a420ee7fff5c7754739379c4459fa185deab3cb2b72e4e2/pypdfium2-5.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f96d8459b4ba9d1330ab44a8dde836b361cb00027830eaab710080888cf44a", size = 4190704, upload-time = "2026-06-29T11:11:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/690b9a1b8de405ff7693c1d10472f0c5395294c6d53654ae1ef97ccf70fa/pypdfium2-5.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba8a78329c10f01c80b1f6e9aa33cac13665c198d056a007827b703958bd986d", size = 3705232, upload-time = "2026-06-29T11:11:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/54/fc/18298367dc073041320bbb5d51fb3b42b0d9164fd13bd4d13dcb0d66bc64/pypdfium2-5.11.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7a60197c5d18b0ec77a695e4a1082f89f323c13063cecf4446f1297ed632a36", size = 4031064, upload-time = "2026-06-29T11:11:17.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/53d5b6ab0869963474b7ea13097c5abd8f2cf13548f0076e9bafc7e9d6b0/pypdfium2-5.11.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d167456433d565398a5c9f1efad23dfa6dfadc1a201ec687594fec645d843e", size = 3995092, upload-time = "2026-06-29T11:11:18.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/7a/942a390e41fb59a797d9303eae6e6a8c3d1b84deade03206ac163076033f/pypdfium2-5.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:953f5bcb6a65067381f0a38c50ffaed864fac6f20b0fa2c14ffb15a52bda8943", size = 4995668, upload-time = "2026-06-29T11:11:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e9/fd1f8d3157c92665166f7d9bfcdd26901290b4271fcd2f90c7419ca0ee4c/pypdfium2-5.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:479b081423b2c16de44ddb6e25826d665298e1ee7cf09d802b656716dd930201", size = 4539453, upload-time = "2026-06-29T11:11:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/e9/56/4280069868245d2346e6ec7b42e155c16a19be731f2f57fb726c35661aac/pypdfium2-5.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:81356f0503356d6f21629fcd47bbfffcfe26487f47a7989a56c02bd4e39b4c55", size = 5237429, upload-time = "2026-06-29T11:11:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/99d962bea28c3305835d07a1732a771104ce958f46e68a1892e740a7c904/pypdfium2-5.11.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:25be9d70f5776c68fa28b92ec42a7be91de5d6c4085ed5c6c89e1bfa373ae918", size = 5143685, upload-time = "2026-06-29T11:11:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4a/c935d1fdd73092fd036627282948c794c8f14a7ddde39cf732898b258866/pypdfium2-5.11.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:8edec20239f77d4dea2eeee5d29ebd83e77e1e2900691b77387fead76f81bc4a", size = 4647708, upload-time = "2026-06-29T11:11:28.006Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/f4f1895efa581f8b85748bf8d620ad37550583ca5226feeb9a33dcb3031e/pypdfium2-5.11.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:2560db37ce77a6caf651dc129bc44ee610a5fbb0050ec70f5a643f76bf241173", size = 5089405, upload-time = "2026-06-29T11:11:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/ca5b1071aee0a96937a30007504a0ca484340709e22a459e99650165fd57/pypdfium2-5.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34086bc1fcb964bb8104889d941ba9c8a35a75866250bb81d2f2bb2791bb42af", size = 5051368, upload-time = "2026-06-29T11:11:31.868Z" }, + { url = "https://files.pythonhosted.org/packages/28/01/e445a77e784a5b0047a2c30b263630dfaa57318ff3196e6ad3093dca6929/pypdfium2-5.11.0-py3-none-win32.whl", hash = "sha256:324054f36acf6bea42a9d2534eb407da2e312dba746d955c9fd7e324bc160898", size = 3645281, upload-time = "2026-06-29T11:11:33.691Z" }, + { url = "https://files.pythonhosted.org/packages/73/d4/8b8af6eedbc5c8af49817d8f37c2e55be8a2c7f75fca9bee78efbfeb40f6/pypdfium2-5.11.0-py3-none-win_amd64.whl", hash = "sha256:d3b698e7b51bdf2d633cc834395677319e1d66757896b30c632dfac7d7236c81", size = 3778234, upload-time = "2026-06-29T11:11:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/657503553694c70fba0aa5d213144d80401611c82e0205f43f1ff39f40af/pypdfium2-5.11.0-py3-none-win_arm64.whl", hash = "sha256:897788d71b740752e29875856c369fdb52283f609aecf2355f0473db05dfc1b6", size = 3567726, upload-time = "2026-06-29T11:11:38.689Z" }, +] + +[[package]] +name = "pypika" +version = "0.51.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pytube" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e7/16fec46c8d255c4bbc4b185d89c91dc92cdb802836570d8004d0db169c91/pytube-15.0.0.tar.gz", hash = "sha256:076052efe76f390dfa24b1194ff821d4e86c17d41cb5562f3a276a8bcbfc9d1d", size = 67229, upload-time = "2023-05-07T19:39:01.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/64/bcf8632ed2b7a36bbf84a0544885ffa1d0b4bcf25cc0903dba66ec5fdad9/pytube-15.0.0-py3-none-any.whl", hash = "sha256:07b9904749e213485780d7eb606e5e5b8e4341aa4dccf699160876da00e12d78", size = 57594, upload-time = "2023-05-07T19:38:59.191Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, + { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tomli" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096, upload-time = "2024-10-02T10:46:13.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237, upload-time = "2024-10-02T10:46:11.806Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929, upload-time = "2024-10-08T11:13:29.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440, upload-time = "2024-10-08T11:13:27.897Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.11.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/a2/bfd6755b40682ef7e775ddb9d52823dea6551352f4244106da4bad37cd3c/uv-0.11.28.tar.gz", hash = "sha256:df86cfd135542a833e9f84708b3b8dbaa987a3b9db85b267062db49ab639d242", size = 5985690, upload-time = "2026-07-07T23:12:47.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/507b829e79353fe3dcb2779cde8f64497d9c99f9b08b18b8f55ee3bf1786/uv-0.11.28-py3-none-linux_armv6l.whl", hash = "sha256:ae5bbdb6150adcd625f5fa720b04abf2014247d878d1035f19751bb0e7274543", size = 25893158, upload-time = "2026-07-07T23:11:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/10/54/50c85a663ce723e061523ab4ac8b01b650077584e80950f9c93fd073979f/uv-0.11.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bb11d94cb848ff58af79e0bb5e4037cd324d27dbe2dabb7746db698b724a9a20", size = 25041511, upload-time = "2026-07-07T23:11:58.885Z" }, + { url = "https://files.pythonhosted.org/packages/32/e1/49968cab72f16a7d6c45d095d319f9efbe8ce05f3f15c5f7104493694289/uv-0.11.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e9eb317b1cdb249887df77ac232d8a9448f26858b2399f9f2949c6a7b9bedf88", size = 23570471, upload-time = "2026-07-07T23:12:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4d/c9fe448dcd5cf65a5f054517aa42551b7f0920710a6891d98af321a06b22/uv-0.11.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:041e4b80bebc58d7142ac9394370cacd73185fd8d066d6675d14707d83408f6d", size = 25594677, upload-time = "2026-07-07T23:12:04.597Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/4c0c71075ba66cc594f856cbd98844058fcb53cb4dd8a6fccda8419562bb/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:185416a5316df8c5442b47178349f1f27fc1034468670ac1fb499eae3b25bd68", size = 25427944, upload-time = "2026-07-07T23:12:07.226Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/50fef66f4e26bf771429e1d88f849476d8ed21f0fb0708acaa53dc5772a5/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4a9fe246cb2882532277f5d5e5bd8a59462981462a2f98426f35ecfca82460e", size = 25448458, upload-time = "2026-07-07T23:12:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/d0/93/720f45af65ebda460166dc64f3318acd65f7bd3a8e326fbd21810fd920ee/uv-0.11.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f7ce6f6015a3e857bc6a663514afa62856b669ee5c1bd120e4c58ac2ef5513d", size = 26917218, upload-time = "2026-07-07T23:12:13.802Z" }, + { url = "https://files.pythonhosted.org/packages/cd/27/a9b68a15a5fe8db7103bea514c2adb79e9b1114fc8dc96fb39dbd7a5b898/uv-0.11.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b3d0ea11e83b373a2166b82dd0864f5677fbadf98db64541ab2e59c42968905", size = 27771542, upload-time = "2026-07-07T23:12:16.519Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fd/208607a7f5f86188775387fe0839ef97cf8d013e8d0e909140b7fdb7d0d1/uv-0.11.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c60294e3be4fa203a04015fc02ac8a31d936e86fde06dcb43c7f8f22661dfff", size = 26972190, upload-time = "2026-07-07T23:12:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/62273ee6c9fbebccd8248c153b44870f81ebf5267c31edf4c095d78537fb/uv-0.11.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fe42df9f42056037473f3876adec1615709b57d3470ed39178ff420f3afb9f", size = 27127688, upload-time = "2026-07-07T23:12:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/b15212904e6f0aa4a3709dc86838c6fa070fe97c7e96b3f10174a26b16e3/uv-0.11.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fab3c31007a611866475824a666f5a721bf0c9335db806355a97fcfba2a6bbb7", size = 25715221, upload-time = "2026-07-07T23:12:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/64/35/b83b7c599474aaf1277c2224c09679640c2320562155c4b6ece1c6f014c1/uv-0.11.28-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:2e91eb8a0b00d5f4427195fc818bcaa4d8bb4fccb79f4e973e74802419ab06ca", size = 26392793, upload-time = "2026-07-07T23:12:27.848Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/8093318206dee51b5cfcabbf110892ff63cfd897a5df002e2d8b61350fe6/uv-0.11.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47e3f12fe6f5c80a01639d8df36efde7bdddfc3bbc52250df623547d8d393105", size = 26522809, upload-time = "2026-07-07T23:12:30.757Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/b26d82e9297c29c201f61698ee56bba956f94953b23089532d026a97d93f/uv-0.11.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:d01c7c665511c047f350e587b8b6557c96b61b2eddafbcd8964f0cc2f5b9afbe", size = 26156793, upload-time = "2026-07-07T23:12:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/163e89424668d6c01499efbe85a854ad38f07834bde3f2b16df159eab1d5/uv-0.11.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3fcfda468448093f4d5961ca8c068b0aeec2d02f7226d58ee8513321a929fe4f", size = 27327614, upload-time = "2026-07-07T23:12:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/db4cb824777d013272ccfa77db07a4d12bf1584899458c1917a4b5a4069d/uv-0.11.28-py3-none-win32.whl", hash = "sha256:692edef9cf1d2dd69bb9d9fc01f281a82610547900ce227a3cb269cdf988b5ce", size = 24665179, upload-time = "2026-07-07T23:12:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/d67b18cddd54c503c7bad2b189a47fd7a1d07ea10b9212624f892b985498/uv-0.11.28-py3-none-win_amd64.whl", hash = "sha256:f4fcf2c8d9f1444b900e6b8dbbb828825fb76eca01acd18aeaa5c90240408cda", size = 27603677, upload-time = "2026-07-07T23:12:41.985Z" }, + { url = "https://files.pythonhosted.org/packages/57/94/dc31a771eac989973219c730552dbcf5bf7ea6652dba4ba89b1bbdc75a80/uv-0.11.28-py3-none-win_arm64.whl", hash = "sha256:e94560995737c50525d586da553521fbafe9ef06641e7d885db4b270f53ee84d", size = 25839294, upload-time = "2026-07-07T23:12:44.893Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "youtube-transcript-api" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/43/4104185a2eaa839daa693b30e15c37e7e58795e8e09ec414f22b3db54bec/youtube_transcript_api-1.2.4.tar.gz", hash = "sha256:b72d0e96a335df599d67cee51d49e143cff4f45b84bcafc202ff51291603ddcd", size = 469839, upload-time = "2026-01-29T09:09:17.088Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/95/129ea37efd6cd6ed00f62baae6543345c677810b8a3bf0026756e1d3cf3c/youtube_transcript_api-1.2.4-py3-none-any.whl", hash = "sha256:03878759356da5caf5edac77431780b91448fb3d8c21d4496015bdc8a7bc43ff", size = 485227, upload-time = "2026-01-29T09:09:15.427Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/aiac/demo/tools/github_tool/Dockerfile b/aiac/demo/tools/github_tool/Dockerfile new file mode 100644 index 000000000..bb276b978 --- /dev/null +++ b/aiac/demo/tools/github_tool/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py . + +ENV PORT=9090 +ENV LOG_LEVEL=INFO + +EXPOSE 9090 + +CMD ["python", "server.py"] diff --git a/aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml b/aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml new file mode 100644 index 000000000..69093a5fa --- /dev/null +++ b/aiac/demo/tools/github_tool/k8s/github-tool-deployment.yaml @@ -0,0 +1,85 @@ +--- +# github-tool — simplified 4-scope MCP stub for UC-1 onboarding. +# +# DISTINCT from the production 44-tool github-tool-mcp used by the github-agent +# for live GitHub operations. These two coexist under different Service names: +# github-tool (this file, port 9090) — UC-1 discovery, client.name = "team1/github-tool" +# github-tool-mcp (authbridge/demos/github-issue/k8s/) — agent execution +# +# Naming invariants (do not rename): +# workload name == Service name == "github-tool" +# analyze_tool endpoint: http://github-tool.team1.svc.cluster.local:9090/mcp +# +# kagenti.io/type=tool is applied by the kagenti-operator when it reconciles +# the AgentRuntime below; do not hand-set it here. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: github-tool + namespace: team1 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: github-tool + namespace: team1 +spec: + replicas: 1 + selector: + matchLabels: + app: github-tool + template: + metadata: + labels: + app: github-tool + spec: + serviceAccountName: github-tool + containers: + - name: github-tool + image: localhost/github-tool:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 9090 + env: + - name: PORT + value: "9090" + - name: LOG_LEVEL + value: "INFO" +--- +# Service — exposes /mcp as the first (and only) port. +# The protocol.kagenti.io/mcp label is a deploy-time prerequisite: +# analyze_tool returns 502 if this label is absent. +# The operator does NOT stamp it; it must be set explicitly here. +apiVersion: v1 +kind: Service +metadata: + name: github-tool + namespace: team1 + labels: + protocol.kagenti.io/mcp: "true" +spec: + selector: + app: github-tool + ports: + - name: mcp + port: 9090 + targetPort: 9090 + type: ClusterIP +--- +# AgentRuntime — enrolls the workload so the kagenti-operator: +# - applies kagenti.io/type=tool to the pod (read by classify_service), and +# - registers a Keycloak client with client.name = "team1/github-tool" +# (so analyze_tool derives scopes github-tool.{source-read,...}). +apiVersion: agent.kagenti.dev/v1alpha1 +kind: AgentRuntime +metadata: + name: github-tool + namespace: team1 +spec: + type: tool + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: github-tool diff --git a/aiac/demo/tools/github_tool/pytest.ini b/aiac/demo/tools/github_tool/pytest.ini new file mode 100644 index 000000000..eea2c1802 --- /dev/null +++ b/aiac/demo/tools/github_tool/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/aiac/demo/tools/github_tool/requirements.txt b/aiac/demo/tools/github_tool/requirements.txt new file mode 100644 index 000000000..e814f2ecf --- /dev/null +++ b/aiac/demo/tools/github_tool/requirements.txt @@ -0,0 +1,5 @@ +mcp[cli]>=1.9.0,<2 +uvicorn[standard]>=0.34.0 +httpx>=0.28.0 +pytest>=8.0.0 +pytest-asyncio>=0.25.0 diff --git a/aiac/demo/tools/github_tool/server.py b/aiac/demo/tools/github_tool/server.py new file mode 100644 index 000000000..c6f2da79e --- /dev/null +++ b/aiac/demo/tools/github_tool/server.py @@ -0,0 +1,36 @@ +import os + +from mcp.server.fastmcp import FastMCP + +# Single editable registry — one entry per canonical UC-1 scope. +# Names match scenario.py::TOOL_SCOPES keys; descriptions are verbatim copies. +TOOLS = [ + ("source-read", "Read source repository contents: file listings and file bodies. Read-only."), + ("source-write", "Create, modify, or delete source repository contents; commit file changes."), + ("issues-read", "Read issues and their comment threads. Read-only."), + ("issues-write", "Create and update issues: open, edit, comment, and close."), +] + +mcp = FastMCP("github-tool", host="0.0.0.0", json_response=True, stateless_http=True) + +for _tool_name, _tool_desc in TOOLS: + def _make_stub(name: str): + def stub() -> str: + return f"stub: {name} not implemented in phase-1 demo" + + stub.__name__ = name.replace("-", "_") + return stub + + mcp.add_tool(fn=_make_stub(_tool_name), name=_tool_name, description=_tool_desc) + +app = mcp.streamable_http_app() + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.getenv("PORT", "9090")), + log_level=os.getenv("LOG_LEVEL", "info").lower(), + ) diff --git a/aiac/demo/tools/github_tool/test/__init__.py b/aiac/demo/tools/github_tool/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/demo/tools/github_tool/test/conftest.py b/aiac/demo/tools/github_tool/test/conftest.py new file mode 100644 index 000000000..3f93ad297 --- /dev/null +++ b/aiac/demo/tools/github_tool/test/conftest.py @@ -0,0 +1,8 @@ +import sys +from pathlib import Path + +# server.py importable as `server` +sys.path.insert(0, str(Path(__file__).parents[1])) + +# scenario.py importable as `scenario` +sys.path.insert(0, str(Path(__file__).parents[4] / "test" / "integration")) diff --git a/aiac/demo/tools/github_tool/test/test_server.py b/aiac/demo/tools/github_tool/test/test_server.py new file mode 100644 index 000000000..8f36aa9e9 --- /dev/null +++ b/aiac/demo/tools/github_tool/test/test_server.py @@ -0,0 +1,59 @@ +import pytest +from starlette.testclient import TestClient + +from scenario import TOOL_SCOPES + + +@pytest.fixture(scope="module") +def client(): + from server import app + + with TestClient(app, raise_server_exceptions=True) as c: + yield c + + +def _tools_list(client: TestClient) -> list[dict]: + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + return response.json()["result"]["tools"] + + +class TestToolsList: + def test_returns_exactly_four_tool_names(self, client): + tools = _tools_list(client) + assert {t["name"] for t in tools} == set(TOOL_SCOPES.keys()) + + def test_descriptions_match_scenario(self, client): + tools = _tools_list(client) + by_name = {t["name"]: t["description"] for t in tools} + for name, expected in TOOL_SCOPES.items(): + assert by_name[name] == expected, f"description mismatch for {name!r}" + + def test_input_schema_is_minimal(self, client): + tools = _tools_list(client) + for tool in tools: + schema = tool["inputSchema"] + assert schema["type"] == "object" + assert schema["properties"] == {} + + +class TestToolsCall: + def test_valid_tool_returns_stub_content(self, client): + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "source-read", "arguments": {}}, + }, + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + body = response.json() + assert "result" in body + assert body["result"]["content"] diff --git a/aiac/docs/analysis/discover_mcp_services.py b/aiac/docs/analysis/discover_mcp_services.py new file mode 100644 index 000000000..172953659 --- /dev/null +++ b/aiac/docs/analysis/discover_mcp_services.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# Copyright 2025 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Discover all Kubernetes services explicitly exposed as MCP servers.""" + +from kubernetes import client, config + +# Load config: in-cluster if available, else fall back to kubeconfig +try: + config.load_incluster_config() +except config.ConfigException: + config.load_kube_config() + +v1 = client.CoreV1Api() + +# Find all Kubernetes services explicitly exposed as MCP servers. +# The label value is "" (empty string) — the selector matches on key presence. +mcp_services = v1.list_namespaced_service( + namespace="team1", + label_selector="protocol.kagenti.io/mcp", +) + +for svc in mcp_services.items: + # Build the internal ClusterIP endpoint + cluster_url = ( + f"http://{svc.metadata.name}.{svc.metadata.namespace}" + f".svc.cluster.local:{svc.spec.ports[0].port}/mcp" + ) + print(f"Discovered available tool endpoint: {cluster_url}") diff --git a/aiac/docs/analysis/github-agent-card.json b/aiac/docs/analysis/github-agent-card.json new file mode 100644 index 000000000..9a5f359fa --- /dev/null +++ b/aiac/docs/analysis/github-agent-card.json @@ -0,0 +1,33 @@ +{ + "capabilities": { + "streaming": true + }, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "description": "Answer queries about Github issues", + "name": "Github issue agent", + "preferredTransport": "JSONRPC", + "protocolVersion": "0.3.0", + "securitySchemes": { + "Bearer": { + "bearerFormat": "JWT", + "description": "OAuth 2.0 JWT token", + "scheme": "bearer", + "type": "http" + } + }, + "skills": [ + { + "description": "Answer queries about Github issues", + "examples": [ + "Find me the issues with the most comments in kubernetes/kubernetes", + "Show all issues assigned to me across any repository" + ], + "id": "github_issue_agent", + "name": "Github issue agent", + "tags": ["git", "github", "issues"] + } + ], + "url": "http://github-agent.team1.svc.cluster.local:8000/", + "version": "1.0.0" +} diff --git a/aiac/docs/analysis/github-mcp-tools-summary.html b/aiac/docs/analysis/github-mcp-tools-summary.html new file mode 100644 index 000000000..24b795c32 --- /dev/null +++ b/aiac/docs/analysis/github-mcp-tools-summary.html @@ -0,0 +1,218 @@ + + + + +GitHub MCP Tool Descriptions + + + +
+ +

GitHub MCP Tool Descriptions

+

Endpoint: http://github-tool-mcp.team1.svc.cluster.local:9090/mcp  ·  44 tools  ·  Server: MitM MCP Broker v0.0.1  ·  Upstream: api.githubcopilot.com/mcp/

+ + +
+
Issues 7
+ + + + + + + + + + + +
ToolDescription
issue_readGet information about a specific issue in a GitHub repository.
issue_writeCreate a new or update an existing issue in a GitHub repository.
list_issuesList issues in a GitHub repository. For pagination, use the endCursor from the previous response's pageInfo in the after parameter.
add_issue_commentAdd a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use with pull requests as well (pass pull request number as issue_number), but only if the user is not asking specifically to add or react to review comments. At least one of body or reaction is required.
list_issue_fieldsList issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.
list_issue_typesList supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly.
sub_issue_writeAdd a sub-issue to a parent issue in a GitHub repository.
+
+ + +
+
Pull Requests 11
+ + + + + + + + + + + + + + + +
ToolDescription
pull_request_readGet information on a specific pull request in a GitHub repository.
list_pull_requestsList pull requests in a GitHub repository. If the user specifies an author, use search_pull_requests instead.
create_pull_requestCreate a new pull request in a GitHub repository.
merge_pull_requestMerge a pull request in a GitHub repository.
update_pull_requestUpdate an existing pull request in a GitHub repository.
update_pull_request_branchUpdate the branch of a pull request with the latest changes from the base branch.
pull_request_review_writeCreate, submit, or delete a pull request review. Methods: create, submit_pending, delete_pending, resolve_thread, unresolve_thread.
add_comment_to_pending_reviewAdd a review comment to the requester's latest pending pull request review. A pending review needs to already exist.
add_reply_to_pull_request_commentAdd a reply and/or reaction to an existing pull request comment. At least one of body or reaction is required.
request_copilot_reviewRequest a GitHub Copilot code review for a pull request. Use for automated feedback before requesting a human reviewer.
search_pull_requestsSearch for pull requests in GitHub repositories using issues search syntax already scoped to is:pr.
+
+ + +
+
Repositories 4
+ + + + + + + + +
ToolDescription
create_repositoryCreate a new GitHub repository in your account or specified organization.
fork_repositoryFork a GitHub repository to your account or specified organization.
list_repository_collaboratorsList collaborators of a GitHub repository. Results are paginated; response includes nextPage, prevPage, firstPage, and lastPage fields.
search_repositoriesFind GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects or locating specific repositories.
+
+ + +
+
Files 4
+ + + + + + + + +
ToolDescription
get_file_contentsGet the contents of a file or directory from a GitHub repository.
create_or_update_fileCreate or update a single file in a GitHub repository. SHA must be provided for existing file updates. Use git rev-parse <branch>:<path> to obtain the SHA before updating.
delete_fileDelete a file from a GitHub repository.
push_filesPush multiple files to a GitHub repository in a single commit.
+
+ + +
+
Branches & Tags 5
+ + + + + + + + + +
ToolDescription
create_branchCreate a new branch in a GitHub repository.
list_branchesList branches in a GitHub repository.
list_tagsList git tags in a GitHub repository.
get_tagGet details about a specific git tag in a GitHub repository.
get_labelGet a specific label from a repository.
+
+ + +
+
Commits 3
+ + + + + + + +
ToolDescription
get_commitGet details for a commit from a GitHub repository.
list_commitsGet list of commits of a branch in a GitHub repository. Returns at least 30 results per page (up to 100 with perPage parameter).
search_commitsSearch for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages. Searches the default branch only.
+
+ + +
+
Releases 3
+ + + + + + + +
ToolDescription
get_latest_releaseGet the latest release in a GitHub repository.
get_release_by_tagGet a specific release by its tag name in a GitHub repository.
list_releasesList releases in a GitHub repository.
+
+ + +
+
Search 4
+ + + + + + + + +
ToolDescription
search_codeFast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.
search_issuesSearch for issues in GitHub repositories using issues search syntax already scoped to is:issue.
search_pull_requestsSearch for pull requests in GitHub repositories using issues search syntax already scoped to is:pr.
search_usersFind GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.
+
+ + +
+
Teams & Users 3
+ + + + + + + +
ToolDescription
get_meGet details of the authenticated GitHub user. Use when a request is about the user's own GitHub profile, or when information is missing to build other tool calls.
get_team_membersGet member usernames of a specific team in an organization. Limited to organizations accessible with current credentials.
get_teamsGet details of the teams the user is a member of. Limited to organizations accessible with current credentials.
+
+ + +
+
Security 1
+ + + + + +
ToolDescription
run_secret_scanningScan files, content, or recent changes for secrets such as API keys, passwords, tokens, and credentials. Accepts a single string or array of strings containing raw file contents or diff hunks. Content must not be empty. Only files within the codebase should be scanned; skip files in .gitignore.
+
+ + +
+ + diff --git a/aiac/docs/analysis/github-mcp-tools-summary.json b/aiac/docs/analysis/github-mcp-tools-summary.json new file mode 100644 index 000000000..461bc5b2b --- /dev/null +++ b/aiac/docs/analysis/github-mcp-tools-summary.json @@ -0,0 +1,234 @@ +{ + "endpoint": "http://github-tool-mcp.team1.svc.cluster.local:9090/mcp", + "server": "MitM MCP Broker v0.0.1", + "upstream": "https://api.githubcopilot.com/mcp/", + "total_tools": 44, + "categories": [ + { + "category": "Issues", + "tools": [ + { + "name": "issue_read", + "description": "Get information about a specific issue in a GitHub repository." + }, + { + "name": "issue_write", + "description": "Create a new or update an existing issue in a GitHub repository." + }, + { + "name": "list_issues", + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter." + }, + { + "name": "add_issue_comment", + "description": "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required." + }, + { + "name": "list_issue_fields", + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly." + }, + { + "name": "list_issue_types", + "description": "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly." + }, + { + "name": "sub_issue_write", + "description": "Add a sub-issue to a parent issue in a GitHub repository." + } + ] + }, + { + "category": "Pull Requests", + "tools": [ + { + "name": "pull_request_read", + "description": "Get information on a specific pull request in GitHub repository." + }, + { + "name": "list_pull_requests", + "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead." + }, + { + "name": "create_pull_request", + "description": "Create a new pull request in a GitHub repository." + }, + { + "name": "merge_pull_request", + "description": "Merge a pull request in a GitHub repository." + }, + { + "name": "update_pull_request", + "description": "Update an existing pull request in a GitHub repository." + }, + { + "name": "update_pull_request_branch", + "description": "Update the branch of a pull request with the latest changes from the base branch." + }, + { + "name": "pull_request_review_write", + "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op." + }, + { + "name": "add_comment_to_pending_review", + "description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure)." + }, + { + "name": "add_reply_to_pull_request_comment", + "description": "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required." + }, + { + "name": "request_copilot_review", + "description": "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer." + } + ] + }, + { + "category": "Repositories", + "tools": [ + { + "name": "create_repository", + "description": "Create a new GitHub repository in your account or specified organization" + }, + { + "name": "fork_repository", + "description": "Fork a GitHub repository to your account or specified organization" + }, + { + "name": "list_repository_collaborators", + "description": "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter." + }, + { + "name": "search_repositories", + "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub." + } + ] + }, + { + "category": "Files", + "tools": [ + { + "name": "get_file_contents", + "description": "Get the contents of a file or directory from a GitHub repository" + }, + { + "name": "create_or_update_file", + "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse :\n\nSHA MUST be provided for existing file updates." + }, + { + "name": "delete_file", + "description": "Delete a file from a GitHub repository" + }, + { + "name": "push_files", + "description": "Push multiple files to a GitHub repository in a single commit" + } + ] + }, + { + "category": "Branches & Tags", + "tools": [ + { + "name": "create_branch", + "description": "Create a new branch in a GitHub repository" + }, + { + "name": "list_branches", + "description": "List branches in a GitHub repository" + }, + { + "name": "list_tags", + "description": "List git tags in a GitHub repository" + }, + { + "name": "get_tag", + "description": "Get details about a specific git tag in a GitHub repository" + }, + { + "name": "get_label", + "description": "Get a specific label from a repository." + } + ] + }, + { + "category": "Commits", + "tools": [ + { + "name": "get_commit", + "description": "Get details for a commit from a GitHub repository" + }, + { + "name": "list_commits", + "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100)." + }, + { + "name": "search_commits", + "description": "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only." + } + ] + }, + { + "category": "Releases", + "tools": [ + { + "name": "get_latest_release", + "description": "Get the latest release in a GitHub repository" + }, + { + "name": "get_release_by_tag", + "description": "Get a specific release by its tag name in a GitHub repository" + }, + { + "name": "list_releases", + "description": "List releases in a GitHub repository" + } + ] + }, + { + "category": "Search", + "tools": [ + { + "name": "search_code", + "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns." + }, + { + "name": "search_issues", + "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue" + }, + { + "name": "search_pull_requests", + "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr" + }, + { + "name": "search_users", + "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members." + } + ] + }, + { + "category": "Teams & Users", + "tools": [ + { + "name": "get_me", + "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls." + }, + { + "name": "get_team_members", + "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials" + }, + { + "name": "get_teams", + "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials" + } + ] + }, + { + "category": "Security", + "tools": [ + { + "name": "run_secret_scanning", + "description": "Scan files, content, or recent changes for secrets such as API keys, passwords, tokens, and credentials.\n\nThis tool is intended for targeted scans of specific files, snippets, or diffs provided directly as content. The files parameter accepts either a single string or an array of strings containing raw file contents or diff hunks, and returns detected secrets with their locations and related secret scanning metadata. Content must not be empty. For full repository scanning, other mechanisms are available.\n\nCaveats:\n\n- Only files within the codebase should be scanned. Files outside of the codebase should not be sent.\n- Files listed in .gitignore should be skipped." + } + ] + } + ] +} diff --git a/aiac/docs/analysis/keycloak-access-control-analysis.md b/aiac/docs/analysis/keycloak-access-control-analysis.md new file mode 100644 index 000000000..3386ce717 --- /dev/null +++ b/aiac/docs/analysis/keycloak-access-control-analysis.md @@ -0,0 +1,789 @@ +# Keycloak Access Control in Kagenti: U → Agent A → Tool T + +> Analysis of the minimum Keycloak configuration required for the +> scenario where a user U calls agent A, which in turn calls tool T — +> and why users Y and agent B are denied. +> Includes a full RBAC section (§10–§13) covering the role-mapped model +> where user U holds realm role UR and user Y holds realm role YR. +> +> **§18–§23 cover the OPA-as-PDP architecture** — the production model where +> OPA plugins replace Keycloak gate 3, reducing the required Keycloak +> configuration to gates 1 and 2 only. §§1–17 remain as reference material +> for the Keycloak-native RBAC model. + +--- + +## 1. Architecture Overview + +The Kagenti platform uses a **three-layer trust chain** enforced by: + +- **Keycloak** — OAuth2/OIDC token issuer and policy decision point (PDP) +- **AuthBridge** — transparent sidecar proxy acting as pure policy enforcement point (PEP) +- **SPIFFE/SPIRE** — workload identity via SPIFFE IDs used as Keycloak `clientId` + +Every workload (agent or tool) has an AuthBridge sidecar injected by the kagenti-operator. The sidecar: +- **Inbound**: validates incoming JWTs (signature, issuer, audience) — returns `401` on failure +- **Outbound**: intercepts HTTP calls to other services and performs RFC 8693 token exchange transparently + +The application code never sees a raw Keycloak call. All token lifecycle management is handled by the sidecar. + +``` +User U + │ (1) POST /token scope=agent-A-aud → token₁ {sub:U, aud:[A-SPIFFE]} + │ + ▼ +Agent A pod + ├── AuthBridge inbound: verify sig + issuer + aud == A-SPIFFE + ├── App processes request, calls Tool T + └── AuthBridge outbound: RFC 8693 exchange + subject_token = token₁ + audience = T-SPIFFE + scope = tool-T-aud + acting client = A's client credentials + → token₂ {sub:U, aud:[T-SPIFFE]} + +Tool T pod + └── AuthBridge inbound: verify sig + issuer + aud == T-SPIFFE ✓ +``` + +--- + +## 2. Minimum Keycloak Configuration + +### 2.1 Required Objects + +| # | Object | Type | Key Properties | Purpose | +|---|--------|------|----------------|---------| +| 1 | `kagenti` | Realm | `enabled: true` | Hosts all principals | +| 2 | Agent A client | Keycloak Client | `clientId=A-SPIFFE`, `standard.token.exchange.enabled=true` | A's service identity; acting party in exchange | +| 3 | Tool T client | Keycloak Client | `clientId=T-SPIFFE`, `standard.token.exchange.enabled=true` | Exchange target; T's audience anchor | +| 4 | `agent-A-aud` scope | Client Scope + `oidc-audience-mapper` | `aud += A-SPIFFE`; assigned as **default** on U's client | Lets U obtain a token that A's inbound will accept | +| 5 | `tool-T-aud` scope | Client Scope + `oidc-audience-mapper` | `aud += T-SPIFFE`; assigned as **optional** on A's client | Lets AuthBridge exchange for a token T's inbound will accept | +| 6 | User U | Realm User | `enabled: true` | Authenticating principal | +| — | `authproxy-routes` | Kubernetes ConfigMap | `host → target_audience + token_scopes` | Tells AuthBridge when and how to exchange outbound tokens | + +### 2.2 `authproxy-routes` ConfigMap (non-Keycloak, but required) + +```yaml +# ConfigMap: authproxy-routes, namespace: +- host: "tool-T-service" + target_audience: "spiffe://…/sa/tool-T" + token_scopes: "openid tool-T-aud" +``` + +### 2.3 Token Exchange: Three Keycloak Gates + +When AuthBridge performs the RFC 8693 exchange on A's outbound call to T, Keycloak enforces three +sequential checks: + +| Gate | What Keycloak checks | What must exist | +|------|----------------------|-----------------| +| **Client allowed?** | `standard.token.exchange.enabled=true` on A's client | Set by the setup script on A's client | +| **Scope registered?** | Is `tool-T-aud` assigned (default or optional) to A's client? | `add_client_optional_client_scope(A_client, tool-T-aud)` | +| **Subject holds it?** | Does the subject's token carry the scope or the required role? | Scope-to-role gate (RBAC mode) or realm default assignment | + +--- + +## 3. Q1 — What ensures User U can access Agent A? + +### Keycloak side (token issuance) + +The `agent-A-aud` client scope carries an `oidc-audience-mapper` that injects A's SPIFFE ID into +the `aud` claim. It is assigned as a **default scope** on the client U authenticates through, so +every token U obtains automatically includes `aud: ["spiffe://…/sa/agent-A"]`. + +```python +# From setup_keycloak_weather_advanced.py +keycloak_admin.add_client_default_client_scope(u_client_internal_id, agent_aud_scope_id, {}) +``` + +### AuthBridge side (token validation) + +The `jwt-validation` plugin in A's sidecar reads the expected audience from `/shared/client-id.txt` +(A's SPIFFE ID, written by the operator at pod start). On every inbound request it calls +`auth.HandleInbound()` (`authlib/auth/auth.go:278`), which performs three checks in sequence: + +| Check | Failure | +|-------|---------| +| **Signature** — verified via JWKS endpoint | `401` — token forged or tampered | +| **Issuer** (`iss` claim) — compared to configured `issuer` | `401` — wrong realm / wrong IdP | +| **Audience** (`aud` claim) — must contain A's SPIFFE ID | `401` — token not issued for A | + +All three must pass before the request reaches agent A's application process. + +--- + +## 4. Q2 — What ensures Agent A can access Tool T on behalf of User U? + +Three components work in sequence: + +**Step 1 — AuthBridge outbound routing (`authproxy-routes`):** +When A calls `http://tool-T-service/…`, the `token-exchange` plugin intercepts the request, +matches the `Host` header against `authproxy-routes`, and resolves `target_audience=T-SPIFFE` and +`token_scopes=openid tool-T-aud`. The current `Authorization: Bearer token₁` is extracted as +`subject_token`. + +**Step 2 — Keycloak RFC 8693 exchange:** +AuthBridge POSTs to Keycloak using A's own client credentials as the acting party: +``` +grant_type = urn:ietf:params:oauth:grant-type:token-exchange +subject_token = +subject_token_type = urn:ietf:params:oauth:token-type:access_token +audience = spiffe://…/sa/tool-T +scope = openid tool-T-aud +client_id = spiffe://…/sa/agent-A ← A is the acting client +client_secret = +``` +Keycloak applies the three gates and — on success — issues token₂ with the same `sub` (U's +identity is preserved) but `aud: ["spiffe://…/sa/tool-T"]`. + +**Step 3 — Tool T inbound validation:** +T's AuthBridge sidecar runs the same `jwt-validation` plugin. It verifies signature, issuer, and +`aud == T-SPIFFE`. Token₂ passes. T's process receives the request. + +--- + +## 5. Q3 — Why can't User Y access Agent A? How to allow U but deny Y? + +### Why Y is denied + +Y is a legitimate Keycloak user but their token does not contain `aud = A-SPIFFE`. This is because +the `agent-A-aud` scope is **not assigned** to the client Y authenticates through. Keycloak only +includes a scope's audience mapper in a token if that scope is assigned to the requesting client. +Y's token therefore has no matching `aud` entry, and A's `jwt-validation` plugin returns `401`. + +### Mechanism 1: per-client scope assignment (different client apps) + +Assign `agent-A-aud` as a default scope only on U's client. Y's client never receives this +assignment: + +```python +keycloak_admin.add_client_default_client_scope(u_client_id, agent_aud_scope_id, {}) +# Y's client is never passed to this function +``` + +### Mechanism 2: scope-to-role gating (same client app, different users) + +When U and Y authenticate through the **same** client, use Keycloak's scope-to-role mapping: + +**① Disable `fullScopeAllowed` on the client** — `fullScopeAllowed` is a boolean field on the +**ClientRepresentation** (not on a client scope). While it is `true`, every role the client can +access is added to its tokens regardless of scope-mappings, which defeats role gating. Set it +`false` on the client so only roles bound via scope-mappings are included: +```python +client_representation["fullScopeAllowed"] = False +admin.update_client(client_id, client_representation) +``` + +**② Bind the scope to a client role via scope-mappings** — the scope is only included in tokens +issued to users who hold the mapped role: +```python +# POST /admin/realms/{realm}/client-scopes/{scope_id}/scope-mappings/clients/{client_id} +admin.connection.raw_post(url, data=json.dumps([role_representation])) +``` + +**③ Assign realm roles to users** — U gets a role that maps to the gating client role; Y does not: +```python +admin.assign_realm_roles(u_id, [admin.get_realm_role("developer")]) +admin.assign_realm_roles(y_id, [admin.get_realm_role("tech-support")]) +``` + +Result: even through the same client, U's token carries `aud=A-SPIFFE`; Y's does not. + +--- + +## 6. Q4 — Why can't Agent A access Tool T on behalf of User Y? How to allow U but deny Y? + +### Why the exchange fails for Y + +When A's AuthBridge exchanges Y's token it sends Y's token as `subject_token` and requests +`scope=tool-T-aud`. Keycloak evaluates gate 3: + +> *"Does the subject (Y) have access to the `tool-T-aud` scope?"* + +The `tool-T-aud` scope has `fullScopeAllowed=false` and a scope-to-role mapping that gates it to +users holding the `tool-T:tool-T-aud` client role. Y's realm role does not map to that client role. +Keycloak refuses to include the scope and returns: + +``` +HTTP 400 {"error": "invalid_scope"} +``` + +AuthBridge receives this and the outbound call to T fails. A returns `503` to Y. T is never reached. + +The policy decision lives entirely in Keycloak's scope-to-role evaluation. Changing which users +can reach T requires only a Keycloak configuration change — no agent redeployment. + +--- + +## 7. Q5 — Why can't Agent B access Tool T? How to allow A but deny B? + +### Why B is denied + +B is a fully registered Keycloak client with `standard.token.exchange.enabled=true`. When B's +AuthBridge attempts the exchange, Keycloak applies gate 2: + +> *"Is `tool-T-aud` assigned (default or optional) to B's client?"* + +The answer is **no**. `tool-T-aud` was added as an optional scope only to A's client. B's client +has no such assignment → `400 invalid_scope`. + +### What if B omits the scope and sends only `audience=T-SPIFFE`? + +Keycloak falls back to T's default scopes. `tool-T-aud` is registered as **optional** (not +default) at realm level, so it is not included. T's inbound `jwt-validation` rejects the token +with `401` because `aud` does not contain T-SPIFFE. + +### The two complementary locks + +| Lock | Blocks | Configuration | +|------|--------|---------------| +| `tool-T-aud` not assigned as optional to B's client | `400 invalid_scope` | `client_audience_targets` omits B; `add_client_optional_client_scope` called only for A | +| `tool-T-aud` is optional (not default) on T's client | `401` at T inbound even if B omits scope | `add_default_optional_client_scope` (optional), never `add_default_default_client_scope` | + +--- + +## 8. Summary (flat model) + +| Boundary | Enforcement point | Keycloak mechanism | Failure | +|----------|------------------|--------------------|---------| +| Y cannot reach A | AuthBridge `jwt-validation` at A | `agent-A-aud` scope not on Y's client / scope-role gate excludes Y | `401 Unauthorized` | +| A cannot reach T on Y's behalf | Keycloak RFC 8693 gate 3 | `tool-T-aud` scope-role mapping excludes Y's realm role | `400 invalid_scope` → `503` to Y | +| B cannot reach T on anyone's behalf | Keycloak RFC 8693 gate 2 | `tool-T-aud` not assigned as optional to B's client | `400 invalid_scope` → `503` to B's caller | + +In all three cases the policy **decision** is made in **Keycloak at token-issuance time** — Keycloak +acts as the **PDP** (Policy Decision Point), deciding which audiences/scopes a token may carry. +AuthBridge is a pure **PEP** (Policy Enforcement Point): it enforces those decisions by validating the +issued token and carries no policy knowledge of its own. + +--- + +--- + +# RBAC Extension: Role-Mapped Model (UR and YR) — Keycloak-Native Reference + +> **Note:** The RBAC sections below (§9–§17) describe the Keycloak-native +> access control model where Keycloak itself evaluates gate 3 (scope-to-role +> gating). This model is retained as reference material. +> +> The **production model** is OPA-as-PDP (§18–§23): OPA plugins on the AuthBridge +> inbound and outbound pipelines own all access control rule evaluation. +> Keycloak is reduced to a token issuer and exchange facilitator (gates 1 and 2 +> only). Scope-to-role mappings, client roles, and composite role mappings on +> realm roles are **not required** in the OPA model. + +> The following sections (§9–§13) re-answer all questions for the RBAC +> security model where: +> - **User U** is assigned realm role **UR** +> - **User Y** is assigned realm role **YR** +> +> UR grants access to A and through A to T. YR does not. + +--- + +## 9. RBAC Model Overview + +Keycloak's RBAC wiring connects four layers in a directed graph: + +``` +Realm role (UR / YR) + │ composite role mapping + ▼ +Client role (e.g. agent-A:github-agent, github-tool:github-tool-aud) + │ scope-to-role mapping + ▼ +Client scope (e.g. agent-A-aud, tool-T-aud) + │ oidc-audience-mapper + ▼ +Token aud claim (A-SPIFFE, T-SPIFFE) +``` + +The key principle: **a user's realm role determines which client roles they hold; client roles gate +which scopes appear in their tokens; scopes carry the audience claims that AuthBridge validates**. + +### Object inventory for the RBAC model + +| Object | Type | Key Properties | +|--------|------|----------------| +| Realm role `UR` | Realm Role | Assigned to User U; made composite of A's and T's client roles | +| Realm role `YR` | Realm Role | Assigned to User Y; no composite mappings toward A or T | +| Client `agent-A` | Keycloak Client | `clientId=A-SPIFFE`; `standard.token.exchange.enabled=true`; `fullScopeAllowed=false` | +| Client `tool-T` | Keycloak Client | `clientId=T-SPIFFE`; `standard.token.exchange.enabled=true`; `fullScopeAllowed=false` | +| Client role `agent-A:github-agent` | Client Role | Defined on A's client; gates the `agent-A-aud` scope | +| Client role `tool-T:tool-T-aud` | Client Role | Defined on T's client; gates the `tool-T-aud` scope | +| Client scope `agent-A-aud` | Client Scope | `oidc-audience-mapper` → A-SPIFFE; `fullScopeAllowed=false`; scope-mapped to `agent-A:github-agent` | +| Client scope `tool-T-aud` | Client Scope | `oidc-audience-mapper` → T-SPIFFE; `fullScopeAllowed=false`; scope-mapped to `tool-T:tool-T-aud` | +| User U | Realm User | `assign_realm_roles(U, [UR])` | +| User Y | Realm User | `assign_realm_roles(Y, [YR])` | + +### Policy file shape (YAML applied via `apply_policy.py`) + +```yaml +# access_control_policy.yaml +policy: + UR: # realm role for User U + - client: "agent-A" # grants access to A + role: "github-agent" + - client: "github-tool" # grants access to T (via A) + role: "github-tool-aud" + YR: # realm role for User Y + [] # no client role mappings → no access to A or T +``` + +Applied with: +```python +# keycloak_ops/apply_policy.py — add_client_role_to_realm_role_composite() +url = f"/admin/realms/{realm}/roles-by-id/{realm_role['id']}/composites" +admin.connection.raw_post(url, data=json.dumps([client_role])) +``` + +--- + +## 10. RBAC Q1 — What ensures User U (role UR) can access Agent A? + +### Full wiring + +``` +U authenticates → token₁ request +Keycloak evaluates for each scope assigned to U's client: + agent-A-aud scope: + fullScopeAllowed=false → check scope-mappings + scope-mapping requires: agent-A:github-agent + Does U hold agent-A:github-agent? + U holds realm role UR + UR is composite of: agent-A:github-agent ✓ + → include aud mapper → token₁ gets aud=[A-SPIFFE] +``` + +Three setup steps enable this: + +**① Create client role on A's client:** +```python +admin.create_client_role(agent_A_internal_id, {"name": "github-agent", "clientRole": True}) +``` + +**② Create scope `agent-A-aud` with `fullScopeAllowed=false` and scope-map it to that role:** +```python +scope_representation["fullScopeAllowed"] = False +admin.update_client_scope(scope_id, scope_representation) +# POST /admin/realms/{realm}/client-scopes/{scope_id}/scope-mappings/clients/{agent_A_id} +# Body: [github-agent role representation] +``` + +**③ Make realm role UR a composite of `agent-A:github-agent`:** +```python +# POST /admin/realms/{realm}/roles-by-id/{UR_id}/composites +# Body: [github-agent client role representation] +``` + +AuthBridge at A then validates as before: signature + issuer + `aud == A-SPIFFE`. Token₁ passes; +the request is forwarded to A's process. + +--- + +## 11. RBAC Q2 — What ensures Agent A can access Tool T on behalf of User U (role UR)? + +The exchange works at two levels simultaneously: + +**Level 1 — A's client is allowed to request `tool-T-aud`** (gate 2): +```python +admin.add_client_optional_client_scope(agent_A_internal_id, tool_T_aud_scope_id, {}) +``` +This is driven by `client_audience_targets` in `config.yaml`: +```yaml +client_audience_targets: + spiffe://…/sa/agent-A: + - github-tool # → assigns tool-T-aud as optional to A's client +``` + +**Level 2 — U's token (subject) carries the right role to pass gate 3:** +``` +Keycloak evaluates gate 3 during exchange: + tool-T-aud scope: + fullScopeAllowed=false → check scope-mappings + scope-mapping requires: tool-T:tool-T-aud + Does U hold tool-T:tool-T-aud? + U holds realm role UR + UR is composite of: tool-T:tool-T-aud ✓ + → include aud mapper → token₂ gets aud=[T-SPIFFE] +``` + +The composite role mapping on UR for the tool-side client role is what grants U's token the right +to pass through the tool boundary. Without it, the exchange returns `400 invalid_scope` even if A +is correctly configured. + +--- + +## 12. RBAC Q3 — Why can't User Y (role YR) access Agent A? + +### The denial chain + +``` +Y authenticates → token request +Keycloak evaluates agent-A-aud scope: + fullScopeAllowed=false → check scope-mappings + scope-mapping requires: agent-A:github-agent + Does Y hold agent-A:github-agent? + Y holds realm role YR + YR has NO composite mapping to agent-A:github-agent ✗ + → scope excluded → token has no aud=A-SPIFFE +``` + +Y's token is issued without `aud=A-SPIFFE`. AuthBridge at A calls `a.verifier.Verify(ctx, token, +[A-SPIFFE])` — the `aud` claim does not contain A-SPIFFE → `DENY_JWT_FAILED` → **HTTP 401**. + +### What makes U different from Y + +The only difference is the composite role mapping on their respective realm roles: + +| Realm role | Composite: `agent-A:github-agent` | Result | +|------------|-----------------------------------|--------| +| **UR** (U's role) | ✓ present | `agent-A-aud` scope included → `aud=[A-SPIFFE]` in token | +| **YR** (Y's role) | ✗ absent | `agent-A-aud` scope excluded → token has no A-SPIFFE audience | + +### How to grant U but deny Y + +The entire distinction lives in the policy YAML applied via `apply_policy.py`. No code changes, no +client reconfiguration — only the composite mappings on UR and YR: + +```yaml +policy: + UR: + - client: "agent-A" + role: "github-agent" # ← present: U gets aud=A-SPIFFE + YR: + [] # ← absent: Y never gets aud=A-SPIFFE +``` + +Applied: +```python +# For UR → adds agent-A:github-agent to UR's composites +add_client_role_to_realm_role_composite(admin, realm, "UR", agent_A_id, "github-agent") +# YR: no call → YR has no composites → Y's tokens never reach A +``` + +--- + +## 13. RBAC Q4 — Why can't Agent A access Tool T on behalf of User Y (role YR)? + +### The denial chain + +Even if Y somehow reaches A (e.g. Y is granted access to A but not to T), when A's AuthBridge +performs the exchange: + +``` +RFC 8693 exchange POST: + subject_token = Y's token + audience = T-SPIFFE + scope = openid tool-T-aud + client_id = A-SPIFFE (acting client — A is still allowed to exchange) + +Keycloak evaluates gate 3: + tool-T-aud scope: + fullScopeAllowed=false → check scope-mappings + scope-mapping requires: tool-T:tool-T-aud + Does Y hold tool-T:tool-T-aud? + Y holds realm role YR + YR has NO composite mapping to tool-T:tool-T-aud ✗ + → scope denied → HTTP 400 invalid_scope +``` + +AuthBridge receives `400 invalid_scope`. The outbound call to T fails with `503` back to Y. +T is never contacted. + +### What makes U different from Y at the T boundary + +| Realm role | Composite: `tool-T:tool-T-aud` | Exchange result | +|------------|--------------------------------|-----------------| +| **UR** (U's role) | ✓ present | Gate 3 passes → token₂ issued with `aud=[T-SPIFFE]` | +| **YR** (Y's role) | ✗ absent | Gate 3 fails → `400 invalid_scope` | + +### How to grant U but deny Y at T + +Again, the entire distinction is in the policy YAML: + +```yaml +policy: + UR: + - client: "agent-A" + role: "github-agent" # access to A + - client: "github-tool" + role: "github-tool-aud" # ← present: U's token passes gate 3 at T + YR: + - client: "agent-A" + role: "github-agent" # access to A (if desired) + # github-tool mapping absent # ← absent: Y's token fails gate 3 → 400 +``` + +This separation means: +- YR can be granted access to A without gaining any access to T +- Revoking Y's access to T requires only removing `tool-T:tool-T-aud` from YR's composites +- No redeployment of A or T is needed + +--- + +## 14. RBAC Q5 — Why can't Agent B access Tool T? (RBAC model, same answer) + +The RBAC model adds no new mechanism here; the answer is identical to §7. The gate that blocks B +is **gate 2** (acting-client check), which is about B's Keycloak client not having `tool-T-aud` +as an optional scope. This is independent of realm roles — realm roles gate the subject token +(gates 3), while the acting-client check (gate 2) gates the exchanger. + +The `client_audience_targets` map in `config.yaml` is still the single source of truth: + +```yaml +client_audience_targets: + spiffe://…/sa/agent-A: + - github-tool # A can call T + # agent-B absent → B's client never gets tool-T-aud as optional scope +``` + +--- + +## 15. RBAC End-to-End: Full Object Map + +``` +Keycloak Realm: kagenti +│ +├── Realm Roles +│ ├── UR ──composite──► agent-A:github-agent +│ │ ──composite──► tool-T:tool-T-aud +│ └── YR (no composites toward A or T) +│ +├── Clients +│ ├── kagenti (UI / E2E) +│ │ └── default scope: agent-A-aud +│ │ +│ ├── agent-A [clientId=A-SPIFFE] +│ │ ├── standard.token.exchange.enabled=true +│ │ ├── fullScopeAllowed=false +│ │ ├── client role: github-agent +│ │ ├── default scope: agent-A-aud +│ │ └── optional scope: tool-T-aud +│ │ +│ └── tool-T [clientId=T-SPIFFE] +│ ├── standard.token.exchange.enabled=true +│ ├── fullScopeAllowed=false +│ └── client role: tool-T-aud +│ +├── Client Scopes +│ ├── agent-A-aud +│ │ ├── oidc-audience-mapper → A-SPIFFE +│ │ ├── fullScopeAllowed=false +│ │ └── scope-mapping → agent-A:github-agent +│ │ +│ └── tool-T-aud +│ ├── oidc-audience-mapper → T-SPIFFE +│ ├── fullScopeAllowed=false +│ └── scope-mapping → tool-T:tool-T-aud +│ +└── Users + ├── U ──realm role──► UR + └── Y ──realm role──► YR +``` + +--- + +## 16. Consolidated Summary: All Boundaries, Both Models + +### Flat model (no RBAC) + +| Boundary | Gate | Keycloak mechanism | Failure | +|----------|------|--------------------|---------| +| Y → A | AuthBridge inbound | `agent-A-aud` scope not assigned to Y's client | `401` | +| A→T on Y's behalf | RFC 8693 gate 3 | `tool-T-aud` scope-role gate excludes Y's role | `400 invalid_scope` | +| B → T (any subject) | RFC 8693 gate 2 | `tool-T-aud` not optional on B's client | `400 invalid_scope` | + +### RBAC model (UR / YR) + +| Boundary | Gate | Keycloak mechanism | Failure | +|----------|------|--------------------|---------| +| Y (role YR) → A | AuthBridge inbound | YR has no composite → `agent-A:github-agent` → scope `agent-A-aud` excluded from Y's token | `401` | +| A → T on Y's behalf | RFC 8693 gate 3 | YR has no composite → `tool-T:tool-T-aud` → scope `tool-T-aud` excluded from Y's token | `400 invalid_scope` | +| B → T (any subject) | RFC 8693 gate 2 | B's client not in `client_audience_targets` → `tool-T-aud` never assigned to B's client | `400 invalid_scope` | + +### Where each policy decision lives + +| Question | Policy object | API call | +|----------|--------------|----------| +| Can U reach A? | UR composite → `agent-A:github-agent` | `POST /roles-by-id/{UR_id}/composites` | +| Can Y reach A? | YR composite (absent) | (no call for YR) | +| Can U's token pass gate 3 at T? | UR composite → `tool-T:tool-T-aud` | `POST /roles-by-id/{UR_id}/composites` | +| Can Y's token pass gate 3 at T? | YR composite (absent) | (no call for YR) | +| Can A act as exchanger for T? | A's optional scope: `tool-T-aud` | `add_client_optional_client_scope(A, tool-T-aud)` | +| Can B act as exchanger for T? | B's optional scope (absent) | (no call for B) | + +### Design principle + +In the RBAC model, all policy decisions are encoded in **composite role mappings** on realm roles. +AuthBridge remains a pure PEP and never changes. A, T, and their client configurations remain +static. Changing who can access what — granting U access to T, revoking Y's access to A — is a +single `POST /roles-by-id/{role_id}/composites` call. No pod restarts, no client reconfiguration, +no AuthBridge changes required. + +--- + +## 17. Key Files Reference + +| File | Relevance | +|------|-----------| +| `authbridge/demos/weather-agent/setup_keycloak_weather_advanced.py` | Concrete scope + client setup for a single agent + tool scenario | +| `authbridge/demos/github-issue/setup_keycloak.py` | Full RBAC mode: clients, roles, scope-role gating (`map_scopes_to_roles`), users | +| `authbridge/demos/github-issue/aiac/config.yaml` | Declarative `client_audience_targets`, `realm_roles`, `users` with role assignments | +| `authbridge/demos/github-issue/aiac/keycloak_ops/apply_policy.py` | Applies composite role mappings: realm role → client role (`add_client_role_to_realm_role_composite`) | +| `authbridge/demos/github-issue/aiac/keycloak_ops/delete_policy.py` | Removes composite mappings from realm roles without touching user assignments | +| `authbridge/demos/github-issue/aiac/policies/regular_policy.txt` | Example natural-language policy the AIAC agent translates into composite mappings | +| `authbridge/docs/plugin-reference.md` | `jwt-validation` and `token-exchange` plugin contracts | +| `authlib/auth/auth.go` | `HandleInbound`: the three JWT checks (signature, issuer, audience) | +| `authlib/plugins/tokenexchange/exchange/client.go` | RFC 8693 exchange POST construction | +| `authbridge/demos/token-exchange-routes/README.md` | `authproxy-routes` ConfigMap shape and troubleshooting | + +--- + +--- + +# OPA-as-PDP Architecture: Minimal Keycloak Configuration + +## 18. OPA as PDP — Design Principle + +In the OPA-as-PDP architecture, two OPA plugins are attached to the AuthBridge inbound and +outbound pipelines: + +- **Inbound OPA plugin** — evaluates whether the incoming request's subject (JWT `sub` claim, + roles, and other token claims) is permitted to call this service. Replaces the audience-only + check of the Keycloak-native model with full policy evaluation. +- **Outbound OPA plugin** — evaluates whether the subject token is permitted to be exchanged + for a target-audience token before AuthBridge performs the RFC 8693 exchange. + +OPA owns **all access control rule formulation and validation**. Keycloak is responsible only for: + +1. **Token issuance** — issuing tokens with the correct `aud` claim so AuthBridge inbound can + verify token authenticity (gate 1: signature + issuer; gate 2: audience anchor). +2. **Token exchange facilitation** — performing RFC 8693 exchanges, enforcing only that the + acting client is registered and has the target scope assigned (gates 1 and 2). Gate 3 + (scope-to-role evaluation) is **bypassed**: scopes are created with `fullScopeAllowed=true` + so Keycloak never evaluates role membership — that decision belongs to OPA. + +--- + +## 19. What Keycloak No Longer Needs (OPA Model) + +The following Keycloak objects and configuration steps from the RBAC model (§9–§17) are +**not required** in the OPA model: + +| Object / Operation | Old purpose | OPA model | +|---|---|---| +| `fullScopeAllowed=false` on scopes | Gate 3: role-based scope inclusion | **Not needed** — scopes use `fullScopeAllowed=true` | +| Scope-to-role mappings (`scope-mappings/realm`) | Gate 3: which roles unlock which scopes | **Not needed** — OPA evaluates this | +| Scope-to-client-role mappings (`scope-mappings/clients/{id}`) | Gate 3 via client role intermediary | **Not needed** | +| Client roles (`agent-A:github-agent`, `tool-T:tool-T-aud`) | Scope-to-role mapping target | **Not needed** | +| Realm roles as composites of client roles | Policy application lever | **Not needed** as Keycloak mechanism | +| `POST /roles-by-id/{role_id}/composites` | Apply composite mapping | **Not needed** | + +> **Realm roles may still be useful as OPA input data.** If users carry realm role claims in +> their JWT (e.g. `roles: ["developer"]`), OPA can use those claims as policy input. But +> their presence in the token is incidental — they are no longer the mechanism that gates +> scope inclusion. + +--- + +## 20. Minimal Keycloak Configuration (OPA Model) + +``` +Realm: kagenti +│ +├── Clients +│ ├── kagenti-ui (or per-user client) +│ │ └── default scope: agent-A-aud ← token₁ always has aud=A-SPIFFE +│ │ +│ ├── agent-A [clientId=A-SPIFFE] +│ │ ├── standard.token.exchange.enabled=true +│ │ └── optional scope: tool-T-aud ← gate 2: A can request T audience +│ │ +│ └── tool-T [clientId=T-SPIFFE] +│ └── standard.token.exchange.enabled=true +│ +├── Client Scopes +│ ├── agent-A-aud (oidc-audience-mapper → A-SPIFFE, fullScopeAllowed=true) +│ └── tool-T-aud (oidc-audience-mapper → T-SPIFFE, fullScopeAllowed=true) +│ +└── Users + ├── U (realm roles populated as JWT claims for OPA input) + └── Y +``` + +Each audience scope (`X-aud`) is created once when the service is registered and assigned as: +- **Default** client scope on the callee's own client — a *default* client scope is added to + **every** token issued for that client automatically, without the caller having to request it via + the `scope` parameter, so those tokens always carry `aud=X-SPIFFE` (from the scope's audience + protocol mapper). +- **Optional** client scope on each caller agent's client — an *optional* scope is included **only + when explicitly requested** (here, during the token exchange), which is what enables gate 2 for + that agent's token exchange calls. + +`fullScopeAllowed=true` everywhere — Keycloak never gates by role. OPA receives the full +subject token and makes all access decisions. + +--- + +## 21. The Two Remaining Keycloak Gates (OPA Model) + +| Gate | Enforced by | What it checks | What must exist | +|------|-------------|----------------|-----------------| +| **Gate 1** | Keycloak | `standard.token.exchange.enabled=true` on the acting client | Set at service registration | +| **Gate 2** | Keycloak | Is `tool-T-aud` assigned (default or optional) to the caller's client? | `add_client_optional_client_scope(caller, tool-T-aud)` | +| **Gate 3** | ~~Keycloak~~ **OPA** | Does the subject have permission to reach this target? | OPA policy — no Keycloak configuration | + +Gate 2 remains a Keycloak-enforced boundary but is now **topological** (which agents can +attempt an exchange toward which tools) rather than **subject-based** (which users can call +which tools through which agents). OPA handles subject-based policy on top. + +--- + +## 22. Service Registration and Wiring (OPA Model) + +The two operations that produce the required Keycloak state for a new service are: + +### `register_service_audience(service)` — callee setup + +Creates the service's canonical audience scope `{serviceId}-aud` and assigns it as a +**default** scope to the service's Keycloak client. Called once when a new agent or tool +is registered. + +``` +POST /scopes {"name": "github-tool-aud", "protocol": "openid-connect"} +POST /services/{id}/scopes/{scope_id}/default +``` + +After this call, any user authenticating through `github-tool`'s client receives a token +with `aud=github-tool-SPIFFE` — AuthBridge inbound at `github-tool` will accept it. + +### `allow_service_to_call(caller, callee)` — gate 2 wiring + +Assigns the callee's canonical audience scope as an **optional** scope to the caller's +Keycloak client. Called whenever a new caller→callee edge is permitted. + +``` +POST /services/{caller.id}/scopes/{callee_aud_scope.id}/optional +``` + +After this call, `caller`'s AuthBridge can perform an RFC 8693 exchange requesting +`scope=github-tool-aud`, passing gate 2. OPA's outbound plugin then makes the final +allow/deny decision before the exchange is forwarded. + +Both operations are **idempotent** — safe to call on every operator reconciliation. + +--- + +## 23. Summary: Both Models Side by Side + +| Dimension | Keycloak-native RBAC (§9–§17) | OPA-as-PDP (§18–§22) | +|---|---|---| +| **Gate 3 enforcement** | Keycloak scope-to-role evaluation | OPA outbound plugin | +| **Policy change mechanism** | `POST /roles-by-id/{id}/composites` | OPA Rego policy update | +| **Client roles required** | Yes (scope-to-role mapping targets) | No | +| **Composite role mappings** | Yes (policy lever) | No | +| **`fullScopeAllowed`** | `false` (role gate enabled) | `true` (OPA owns the gate) | +| **Keycloak objects per boundary** | Realm role + client role + scope-mapping + composite | Audience scope only | +| **Policy redeployment on change** | No (Keycloak API call only) | No (OPA policy update only) | +| **AuthBridge changes on policy change** | None | None | diff --git a/aiac/docs/specs/ARCHITECTURE-SUMMARY.md b/aiac/docs/specs/ARCHITECTURE-SUMMARY.md index 5ad1adedd..c589177d0 100644 --- a/aiac/docs/specs/ARCHITECTURE-SUMMARY.md +++ b/aiac/docs/specs/ARCHITECTURE-SUMMARY.md @@ -2,7 +2,7 @@ ## Abstract -AI-based Access Control (AIAC) is a Rossoctl platform extension that automates RBAC/ABAC policy +AI-based Access Control (AIAC) is a Kagenti platform extension that automates RBAC/ABAC policy enforcement for AI agents running on Kubernetes. A LangGraph-based AI agent continuously translates a natural-language access control policy — stored in a vector knowledge base — into concrete permission configurations in the active Policy Decision Point (PDP), eliminating manual policy @@ -14,7 +14,7 @@ management (subjects, roles, services). ## Problem Description -Rossoctl AI agents call services across a shared platform. Every call must carry a token scoped to +Kagenti AI agents call services across a shared platform. Every call must carry a token scoped to exactly the permissions the caller's role entitles on the target service. Without a dedicated policy management layer, access policy ends up scattered across per-deployment configuration, creating three compounding problems: @@ -38,12 +38,15 @@ AIAC introduces a strict three-layer model that cleanly separates policy concern | **Policy Decision (PDP)** | OPA | Evaluates LLM-generated Rego rules; returns the caller's entitlements/decision | | **Policy Enforcement (PEP)** | AuthBridge | Intercepts traffic; exchanges tokens; carries no policy knowledge | -The AIAC Agent subscribes to an event stream (NATS JetStream) and reacts to entity lifecycle -events — new services, role changes, policy updates — by retrieving the current policy from a RAG -knowledge base, querying live PDP state, and applying the minimal required diff via a dedicated -PDP Policy Writer. AuthBridge performs RFC 8693 token exchanges sending only the target +The AIAC Agent reacts to entity lifecycle events — new services, role changes, policy updates — +by retrieving the current policy from a RAG knowledge base, querying live PDP state, and applying +the minimal required diff via a dedicated PDP Policy Writer. _(**Phase status:** in **Phase 1** the +Agent is triggered by **direct invocation of the Controller `/apply/*` routes** — by the operator or +the integration harness. The event-driven path — a NATS JetStream subscription fed by the Keycloak +SPI listener — is **Phase 2** (Event Broker, issue 4.19), consistent with [`event-broker.md`](components/event-broker.md).)_ AuthBridge performs RFC 8693 token exchanges sending only the target `audience` — no `scope` parameter. OPA evaluates the caller's role against the Rego rules and -issues a token containing exactly the entitlements that role grants on the target service. +returns the entitlements that role grants on the target service; the IdP (Keycloak, via +AuthBridge) issues the exchanged token scoped to exactly those entitlements. **Policy intent lives entirely in OPA, kept current by AIAC.** --- @@ -54,8 +57,9 @@ issues a token containing exactly the entitlements that role grants on the targe **Trigger:** A Role or Keycloak Client is created, updated, or removed. -The Keycloak SPI listener publishes a scoped event to the Event Broker. The AIAC Agent retrieves -relevant context from the RAG store, reads the current OPA policy state, and asks the LLM to +The trigger reaches the AIAC Agent — in Phase 1 by direct Controller `/apply/*` invocation; in +Phase 2 via a Keycloak SPI listener that publishes a scoped event to the Event Broker. The AIAC +Agent retrieves relevant context from the RAG store, reads the current OPA policy state, and asks the LLM to compute the minimal permission diff scoped to the affected entity. The diff is validated by a second LLM pass and applied to OPA as updated Rego rules. Supports both **auto-apply** (fully automated, least-privilege) and **recommendation + human review** modes. @@ -98,8 +102,8 @@ Eight components across five Kubernetes Pods plus a Python library layer, all im | 3 | **Policy Store** | REST service that owns an in-memory `PolicyModel` cache backed by SQLite as the authoritative structured policy store. Enables the Policy Computation Engine to read current `AgentPolicyModel` state for additive merging. Deployed as a dedicated single-replica StatefulSet (`aiac-policy-store`) at `:7074`. Python library: `aiac.policy.store.library`. | | 4 | **Policy Computation Engine** | Pure Python library module (`aiac.policy.computation`). No service, no Kubernetes deployment. Receives `list[PolicyRule]` from AIAC Agent sub-agents, queries IdP to resolve owning services, additively merges rules into `AgentPolicyModel` objects in the Policy Store, and pushes the updated `PolicyModel` to the PDP Policy Writer. Single entry point: `compute_and_apply(rules)`. | | 5 | **Policy and Domain Knowledge RAG** | ChromaDB vector store holding the access control policy and domain knowledge in persistent, queryable form, populated via a co-located RAG Ingest Service. | -| 6 | **Event Broker** | NATS JetStream pod that decouples event producers (Keycloak SPI listener, RAG Ingest Service) from the AIAC Agent. Provides durable, at-least-once delivery with automatic replay on Agent pod restart. Competing consumer model ensures each event is processed exactly once. | -| 7 | **AIAC Agent** | LangGraph-based AI agent triggered by Event Broker subscriptions (`aiac.apply.>` subjects) and directly by the operator (`rebuild` only). Retrieves the current policy from the RAG store, interprets it against live PDP state, and applies the required policy changes immediately. | +| 6 | **Event Broker** _(Phase 2)_ | NATS JetStream pod that decouples event producers (Keycloak SPI listener, RAG Ingest Service) from the AIAC Agent. Provides durable, at-least-once delivery with automatic replay on Agent pod restart. Competing consumer model ensures each event is processed exactly once. **Deferred to Phase 2 (issue 4.19); not deployed in Phase 1** — see [`event-broker.md`](components/event-broker.md). | +| 7 | **AIAC Agent** | LangGraph-based AI agent. **Phase 1:** triggered by **direct Controller `/apply/*` invocation** (operator / integration harness). **Phase 2:** additionally triggered by Event Broker subscriptions (`aiac.apply.>` subjects). Retrieves the current policy from the RAG store, interprets it against live PDP state, and applies the required policy changes immediately. | | 8 | **Python library** | Python API library provides typed access to IdP and policy services via `aiac.idp.configuration`, `aiac.policy.model`, `aiac.policy.store.library`, `aiac.pdp.policy.library`, and `aiac.policy.computation` modules backed by generic Pydantic models. | ``` @@ -108,7 +112,7 @@ Eight components across five Kubernetes Pods plus a Python library layer, all im │ | (𝘶𝘴𝘦𝘳𝘴, 𝘳𝘰𝘭𝘦𝘴, 𝘤𝘭𝘪𝘦𝘯𝘵𝘴) (𝘈𝘶𝘵𝘩𝘰𝘳𝘪𝘻𝘢𝘵𝘪𝘰𝘯𝘗𝘰𝘭𝘪𝘤𝘺 𝘊𝘙) ┌──────────────┼──────────────────────┼───────────────────┐ -│ Rossoctl Interface Pod │ │ +│ Kagenti Interface Pod │ │ │ │ │ │ │ ┌───────┴──────┐ ┌────────┴───────┐ │ │ │ IdP Config │ │ PDP Policy │ │ @@ -154,13 +158,14 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi --- -## Rossoctl / Keycloak / OPA Interfaces +## Kagenti / Keycloak / OPA Interfaces -**AIAC ↔ Rossoctl platform** +**AIAC ↔ Kagenti platform** The AIAC Agent reads `AgentRuntime` and `AgentCard` custom resources from the Kubernetes API to -extract service metadata during UC-1 service onboarding. The `aiac.idp.library` and -`aiac.pdp.library` Python packages are the integration surface for other Rossoctl components -needing typed access to IdP configuration and PDP policy state. +extract service metadata during UC-1 service onboarding. The `aiac.idp.configuration.api` and +`aiac.pdp.policy.library.api` Python modules are the integration surface for other Kagenti components +needing typed access to IdP configuration and PDP policy state. (Each library package keeps an +empty `__init__.py`; the public functions live in its `.api` submodule.) **AIAC ↔ Keycloak** The IdP Configuration Service proxies Keycloak Admin REST endpoints under generic IdP entity diff --git a/aiac/docs/specs/PRD.md b/aiac/docs/specs/PRD.md index 1f3be5a8b..339ee2a53 100644 --- a/aiac/docs/specs/PRD.md +++ b/aiac/docs/specs/PRD.md @@ -2,7 +2,7 @@ ## Abstract -AI-based Access Control (AIAC) is a Rossoctl platform extension that automates RBAC/ABAC policy +AI-based Access Control (AIAC) is a Kagenti platform extension that automates RBAC/ABAC policy enforcement for AI agents running on Kubernetes. A LangGraph-based AI agent continuously translates a natural-language access control policy — stored in a vector knowledge base — into concrete permission configurations in the active Policy Decision Point (PDP), eliminating manual policy @@ -14,7 +14,7 @@ management (subjects, roles, services). ## 1. Problem Description -Rossoctl AI agents call services across a shared platform. Every call must carry a token scoped to +Kagenti AI agents call services across a shared platform. Every call must carry a token scoped to exactly the permissions the caller's role entitles on the target service. Without a dedicated policy management layer, access policy ends up scattered across per-deployment configuration, creating three compounding problems: @@ -55,7 +55,7 @@ AIAC enforces a strict three-layer model: | **Policy Decision (PDP)** | OPA | Evaluates LLM-generated Rego rules; decides what a caller may access | | **Policy Enforcement (PEP)** | AuthBridge | Intercepts traffic; exchanges tokens; carries no policy knowledge | -The PEP (AuthBridge) is a pure enforcement layer. It performs RFC 8693 token exchanges sending only the target `audience` — no `scope` parameter. OPA evaluates the caller's role against the Rego rules and returns exactly the entitlements that role grants on the target service; Keycloak, as the authorization server, issues the token scoped to those entitlements. +The PEP (AuthBridge) is a pure enforcement layer. It performs RFC 8693 token exchanges sending only the target `audience` — no `scope` parameter. OPA evaluates the caller's role against the Rego rules and returns the entitlements that role grants on the target service; the IdP (Keycloak, via AuthBridge) issues the exchanged token scoped to exactly those entitlements. This means `token_scopes` is absent from `authproxy-routes`. Route configuration carries routing intent only (`host` → `target_audience`). Policy intent lives entirely in OPA, kept current by AIAC. @@ -125,7 +125,7 @@ Eight components across five Kubernetes Pods plus a Python library layer, all im │ | (𝘶𝘴𝘦𝘳𝘴, 𝘳𝘰𝘭𝘦𝘴, 𝘤𝘭𝘪𝘦𝘯𝘵𝘴) (𝘈𝘶𝘵𝘩𝘰𝘳𝘪𝘻𝘢𝘵𝘪𝘰𝘯𝘗𝘰𝘭𝘪𝘤𝘺 𝘊𝘙) ┌──────────────┼──────────────────────┼───────────────────┐ -│ Rossoctl Interface Pod │ │ +│ Kagenti Interface Pod │ │ │ │ │ │ │ ┌───────┴──────┐ ┌────────┴───────┐ │ │ │ IdP Config │ │ PDP Policy │ │ @@ -271,10 +271,10 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi | Component | Called by | Calls | Returns | |-----------|-----------|-------|---------| -| IdP Configuration Service (in Rossoctl Interface Pod) | `aiac.idp.configuration.api` | Keycloak Admin REST API | Raw Keycloak JSON (generic endpoint names) | -| PDP Policy Writer — OPA (in Rossoctl Interface Pod) | `aiac.pdp.policy.library` | Kubernetes CR (`AuthorizationPolicy`) | 204 on success | +| IdP Configuration Service (in Kagenti Interface Pod) | `aiac.idp.configuration.api` | Keycloak Admin REST API | Raw Keycloak JSON (generic endpoint names) | +| PDP Policy Writer — OPA (in Kagenti Interface Pod) | `aiac.pdp.policy.library` | Kubernetes CR (`AuthorizationPolicy`) | 204 on success | | Policy Store (StatefulSet `aiac-policy-store`) | `aiac.policy.store.library` | SQLite (`agent_policies` table, in-memory cache) | `AgentPolicyModel` / `PolicyModel` on read; 204 on write | -| Policy Computation Engine (`aiac.policy.computation`) | AIAC Agent sub-UC agents | `aiac.idp.configuration.api`, `aiac.policy.store.library`, `aiac.pdp.policy.library` | None (fire-and-forget; logs exceptions) | +| Policy Computation Engine (`aiac.policy.computation`) | AIAC Agent sub-UC agents | `aiac.idp.configuration.api`, `aiac.policy.store.library`, `aiac.pdp.policy.library` | `None` on success; exceptions logged and re-raised (propagate to the caller) | | `aiac.idp.configuration.models` | `aiac.idp.configuration.api`, `aiac.policy.model`, AIAC Agent | — | Pydantic model definitions for IdP entities (Subject, Role, Service, Scope) | | `aiac.idp.configuration.api` | AIAC Agent, Policy Computation Engine, Python scripts | IdP Configuration Service (HTTP) | Typed Pydantic instances (reads and writes IdP configuration entities) | | `aiac.policy.model` | `aiac.pdp.policy.library`, `aiac.policy.store.library`, `aiac.policy.computation`, AIAC Agent | — | Pydantic model definitions for policy entities (PolicyRule, AgentPolicyModel, PolicyModel) | @@ -287,7 +287,7 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi ### Key architectural decisions -- **Stateless PDP services are co-located in the Rossoctl Interface Pod; the stateful Policy Store is separate.** IdP Configuration Service and PDP Policy Writer run as two containers in the Interface Pod, sharing a Kubernetes ServiceAccount. The Policy Store is a dedicated single-replica StatefulSet (`aiac-policy-store`) with its own PVC — decoupled from the Interface Pod's restart lifecycle. Three ClusterIP Services (`aiac-pdp-config-service:7071`, `aiac-pdp-policy-service:7072`, `aiac-policy-store-service:7074`) provide stable addressing. +- **Stateless PDP services are co-located in the Kagenti Interface Pod; the stateful Policy Store is separate.** IdP Configuration Service and PDP Policy Writer run as two containers in the Interface Pod, sharing a Kubernetes ServiceAccount. The Policy Store is a dedicated single-replica StatefulSet (`aiac-policy-store`) with its own PVC — decoupled from the Interface Pod's restart lifecycle. Three ClusterIP Services (`aiac-pdp-config-service:7071`, `aiac-pdp-policy-service:7072`, `aiac-policy-store-service:7074`) provide stable addressing. - **Policy Computation Engine is a library, not a service.** `aiac.policy.computation` runs in-process within the AIAC Agent pod. It requires no Kubernetes deployment, no container image, and no ClusterIP Service. Sub-agents call `compute_and_apply(rules)` directly. - **One CR + one SQLite store, distinct owners, distinct purposes.** The Policy Store owns a SQLite `agent_policies` table (backed by a 1 Gi RWO PVC) holding structured `AgentPolicyModel` data — the source of truth for policy state, served from an in-memory cache. The `AuthorizationPolicy` CR (one total, owned by the PDP Policy Writer) holds derived Rego packages for OPA runtime. The two services have no dependency on each other; both are driven by the PCE via their respective libraries. - **`aiac.pdp.policy.library` has one caller: `aiac.policy.computation`.** AIAC Agent sub-agents do not call the PDP Policy Library directly; they call `compute_and_apply()` instead. This centralises all Policy Store ↔ PDP Policy Writer coordination. @@ -295,7 +295,7 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi - **`aiac.policy.model` is dependency-free (only `pydantic` + `aiac.idp.configuration.models`).** `PolicyRule`, `AgentPolicyModel`, and `PolicyModel` live in a neutral namespace importable by any consumer — Policy Store library, PDP Policy Library, PCE — without forcing a dependency on any service namespace. - **`PolicyRule.role` and `PolicyRule.scope` are typed objects.** They hold `Role` and `Scope` instances from `aiac.idp.configuration.models`, enabling the PCE to call `Configuration.get_services_by_role` and `Configuration.get_services_by_scope` without additional type conversion. - **`AgentPolicyModel` relationship maps are keyed by string `id`.** `source_roles`, `subject_roles`, and `target_scopes` use the entity's string `id` as the dict key, so `Service`, `Role`, `Scope`, and `Subject` need no custom hash/eq and keep pydantic's default field-based equality. This also lets the maps serialize to JSON without a custom key serializer. -- **PCE merge semantics are additive only.** New rules are appended to existing `inbound_rules`/`outbound_rules`; existing rules are never removed. Rule revocation is TBD. +- **PCE merge semantics are additive, with drift-GC and an authoritative offboard.** The default merge (`override=False`) is additive — new rules are appended to a service's SPM `inbound_rules` (dedup by `role.id + scope.id`); existing edges are preserved. Two mechanisms remove edges: (1) **reconcile drift-GC** prunes each *touched* SPM against the `get_services()` catalog on every write, dropping edges whose scope or agent-role no longer exists and collapsing churned/duplicate user-role generations (order-independent; skipped on a catalog miss so a transient outage never wipes an SPM); and (2) **`decommission(service_id)`** — the authoritative service **offboard** — deletes a decommissioned service's SPM, purges its outbound footprint from other SPMs, deletes its APM/Rego if it was an agent, and re-derives every affected agent (keyed by clientId, since an offboarded client is gone from `get_services()`). Fine-grained **single-rule** revocation is still TBD; `override=True` gives role-level replace. - **PDP services bind to `0.0.0.0`.** Exposed as Kubernetes ClusterIP Services so that the Agent Pod can reach them over the cluster network. - **RBAC via OPA Rego rules.** AIAC manages role → service permission mappings by writing `AgentPolicyModel` instances to the `AuthorizationPolicy` CR. Each agent pod's OPA plugin fetches its packages from the CR at startup. - **RAG Pod is a StatefulSet with persistent ChromaDB storage.** ChromaDB data is stored on a 1 Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/chroma/chroma` (ChromaDB default). On pod recreation, the StatefulSet rebinds the same PVC and ChromaDB resumes from persisted state without re-ingestion. The pod runs a single replica. @@ -306,18 +306,18 @@ All inter-pod traffic is Kubernetes ClusterIP. External access is exclusively vi - **NATS consumer is a thin adapter.** It receives events from the Event Broker and calls the same internal handler functions used by the debug HTTP endpoints. No business logic lives in the consumer. - **Agent HTTP endpoints are retained for debugging.** They are not the primary trigger path; the NATS consumer is. `kubectl port-forward` to the Agent is used only for `rebuild` and debugging. - **Event Broker uses WorkQueuePolicy.** Messages are removed from the stream after acknowledgement. Unacknowledged messages survive Agent pod restarts and are redelivered automatically. After 5 failed deliveries, messages are routed to `aiac.apply.dlq`. -- **AIAC init container gates Agent startup.** Before the Agent container starts, the init container waits for NATS, IdP Configuration Service, PDP Policy Writer, and RAG Ingest Service to be healthy, then creates the `aiac-events` JetStream stream idempotently. +- **AIAC init container gates Agent startup.** Before the Agent container starts, the init container waits for NATS, IdP Configuration Service, PDP Policy Writer, and RAG Ingest Service to be healthy, then creates the `aiac-events` JetStream stream idempotently. _(Deferred to Phase 2 — issue 4.21; the Phase 1 Agent pod runs without it.)_ - **All `__init__.py` files under `aiac.*` are empty.** Callers use explicit submodule paths: `from aiac.idp.configuration.models import Subject`, `from aiac.policy.model.models import PolicyModel`. - **ChromaDB hosts two collections: `aiac-policies` and `aiac-domain-knowledge`.** Collection slug to ChromaDB name mapping: `policy` → `aiac-policies`, `domain-knowledge` → `aiac-domain-knowledge`. - **`user/{id}` trigger not implemented.** OPA rules are role-scoped; individual user creation/update does not require agent intervention — OPA rule evaluation resolves entitlements from the caller's role automatically. --- -## 6. Rossoctl / Keycloak / OPA Interfaces +## 6. Kagenti / Keycloak / OPA Interfaces -**AIAC ↔ Rossoctl platform** +**AIAC ↔ Kagenti platform** The AIAC Agent reads `AgentRuntime` and `AgentCard` custom resources from the Kubernetes API to -extract service metadata during UC-1 service onboarding. The `aiac.idp.configuration` and `aiac.pdp.policy.library` Python packages are the integration surface for other Rossoctl components needing typed access to the IdP and PDP respectively. +extract service metadata during UC-1 service onboarding. The `aiac.idp.configuration` and `aiac.pdp.policy.library` Python packages are the integration surface for other Kagenti components needing typed access to the IdP and PDP respectively. **AIAC ↔ Keycloak** The IdP Configuration Service proxies Keycloak Admin REST endpoints under generic entity names (subjects, roles, services, scopes, assignments). Read endpoints include per-service role and scope enrichment. The Keycloak SPI listener publishes entity lifecycle events to NATS; it is a separate component outside the AIAC codebase. @@ -336,7 +336,7 @@ See Section 7.5 (Event Broker) and Section 8 (Deployment) for subject names and ### 7.1 IdP Configuration Service -FastAPI service (`0.0.0.0:7071`) co-located with the PDP Policy Writer in the **Rossoctl Interface Pod**. Manages IdP (Keycloak) entity data (subjects, roles, services, scopes) via Keycloak Admin REST API. Exposes read and write endpoints for configuration entities. Stateless. All endpoints except `/health` require a `?realm=` query parameter; returns `422` if absent. `/health` requires no realm parameter — it uses `KEYCLOAK_ADMIN_REALM` directly. `KeycloakAdmin` instances are created lazily per realm and cached in a thread-safe map; the admin always authenticates via the realm in `KEYCLOAK_ADMIN_REALM`. +FastAPI service (`0.0.0.0:7071`) co-located with the PDP Policy Writer in the **Kagenti Interface Pod**. Manages IdP (Keycloak) entity data (subjects, roles, services, scopes) via Keycloak Admin REST API. Exposes read and write endpoints for configuration entities. Stateless. All endpoints except `/health` require a `?realm=` query parameter; returns `422` if absent. `/health` requires no realm parameter — it uses `KEYCLOAK_ADMIN_REALM` directly. `KeycloakAdmin` instances are created lazily per realm and cached in a thread-safe map; the admin always authenticates via the realm in `KEYCLOAK_ADMIN_REALM`. **Full spec:** [components/idp-configuration-service.md](components/idp-configuration-service.md) @@ -344,7 +344,7 @@ FastAPI service (`0.0.0.0:7071`) co-located with the PDP Policy Writer in the ** ### 7.2 PDP Policy Writer -FastAPI service (`0.0.0.0:7072`, `aiac-pdp-policy-opa`) co-located with the IdP Configuration Service in the **Rossoctl Interface Pod**. Writes LLM-generated Rego packages to an `AuthorizationPolicy` Kubernetes CR. Each AuthBridge OPA plugin instance fetches its Rego packages from the CR at startup. +FastAPI service (`0.0.0.0:7072`, `aiac-pdp-policy-opa`) co-located with the IdP Configuration Service in the **Kagenti Interface Pod**. Writes LLM-generated Rego packages to an `AuthorizationPolicy` Kubernetes CR. Each AuthBridge OPA plugin instance fetches its Rego packages from the CR at startup. **Full spec:** [components/pdp-policy-writer-opa.md](components/pdp-policy-writer-opa.md) @@ -362,7 +362,7 @@ FastAPI service (`0.0.0.0:7074`, `aiac-policy-store-service`) deployed as a dedi Pure Python library module (`aiac.policy.computation`). No FastAPI, no Kubernetes deployment, no container image. Runs in-process within the AIAC Agent pod. AIAC Agent sub-UC agents call `compute_and_apply(rules: list[PolicyRule]) -> None` to translate partial policy rule lists into merged `AgentPolicyModel` objects and push them to OPA. -The PCE is the **single point of coordination** between the Policy Store and PDP Policy Writer: it reads current agent policy state, additively merges new rules, writes back to the Policy Store, then pushes the updated `PolicyModel` to `aiac.pdp.policy.library.apply_policy()`. All exceptions are logged; none propagate to the caller. +The PCE is the **single point of coordination** between the Policy Store and PDP Policy Writer: it reads current agent policy state, additively merges new rules, writes back to the Policy Store, then pushes the updated `PolicyModel` to `aiac.pdp.policy.library.apply_policy()`. All exceptions from any dependency (IdP, Policy Store, PDP) are logged and **re-raised** so the caller (the Controller / HTTP layer) surfaces the failure — e.g. as a 500 — instead of returning success while silently applying nothing. **Full spec:** [components/policy-computation-engine.md](components/policy-computation-engine.md) @@ -408,7 +408,7 @@ FastAPI + LangGraph service (`0.0.0.0:7070`). Receives automated triggers via th | Policy Update | `aiac.apply.policy.build`, `/apply/policy/rebuild` (HTTP) | Build sub-agent or Rebuild sub-agent (alternative) | | Role Update | `aiac.apply.role.{id}` | Role sub-agent | -All sub-agent `StateGraph` instances are logically separated modules running within a single pod and process. Sub-UC agents produce `list[PolicyRule]` and call `compute_and_apply(rules)` — they do not call `aiac.policy.store.library` or `aiac.pdp.policy.library` directly. The **Policy Update** sub-agents compute a minimal rule delta between the current ChromaDB policy and live OPA state. The **Rebuild** variant additionally clears the Policy Store and all OPA policy rules before recomputing. The **Role Update** orchestrator computes rules for all services affected by the role change. The **Service Onboarding** orchestrator classifies the new service via the pod's `rossoctl.io/type` label (for agents reads the `AgentCard` CR; for tools calls `tools/list` on the MCP endpoint discovered via K8s Service label lookup), then computes rules and calls `compute_and_apply`. Stateless; changes are applied immediately. Integrated retry with differentiated error codes per upstream. +All sub-agent `StateGraph` instances are logically separated modules running within a single pod and process. Sub-UC agents produce `list[PolicyRule]` and call `compute_and_apply(rules)` — they do not call `aiac.policy.store.library` or `aiac.pdp.policy.library` directly. The **Policy Update** sub-agents compute a minimal rule delta between the current ChromaDB policy and live OPA state. The **Rebuild** variant additionally clears the Policy Store and all OPA policy rules before recomputing. The **Role Update** orchestrator computes rules for all services affected by the role change. The **Service Onboarding** orchestrator classifies the new service via the pod's `kagenti.io/type` label (for agents reads the `AgentCard` CR; for tools calls `tools/list` on the MCP endpoint discovered via K8s Service label lookup), then computes rules and calls `compute_and_apply`. Stateless; changes are applied immediately. Integrated retry with differentiated error codes per upstream. **Full spec:** [components/aiac-agent.md](components/aiac-agent.md) @@ -452,32 +452,32 @@ Four separate manifest files: | File | Contents | |------|----------| -| `aiac/k8s/pdp-interface-deployment.yaml` | `aiac-pdp-config` ConfigMap + Rossoctl Interface Pod Deployment (IdP Configuration Service container + PDP Policy Writer container) + two ClusterIP Services (`aiac-pdp-config-service:7071`, `aiac-pdp-policy-service:7072`) | +| `aiac/k8s/pdp-interface-deployment.yaml` | `aiac-pdp-config` ConfigMap + Kagenti Interface Pod Deployment (IdP Configuration Service container + PDP Policy Writer container) + two ClusterIP Services (`aiac-pdp-config-service:7071`, `aiac-pdp-policy-service:7072`) | | `aiac/k8s/policy-store-statefulset.yaml` | `aiac-policy-store` StatefulSet (Policy Store container) + `volumeClaimTemplate` (1 Gi, `ReadWriteOnce`, mounted at `/data`) + headless Service + `aiac-policy-store-service:7074` ClusterIP Service | -| `aiac/k8s/agent-deployment.yaml` | Agent Pod Deployment (aiac-init container + AIAC Agent container) + ClusterIP Service | +| `aiac/k8s/agent-deployment.yaml` | Agent Pod Deployment (AIAC Agent container) + ClusterIP Service _(Phase 1; `aiac-init` init container added in Phase 2, issue 4.21)_ | | `aiac/k8s/event-broker-deployment.yaml` _(pending)_ | Event Broker Pod Deployment (NATS JetStream) + ClusterIP Service | | `aiac/k8s/rag-statefulset.yaml` _(pending)_ | RAG StatefulSet (ChromaDB + RAG Ingest Service containers) + 1 Gi PVC template + ClusterIP Service | -The two Interface Pod containers mount `aiac-pdp-config` (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_REALM) and `keycloak-admin-secret` (KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD) as env vars. The IdP Configuration Service uses `KEYCLOAK_ADMIN_REALM` (admin auth realm) and ignores `KEYCLOAK_REALM`; the PDP Policy Writer uses `KEYCLOAK_REALM` as its default operating realm. The Policy Store container mounts `aiac-policy-store-config` for `AGENTPOLICY_DB_PATH` (default `/data/state.db`) — no Kubernetes API access or RBAC required. +Both Interface Pod containers mount `aiac-pdp-config` (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_REALM) as env vars; only the IdP Configuration Service container also mounts `keycloak-admin-secret` (KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD) and uses `KEYCLOAK_ADMIN_REALM` (ignoring `KEYCLOAK_REALM`). The PDP Policy Writer (`aiac-pdp-policy-opa`, the Phase 1 rego-file mock) needs no Keycloak credentials — it writes `.rego` files to `REGO_OUTPUT_DIR` (default `/rego`, an `emptyDir` volume). The Policy Store container mounts `aiac-policy-store-config` for `AGENTPOLICY_DB_PATH` (default `/data/state.db`) — no Kubernetes API access or RBAC required. ### Docker images Built independently. No entry in the repo's `build.yaml` CI matrix. ```bash -# Build IdP Configuration Service (Rossoctl Interface Pod container 1) +# Build IdP Configuration Service (Kagenti Interface Pod container 1) docker build -f aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile -t aiac-pdp-config:latest aiac/src/ -# Build PDP Policy Writer — Phase 1 mock (Rossoctl Interface Pod container 2; writes Rego to filesystem) -docker build -f aiac/src/aiac/pdp/service/policy/keycloak/Dockerfile -t aiac-pdp-policy-keycloak:latest aiac/src/ - -# Build PDP Policy Writer — Phase 2 OPA (replaces mock via issue 4.18; writes to AuthorizationPolicy CR) +# Build PDP Policy Writer — Phase 1 OPA rego-file mock (Kagenti Interface Pod container 2; writes .rego to filesystem) docker build -f aiac/src/aiac/pdp/service/policy/opa/Dockerfile -t aiac-pdp-policy-opa:latest aiac/src/ +# Legacy: PDP Policy Writer — Keycloak composite-role implementation, superseded, not deployed by any current manifest +docker build -f aiac/src/aiac/pdp/service/policy/keycloak/Dockerfile -t aiac-pdp-policy-keycloak:latest aiac/src/ + # Build Policy Store (deployed as StatefulSet aiac-policy-store) docker build -f aiac/src/aiac/policy/store/service/Dockerfile -t aiac-policy-store:latest aiac/src/ -# Build Agent (includes aiac-init container) +# Build Agent (aiac-init init container deferred to Phase 2, issue 4.21) docker build -f aiac/src/aiac/agent/controller/Dockerfile -t aiac-agent:latest aiac/src/ # Build RAG Ingest Service @@ -495,12 +495,14 @@ metadata: name: aiac-pdp-config data: KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "rossoctl" + KEYCLOAK_REALM: "kagenti" KEYCLOAK_ADMIN_REALM: "master" AIAC_PDP_CONFIG_URL: "http://aiac-pdp-config-service:7071" AIAC_PDP_POLICY_URL: "http://aiac-pdp-policy-service:7072" AIAC_POLICY_STORE_URL: "http://aiac-policy-store-service:7074" + # Added in Phase 2 by issue 4.19 (Event Broker): NATS_URL: "nats://aiac-event-broker-service:4222" + # Added in Phase 3 by issue 4.20 (RAG Pod): AIAC_RAG_INGEST_URL: "http://aiac-rag-service:7073" AIAC_CHROMADB_URL: "http://aiac-rag-service:8000" ``` @@ -537,7 +539,7 @@ Tests live in `aiac/test/`. | `aiac.policy.model` | No mock needed | `extra='ignore'` drops unknown fields; relationship maps keyed by string `id` round-trip through `model_dump(mode="json")` / `model_validate` with typed `Role` / `Scope` values preserved | | `aiac.idp.configuration.api` functions | IdP Configuration Service HTTP endpoints | Returns correct Pydantic model instances; `RuntimeError` on non-2xx; default URL fallback; `get_services_by_role` and `get_services_by_scope` issue correct query params | | `aiac.pdp.policy.library` functions | PDP Policy Writer HTTP endpoints | Correct serialisation; `RuntimeError` on non-2xx; default URL fallback | -| `aiac.policy.computation` | `aiac.idp.configuration.api`, `aiac.policy.store.library`, `aiac.pdp.policy.library` (import-boundary mocks) | Correct `apply_agent_policy` calls per resolved service; additive merge preserves existing rules; no duplicate rule insertion; `apply_policy` called once after all writes; exceptions logged, not propagated | +| `aiac.policy.computation` | `aiac.idp.configuration.api`, `aiac.policy.store.library`, `aiac.pdp.policy.library` (import-boundary mocks) | Correct `apply_agent_policy` calls per resolved service; additive merge preserves existing rules; no duplicate rule insertion; `apply_policy` called once after all writes; exceptions logged and re-raised (propagate to the caller) | | Event Broker NATS consumer | NATS message delivery (mock `nats-py` subscription) | Correct handler dispatched per subject; ack issued on success; no ack on handler exception | | Event Broker DLQ | NATS max redelivery exceeded | Message routed to `aiac.apply.dlq` after 5 failures | | Init container health-check | HTTP 4xx then 200 sequence; NATS TCP refused then connected | Exits 0 only after all four dependencies healthy; `add_stream` called with correct config | @@ -571,9 +573,9 @@ Beyond the marker-gated pytest tests above, individual integration tests are spe |---|---|---| | PDP Policy Writer — `generate_rego.py` | Standalone launcher (no Docker) that boots the OPA stub locally, applies a `PolicyModel` through `aiac.pdp.policy.library`, and writes the generated Rego to a known directory for manual inspection. Write-only; not `@pytest.mark.integration`. | [integration-test/pdp-policy-writer.md](integration-test/pdp-policy-writer.md) | | `policy-pipeline` — `policy_pipeline.py` | Standalone launcher (no Docker) driving the full identity→policy pipeline — provisions a Keycloak realm + entities, runs the three PRB mappings, applies via the PCE, and writes the generated Rego to a known directory for manual inspection. Write-only; not `@pytest.mark.integration`. | [integration-test/policy-pipeline.md](integration-test/policy-pipeline.md) | -| `uc1-onboarding-pipeline` — `test_uc1_onboarding_pipeline.py` | Discovery-driven sibling of `policy-pipeline` validating the **phase-1** deliverable: deploys the real `github-agent` + a simplified `github-tool` to a live Rossoctl cluster, drives **real UC-1 onboarding** (`POST /apply/service/{id}`) to infer roles/scopes, and asserts the generated Rego with `opa eval`. Same scenario facts/tables as `policy-pipeline`; Rego is semantically similar (not byte-identical). `@pytest.mark.integration`. | [integration-test/uc1-onboarding-pipeline.md](integration-test/uc1-onboarding-pipeline.md) | +| `uc1-onboarding-pipeline` — a **ladder** of UC-1 onboarding tests | Discovery-driven sibling of `policy-pipeline` validating the **phase-1** deliverable against **one** in-cluster AIAC stack (OPA filesystem-stub writer, single abstract `policy.md`): with `github-agent` + a simplified `github-tool` **already deployed and registered** as Keycloak clients, three gradual rungs drive **real UC-1 onboarding** (`POST /apply/service/{id}`) — agent-only, agent→tool, tool→agent — and assert the generated Rego with `opa eval` (verdicts from `scenario_uc1.py`). Rungs 2/3 assert onboarding-**order-independence**. A fourth two-policy rung is **deferred** (two-stack topology discarded). Same scenario facts/tables as `policy-pipeline`; Rego semantically similar (not byte-identical). `@pytest.mark.integration`. | [integration-test/uc1-onboarding-pipeline.md](integration-test/uc1-onboarding-pipeline.md) | -Tracking issues: the live-Keycloak pytest integration tests in `testing/5.1-integration-tests.md`; the PDP Policy Writer integration test in `testing/5.2-pdp-writer-integration-test.md`; the policy-pipeline integration test in `testing/5.3-policy-pipeline-integration-test.md`; the UC-1 onboarding pipeline integration test in `testing/5.4-uc1-onboarding-integration-test.md`. +Tracking issues: the live-Keycloak pytest integration tests in `testing/5.1-integration-tests.md`; the PDP Policy Writer integration test in `testing/5.2-pdp-writer-integration-test.md`; the policy-pipeline integration test in `testing/5.3-policy-pipeline-integration-test.md`; the UC-1 onboarding pipeline integration-test ladder in `testing/5.4-uc1-onboarding-integration-test.md` (epic) with rungs `testing/5.4.1`/`5.4.2`/`5.4.3` and the deferred two-policy `testing/5.4.4`. --- diff --git a/aiac/docs/specs/components/aiac-agent.md b/aiac/docs/specs/components/aiac-agent.md index 5b2952c52..69f5d8547 100644 --- a/aiac/docs/specs/components/aiac-agent.md +++ b/aiac/docs/specs/components/aiac-agent.md @@ -88,6 +88,8 @@ A thin adapter started as an **asyncio background task** in the FastAPI `lifespa | `aiac.apply.role.{id}` | Role Update sub-agent (UC3, via Controller) | | `aiac.apply.policy.build` | Policy Update Build sub-agent (UC2, via Controller) | +> **Follow-up:** `aiac.apply.offboard.{id}` (Service Offboarding, UC4) is the intended subject for event-driven offboard. It is **not yet wired** into the consumer — offboard is reachable today only via the `POST /apply/offboard/{service_id}` HTTP route. + ### Ack contract The consumer **awaits** the internal handler before issuing the NATS acknowledgement. On handler success → ack. On handler exception → do not ack; NATS redelivers after `AckWait`. After 5 unacknowledged redeliveries, NATS routes the message to `aiac.apply.dlq`. @@ -129,12 +131,13 @@ Each use case (and the UC1 Orchestrator) is specified in a dedicated sub-PRD: | Service Onboarding | [aiac-agent/uc1-service-onboarding.md](aiac-agent/uc1-service-onboarding.md) | `aiac.apply.service.{id}`, `POST /apply/service/{id}` | Orchestrator sequences: Service Provision → Service Policy Builder (IdP reader + PRB invoker) | | Policy Update | [aiac-agent/uc2-policy-update.md](aiac-agent/uc2-policy-update.md) | `aiac.apply.policy.build`, `POST /apply/policy/build`, `POST /apply/policy/rebuild` | | | Role Update | [aiac-agent/uc3-role-update.md](aiac-agent/uc3-role-update.md) | `aiac.apply.role.{id}`, `POST /apply/role/{id}` | | +| Service Offboarding | (see PCE `decommission`) | `POST /apply/offboard/{service_id}` (`aiac.apply.offboard.{id}` — NATS wiring is a follow-up) | Thin sub-agent; calls the PCE's `decommission(service_id)` **directly** (whole-service teardown, not a rule fold — bypasses the PRB and `compute_and_apply`). Keyed by **clientId, not UUID** (an offboarded client is gone from `get_services()`). | -> **Note:** Each producing sub-agent calls the **shared Policy Rules Builder** directly, merges the results, and returns `list[PolicyRule]` to the Controller. The Controller calls `compute_and_apply(merged_rules)` from `aiac.policy.computation` (PCE) once. Policy rule application is fully specified in [policy-computation-engine.md](policy-computation-engine.md). The Policy Rules Builder is specified in [aiac-agent/policy-rules-builder.md](aiac-agent/policy-rules-builder.md). +> **Note:** Each producing sub-agent (UC1–UC3) calls the **shared Policy Rules Builder** directly, merges the results, and returns `list[PolicyRule]` to the Controller. The Controller calls `compute_and_apply(merged_rules)` from `aiac.policy.computation` (PCE) once. Policy rule application is fully specified in [policy-computation-engine.md](policy-computation-engine.md). The Policy Rules Builder is specified in [aiac-agent/policy-rules-builder.md](aiac-agent/policy-rules-builder.md). **UC4 (Service Offboarding) is the exception:** it produces no rules — its handler resolves the clientId and calls the PCE's authoritative `decommission(service_id)` (specified in [policy-computation-engine.md → Decommission](policy-computation-engine.md#decommission-service-offboard)) to tear down the service's entire policy footprint. ### IdP access — library, not service -Every sub-agent (UC1 Provision + Service Policy Builder, UC2 Build + Rebuild, UC3 Role) performs **all** IdP reads and writes through the **idp-library** API — `aiac.idp.configuration.api.Configuration` — and **never** calls the IdP Configuration **service** (`aiac.idp.service.configuration.*`) or its HTTP endpoints directly. The library owns the HTTP transport, retry/backoff, and Keycloak↔model mapping; sub-agents depend only on its typed `Configuration` methods (e.g. `get_service`, `get_roles`, `get_scopes`, `create_service_role`, `create_service_scope`, `set_service_type`). The shared service-type vocabulary is `aiac.idp.configuration.models.ServiceType` (`Agent`/`Tool`) — the same enum used by `Service.type`. See [library-idp.md](library-idp.md). +Every sub-agent (UC1 Provision + Service Policy Builder, UC2 Build + Rebuild, UC3 Role) performs **all** IdP reads and writes through the **idp-library** API — `aiac.idp.configuration.api.Configuration` — and **never** calls the IdP Configuration **service** (`aiac.idp.service.configuration.*`) or its HTTP endpoints directly. The library owns the HTTP transport, retry/backoff, and Keycloak↔model mapping; sub-agents depend only on its typed `Configuration` methods (e.g. `get_service`, `get_services`, `get_subjects`, `get_scopes`, `create_service_role`, `create_service_scope`, `set_service_type`). The shared service-type vocabulary is `aiac.idp.configuration.models.ServiceType` (`Agent`/`Tool`) — the same enum used by `Service.type`. See [library-idp.md](library-idp.md). --- @@ -146,6 +149,9 @@ Every sub-agent (UC1 Provision + Service Policy Builder, UC2 Build + Rebuild, UC | POST | `/apply/policy/rebuild` | Policy Update | Rebuild | | POST | `/apply/role/{role_id}` | Role Update | Role | | POST | `/apply/service/{service_id}` | Service Onboarding | Provision | +| POST | `/apply/offboard/{service_id}` | Service Offboarding | Offboard (calls PCE `decommission` directly) | + +The `/apply/offboard/{service_id}` path uses the `{service_id:path}` converter (slash-bearing SPIFFE-URI clientIds) and is keyed on the **clientId (SPM key)**, not the Keycloak UUID that `/apply/service/{service_id}` carries — an offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible. All endpoints return bare HTTP status codes: `200 OK` on success (no response body), and the status codes from the Error Handling table on upstream failure. Success responses carry no body; upstream failures are raised as FastAPI `HTTPException`s, so error responses carry FastAPI's default JSON error body (`{"detail": ...}`) alongside the status code. Summary, applied-rule details, and debug information are written to the service log. Validation failures surface as an error status and log entry; detailed reporting is specified in [policy-rules-builder.md](aiac-agent/policy-rules-builder.md). diff --git a/aiac/docs/specs/components/aiac-agent/uc1-service-onboarding.md b/aiac/docs/specs/components/aiac-agent/uc1-service-onboarding.md index 1948779d9..0f1cfd903 100644 --- a/aiac/docs/specs/components/aiac-agent/uc1-service-onboarding.md +++ b/aiac/docs/specs/components/aiac-agent/uc1-service-onboarding.md @@ -54,7 +54,7 @@ flowchart TD **Sequence:** 1. Call `ServiceProvisionGraph.invoke()` → get back `ServiceProvision { roles, scopes }` + `service_type`. -2. Call `ServicePolicyBuilder.build(service_id, service_type)` → get back `list[PolicyRule]`. Service Policy Builder re-reads the service's own roles/scopes from the IdP by `service_id` (Provision has already persisted them), so it needs only the id, not the `ServiceProvision`. +2. Call `ServicePolicyBuilder.build(service_id, service_type)` → get back `list[PolicyRule]`. Service Policy Builder re-resolves the focus service from the IdP catalog by its internal client UUID (`service_id`; Provision has already persisted its roles/scopes), so it needs only the id, not the `ServiceProvision`. 3. Return `(list[PolicyRule], override=False)` to the Controller. No LLM calls, retry logic, or response assembly in the Orchestrator beyond sequencing. @@ -81,11 +81,11 @@ START → classify_service → [analyze_agent | analyze_tool] → provision_serv ### Nodes -- **`classify_service`**: resolves identity + determines service type from the operator's authoritative `rossoctl.io/type` label (values `agent`/`tool`) — **not** from the `entity_id` format. - 1. Store `service_id = trigger.entity_id` (Keycloak `client_id`). - 2. Resolve identity: call `get_service(service_id)` from `aiac.idp.configuration.api` → `client.name`, which the operator sets to `"{namespace}/{workload_name}"` for every workload (agents and tools, SPIRE-enabled or not). Split on the first `/` → store `namespace` and `workload_name`. `502` if `client.name` has no `/` (namespace unrecoverable). +- **`classify_service`**: resolves identity + determines service type from the operator's authoritative `kagenti.io/type` label (values `agent`/`tool`) — **not** from the `entity_id` format. + 1. Store `service_id = trigger.entity_id` (the Keycloak **internal client UUID** — `Service.id` — **not** the `clientId`/`serviceId`). The `/apply/service/{service_id}` route and every downstream lookup (`get_service` → `admin.get_client`, and the builder's focus resolution) are keyed on this UUID because a `clientId` can be a slash-bearing SPIFFE URI that a single path segment cannot carry. + 2. Resolve identity: call `get_service(service_id)` from `aiac.idp.configuration.api` → `client.name`, which the kagenti-operator sets to `"{namespace}/{workload_name}"` for every workload (agents and tools, SPIRE-enabled or not). Split on the first `/` → store `namespace` and `workload_name`. `502` if `client.name` has no `/` (namespace unrecoverable). 3. LIST pods in `namespace`; select the pod owned by `workload_name` via `ownerReferences` (Deployment → ReplicaSet name prefix, or `StatefulSet`/`Sandbox` name match). `502` on Kubernetes API failure or no matching pod. - 4. Read the `rossoctl.io/type` label on that pod and normalize it to a `ServiceType` + 4. Read the `kagenti.io/type` label on that pod and normalize it to a `ServiceType` member via `ServiceType(label.capitalize())` — the label is lowercase (`agent`/`tool`); `ServiceType` values are capitalized (`Agent`/`Tool`): - `agent` → `ServiceType.AGENT`; route to `analyze_agent`. @@ -93,38 +93,53 @@ START → classify_service → [analyze_agent | analyze_tool] → provision_serv - Absent or any other value (normalization raises `ValueError`) → `502` (inconsistent deployment). > K8s access: `list` on `pods` in the target namespace (both paths). - > `rossoctl.io/type` is authoritative — applied by the operator (via the AgentRuntime CR) and propagated to pod labels; it is the operator's own agent/tool discriminator (`SkipReason`, operator `internal/clientreg/names.go`). The operator only registers a Keycloak client for a workload that already carries this label, so it is effectively guaranteed for operator-registered clients; a missing/invalid value still fails loud (`502`, naming the workload + label). The `entity_id` format (SPIFFE vs plain) reflects whether SPIRE is enabled, **not** the service type, so it is not used for classification. + > `kagenti.io/type` is authoritative — applied by the kagenti-operator (via the AgentRuntime CR) and propagated to pod labels; it is the operator's own agent/tool discriminator (`SkipReason`, kagenti-operator `internal/clientreg/names.go`). The operator only registers a Keycloak client for a workload that already carries this label, so it is effectively guaranteed for operator-registered clients; a missing/invalid value still fails loud (`502`, naming the workload + label). The service's `clientId` format (SPIFFE vs plain) reflects whether SPIRE is enabled, **not** the service type, so it is not used for classification (and is why `service_id`/`entity_id` is the slash-free internal UUID, not the clientId). - **`analyze_agent`**: non-LLM node; reads AgentCard CR. - 1. LIST `AgentCard` CRs (`agent.rossoctl.dev/v1alpha1`) in `namespace`; find the one matching `workload_name`. - 2. **AgentCard found** → produce `ServiceProvision`: + 1. LIST `AgentCard` CRs (`agent.kagenti.dev/v1alpha1`) in `namespace`; find the one whose + `spec.targetRef.name` is `workload_name` (the operator names the CR after the Deployment, e.g. + `{workload}-deployment-card`, **not** after the workload — so match by `targetRef`, falling back + to `metadata.name == workload_name` for hand-authored cards). + 2. **AgentCard with synced skills found** → produce `ServiceProvision`: - `roles`: `[RoleDefinition(name=f"{workloadName}.agent", description="Agent role")]` - - `scopes`: `[ScopeDefinition(name=f"{workloadName}.{skill.name}", description=skill.description) for skill in card.skills]` + - `scopes`: `[ScopeDefinition(name=f"{workloadName}.{skill.id}", description=skill.description) for skill in card.status.card.skills]` — + the operator syncs the fetched A2A card onto `status.card`; each skill's machine `id` + (e.g. `source_operations`) is used for the scope name (a stable identifier), not the display + `name` (which may contain spaces). - `reasoning`: `f"derived from AgentCard: {len(skills)} skills"` - 3. **AgentCard not found** (legacy deployment) → produce minimal `ServiceProvision`: + 3. **No AgentCard, or its `status.card` has no synced skills yet** → produce minimal `ServiceProvision`: - `roles`: `[RoleDefinition(name=f"{workloadName}.agent", description="Agent role")]` - `scopes`: `[ScopeDefinition(name=f"{workloadName}.access", description="Default access scope")]` - - `reasoning`: `"partial: no AgentCard found, default scope assigned"` + - `reasoning`: `"partial: no AgentCard found, default scope assigned"` (no CR) or + `"partial: AgentCard has no synced skills, default scope assigned"` (CR present, unsynced). - > K8s access: `list` on `agentcards.agent.rossoctl.dev` in the target namespace. + > K8s access: `list` on `agentcards.agent.kagenti.dev` in the target namespace. -- **`analyze_tool`**: non-LLM node; discovers MCP tools. `namespace` + `workload_name` are already resolved by `classify_service` (from the `client.name` split). MCP endpoint lookup uses the **hybrid Keycloak→K8s strategy** decided in issue `docs/gh-issues/6.2-analyze-tool-lookup-strategy.md`: the Keycloak client name supplied the key `{namespace, workload_name}`; K8s supplies the reachable endpoint. +- **`analyze_tool`**: non-LLM node; discovers MCP tools. `namespace` + `workload_name` are already resolved by `classify_service` (from the `client.name` split). MCP endpoint lookup uses the **hybrid Keycloak→K8s strategy** decided in issue [`6.2`](../../../issues/agent/service-onboarding/6.2-analyze-tool-lookup-strategy.md): the Keycloak client name supplied the key `{namespace, workload_name}`; K8s supplies the reachable endpoint. 1. Locate MCP endpoint: a. GET the K8s `Service` named `workload_name` in `namespace` (operator convention: Service name == workload name). - b. Require the `protocol.rossoctl.io/mcp` label present on that Service; `502` (actionable) if absent — the label is applied at deploy time, not stamped by the operator. + b. Require the `protocol.kagenti.io/mcp` label present on that Service; `502` (actionable) if absent — the label is applied at deploy time, not stamped by the operator. c. Build `http://{workload_name}.{namespace}.svc.cluster.local:{port}/mcp`, where `port` is the Service's first port (not hardcoded). - 2. Call `tools/list` (HTTP POST, MCP protocol) on the resolved endpoint. - 3. Produce `ServiceProvision`: + 2. Mint a discovery token via `Configuration.mint_discovery_token(service_id)` — the tool's MCP + endpoint sits behind its AuthBridge sidecar, whose inbound `jwt-validation` plugin enforces an + `aud` matching the tool's own Keycloak client-id; the config service mints the token as the tool's + own client (client-credentials + a self-audience mapper) so it passes that gate. `502` (actionable, + naming the service id) if minting fails. + 3. Call `tools/list` (HTTP POST, MCP protocol) on the resolved endpoint, sending the minted token as + `Authorization: Bearer `. + 4. Produce `ServiceProvision`: - `roles`: `[]` (tools do not initiate further calls) - `scopes`: `[ScopeDefinition(name=f"{workload_name}.{tool.name}", description=tool.description) for tool in manifest.tools]` - `reasoning`: `f"derived from MCP manifest: {len(tools)} tools"` - 4. Returns `502` on Service/label lookup failure or MCP call failure. + 5. Returns `502` on Service/label lookup failure, discovery-token minting failure, or MCP call failure. > K8s access: `get` on `services` in the workload namespace (tool path). Identity is resolved by `classify_service` (config API). - > MCP path convention: all MCP tool services must serve at `/mcp` and carry the `protocol.rossoctl.io/mcp` label. This label is a **deploy-time prerequisite** — the operator does not stamp it today; automatic stamping is requested upstream (`docs/gh-issues/operator-mcp-label-stamping.md`). Until then it must be applied at deploy time; `analyze_tool` fails loud (`502`, naming the workload + missing label) if it is absent. + > MCP path convention: all MCP tool services must serve at `/mcp` and carry the `protocol.kagenti.io/mcp` label. This label is a **deploy-time prerequisite** — the kagenti-operator does not stamp it today; automatic stamping is requested upstream (`docs/issues/kagenti-operator-mcp-label-stamping.md`). Until then it must be applied at deploy time; `analyze_tool` fails loud (`502`, naming the workload + missing label) if it is absent. + > Discovery auth: the tool's inbound `jwt-validation` plugin stays fully enforcing — there is no + > path bypass for `/mcp`. `analyze_tool` authenticates instead of relaxing the sidecar's auth. - **`provision_service`**: non-LLM node; calls `create_service_role` and `create_service_scope` from `aiac.idp.configuration.api` for each entry in `ServiceProvision`. Reads `service_id` from state. Writes are **idempotent** (create-or-get). - - Also persists the discovered `service_type` onto the Keycloak client via `Configuration.set_service_type(service, service_type)`, which stores it as the **`client.type`** attribute. This is the **authoritative origin** of the attribute that the IdP library's `Service._resolve_keycloak_fields` reads back (see the IdP library spec's type-resolution precedence). No case mapping is needed here: `service_type` is a `ServiceType` (values `Agent`/`Tool`), already matching `client.type` and `Service.type`. Case normalization happens once, upstream, when `classify_service` reads the lowercase `rossoctl.io/type` label. + - Also persists the discovered `service_type` onto the Keycloak client via `Configuration.set_service_type(service, service_type)`, which stores it as the **`client.type`** attribute. This is the **authoritative origin** of the attribute that the IdP library's `Service._resolve_keycloak_fields` reads back (see the IdP library spec's type-resolution precedence). No case mapping is needed here: `service_type` is a `ServiceType` (values `Agent`/`Tool`), already matching `client.type` and `Service.type`. Case normalization happens once, upstream, when `classify_service` reads the lowercase `kagenti.io/type` label. ### State: `OnboardingProvisionState` @@ -132,7 +147,7 @@ Extends `BaseAgentState` with: | Field | Type | Description | |---|---|---| -| `service_id` | `str \| None` | Keycloak `client_id` = `trigger.entity_id` | +| `service_id` | `str \| None` | Keycloak **internal client UUID** (`Service.id`) = `trigger.entity_id` — not the `clientId` | | `namespace` | `str \| None` | From the `client.name` split in `classify_service` (agents and tools) | | `workload_name` | `str \| None` | From the `client.name` split in `classify_service` (agents and tools) | | `service_type` | `ServiceType \| None` | `agent` or `tool`; routing field | @@ -180,36 +195,41 @@ class ServiceProvision(BaseModel): **Nature:** deterministic IdP reader + PRB invoker. -**Purpose:** given the just-provisioned service's `service_id`, fetch its own roles + scopes from the IdP (`get_service(service_id)`), read the full IdP universe **excluding the new service's own entities**, call the PRB for each applicable (roles, scope) or (role, scopes) pair, and return a merged `list[PolicyRule]` to the Orchestrator. +**Purpose:** given the just-provisioned service's `service_id`, source candidates from the same worldview as the Policy Computation Engine (PCE) — `get_services()` for correct `kind`/ownership, `get_subjects()` for membership-derived user roles — exclude the focus service's own entities **by ownership**, call the PRB for each applicable (roles, scope) or (role, scopes) pair, and return a merged `list[PolicyRule]` to the Orchestrator. -**Why `service_id`, not `ServiceProvision`:** own roles/scopes must be id-bearing `Role`/`Scope` — `flatten_role` needs a `Role` (with `childRoles`) and the PRB builds `PolicyRule(role=Role, scope=Scope)`. The Provision-time `RoleDefinition`/`ScopeDefinition` carry only name+description (no Keycloak id), so they cannot be passed to the PRB. Provision has already persisted these entities, so `get_service(service_id).roles` / `.scopes` returns them with ids. +**Why `service_id`, not `ServiceProvision`:** own roles/scopes must be id-bearing `Role`/`Scope` — `flatten_role` needs a `Role` (with `childRoles`) and the PRB builds `PolicyRule(role=Role, scope=Scope)`. The Provision-time `RoleDefinition`/`ScopeDefinition` carry only name+description (no Keycloak id), so they cannot be passed to the PRB. Provision has already persisted these entities, so resolving the focus service from `get_services()` returns them with ids and correct `kind`. -**Terminology — own vs other (used throughout this section):** -- **Own roles / own scopes** — the roles and scopes the just-provisioned service defines for *itself*, fetched from the IdP by `service_id`: `service.roles` / `service.scopes` (`get_service(service_id)`). These are exactly the entities Service Provision wrote. -- **Other roles / other scopes** — every *pre-existing* role/scope in the IdP universe **minus** the new service's own entities. These belong to other services. +**Terminology — own vs candidate (used throughout this section):** +- **Own roles / own scopes** — the focus service's `aiac.managed` roles/scopes, found on the `Service` object returned by `get_services()` (matched by `id == service_id`, the internal client UUID). These are exactly the entities Service Provision wrote. +- **Candidate roles** — every role eligible to be mapped onto an own scope: other services' `aiac.managed` roles (`kind=Agent`) plus membership-derived user roles (`kind=User`; realm roles held by at least one user, composite-expanded, and not owned by any service). Never includes the focus service's own roles. +- **Other scopes** — every other service's `aiac.managed` scope (`scope.serviceId != service_id`). Never includes the focus service's own scopes. **Self-mapping invariant (must hold):** the PRB must **never** be handed an *(own role, own scope)* pair — a service's own role must never be mapped to its own scope. Onboarding only ever grants **cross-service** access: *who else* may call this service, and (agents only) *what else* this service may call. A service's own role reaching its own scope is not something onboarding needs to author (that access is intrinsic and out of scope here) and would pollute the policy set. The Service Policy Builder sub-agent guarantees the invariant **by construction** through two complementary guards: -1. **Exclusion (own entities never appear on the "other" side).** Own roles are removed from `other_roles` and own scopes from `other_scopes` before any PRB call (steps 3–4). Flattening runs *after* exclusion and cannot reintroduce an own role: the just-provisioned roles are brand new and are not yet referenced as `childRoles` by any existing role. -2. **Call direction (each call's "self" side is one own entity of the *opposite* kind).** Each PRB call pairs a single own entity with the other-side universe, never own-with-own, and keeps the semantic intent crisp: - - `build_scope_rules(flattened_other_roles, own_scope)` = *who else may call this skill* (an **own scope** against **other roles**) - - `build_role_rules(own_role, other_scopes)` = *what else may this role call* (an **own role** against **other scopes**; agent path only) +1. **Exclusion (own entities never appear on the candidate side), by ownership.** Own roles/scopes are identified by `role.id` / `scope.serviceId` matching the focus service — never by name — and are never added to `candidate_roles` / `other_scopes` (steps 3–4). This is immune to name collisions between services. +2. **Call direction (each call's "self" side is one own entity of the *opposite* kind).** Each PRB call pairs a single own entity with the candidate-side universe, never own-with-own, and keeps the semantic intent crisp: + - `build_scope_rules(candidate_roles, own_scope)` = *who else may call this skill* (an **own scope** against candidate roles) + - `build_role_rules(own_role, other_scopes)` = *what else may this role call* (an **own role** against other scopes; agent path only) -Neither guard alone is sufficient — exclusion keeps own entities off the other side, and the call direction keeps the self side and the other side of *opposite* kinds (a scope vs roles, or a role vs scopes). Together they make an *(own role, own scope)* pair unrepresentable in any PRB call. +Neither guard alone is sufficient — ownership-based exclusion keeps own entities off the candidate side, and the call direction keeps the self side and the other side of *opposite* kinds (a scope vs roles, or a role vs scopes). Together they make an *(own role, own scope)* pair unrepresentable in any PRB call. ### Steps 1. Receive `service_id: str` + `service_type: ServiceType` from the Orchestrator. -2. Fetch the service's **own roles + scopes** from the IdP by `service_id` via `aiac.idp.configuration.api` (`get_service(service_id)` → `service.roles` / `service.scopes`, id-bearing `Role`/`Scope`). -3. Read **all roles** from `aiac.idp.configuration.api`, **excluding** the service's own roles (i.e. exclude `role.name in {r.name for r in service.roles}`). -4. Read **all scopes** from `aiac.idp.configuration.api`, **excluding** the service's own scopes (i.e. exclude `scope.name in {s.name for s in service.scopes}`). -5. **Flatten roles to their closure** before any PRB call, via the shared `flatten_role` helper (see [Composite role flattening](#composite-role-flattening)): expand `other_roles` into the union of every role's closure, de-duplicated by `role.id` (call this `flattened_other_roles`); on the agent path, also expand each of the service's own roles. +2. Fetch `services = get_services()`, `all_scopes = get_scopes()`, `subjects = get_subjects()` from `aiac.idp.configuration.api`. +3. Resolve the focus service: `focus = next((s for s in services if s.id == service_id), None)` (matching on `id`, the internal client UUID carried by the route/`Trigger.entity_id` — **not** `serviceId`/clientId, which may be a slash-bearing SPIFFE URI); if `focus is None`, raise a clear `404` rather than letting `next(...)` raise `StopIteration`. +4. Compute candidate sets, all by ownership: + - **own roles/scopes** — `focus.roles`/`focus.scopes` filtered to `aiac.managed` (drops Keycloak's built-in default client scopes, e.g. `profile`, which are stamped with this service's `serviceId` but are not `aiac.managed`). + - **other-agent roles** — `aiac.managed` roles from every *other* service's `roles` (`kind=Agent`, ownership-excluded by `serviceId != focus.serviceId`). + - **user roles** — realm roles linked to at least one subject (via `subjects[*].roles`, composite-expanded through `flatten_role`) and not owned by any service (`role.id` not in the union of every service's role ids). These carry `kind=User`. + - **other scopes** — `aiac.managed` scopes from `all_scopes` with `serviceId != focus.serviceId`. +5. **Flatten candidate roles to their closure** before any PRB call, via the shared `flatten_role` helper (see [Composite role flattening](#composite-role-flattening)): union of other-agent roles + user roles, deduplicated by `role.id` (`candidate_roles`); on the agent path, also expand each of the focus service's own roles. 6. Call PRB and merge: - - **`service_type = tool`:** call `build_scope_rules(flattened_other_roles, scope)` for each of the service's own scopes. Merge results into a single `list[PolicyRule]`. - - **`service_type = agent`:** call `build_scope_rules(flattened_other_roles, scope)` for each own scope; for each of the service's own roles, call `build_role_rules(r, other_scopes)` **once per role `r` in that role's closure**. Merge all results into a single `list[PolicyRule]`. + - **`service_type = tool`:** call `build_scope_rules(candidate_roles, scope)` for each of the focus service's own scopes. Merge results into a single `list[PolicyRule]`. + - **`service_type = agent`:** call `build_scope_rules(candidate_roles, scope)` for each own scope; for each of the focus service's own roles, call `build_role_rules(r, other_scopes)` **once per role `r` in that role's closure**. Merge all results into a single `list[PolicyRule]`. 7. Return the merged `list[PolicyRule]` to the Orchestrator. (The Orchestrator pairs it with `override=False` for the Controller — see [Architecture overview](#architecture-overview).) -**Note on "all relevant scopes":** relevance (which of `other_scopes` maps to each `agent_role`) is determined by the PRB, not here. This module always passes the full excluded-self scope universe; the PRB emits only the relevant rule mappings. See [`policy-rules-builder.md`](policy-rules-builder.md). +**Note on "all relevant scopes":** relevance (which of `other_scopes` maps to each `agent_role`) is determined by the PRB, not here. This module always passes the full ownership-excluded scope universe; the PRB emits only the relevant rule mappings. See [`policy-rules-builder.md`](policy-rules-builder.md). ### Composite role flattening @@ -242,4 +262,4 @@ aiac/src/aiac/agent/uc/ - PRB internals — see [`policy-rules-builder.md`](policy-rules-builder.md). - PCE reconcile mechanics — see [`../policy-computation-engine.md`](../policy-computation-engine.md). - Response body shape — no success body; handlers return bare HTTP status codes (error responses carry FastAPI's default JSON error body from the raised `HTTPException`). Summary + debug go to the log. -- MCP endpoint lookup strategy for tools — **resolved** (hybrid Keycloak→K8s) in `docs/gh-issues/6.2-analyze-tool-lookup-strategy.md` and reflected in the `analyze_tool` node above. +- MCP endpoint lookup strategy for tools — **resolved** (hybrid Keycloak→K8s) in `docs/issues/agent/service-onboarding/6.2-analyze-tool-lookup-strategy.md` and reflected in the `analyze_tool` node above. diff --git a/aiac/docs/specs/components/event-broker.md b/aiac/docs/specs/components/event-broker.md index 0e3b90c7f..0f190e4d3 100644 --- a/aiac/docs/specs/components/event-broker.md +++ b/aiac/docs/specs/components/event-broker.md @@ -84,13 +84,13 @@ No authentication credentials are required. The NATS server runs with no-auth co ## AIAC Init Container -A dedicated `aiac-init` init container runs in the **Agent Pod** before the Agent container starts. It orchestrates the AIAC startup sequence: +A dedicated `aiac-init` init container runs in the **Agent Pod** before the Agent container starts. It orchestrates the AIAC startup sequence. _Deployment of this container is deferred to Phase 2 (issue 4.21) — the Phase 1 Agent pod runs without it._ 1. **Wait for NATS** — poll `aiac-event-broker-service:4222` until TCP connection succeeds. 2. **Wait for IdP Configuration Service** — poll `AIAC_PDP_CONFIG_URL/health` until HTTP 200. 3. **Wait for PDP Policy Writer** — poll `AIAC_PDP_POLICY_URL/health` until HTTP 200. 4. **Wait for RAG Ingest Service** — poll `AIAC_RAG_INGEST_URL/health` until HTTP 200 (confirms ChromaDB in the same RAG pod is also up). -5. **Create NATS JetStream stream** — call `js.add_stream()` idempotently with the `aiac-events` stream configuration. Safe to call on every restart. +5. **Create NATS JetStream stream** — call `js.add_stream()` idempotently with the `aiac-events` stream configuration. Safe to call on every restart. _Like the rest of this init container, the Event Broker and this stream-provisioning path are **deferred (not Phase 1)** — the Phase 1 Agent pod does not run the Event Broker or provision the JetStream stream, consistent with the PRD out-of-scope list._ The init container uses `python:3.12-slim` with `nats-py` and `httpx`. It is version-controlled alongside the Agent. All dependency URLs are read from the `aiac-pdp-config` ConfigMap. diff --git a/aiac/docs/specs/components/idp-configuration-service.md b/aiac/docs/specs/components/idp-configuration-service.md index e62ed9267..49bed69ee 100644 --- a/aiac/docs/specs/components/idp-configuration-service.md +++ b/aiac/docs/specs/components/idp-configuration-service.md @@ -17,13 +17,14 @@ A FastAPI web service that proxies Keycloak Admin REST API endpoints. Returns Id | GET | `/services/{service_id}` | `GET /admin/realms/{realm}/clients/{service_id}` | Single service by ID | | POST | `/services/{service_id}/type` | `admin.get_client(service_id)` → `admin.update_client(service_id, {"attributes": {...}})` | Set a service's type via the `client.type` client attribute | | GET | `/scopes` | `GET /admin/realms/{realm}/client-scopes` | All scopes | -| GET | `/services/{service_id}/roles` | `admin.get_client_service_account_user(service_id)` → `admin.get_realm_roles_of_user(user_id)` | Realm roles assigned to a service's account | +| GET | `/services/{service_id}/roles` | `admin.get_client_roles(service_id)` **+** `aiac.managed` realm roles on the service account | **An agent's own roles (`R_A`)** from **two** sources: this service's client roles, **plus** the `aiac.managed` realm roles assigned to its service account (the `Configuration` library's provisioning path). Both surfaced as `kind = Agent`. See "Agent roles are client roles" below. | | GET | `/services/{service_id}/scopes` | `admin.get_client_default_client_scopes(service_id)` | Default client scopes assigned to a service | | GET | `/roles/{role_name}/composites` | `GET /admin/realms/{realm}/roles/{role-name}/composites` | Current composite permissions assigned to a role | | POST | `/scopes` | `POST /admin/realms/{realm}/client-scopes` | Create realm-level scope | | POST | `/services/{service_id}/scopes/{scope_id}` | `PUT /admin/realms/{realm}/default-default-client-scopes/{scope_id}` | Assign existing scope as default scope to service | | POST | `/roles` | `POST /admin/realms/{realm}/roles` | Create realm-level role | | POST | `/services/{service_id}/roles/{role_id}` | `admin.get_client_service_account_user(service_id)` → `admin.assign_realm_roles(user_id, ...)` | Assign existing realm role to service account | +| GET | `/services/{service_id}/discovery-token` | `admin.get_client(service_id)` → (idempotent) `add_mapper_to_client` → `KeycloakOpenID(...).token(grant_type="client_credentials")` | Mint a bearer token, minted **as the service's own client**, whose `aud` contains that client's client-id — for authenticating UC-1 tool discovery against the tool's AuthBridge sidecar | | GET | `/health` | `admin.get_server_info()` — uses `KEYCLOAK_ADMIN_REALM`; no `?realm=` param | Readiness probe | `GET /subjects?role_id={role_id}` (filtered variant): @@ -68,13 +69,32 @@ Accepts JSON body `{"name": ..., "description": ...}`. It: 3. Returns `409 Conflict` if a role with that name already exists. 4. Returns `502 Bad Gateway` with `{"error": ...}` on `KeycloakError`. -`GET /services/{service_id}/roles`: -1. Calls `admin.get_client_service_account_user(service_id)` to get the service account user. -2. Extracts `user["id"]` from the result. -3. Calls `admin.get_realm_roles_of_user(user_id)` to return the realm roles assigned to the service account. -4. Returns `200 OK` with a JSON array of realm role objects. -5. Returns `[]` (empty array) if `KeycloakError` has `response_code == 400` (service has no service account — not an error). -6. Returns `502 Bad Gateway` with `{"error": ...}` on other `KeycloakError`. +`GET /services/{service_id}/roles`: returns this service's agent roles (`R_A` in the PCE +derivation) from **two** sources, both stamped `kind = Agent`, `actorIds = [this client's +serviceId]`: +1. **Client roles** on the service's own client — `admin.get_client_roles(service_id)`. Each + `RoleRepresentation` carries `clientRole: true` and `containerId` (the client UUID); the owner is + resolved from `containerId` → `get_client(containerId)["clientId"]`. +2. **`aiac.managed` realm roles assigned to the service account** — `get_client_service_account_user(service_id)` + → `get_realm_roles_of_user(user_id)`. This is how the `Configuration` library provisions an agent's + roles (`POST /services/{id}/roles/{role_id}` → `assign_realm_roles` on the service account, below), + so without it a library-onboarded agent would expose no roles. The role-of-user stub omits + attributes, so each is re-fetched via `get_realm_role_by_id` to test the `aiac.managed` marker; + non-managed realm roles (e.g. `default-roles-`) are skipped. The owner is this service's own + `clientId`. +3. Returns `200 OK` with the merged JSON array (client-role ids dedup against the realm-role set). +4. Returns `[]` if `KeycloakError` has `response_code == 400` (service has no client roles — not an + error); a missing service account is caught and simply contributes no realm roles. +5. Returns `502 Bad Gateway` with `{"error": ...}` on other `KeycloakError`. + +> **Redesign note (SPM/APM + provisioning reconciliation).** Under the original SPM/APM redesign this +> endpoint sourced roles **only** from the client's client roles (`admin.get_client_roles`), on the +> premise that an agent's role is always a Keycloak client role (Assumption 3). In practice the write +> path (`POST /services/{id}/roles/{role_id}`, used by the `Configuration` library) assigns a **realm +> role to the service account**, not a client role — so the read and write paths did not meet, and a +> library-provisioned agent exposed no roles (empty `agent_roles` → all-deny outbound Rego; surfaced by +> the 5.3 live pipeline). The endpoint now returns **both** sources so the two paths reconcile. The +> long-term option of making provisioning create true client roles instead is tracked with issue 1.7. `GET /services/{service_id}/scopes`: 1. Calls `admin.get_client_default_client_scopes(service_id)` to return the realm-level client scopes assigned as defaults to the service. @@ -89,6 +109,24 @@ Accepts JSON body `{"name": ..., "description": ...}`. It: 5. Returns `409 Conflict` if the role is already assigned. 6. Returns `502 Bad Gateway` with `{"error": ...}` on `KeycloakError`. +`GET /services/{service_id}/discovery-token`: +1. Calls `admin.get_client(service_id)` to resolve `client_id = client["clientId"]`. +2. Reads the client's **existing** secret (`client.get("secret")` or `admin.get_client_secrets(service_id)`) + — **never** calls `generate_client_secrets` (rotating the live secret would break the deployed + workload). Returns `502 Bad Gateway` if no secret is present (e.g. a public client). +3. Idempotently ensures a self-audience `oidc-audience-mapper` named `aiac-discovery-audience` is + attached to the client (`get_mappers_from_client` then `add_mapper_to_client` only if absent), so the + minted token's `aud` includes `client_id`. +4. Mints via `KeycloakOpenID(server_url=..., realm_name=realm, client_id=client_id, + client_secret_key=secret).token(grant_type="client_credentials")` — i.e. as the **service's own + client**, not the realm admin. +5. Decodes the minted token's payload (no signature check needed — this endpoint trusts its own mint) + and asserts `client_id in aud`; if `AIAC_KEYCLOAK_ISSUER` is set, also asserts `iss` matches it. + Returns `502 Bad Gateway` if either assertion fails — this endpoint never returns a token the + consuming AuthBridge sidecar's `jwt-validation` plugin would reject. +6. Returns `200 OK` with `{"access_token": ..., "client_id": ..., "issuer": ..., "audience": [...]}`. +7. Returns `502 Bad Gateway` with `{"error": ...}` on `KeycloakError`. + All endpoints except `/health` require a `?realm=` query parameter specifying the Keycloak realm to operate in. Returns `422 Unprocessable Entity` if the parameter is absent. `/health` accepts no realm parameter — it calls `_get_or_create_admin(os.environ["KEYCLOAK_ADMIN_REALM"])` directly. All GET endpoints return `200 OK` with a JSON array on success, except `/subjects/{subject_id}/assignments` which returns a JSON object with `realmMappings` and `serviceMappings` fields. All endpoints return `502 Bad Gateway` with a JSON error body if the Keycloak Admin API call fails. @@ -97,6 +135,28 @@ All GET endpoints return `200 OK` with a JSON array on success, except `/subject Every role and client scope this service creates is stamped with the Keycloak attribute `aiac.managed` = `true` — the AIAC naming convention that distinguishes AIAC-provisioned entities from Keycloak's own built-ins (default client scopes, the `default-roles-` composite). Attribute value shape differs by entity: realm-role attribute values are lists (`{"aiac.managed": ["true"]}`), client-scope attribute values are plain strings (`{"aiac.managed": "true"}`). Because Keycloak's brief role representation omits attributes, `GET /roles` requests the full representation so the marker survives the read. Downstream consumers (the Policy Computation Engine's P2 embed) filter on this marker to keep only domain entities. +### Agent roles are client roles, field population, and assumption enforcement (SPM/APM) + +Under the SPM/APM policy-model redesign the Policy Computation Engine (PCE) performs **no IdP lookup for routing or classification** — it relies on entities being self-describing (`Role.kind`, `Role.actorIds`, `Scope.serviceId`; the fields are defined in the models spec). The IdP Configuration Service is the **only** layer that sees Keycloak's raw facts, so it is where these fields are populated and where the underlying assumptions are validated. Field definitions live with the models (library) spec; this service **populates** them. + +**Assumption 3 — an agent's role is a Keycloak _client role_ (or an `aiac.managed` _realm role_ assigned to the agent's service account); a user's role is a plain Keycloak _realm role_.** In Keycloak a `RoleRepresentation` carries `clientRole: bool` and `containerId` (the client UUID for client roles, the realm id for realm roles). An agent role therefore has **two** valid representations — a client role on the agent's client, or an `aiac.managed` realm role held by the agent's service account (the provisioning path) — and both are classified `kind = Agent`; only a realm role **not** owned by any service account is a `kind = User` role. This service holds the invariant end-to-end: + +- **Agent roles.** `GET /services/{service_id}/roles` returns an agent's own roles (`R_A`) from two sources: the client's client roles (`admin.get_client_roles`, `clientRole == true`), **and** the `aiac.managed` realm roles assigned to the service account (the provisioning path the `Configuration` library uses). Both are surfaced as `kind = Agent` owned by this service — see the endpoint description and its Redesign note above. +- **User roles are realm roles.** `GET /roles` continues to read realm-level roles (`kind = User`), excluding the Keycloak-generated `default-roles-` composite (exact-name match). That composite is the *only* path to Keycloak's built-ins (`offline_access`, `uma_authorization`, `view-profile`, the `account` client roles) — no user holds them directly — so dropping it keeps AIAC policy free of Keycloak built-ins without a per-name blocklist. + +**Field population** (in the Keycloak → generic-model mapping layer, from the raw facts above): + +- **`Role.kind`** — a role read via `GET /services/{service_id}/roles` is always `kind = Agent` (whether it came from the client's client roles or from an `aiac.managed` realm role on the service account); a realm role read via `GET /roles` is `kind = User`. Equivalently: agent-context (`clientRole == true`, or `aiac.managed` realm role held by a service account) → `Agent`; plain realm role → `User`. Kind is **never** inferred from role naming. +- **`Role.actorIds` per kind:** + - `Agent`: the owning agent's `serviceId` — resolved from the role's `containerId` → client (`clientId`) — and/or the agent service account(s) that hold the role. + - `User`: the **member usernames** of the role. This aligns with `GET /subjects?role_id=` / `get_subjects_by_role`, which already resolves a role → its member subjects; the usernames it returns are exactly `actorIds` for a user (realm) role. +- **`Scope.serviceId`** = the **owning client** — the client that defines/exposes the client scope. + +**Fail-loud enforcement at this boundary** (detectable here via membership queries; do not silently pick a side): + +- **Assumption 1 — no cross-kind role.** A role held by *both* human users and agent service accounts cannot be represented by a single `actorIds` list. On violation, **raise/log** rather than choosing one kind. +- **Assumption 2 — single scope owner.** `get_services_by_scope` returns `list[Service]` (Keycloak client scopes are realm-level and assignable to many clients). For **AIAC-managed** scopes (see the `aiac.managed` marker above) that list must have length 1; if Keycloak reports multiple owners, that is an invariant violation → **raise/log**. + ## Configuration Environment variables (injected via Kubernetes Deployment manifest): @@ -115,8 +175,8 @@ Environment variables (injected via Kubernetes Deployment manifest): - Bind: `0.0.0.0:7071` - Base image: `python:3.12-slim` - Kubernetes ClusterIP Service: `aiac-pdp-config-service:7071` -- Deployment: co-located with PDP Policy Writer as a container in the **Rossoctl Interface Pod** (`pdp-interface-deployment.yaml`) -- Python library: `aiac.idp.library.configuration` +- Deployment: co-located with PDP Policy Writer as a container in the **Kagenti Interface Pod** (`pdp-interface-deployment.yaml`) +- Python library: `aiac.idp.configuration` ## Dependencies (`requirements.txt`) @@ -152,7 +212,8 @@ docker build -f aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile \ - `get_admin(realm: str = Query(...))` is a FastAPI dependency. On each call it checks the cache; on a miss it acquires the lock, double-checks, and constructs a new `KeycloakAdmin(realm_name=realm, user_realm_name=KEYCLOAK_ADMIN_REALM, ...)`. FastAPI returns `422` automatically if `realm` is absent. - All endpoints except `/health` declare `admin: KeycloakAdmin = Depends(get_admin)`. `/health` calls `_get_or_create_admin` directly with `os.environ["KEYCLOAK_ADMIN_REALM"]` — no FastAPI dependency, no realm query param. - Each GET endpoint calls the corresponding `python-keycloak` method and returns the result directly via `JSONResponse`. -- `GET /services/{service_id}/roles`: call `admin.get_client_service_account_user(service_id)` → extract `user["id"]` → call `admin.get_realm_roles_of_user(user_id)`. Returns `[]` if `KeycloakError.response_code == 400` (service has no service account); `502` on other `KeycloakError`. +- `GET /roles`: call `admin.get_realm_roles(brief_representation=False)`, then drop the role named `default-roles-{realm}` (the Keycloak-generated default composite for the realm) before enrichment. +- `GET /services/{service_id}/roles`: return the service's agent roles (`kind = Agent`) from **two** sources — `admin.get_client_roles(service_id)` (the client's client roles) **and** the `aiac.managed` realm roles assigned to its service account (`get_client_service_account_user` → `get_realm_roles_of_user`, each re-fetched via `get_realm_role_by_id` to test the `aiac.managed` marker; the client-role ids dedup against the realm-role set). Returns `[]` if `KeycloakError.response_code == 400` (service has no client roles); a missing service account simply contributes no realm roles; `502` on other `KeycloakError`. See the detailed contract above. - `GET /services/{service_id}/scopes`: call `admin.get_client_default_client_scopes(service_id)`. - `GET /roles/{role_name}/composites`: call `admin.get_composite_realm_roles_of_role(role_name=role_name)`. - `POST /services/{service_id}/roles/{role_id}`: call `admin.get_client_service_account_user(service_id)` → extract `user["id"]` → call `admin.assign_realm_roles(user_id, [{"id": role_id}])`. diff --git a/aiac/docs/specs/components/keycloak-service.md b/aiac/docs/specs/components/keycloak-service.md index d04a1f613..b6d4a1d56 100644 --- a/aiac/docs/specs/components/keycloak-service.md +++ b/aiac/docs/specs/components/keycloak-service.md @@ -5,6 +5,8 @@ > - **PDP Policy Writer — Keycloak Implementation** (write endpoints) — see [pdp-policy-keycloak-service.md](pdp-policy-keycloak-service.md) > > The content below is retained for reference only. +> +> **SPM/APM note.** Under the SPM/APM redesign a user's role is a Keycloak **realm role** and an agent's role is surfaced as `kind = Agent` — sourced from the agent client's **client roles** *and* from the `aiac.managed` **realm roles assigned to its service account** (the provisioning path the `Configuration` library uses); the IdP Configuration Service populates `Role.kind`, `Role.actorIds`, and `Scope.serviceId` and fails loud on cross-kind roles (Assumption 1) and multi-owner AIAC-managed scopes (Assumption 2). See [idp-configuration-service.md](idp-configuration-service.md) for the authoritative, current description. ## Location `aiac/src/aiac/keycloak/service/` @@ -39,7 +41,7 @@ Environment variables (injected via Kubernetes Deployment manifest): | Variable | Required | Description | |----------|----------|-------------| | `KEYCLOAK_URL` | Yes | Keycloak base URL, e.g. `http://keycloak-service.keycloak.svc:8080` | -| `KEYCLOAK_REALM` | Yes | Realm name, e.g. `rossoctl` | +| `KEYCLOAK_REALM` | Yes | Realm name, e.g. `kagenti` | | `KEYCLOAK_ADMIN_USERNAME` | Yes | Admin username (from `keycloak-admin-secret`) | | `KEYCLOAK_ADMIN_PASSWORD` | Yes | Admin password (from `keycloak-admin-secret`) | diff --git a/aiac/docs/specs/components/library-idp.md b/aiac/docs/specs/components/library-idp.md index b4a98b062..1dabaeba6 100644 --- a/aiac/docs/specs/components/library-idp.md +++ b/aiac/docs/specs/components/library-idp.md @@ -56,7 +56,7 @@ Represents a user (Keycloak: `user`). #### `Role` -Represents a role (Keycloak: realm role). +Represents a role. Per Assumption 3 (policy-model spec), **user** roles are Keycloak realm roles and **agent** roles are Keycloak client roles on the agent's client; `kind` (a `RoleKind`) carries that distinction and is populated from Keycloak's `clientRole` flag at the IdP boundary. | Field | Type | Keycloak field | Default | |-------|------|----------------|---------| @@ -66,9 +66,20 @@ Represents a role (Keycloak: realm role). | `composite` | `bool` | `composite` | | | `childRoles` | `list[Role]` | `composites.realm` | `[]` | | `attributes` | `dict[str, Any]` | `attributes` | `{}` | +| `kind` | `RoleKind` | `clientRole` flag (user = realm role, agent = client role; resolved at the IdP boundary) | | +| `actorIds` | `list[str]` | _(member **usernames** of a user-kind (realm) role, or the owning agent's `serviceId`(s) for an agent-kind (client) role; populated service-side, user-kind values aligned with `get_subjects_by_role`, handoff 02)_ | `[]` | + +> **Field pass-through (handoff 03 audit).** `kind`, `actorIds`, and `Scope.serviceId` are declared +> on the models by **handoff 01** and populated by the **IdP service** (handoff 02). The +> `Configuration` library deserializes them **automatically** — `model_validate` picks up any declared +> field present in the service JSON, and there is no hand-rolled field mapping that would omit them. +> `kind` is **authoritative**: the library never re-derives a role's user/agent kind client-side +> (e.g. by calling `get_services_by_role`). See the api-submodule audit note below. Roles also expose an `aiac_managed` property (`bool`): `True` when `attributes` carries the AIAC provisioning marker `aiac.managed` (realm-role attribute values are lists, so the marker appears as `["true"]`). See the naming convention in the idp-configuration-service spec. +> **SPM/APM (service-side).** `Role.actorIds` is populated per kind at the IdP boundary: for an **agent** (client) role, the owning client's `serviceId`(s) (resolved from the role's `containerId`); for a **user** (realm) role, the role's member usernames (aligned with `get_subjects_by_role`). The IdP service also fails loud on cross-kind roles (Assumption 1) and multi-owner AIAC-managed scopes (Assumption 2). See "Agent roles are client roles, field population, and assumption enforcement" in idp-configuration-service.md. + #### `Service` Represents a service (Keycloak: `client`). @@ -90,9 +101,9 @@ Represents a service (Keycloak: `client`). 2. Otherwise the Keycloak client attribute **`client.type`** ∈ {`Agent`, `Tool`} — a **plain string**. Client attribute values are plain strings; a **list** value (e.g. `["Agent"]`, the shape realm-role attributes use) fails the check and resolves to `None`. Capitalization matches the `ServiceType` values. 3. Otherwise `None`. -The attribute is set via `Configuration.set_service_type` (below); its authoritative origin is UC1 Service Onboarding — `classify_service` **discovers** the type from the operator's `rossoctl.io/type` label and `provision_service` **persists** it onto the client via `set_service_type` (see the aiac-agent UC1 spec). There is **no** `spiffe://` clientId fallback and **no** description-keyword inference — typing is `client.type`-attribute-only. (The former `spiffe:// ⇒ Agent` fallback was **removed**: a `spiffe://` clientId indicates a SPIRE-enabled workload, **not** necessarily an agent — it could mis-type a SPIRE-enabled tool — so clients without a `client.type` attribute now resolve to `None`.) +The attribute is set via `Configuration.set_service_type` (below); its authoritative origin is UC1 Service Onboarding — `classify_service` **discovers** the type from the operator's `kagenti.io/type` label and `provision_service` **persists** it onto the client via `set_service_type` (see the aiac-agent UC1 spec). There is **no** `spiffe://` clientId fallback and **no** description-keyword inference — typing is `client.type`-attribute-only. (The former `spiffe:// ⇒ Agent` fallback was **removed**: a `spiffe://` clientId indicates a SPIRE-enabled workload, **not** necessarily an agent — it could mis-type a SPIRE-enabled tool — so clients without a `client.type` attribute now resolve to `None`.) -> **`ServiceType`** (`aiac.idp.configuration.models`) is a `str` enum — `AGENT = "Agent"`, `TOOL = "Tool"` — shared by `Service.type`, `set_service_type`, and the aiac-agent sub-agents (one vocabulary, no duplication). Values are capitalized to match the `client.type` attribute; because it subclasses `str`, `ServiceType.AGENT == "Agent"`, so it is a drop-in for the former `Literal["Agent", "Tool"]`. The operator's lowercase `rossoctl.io/type` pod label is normalized to a member via `ServiceType(label.capitalize())` in UC1 `classify_service`. +> **`ServiceType`** (`aiac.idp.configuration.models`) is a `str` enum — `AGENT = "Agent"`, `TOOL = "Tool"` — shared by `Service.type`, `set_service_type`, and the aiac-agent sub-agents (one vocabulary, no duplication). Values are capitalized to match the `client.type` attribute; because it subclasses `str`, `ServiceType.AGENT == "Agent"`, so it is a drop-in for the former `Literal["Agent", "Tool"]`. The operator's lowercase `kagenti.io/type` pod label is normalized to a member via `ServiceType(label.capitalize())` in UC1 `classify_service`. #### `Scope` @@ -104,6 +115,11 @@ Represents a service scope (Keycloak: `client scope`). | `name` | `str` | `name` | | `description` | `str \| None` | `description` | | `attributes` | `dict[str, Any]` | `attributes` | +| `serviceId` | `str \| None` | _(the `serviceId`/`clientId` of the service that exposes this scope; declared by handoff 01, populated service-side by handoff 02)_ | + +> **Field pass-through (handoff 03 audit).** `Scope.serviceId` round-trips through the library's +> deserialization automatically (same `model_validate` pass-through as `Role.kind`/`actorIds`). It makes +> a scope's exposing service an explicit field read rather than something the client must infer. Scopes also expose an `aiac_managed` property (`bool`): `True` when `attributes` carries the AIAC provisioning marker `aiac.managed` (client-scope attribute values are plain strings, so the marker appears as `"true"`). See the naming convention in the idp-configuration-service spec. @@ -125,6 +141,19 @@ HTTP client library that wraps the IdP Configuration Service REST API. Provides All Keycloak interactions are consolidated here; the PDP Policy Writer (OPA) does not touch Keycloak directly. +> **Handoff 03 audit outcome (amended).** Most read paths surface `Role.kind` / `Role.actorIds` / +> `Scope.serviceId` faithfully as a thin `model_validate` pass-through with no code change, and the P1 +> client-side filter in `get_services_by_role` / `get_services_by_scope` was already an `id`-membership +> read (not an inference) needing no simplification. **Exception — `_build_service` (nested +> service roles/scopes) is _not_ a plain pass-through and required a fix:** it looked service roles up +> in the `all_roles` map (built from `GET /roles`, where every role is `kind = User`), discarding the +> per-service endpoint's authoritative `kind = Agent` / `actorIds`, and it never stamped nested +> `Scope.serviceId`. It now merges the endpoint's `kind`/`actorIds` and stamps `serviceId` (see +> `get_services()` below). No client-side *re-derivation* of role kind is introduced — the merged +> `kind` still comes from the service, which remains authoritative. `get_subjects_by_role` stays +> consistent with a role's `actorIds` (both from the same service-side source). See the per-method +> audit notes below. + **Transport retries.** Every HTTP call is issued through a private `_request(method, path, **kwargs)` helper that wraps the request in the project-level `run_upstream` retry primitive (`aiac.shared.upstream`): transient failures are retried up to `UPSTREAM_MAX_RETRIES` times (default `3`) with exponential backoff before a non-2xx status is raised as `RuntimeError`. Retry lives inside the library (not in callers), and applies at the leaf request, so composite methods (`create_service_role` / `create_service_scope`) retry each sub-request without compounding. ### Dependencies @@ -169,6 +198,11 @@ class Configuration: def create_service_scope(self, service_id: str, scope) -> Scope: ... def set_service_type(self, service: Service, service_type: ServiceType) -> Service: ... + + # Mint a bearer token, minted as the service's own client, whose `aud` contains that + # client's client-id — for authenticating UC-1 tool discovery against the tool's + # AuthBridge sidecar. Returns the raw access_token string. + def mint_discovery_token(self, service_id: str) -> str: ... ``` `get_scopes()` — simple read: @@ -186,8 +220,17 @@ class Configuration: 1. `GET {AIAC_PDP_CONFIG_URL}/services?realm=` — fetch the base service list. 2. Call `get_roles()` and `get_scopes()` once upfront to build `{id: Role}` and `{id: Scope}` lookup maps. 3. For each service, delegate to `_build_service(raw, all_roles, all_scopes)` which issues: - - `GET /services/{id}/roles?realm=` → filter `all_roles` map → `Service.roles` - - `GET /services/{id}/scopes?realm=` → filter `all_scopes` map → `Service.scopes` + - `GET /services/{id}/roles?realm=` → for each returned role, **copy the matching + `all_roles` object and merge its authoritative `kind`/`actorIds` onto the copy** — the merge is a + fresh dict (`{**base.model_dump(), **authoritative_fields}`), so the shared `all_roles` entry is + never mutated and cannot leak one service's agent-context values into another's view → `Service.roles`. + The copy carries the full representation (`composite`/`childRoles`/`attributes`). Merging (not a + plain `all_roles` lookup) is required: the per-service endpoint is the only source of the + agent-context `kind = Agent` / `actorIds = [serviceId]`; taking the role from `all_roles` alone + would leave it at the realm-level `kind = User` / `actorIds = [members]`. + - `GET /services/{id}/scopes?realm=` → filter `all_scopes` map → `Service.scopes`, and + **stamp each scope's `serviceId` = this service's `clientId`** (the owning client — the PCE's SPM + routing key; `all_scopes` from `GET /scopes` carries no owner). 4. Raise `RuntimeError` on any non-2xx response. 5. Return `list[Service]` with fully-enriched `roles` (including `childRoles`) and `scopes` (including `description`). @@ -202,6 +245,13 @@ class Configuration: > **Note:** Callers that previously called `get_services()` and filtered by ID should be switched to `get_service(service_id)` to avoid fetching the full list. +`mint_discovery_token(service_id)` — thin wrapper over the config service's minting endpoint: +1. `GET {AIAC_PDP_CONFIG_URL}/services/{service_id}/discovery-token?realm=`. +2. Raise `RuntimeError` on non-2xx HTTP status. +3. Return `response.json()["access_token"]` — the raw bearer token string. The config service (which + holds the Keycloak admin) does the minting and the in-endpoint `iss`/`aud` verification; this method + does no decoding itself. + `get_roles()` — enriched read: 1. `GET {AIAC_PDP_CONFIG_URL}/roles?realm=` — fetch all realm roles. 2. For each role, if `role.composite` is `True`: `GET /roles/{name}/composites?realm=` → `Role.childRoles` @@ -220,6 +270,20 @@ class Configuration: > **Performance note:** because both methods delegate to `get_services()`, each call inherits its full fan-out cost (see the `get_services()` performance note above — `2N + 1 + roles` HTTP requests for N services). Acceptable for the low-frequency PCE resolution path. If it becomes a bottleneck, the right fix is a real server-side `role_id` / `scope_id` filter on `GET /services`. +> **P1 client-side filter — handoff 03 audit (no change required).** With ownership now explicit on +> the entities (`Role.kind`/`actorIds`, `Scope.serviceId`), the P1 client-side filter was audited for +> possible simplification. **Outcome: the filter is retained as-is.** `get_services_by_role` / +> `get_services_by_scope` already select owners/exposers by **`id`-membership** in each service's +> enriched `.roles` / `.scopes` lists — that is a direct field read, **not an inference**, so there is +> nothing to simplify away. Membership in the service's `.scopes` remains the authoritative +> owner/exposer signal (consistent with the new `Scope.serviceId`); the two agree because both derive +> from the service-side truth. The filter still returns **only** genuine owners/exposers and returns +> `[]` for realm-level roles that no service owns. No client-side re-derivation of role kind is +> introduced by these methods; `get_services_by_role` is **retained as a method** for API completeness +> and ad-hoc callers, but the current SPM-based PCE no longer calls it: its **only** runtime IdP read is +> `Configuration.get_services()` (see `aiac.policy.computation`), and owner/role lookups against stored +> policy go through the Policy Store's `get_service_policies_by_role`, not this IdP filter. + `get_subjects_by_role(role: Role) -> list[Subject]`: 1. `GET {AIAC_PDP_CONFIG_URL}/subjects?role_id={role.id}&realm=` 2. Returns all subjects (users) that have this role directly assigned, enriched with their full realm role assignments (same enrichment as `get_subjects()`). @@ -227,6 +291,12 @@ class Configuration: > **Note:** This method returns only subjects with a **direct** assignment of the given role. Composite role traversal (resolving `childRoles` and querying each) is the caller's responsibility — see PCE algorithm in `aiac.policy.computation`. +> **`actorIds` consistency — handoff 03 audit.** The subject/username set this method reports is the +> same set the **IdP service** uses to populate a user-kind role's `Role.actorIds` (handoff 02). The +> library surfaces both from the same service-side source, so there is **no divergence** between the +> `actorIds` carried on a returned `Role` and the subjects `get_subjects_by_role(role)` reports. The +> library does not recompute `actorIds` client-side. + `create_scope`: 1. Issues `POST {AIAC_PDP_CONFIG_URL}/scopes` with body `{"name": scope_name, "description": scope_description}`, appending `?realm=`. 2. Raises `RuntimeError` on non-2xx HTTP status (including 409 if a scope with that name already exists). @@ -282,7 +352,7 @@ Read from a `.env` file co-located with `api.py` (`aiac/src/aiac/idp/configurati ```python from aiac.idp.configuration.api import Configuration -cfg = Configuration.for_realm("rossoctl") +cfg = Configuration.for_realm("kagenti") subjects = cfg.get_subjects() for s in subjects: print(s.username, s.email) diff --git a/aiac/docs/specs/components/library-policy-store.md b/aiac/docs/specs/components/library-policy-store.md index ad42598bc..65c6853a2 100644 --- a/aiac/docs/specs/components/library-policy-store.md +++ b/aiac/docs/specs/components/library-policy-store.md @@ -11,26 +11,36 @@ Companion library for the [AIAC Policy Store](policy-store.md). Follows the same aiac/src/aiac/policy/store/ └── library/ ├── __init__.py # empty - └── api.py # six module-level functions + └── api.py # five module-level functions (SPM-centric surface) ``` All `__init__.py` files are empty. Callers use explicit submodule paths: ```python from aiac.policy.store.library.api import ( - get_policy, get_agent_policy, - apply_policy, apply_agent_policy, - delete_agent_policy, delete_policy, + get_service_policy, + get_service_policy_by_scope, + get_service_policies_by_role, + apply_service_policy, + delete_service_policy, ) -from aiac.policy.model.models import PolicyModel, AgentPolicyModel +from aiac.policy.model.models import ServicePolicyModel, Scope, Role ``` --- +## SPM redesign context + +The `ServicePolicyModel` (SPM), keyed by `serviceId`, is the **persistent source of truth**. +The `AgentPolicyModel` (APM) is now **derived and never persisted** — so the store no longer +exposes any per-agent read/write functions. The library surface is entirely SPM-centric. + +--- + ## Submodule: `aiac.policy.store.library.api` ### Description -HTTP client module wrapping the [AIAC Policy Store](policy-store.md) REST API. Exposes six module-level functions returning `PolicyModel` and `AgentPolicyModel` objects directly — no Kubernetes client boilerplate. Service URL is read from the `AIAC_POLICY_STORE_URL` environment variable (default: `http://127.0.0.1:7074`). All functions raise `RuntimeError` on non-2xx response. +HTTP client module wrapping the [AIAC Policy Store](policy-store.md) REST API. Exposes five module-level functions returning `ServicePolicyModel` objects directly — no Kubernetes client boilerplate. Service URL is read from the `AIAC_POLICY_STORE_URL` environment variable (default: `http://127.0.0.1:7074`). All functions raise `RuntimeError` on an unexpected non-2xx response (a `404` on the by-id read is handled, not raised — see below). ### Dependencies ``` @@ -42,24 +52,55 @@ python-dotenv ### Functions ```python -def get_policy() -> PolicyModel - # GET /policy - -def get_agent_policy(agent_id: str) -> AgentPolicyModel - # GET /policy/agents/{agent_id} +def get_service_policy(service_id: str) -> ServicePolicyModel + # GET /policy/services/{service_id} + # On miss (service returns 404) the library returns a *fresh empty* + # ServicePolicyModel for that service_id — never raises on 404. + # (Matches the existing "engine creates a fresh model on 404" convention.) + +def get_service_policy_by_scope(scope: Scope) -> ServicePolicyModel | None + # Singular: a scope has exactly one owning service (Assumption 2). + # Sugar over get_service_policy(scope.serviceId) — resolves the owner via + # scope.serviceId; no dedicated HTTP route. + # Returns None ONLY when the scope has no resolved owner (scope.serviceId + # is unset/empty). When serviceId is present it delegates to + # get_service_policy, so a store miss (404) yields a *fresh empty* SPM, + # not None — None means "unowned scope", empty SPM means "owner exists, + # no policy stored yet". + +def get_service_policies_by_role(role: Role) -> list[ServicePolicyModel] + # GET /policy/services?role={role.id} (the one genuinely new route) + # Plural: a role (especially a user role) appears across many SPMs. + # Returns every SPM whose inbound_rules contains a rule referencing + # role.id. Empty list when none match. + +def apply_service_policy(service_id: str, spm: ServicePolicyModel) -> None + # POST /policy/services/{service_id} — upsert. + +def delete_service_policy(service_id: str) -> None + # DELETE /policy/services/{service_id} — off-board a decommissioned service. + # No-op on the server if the service is absent (still 204). +``` -def apply_policy(model: PolicyModel) -> None - # POST /policy +`service_id` is a plain string everywhere in this API (slashes and all) — callers never encode +anything. Internally, the three functions above base64url-encode `service_id` into the URL path +segment via `aiac.policy.store.keying.encode_service_id` before issuing the request (the clientId is +slash-bearing and can't be a single path segment); the service decodes it back. `service_id` in every +returned `ServicePolicyModel` is always the original, decoded form. -def apply_agent_policy(agent_id: str, model: AgentPolicyModel) -> None - # POST /policy/agents/{agent_id} +**Removed** (APMs are no longer persisted): `get_agent_policy`, `apply_agent_policy`, and the +prior whole-collection `get_policy` / `apply_policy` / `delete_policy` / `delete_agent_policy` +functions. The only legitimate consumer is the Policy Computation Engine, which is migrated to the +functions above. -def delete_agent_policy(agent_id: str) -> None - # DELETE /policy/agents/{agent_id} +### Why by-role must be a store query (not an IdP lookup) -def delete_policy() -> None - # DELETE /policy -``` +`get_service_policies_by_role` must return **stored** rows — including stale role→service mappings +that the live IdP no longer reflects. The Policy Computation Engine's override-purge (handoff 05) +depends on seeing exactly those stale rows so it can remove them. Because the SPM store is the +source of truth and the IdP is not, this query cannot be answered from the IdP; it is a query over +persisted SPMs. It may start as a full scan and later gain a `role.id -> {service_id}` index behind +the same signature without changing callers. ### Configuration @@ -73,24 +114,25 @@ Read from `AIAC_POLICY_STORE_URL` environment variable (or `.env` file co-locate ```python from aiac.policy.store.library.api import ( - get_policy, get_agent_policy, - apply_policy, apply_agent_policy, - delete_agent_policy, delete_policy, + get_service_policy, + get_service_policy_by_scope, + get_service_policies_by_role, + apply_service_policy, + delete_service_policy, ) -from aiac.policy.model.models import PolicyModel, AgentPolicyModel +from aiac.policy.model.models import ServicePolicyModel, Scope, Role -# Read current state for additive merge -current = get_agent_policy("weather-agent") +# Read current state for additive merge (fresh empty SPM on first sight) +current = get_service_policy("weather-service") -# Write updated state -apply_agent_policy("weather-agent", updated_model) +# Resolve the owning SPM of a scope +owner = get_service_policy_by_scope(scope) -# Full rebuild -delete_policy() -apply_policy(full_model) +# Find every SPM that grants a role (incl. stale mappings, for override-purge) +affected = get_service_policies_by_role(role) -# Off-boarding -delete_agent_policy("weather-agent") +# Write updated state (upsert) +apply_service_policy("weather-service", updated_spm) ``` --- @@ -102,11 +144,13 @@ delete_agent_policy("weather-agent") **Prior art:** `3.14-unit-tests-write-api.md` (mock PDP Policy Writer HTTP; cover module-level functions). Key behaviors to assert: -- `get_policy()` issues `GET /policy`; response body deserialized to `PolicyModel`. -- `get_agent_policy(id)` issues `GET /policy/agents/{id}`; response body deserialized to `AgentPolicyModel`. -- `apply_policy(model)` issues `POST /policy` with serialized `PolicyModel`. -- `apply_agent_policy(id, model)` issues `POST /policy/agents/{id}` with serialized `AgentPolicyModel`. -- `delete_agent_policy(id)` issues `DELETE /policy/agents/{id}`. -- `delete_policy()` issues `DELETE /policy`. -- Any non-2xx response raises `RuntimeError`. +- `get_service_policy(id)` issues `GET /policy/services/{id}`; response body deserialized to `ServicePolicyModel` (hit). +- `get_service_policy(id)` on `404` returns a fresh empty `ServicePolicyModel` for that `service_id` — no `RuntimeError` (miss). +- `get_service_policy_by_scope(scope)` resolves via `scope.serviceId` (sugar over the by-id read). +- `get_service_policies_by_role(role)` issues the by-role query; returns every SPM referencing `role.id`; returns `[]` when none match; returns multiple when several match. +- `apply_service_policy(id, spm)` issues `POST /policy/services/{id}` with serialized `ServicePolicyModel`; upsert round-trip (write then read back the same SPM). +- `delete_service_policy(id)` issues `DELETE /policy/services/{id}`; returns `None` on success. +- Any unexpected non-2xx response raises `RuntimeError`. - `AIAC_POLICY_STORE_URL` is read from env; falls back to `http://127.0.0.1:7074`. + + diff --git a/aiac/docs/specs/components/pdp-policy-keycloak-service.md b/aiac/docs/specs/components/pdp-policy-keycloak-service.md index e913f0178..2073c3d6b 100644 --- a/aiac/docs/specs/components/pdp-policy-keycloak-service.md +++ b/aiac/docs/specs/components/pdp-policy-keycloak-service.md @@ -6,7 +6,7 @@ ## Description A FastAPI web service that applies RBAC policy changes to Keycloak by managing composite role mappings. Roles are made composites of service (client) permissions (roles), so that any subject (user) assigned a role automatically inherits the associated service permissions. Stateless — no caching. -This is the **Phase 1** implementation of the PDP Policy Writer. It is deployed as a container in the **Rossoctl Interface Pod** alongside the IdP Configuration Service, behind the `aiac-pdp-policy-service:7072` ClusterIP. Phase 2 replaces only this container image (`aiac-pdp-policy-keycloak` → `aiac-pdp-policy-opa`) within the same pod. The service name and port remain stable so the AIAC Agent and library require no reconfiguration. +**Superseded — not deployed by any current manifest.** This was an earlier implementation of the PDP Policy Writer, managing Keycloak composite role mappings directly. The Kubernetes Interface Pod (`aiac/k8s/pdp-interface-deployment.yaml`) deploys the **OPA rego-file mock** (`aiac-pdp-policy-opa`, see [pdp-policy-writer-opa.md](pdp-policy-writer-opa.md)) as its Phase 1 PDP Policy Writer container instead, behind the same `aiac-pdp-policy-service:7072` ClusterIP. This service's source remains in-tree for reference but is not built or deployed by the current K8s manifests. ## Endpoints @@ -33,7 +33,7 @@ All endpoints return `502 Bad Gateway` with a JSON error body if the Keycloak Ad | Variable | Required | Description | |----------|----------|-------------| | `KEYCLOAK_URL` | Yes | Keycloak base URL, e.g. `http://keycloak-service.keycloak.svc:8080` | -| `KEYCLOAK_REALM` | Yes | Realm name, e.g. `rossoctl` | +| `KEYCLOAK_REALM` | Yes | Realm name, e.g. `kagenti` | | `KEYCLOAK_ADMIN_USERNAME` | Yes | Admin username (from `keycloak-admin-secret`) | | `KEYCLOAK_ADMIN_PASSWORD` | Yes | Admin password (from `keycloak-admin-secret`) | @@ -44,7 +44,7 @@ All endpoints return `502 Bad Gateway` with a JSON error body if the Keycloak Ad - Bind: `0.0.0.0:7072` - Base image: `python:3.12-slim` - Kubernetes ClusterIP Service: `aiac-pdp-policy-service:7072` -- Deployment: co-located with IdP Configuration Service as a container in the **Rossoctl Interface Pod** (`pdp-interface-deployment.yaml`) +- Deployment: co-located with IdP Configuration Service as a container in the **Kagenti Interface Pod** (`pdp-interface-deployment.yaml`) ## Dependencies (`requirements.txt`) diff --git a/aiac/docs/specs/components/pdp-policy-writer-opa.md b/aiac/docs/specs/components/pdp-policy-writer-opa.md index 65c3060a8..f7c65c4e2 100644 --- a/aiac/docs/specs/components/pdp-policy-writer-opa.md +++ b/aiac/docs/specs/components/pdp-policy-writer-opa.md @@ -6,9 +6,9 @@ ## Description A FastAPI web service that translates a **Policy Model** into OPA Rego packages and writes them to an `AuthorizationPolicy` Kubernetes Custom Resource. The OPA plugin embedded in each AuthBridge instance fetches the Rego packages relevant to its pod from this CR at startup. -The service is deployed as a container in the **Rossoctl Interface Pod** alongside the IdP Configuration Service, behind the `aiac-pdp-policy-service:7072` ClusterIP. +The service is deployed as a container in the **Kagenti Interface Pod** alongside the IdP Configuration Service, behind the `aiac-pdp-policy-service:7072` ClusterIP. -The service has no dependency on Keycloak. All Keycloak operations (entity reads) are handled by the **IdP Configuration Service** and its library (`aiac.idp.library.configuration`). +The service has no dependency on Keycloak. All Keycloak operations (entity reads) are handled by the **IdP Configuration Service** and its library (`aiac.idp.configuration`). --- @@ -51,7 +51,7 @@ Complete policy definition for a single agent (service). Contains two sets of `P **Outbound rule semantics:** this agent acting as realm role `role` is permitted to request the target scope `scope`. Grouped by role, these rules become the `agent_role_scopes` map (agent role → target scopes) that the outbound package evaluates. -**Outbound subject rule semantics:** a subject holding realm role `role` (a **user** role) is permitted to reach a **tool** exposing scope `scope`. Grouped by role, these rules become the `outbound_subject_role_scopes` map (user role → tool scopes) that the **outbound** package's subject gate evaluates against `target_scopes[input.target]`. This is distinct from `inbound_rules` (user → *agent* scope): the outbound subject gate answers "may this user reach the tool?", not "may this user call the agent?". +**Outbound subject rule semantics:** a subject holding realm role `role` (a **user** role) is permitted to reach a **tool** exposing scope `scope`. Grouped by role, these rules become the `subject_role_scopes` map (user role → tool scopes) that the **outbound** package's subject gate evaluates against `target_scopes[input.target]`. This is distinct from `inbound_rules` (user → *agent* scope): the outbound subject gate answers "may this user reach the tool?", not "may this user call the agent?". **Note on `target_scopes` direction:** the map is keyed by **target service id → allowed scopes** (the inverse of the former `scope_targets`, which was `scope → targets`). The outbound Rego generator emits this map **verbatim** and evaluates `target_scopes[input.target]` directly — there is no inversion (see below). @@ -97,7 +97,9 @@ No `?realm=` parameter — the service operates on a Kubernetes CR, not a Keyclo ## Rego package structure -For each `AgentPolicyModel`, the service generates **two Rego packages**: one for the inbound pipeline and one for the outbound pipeline. The `agent_id` is slugified for use in the package name (hyphens → underscores, lowercase). +For each `AgentPolicyModel`, the service generates **two Rego packages**: one for the inbound pipeline and one for the outbound pipeline. The `agent_id` is slugified for use in the package name (and filename). `agent_id` is the Keycloak clientId — a SPIFFE URI under SPIRE (`spiffe://host/ns/{ns}/sa/{name}`), or the plain `{ns}/{name}` clientId without it — so slugifying is not a simple hyphen→underscore substitution: the trust domain/host is dropped, the `{ns}/{name}` portion is extracted, and every remaining non-alphanumeric run collapses to a single underscore, lowercased (`aiac.pdp.service.policy.opa.rego.slugify`). This keeps the slug short and identical regardless of whether SPIRE is enabled — e.g. both `spiffe://localtest.me/ns/team1/sa/github-agent` and `team1/github-agent` slugify to `team1_github_agent`. + +> **Two identifiers, two layers (no contradiction).** UC-1 onboarding and the Trigger use the internal Keycloak **client UUID** (`service.id` / `Trigger.entity_id`) purely to *look up* a service in the IdP — that UUID **never reaches this writer**. What flows down the policy pipeline into `PolicyRule.scope.serviceId` / `Role.actorIds` and lands as `AgentPolicyModel.agent_id` is the **clientId** (the `{ns}/{name}` / SPIFFE form), which is what this writer slugifies for the Rego package name. The UUID→clientId resolution happens once, in the IdP Configuration Service, before the AgentPolicyModel is ever built. **Input is identifiers only.** Both packages receive an input document of **IDs**, never roles or scopes: inbound input is `{subject, source}`, outbound input is `{subject, target}` (`subject` is the end-user id, `source` the calling service id, `target` the called service id). Every role/scope mapping is therefore **embedded in the package itself**, and the `allow` logic resolves IDs → roles → scopes internally. Because no per-request scope is supplied, the decision is **coarse**: a principal passes when it has access to **at least one** relevant scope. @@ -107,10 +109,10 @@ The generator embeds these symbols, derived from the `AgentPolicyModel`: |-------------|--------|-------| | `subject_roles` | `model.subject_roles` | subject id → `[role.name, …]` | | `source_roles` | `model.source_roles` | source id → `[role.name, …]` | -| `agent_scopes` | `model.agent_scopes` | `[scope.name, …]` | +| `agent_scopes` | `model.agent_scopes` | `[scope.name, …]` — **inbound package only** (the audience gate; outbound decisions do not consider the agent's own scopes) | | `agent_roles` | `model.agent_roles` | `[role.name, …]` | | `role_scopes` | grouped `model.inbound_rules` | role.name → `[scope.name, …]` (agent scopes granted per subject role) — **inbound package only** | -| `outbound_subject_role_scopes` | grouped `model.outbound_subject_rules` | role.name → `[scope.name, …]` (tool scopes granted per user role) — **outbound package only** | +| `subject_role_scopes` | grouped `model.outbound_subject_rules` | role.name → `[scope.name, …]` (tool scopes granted per user role) — **outbound package only** | | `agent_role_scopes` | grouped `model.outbound_rules` | role.name → `[scope.name, …]` (tool scopes per agent role) | | `target_scopes` | `model.target_scopes` | target id → `[scope.name, …]` | @@ -146,31 +148,27 @@ allow if { subject_ok; source_ok } ### Outbound package: `authz.{agent_slug}.outbound` -Evaluated by the AuthBridge OPA plugin in the **outbound pipeline** — "what this agent may call". Input document: `{subject, target}` (IDs). The gate requires **both** the subject and the agent to pass, but the outbound **subject** gate is user→**tool** (distinct from the inbound user→agent gate): the subject must hold a role granting at least one **tool** scope the `target` accepts (via `outbound_subject_role_scopes`, grouped from `outbound_subject_rules`), **and** the agent (via its own `agent_roles`) must be permitted at least one scope that the `target` accepts. Both gates match against `target_scopes[input.target]`; `target_scopes` is consumed **directly** (target id → scopes) — it is not inverted. The inbound `role_scopes`/`agent_scopes` subject gate is **not** used here. +Evaluated by the AuthBridge OPA plugin in the **outbound pipeline** — "what this agent may call". Input document: `{subject, target, function_name}` (IDs; `function_name` is the requested target scope). `allow` is a **per-scope AND** keyed on `input.function_name`, requiring **both** gates to pass on that same scope. The outbound **subject** gate is user→**tool** (distinct from the inbound user→agent gate): it passes iff the subject holds a role granted the **requested** `function_name` (via `subject_role_scopes`, grouped from `outbound_subject_rules`). The **capability** gate (`target_ok`) passes iff the requested `function_name` is one of the scopes the `target` accepts — `target_scopes[input.target]`, consumed **directly** (target id → scopes, not inverted). Because both gates test the *same* `function_name`, `allow` is a genuine per-scope intersection: a mismatch (user reaches scope A, agent reaches scope B) denies both. `agent_roles` / `agent_role_scopes` are still emitted (informational / debugging) but `allow` does **not** reference them — `target_scopes[input.target]` already *is* the per-scope capability gate. The inbound `role_scopes`/`agent_scopes` subject gate is **not** used here, and the outbound package does not emit `agent_scopes` at all: outbound decisions never consider the agent's own audience scopes. ```rego package authz.{agent_slug}.outbound agent_roles := ["{role.name}", ...] # from agent_roles -agent_scopes := ["{scope.name}", ...] # from agent_scopes subject_roles := { "{subject_id}": ["{role.name}", ...], ... } -outbound_subject_role_scopes := { "{role.name}": ["{scope.name}", ...], ... } # from outbound_subject_rules (user role → tool scopes) +subject_role_scopes := { "{role.name}": ["{scope.name}", ...], ... } # from outbound_subject_rules (user role → tool scopes) agent_role_scopes := { "{role.name}": ["{scope.name}", ...], ... } # from outbound_rules (agent role → tool scopes) target_scopes := { "{target_id}": ["{scope.name}", ...], ... } # from target_scopes -# user may reach the tool: holds a role granting >=1 tool scope the target accepts +# user may reach the tool: holds a role granted the REQUESTED scope (input.function_name) subject_ok if { some role in subject_roles[input.subject] - some scope in outbound_subject_role_scopes[role] - scope in target_scopes[input.target] + input.function_name in subject_role_scopes[role] } -# agent may reach the tool: agent role grants >=1 tool scope the target accepts +# agent may reach the tool: the requested scope is one the target accepts (direct, per-scope) target_ok if { - some role in agent_roles - some scope in agent_role_scopes[role] - scope in target_scopes[input.target] + input.function_name in target_scopes[input.target] } default allow := false @@ -181,7 +179,7 @@ A worked example (agent `github-agent`, users `developer`/`tester`, tool `github --- -## Library: `aiac.pdp.policy.library` +## Library: `aiac.pdp.policy.library.api` HTTP client module wrapping the PDP Policy Writer REST API. Exposes four module-level functions. Service URL is read from the `AIAC_PDP_POLICY_URL` environment variable (default: `http://127.0.0.1:7072`). All functions raise `RuntimeError` on non-2xx response. @@ -242,7 +240,7 @@ For local development, the `kubernetes` client falls back to `~/.kube/config` au - Bind: `0.0.0.0:7072` - Base image: `python:3.12-slim` - Kubernetes ClusterIP Service: `aiac-pdp-policy-service:7072` -- Deployment: co-located with IdP Configuration Service as a container in the **Rossoctl Interface Pod** (`pdp-interface-deployment.yaml`) +- Deployment: co-located with IdP Configuration Service as a container in the **Kagenti Interface Pod** (`pdp-interface-deployment.yaml`) --- @@ -270,11 +268,11 @@ aiac/src/aiac/pdp/service/ ├── requirements.txt └── main.py -aiac/src/aiac/pdp/ +aiac/src/aiac/pdp/policy/ ├── __init__.py └── library/ ├── __init__.py - └── policy.py # apply_policy, apply_agent_policy, delete_agent_policy, delete_policy + └── api.py # apply_policy, apply_agent_policy, delete_agent_policy, delete_policy # (models now imported from aiac.policy.model.models) ``` @@ -290,9 +288,9 @@ docker build -f aiac/src/aiac/pdp/service/policy/opa/Dockerfile \ - Load Kubernetes in-cluster config at startup via `kubernetes.config.load_incluster_config()`; fall back to `kubernetes.config.load_kube_config()` for local development. - Instantiate a `kubernetes.client.CustomObjectsApi` for all CR operations. -- `_slugify(agent_id: str) -> str`: replace hyphens with underscores, lowercase — produces a valid Rego package name segment. +- `_slugify(agent_id: str) -> str`: extract `{namespace}/{name}` from a SPIFFE URI (or use the plain `{ns}/{name}` clientId as-is), then collapse every non-alphanumeric run to `_` and lowercase — produces a valid Rego package name segment, short and SPIRE-independent. - `_generate_inbound_rego(model: AgentPolicyModel) -> str`: render the inbound Rego package string. Embeds `agent_scopes`, `subject_roles`, `source_roles`, and a `role_scopes` map (grouping `inbound_rules` by role → agent scope names); emits `subject_ok` (mandatory) and `source_ok` (optional — an absent `input.source` passes); `allow if { subject_ok; source_ok }`. -- `_generate_outbound_rego(model: AgentPolicyModel) -> str`: render the outbound Rego package string. Embeds `agent_roles`, `agent_scopes`, `subject_roles`, `outbound_subject_role_scopes` (from `outbound_subject_rules`), `agent_role_scopes` (from `outbound_rules`), and `target_scopes` (consumed directly, target id → scopes — **no inversion**); emits a user→tool `subject_ok` (matching `scope in target_scopes[input.target]`, **not** `role_scopes`/`agent_scopes`) and `target_ok`; `allow if { subject_ok; target_ok }`. The inbound `role_scopes` map is **not** embedded in the outbound package. +- `_generate_outbound_rego(model: AgentPolicyModel) -> str`: render the outbound Rego package string. Embeds `agent_roles`, `subject_roles`, `subject_role_scopes` (from `outbound_subject_rules`), `agent_role_scopes` (from `outbound_rules`), and `target_scopes` (consumed directly, target id → scopes — **no inversion**); emits a user→tool `subject_ok` (matching `input.function_name in subject_role_scopes[role]`, **not** the inbound `role_scopes`/`agent_scopes`) and a capability `target_ok` (matching `input.function_name in target_scopes[input.target]`); `allow if { subject_ok; target_ok }` — a per-scope AND on the same `input.function_name`. Neither the inbound `role_scopes` map nor `agent_scopes` is embedded in the outbound package — outbound decisions never consider the agent's own audience scopes. - `_upsert_agent(agent_id: str, inbound_rego: str, outbound_rego: str)`: patch the `AuthorizationPolicy` CR to upsert the two packages for `agent_id`. Schema TBD. - `_delete_agent(agent_id: str)`: patch the CR to remove all packages for `agent_id`. - `_delete_all()`: patch the CR to remove all packages. diff --git a/aiac/docs/specs/components/policy-computation-engine.md b/aiac/docs/specs/components/policy-computation-engine.md index 6f2962eb0..15a386407 100644 --- a/aiac/docs/specs/components/policy-computation-engine.md +++ b/aiac/docs/specs/components/policy-computation-engine.md @@ -2,33 +2,56 @@ ## Problem Statement -AIAC Agent sub-agents produce `list[PolicyRule]` objects representing partial policy updates — a new onboarding event may produce a handful of rules covering one agent's inbound and outbound access. Before this component, merging those rules into full `AgentPolicyModel` objects required each sub-agent to independently: +AIAC Agent sub-agents produce `list[PolicyRule]` objects representing partial policy updates — a new onboarding event may produce a handful of rules covering one agent's inbound and outbound access. `compute_and_apply(rules, override)` merges those rules into persisted policy. -1. Query the IdP Configuration Service to resolve which services own each role and scope. -2. Read the current `AgentPolicyModel` from the Policy Store. -3. Additively merge the new rules into the existing model. -4. Write the updated model back to the Policy Store. -5. Build a `PolicyModel` and push it to the PDP Policy Writer. +The **original design persisted only per-agent `AgentPolicyModel` (APM) records**, storing rules denormalised onto the agent that reaches or is reached. Because a rule was only ever attached to an agent that already existed in the store, the merge outcome depended on the **order** in which services were onboarded. -This bespoke logic was duplicated across every sub-agent that produced policy rules, making the merge semantics inconsistent and the IdP query pattern scattered. +### The order-dependence bug (repro) + +Let: + +- `UR` = a user (realm) role, mapped to agent `A`'s scope `AS` **and** tool `T`'s scope `TS`. +- `AR` = agent `A`'s (client) role, mapped to `TS`. + +Onboarding **A then T** yields `APM(A)` outbound `{AR→TS, UR→TS}` — correct. Onboarding **T then A** **loses `UR→TS`**: at T-onboarding no agent yet targets `TS`, so the `(UR → TS)` outbound-subject rule has nowhere to attach and is dropped; at A-onboarding it is never re-emitted. The two orders diverge. + +The same shape produces a **latent sibling bug**: a user role added *later* (UC3) to an already-onboarded agent+tool pair could not be reconstructed onto the agent, because nothing re-derived the agent's gates from durable facts. ## Solution -A pure Python library module `aiac.policy.computation` centralises all policy computation. Sub-agents call a single function `compute_and_apply(rules: list[PolicyRule], override: bool = False) -> None`, which handles IdP resolution, merging (additive append or override-replace), Policy Store read/write, and PDP Policy Writer invocation. No FastAPI service, no Kubernetes deployment — the module is imported directly into the calling sub-agent's process. +A **two-layer** model (see the policy-model component spec, handoff 01): + +- **`ServicePolicyModel` (SPM)** — one per service, **persistent**, the **source of truth**. It carries the service's own identity (`owned_roles` / `owned_scopes` / `service_type`) and its `inbound_rules`: every `(role → scope)` rule whose `scope` this service owns. `UR→TS` lives durably on `SPM(T)`. +- **`AgentPolicyModel` (APM)** — **derived on demand** from the relevant SPMs and **partial-upserted** to the PDP. Never persisted as source of truth. + +`compute_and_apply` routes each incoming rule to `SPM(scope.serviceId).inbound_rules`, persists the changed SPMs, computes the set of **affected agents** from the batch, re-derives each affected agent's APM **entirely from SPMs (zero IdP)**, and partial-upserts them to the PDP in a single `apply_policy` call. + +Because `UR→TS` is durable on `SPM(T)` and is reconstructed onto `A` whenever `A` is derived, both onboarding orders converge to the same `APM(A)` = inbound `{UR→AS}`, outbound `{AR→TS, UR→TS}`. The latent sibling bug is fixed too: a late UC3 user role routes to `SPM(T)`, marks `A` affected, and re-derives `A`'s subject gate. + +The module is pure Python (`aiac.policy.computation`), imported directly into the calling sub-agent's process. No FastAPI service, no Kubernetes deployment, no container image. + +--- + +## Assumptions + +These AIAC invariants (from the policy-model spec, handoff 01) are relied on by the PCE and are **enforced upstream at the Keycloak IdP boundary** (handoff 02), not re-checked here: + +1. **No role spans both kinds.** A role is held by users *or* by agent service accounts, never both. This is what lets `Role.actorIds` be a single list and lets the PCE split inbound rules cleanly by `role.kind`. AIAC invariant, *not* a Keycloak guarantee. +2. **No scope shared across services.** A scope has exactly one owner, so `Scope.serviceId` is single-valued and `SPM(scope.serviceId)` is unambiguous. (Keycloak client scopes are realm-level and assignable to many clients; for AIAC-managed scopes the owner set is always length 1.) +3. **Agent role ⇔ Keycloak client role; user role ⇔ Keycloak realm role.** `Role.kind` is populated from Keycloak's `clientRole` flag by the IdP config service; agent roles come from a service's **client** roles (`Service.roles`). --- ## User Stories -1. As an AIAC Agent sub-UC agent, I want to submit a list of `PolicyRule` objects and have them automatically merged into the relevant `AgentPolicyModel` records, so that I do not need to implement IdP resolution or storage merge logic. -2. As an AIAC Agent sub-UC agent, I want the computation to be fire-and-forget, so that my sub-agent is not blocked waiting for Rego generation to complete. -3. As the Policy Computation Engine, I want to resolve which services own a given `Role`, so that I know which `AgentPolicyModel` records receive new outbound rules. -4. As the Policy Computation Engine, I want to resolve which services expose a given `Scope`, so that I know which `AgentPolicyModel` records receive new inbound rules. -5. As the Policy Computation Engine, I want to read each affected agent's current `AgentPolicyModel` before merging, so that additive append does not lose previously established rules. -6. As the Policy Computation Engine, I want to skip duplicate rules on append, so that re-processing the same event does not create redundant entries. -7. As the Policy Computation Engine, I want to push the updated `PolicyModel` to the PDP Policy Writer after all store writes succeed, so that OPA reflects the latest policy state. -8. As a developer, I want exceptions from the computation to be logged without propagating, so that a transient IdP or Policy Store failure does not crash the calling sub-agent. -9. As a developer, I want to import the engine from a stable path, so that the calling convention does not change as the module grows. +1. As an AIAC Agent sub-UC agent, I want to submit a list of `PolicyRule` objects and have them durably recorded on the right service and reflected in every affected agent's policy, without implementing routing or storage merge logic myself. +2. As an AIAC Agent sub-UC agent, I want to submit the rules and get no return value to unpack on success, so I stay decoupled from routing, storage, and derivation — while a failure still surfaces to me (US 7). +3. As the Policy Computation Engine, I want each rule recorded on the SPM of the service that **owns the rule's scope**, so the fact survives regardless of which services already exist. +4. As the Policy Computation Engine, I want to derive an affected agent's APM purely from the persisted SPMs, so the result is **independent of onboarding order**. +5. As the Policy Computation Engine, I want to skip duplicate rules on append, so re-processing the same event does not create redundant entries. +6. As the Policy Computation Engine, I want to partial-upsert only the affected agents' packages to the PDP, so unaffected agents are left untouched. +7. As a developer, I want exceptions from the computation logged **and re-raised**, so a failed IdP / store / PDP interaction surfaces to the caller (the Controller returns HTTP 500; a NATS consumer nacks → at-least-once redelivery) instead of being silently dropped while nothing is applied. +8. As a developer, I want a stable import path, so the calling convention does not change as the module grows. --- @@ -40,8 +63,6 @@ A pure Python library module `aiac.policy.computation` centralises all policy co **Location:** `aiac/src/aiac/policy/computation/` -**Package structure:** - ``` aiac/src/aiac/policy/ └── computation/ @@ -53,126 +74,195 @@ No FastAPI. No Kubernetes deployment. No container image. Imported as a library ### Public API -Single entry point: +Two entry points — an incremental fold and an authoritative offboard: ```python def compute_and_apply(rules: list[PolicyRule], override: bool = False) -> None +def decommission(service_id: str) -> None # service_id = clientId (SPM key), not the Keycloak UUID ``` -- **Fire-and-forget:** the caller receives no return value. The function logs exceptions and does not propagate them — a transient failure in IdP resolution, Policy Store I/O, or PDP Policy Writer push must not crash the calling sub-agent. -- **`override`:** selects the merge mode (see [Merge Semantics](#merge-semantics)). `False` (default) appends additively; `True` authoritatively replaces every input role's mappings. Set by the caller (the Controller) from the producing UC's choice — UC1 = `False`, UC3 = `True`, UC2 Rebuild = `True`, UC2 Build = TBD. -- Import path: `from aiac.policy.computation.engine import compute_and_apply` +- **No return value; failures propagate:** on success the caller receives no return value. Both functions log exceptions and **re-raise** them — a failure in IdP resolution, Policy Store I/O, or PDP Policy Writer push surfaces to the caller (the Controller returns HTTP 500; a NATS consumer nacks → at-least-once redelivery) rather than being silently swallowed while nothing is applied. +- **`override`:** selects the merge mode (see [Merge Semantics](#merge-semantics)). `False` (default) appends additively at the SPM layer; `True` authoritatively replaces every input role's mappings **across all SPMs** (role-level revocation). Set by the caller (the Controller) from the producing UC's choice — UC1 = `False`, UC3 = `True`, UC2 Rebuild = `True`, UC2 Build = TBD. +- **`decommission`:** the authoritative service **offboard** — tears down a decommissioned service's entire policy footprint (see [Decommission (service offboard)](#decommission-service-offboard)). Keyed by the **clientId (SPM key)**, since an offboarded client is gone from `get_services()` and its UUID can no longer be resolved. +- Import path: `from aiac.policy.computation.engine import compute_and_apply, decommission` + +### Rule-builder input contract (upstream) + +Each incoming `PolicyRule` arrives with `scope.serviceId`, `role.kind`, and `role.actorIds` **already populated**, and with roles **already flattened** to their closure (role + descendants, dedup by `role.id`). The PCE performs **no IdP lookup for routing or classification** and **no role flattening** — it treats each rule's `role` and `scope` as-is. + +- The boundary that **derives** `scope.serviceId` / `role.kind` / `role.actorIds` from Keycloak facts is the **Keycloak IdP config service (handoff 02)**. +- The rule-builder (`src/aiac/agent/policy/api.py` — `role_to_scopes` / `roles_to_scope`, `PolicyRule`) merely **carries those fields through**. Ensure it does (add the pass-through if missing); it does not compute them. ### Algorithm -Given `rules: list[PolicyRule]` and an `override` flag, the engine executes these steps. +Given `rules: list[PolicyRule]` and an `override` flag, `compute_and_apply` executes: + +1. **Catalog once.** Call `Configuration.get_services()` — the **only** runtime IdP read. For every service touched this batch, seed its SPM's `service_type` / `owned_roles` / `owned_scopes` from its catalog `Service` record, keeping only **AIAC-provisioned** entities (the `aiac.managed` marker on `Role.aiac_managed` / `Scope.aiac_managed`; Keycloak built-ins — the default client scopes `profile`, `email`, `roles`, `web-origins`, `acr`, `basic`, `service_account`, and the `default-roles-` composite — are dropped). This seed drives **P2** identity and the **P4** "only agents modelled" rule. It is a seed, **not** a per-derive dependency. + +2. **Route each rule to its owning service's SPM.** For each rule `(role, scope)`, append it to `SPM(scope.serviceId).inbound_rules` — fetch the SPM via `get_service_policy_by_scope` / `get_service_policy`. **Append-dedup by `role.id + scope.id`.** There is **no** write-time 3-way P5b classification (the old (user,agent-scope)/(user,tool-scope)/(agent,tool-scope) routing table is gone) — a rule always lands on the SPM of the service that owns its scope, whatever the kinds. + +3. **Override (`override=True`) — role-level revocation.** *Before* appending, purge the **distinct input-role set** from **every** SPM that contains any of them: one up-front pass using `get_service_policies_by_role` per distinct input role, removing every stored rule whose `role.id` matches. Then append the fresh rules. Purging once, up-front, ensures a role shared across the input is not wiped after being added. The old algorithm's `target_scopes` reconciliation is **deleted** — `target_scopes` is a derived, never-stored quantity. -> **Input rules arrive with roles already flattened to their closure** by the calling -> sub-agent (UC1/UC2/UC3) — the role itself plus all descendant roles (recursively via -> `role.childRoles`), de-duplicated by `role.id`. The PCE performs **no role flattening**; -> each rule's `role` is treated as-is (it may be a composite role or one of its children). +3b. **Reconcile (drift GC) — after routing/override, before persist.** Prune each **touched** SPM against the step-1 `get_services()` catalog (no additional IdP read) so drift cannot accumulate across re-onboarding. Runs under **both** merge modes and is order-independent (drops only edges whose entity no longer exists). See [Reconcile (drift GC)](#reconcile-drift-gc) under Merge Semantics for the keep rules. -0. **Service catalog + classification.** Resolve the full service catalog once via `Configuration.get_services() -> list[Service]`, keyed as `serviceId → Service`. Each `Service` carries its `type` (inferred as `Agent` / `Tool`), its own service-account realm roles, and its exposed scopes. A service is an **agent** iff `type == "Agent"`; any other service (notably `Tool`) is a **pure target**. This catalog drives both agent identity (P2) and the "only agents are modelled" rule (P4). `get_services_by_role` / `get_services_by_scope` honor the **P1 client-side service filter**, so they return only services that genuinely own the role / expose the scope. +4. **Persist** each changed SPM via `apply_service_policy`. -1. **Classify and route each rule by kind (P5b).** The PCE is called with a single concatenated `list[PolicyRule]` spanning all three mappings, so each rule is classified by the kind of its role and scope and routed accordingly: +5. **Compute the affected-agent set from the batch** — from the batch's roles/scopes, **not** by scanning all agents: + - For each input (or purged) role `r` with `r.kind == Agent`: the owning agents in `r.actorIds` are affected (their outbound changed). + - For each touched scope `s` with owner `X = s.serviceId`: + - if `X` is an **Agent**, `X` is affected (its inbound changed); **and** + - every agent **targeting** `s` is affected — namely the owners (`actorIds`) of the **Agent-kind** inbound rules on `SPM(X)` whose scope is `s`. - | Rule kind (role, scope) | Routed to (on the agent model) | - |---|---| - | (user role, agent scope) | `inbound_rules` (+ `subject_roles`) | - | (user role, tool scope) | `outbound_subject_rules` (+ `subject_roles`) | - | (agent role, tool scope) | `outbound_rules` + `target_scopes[tool]` | +6. **Derive** each affected agent's APM (see below), collect them into one `PolicyModel`, and **partial-upsert** via `aiac.pdp.policy.library.apply_policy` **exactly once**. Exceptions are logged and re-raised (they propagate to the caller). **Tools get an SPM but no APM** (P4). - - **Role kind** is read from ownership: `get_services_by_role(role)` returning an **agent** service ⇒ *agent role*; returning no agent (realm-level) ⇒ *user role*. - - **Scope kind** is read from exposure: `get_services_by_scope(scope)` returning an **agent** ⇒ *agent scope*; returning a **tool** ⇒ *tool scope*. +### Derivation of `APM(A)` — 100% from SPMs, zero IdP -2. **(agent role, tool scope) — mapping c:** for each agent owning the rule's role, add the rule to that agent's `outbound_rules`, and append the tool scope to `target_scopes[tool.serviceId]` for each tool exposing it (keyed by the tool's string `serviceId`, value is the typed `Scope`). This records "the agent may reach the tool". +Let `R_A = SPM(A).owned_roles` (A's client roles) and `S_A = SPM(A).owned_scopes`. -3. **(user role, agent scope) — mapping a:** for each agent exposing the rule's scope, add the rule to that agent's `inbound_rules`, and record the role's subjects — `get_subjects_by_role(role)` → append the typed `Role` to `subject_roles[subject.username]`. This records "the user may call the agent". +- **Identity (P2):** `agent_roles` ← `R_A`; `agent_scopes` ← `S_A`. +- **Inbound:** `inbound_rules` ← all of `SPM(A).inbound_rules`. Split each by `role.kind`: + - `User` → `subject_roles[username] += role` (usernames from `role.actorIds`); + - `Agent` → `source_roles[serviceId] += role` (serviceIds from `role.actorIds`). +- **Outbound:** for each `r ∈ R_A`, find the `r`-rules in `get_service_policies_by_role(r)`. For each such `(r → s)`: add to `outbound_rules` and `target_scopes[s.serviceId] += s`. +- **Outbound subject gate:** for each target `(X, s)` in `target_scopes` — where `X` is the callee, a **tool or another agent** — take the **User**-kind inbound rules `(u → s)` on `SPM(X)`, append them to `outbound_subject_rules`, and `subject_roles += u.actorIds`. The gate's range is tool ∪ agent scopes. -4. **(user role, tool scope) — mapping b:** deferred until `target_scopes` is populated by mapping-c rules in the same batch. Then, for each agent model whose `target_scopes` already exposes that tool scope, add the rule to that agent's `outbound_subject_rules` and record the role's subjects in `subject_roles`. This records "the user may reach the tool the agent targets" — the outbound subject gate. A (user role, tool scope) rule with no agent targeting that tool is dropped (it cannot be attached to any agent). +**Relevance is directional.** An SPM contributes to `A` **iff** it *is* `SPM(A)` (contributes inbound) **or** it contains a rule whose role is one of A's **agent** roles `R_A` (contributes outbound). A merely *shared user role* never confers relevance — this is what prevents a **false outbound edge** to a target (a tool or another agent) `A` does not actually target. This is a **derivation-layer** relevance rule: it does **not** imply the outbound user gate is empty. When the agent holds a per-skill operator role that the PRB maps (by capability-match) to a target's scope, the agent *does* target that callee, and the nested derivation then surfaces the shared-user edges. -5. **P4 — only agents are modelled.** Routing only ever creates an `AgentPolicyModel` for a service identified as an **agent** (one owning the rule's role, or exposing the agent scope). A pure-target **Tool** therefore never gets its own model — no `github_tool.*.rego` is emitted. The agent→tool `target_scopes` edge is still recorded on the agent's model. +### P2 / P4 / P5b reconciliation -6. **P2 — embed each agent's own identity.** For every agent model the PCE writes, set `agent_roles` / `agent_scopes` from that agent's `Service` record in the catalog (its own service-account realm roles and exposed scopes). Only **AIAC-provisioned** entities are embedded: the PCE keeps only roles/scopes carrying the `aiac.managed` marker (`Role.aiac_managed` / `Scope.aiac_managed`) and drops Keycloak's built-ins — the default client scopes (`profile`, `email`, `roles`, `web-origins`, `acr`, `basic`, `service_account`) and the `default-roles-` composite — that every OIDC client / service account carries. This keeps the embed to domain entities only and makes it deterministic across runs (built-ins are returned in an arbitrary order by Keycloak). A realm-level agent with no owning service in the catalog keeps `[]`. Without the embed, both generated gates would deny-all (inbound `subject_ok` needs a non-empty `agent_scopes`; outbound `target_ok` needs a non-empty `agent_roles`). +- **P2 (identity embed):** copy `owned_roles` / `owned_scopes` from `SPM(A)` onto the APM's `agent_roles` / `agent_scopes`. AIAC-managed filter applied at catalog-seed time. Without the embed both generated gates would deny-all (inbound `subject_ok` needs a non-empty `agent_scopes`; outbound `target_ok` needs a non-empty `agent_roles`). +- **P4 (only agents modelled):** emit an APM / Rego only for SPMs with `service_type == Agent`. Tools keep an SPM (durable `inbound_rules`) but never get an APM — no `github_tool.*.rego` is emitted. +- **P5b (classification):** now expressed purely as `role.kind` + `scope.serviceId`. The write-time 3-way routing table is gone; classification happens at **derive** time by splitting inbound rules on `role.kind`. -7. **Merge (additive append, or override replace):** for each affected agent, read the current `AgentPolicyModel` from the Policy Store via `get_agent_policy(agent_id)`. - - **If `override` is `True`:** first purge the **distinct set of input roles** from the model — remove every stored `PolicyRule` whose `role.id` matches from `inbound_rules`, `outbound_rules`, **and** `outbound_subject_rules`, drop those role `id`s from all `source_roles` / `subject_roles` lists, and reconcile `target_scopes` by recomputing it from the surviving `outbound_rules`. This purge is done **once, up-front** for the whole input-role set — before any new rule is applied — so rules for a shared role are not wiped after being added. - - **Then (both modes):** append new rules and map entries that are not already present (de-duplicate rules by value; de-duplicate map list values by the entity's `id`). Because the maps are keyed by plain strings, merging is a plain dict-key lookup. P2's `agent_roles` / `agent_scopes` are then set from the catalog (authoritative for the agent's own identity). +### Agent → agent access — in scope, for free - Write the updated model back via `apply_agent_policy(agent_id, model)`. +An agent-to-agent edge `AR→BS` (agent A's role → agent B's scope) is stored on `SPM(B)` and handled uniformly, with **no target-type branching anywhere**: -8. **PDP push:** once all Policy Store writes complete, build a `PolicyModel` from the updated agents and call `aiac.pdp.policy.library.apply_policy(model)` (fire-and-forget within this function). +- A's derivation: `AR ∈ R_A`, so `get_service_policies_by_role(AR)` finds `AR→BS` on `SPM(B)` → `outbound_rules += AR→BS`, `target_scopes[B] += BS`, plus B's user gates as `outbound_subject_rules`. +- B's derivation: `AR→BS ∈ SPM(B).inbound_rules`, `AR.kind == Agent` → `source_roles[A] += AR`. + +Add a test for this. + +**Future-optimization note (document, do NOT build now):** a shared edge like `AR→BS` is stored **once** canonically on `SPM(B)` but **projected into two APMs** (A's `outbound_rules` and B's `source_roles`), so the generated Rego duplicates it across two packages. This is acceptable; a future optimization could share the representation. + +### Two implementation-time verification gates + +Confirm both while coding (they gate correctness of the whole approach): + +1. **`apply_policy` must be a partial (per-agent) upsert.** The PCE upserts only the affected agents. If `apply_policy` rewrites the **whole** policy set instead of replacing per-agent packages, partial upsert would delete every non-affected agent. If so, either make `apply_policy` per-agent, or push a full snapshot. Check `aiac.pdp.policy.library` / the pdp-policy library + writer specs. +2. **Rego must consume `source_roles`** for the inbound gate (not only `subject_roles`), or agent→agent inbound is *modelled but not enforced*. `source_roles` already exists on `AgentPolicyModel`, so the path likely exists — confirm. ### Merge Semantics -The `override` flag (set by the caller from the producing UC's choice) selects the merge mode: +The `override` flag (set by the caller from the producing UC's choice) selects the merge mode, applied at the **SPM layer**: + +- **`override=False` (default — additive append):** each rule is appended to `SPM(scope.serviceId).inbound_rules` if not already present (dedup by `role.id + scope.id`). Existing SPM rules are preserved. Incremental path (e.g. UC1 Service Onboarding, where existing roles must not lose their other access). +- **`override=True` (authoritative role-keyed replace):** before appending, the engine purges the distinct input-role set from **every** SPM containing them (`get_service_policies_by_role`), once, up-front, so the fresh rules become each role's complete mapping. Used by role-scoped recomputes (UC3 Role Update) and full rebuilds (UC2 Rebuild). + +`override=True` provides **role-level** revocation. Finer-grained single-rule revocation (removing one `PolicyRule` without replacing its whole role) is still **TBD**. + +#### Reconcile (drift GC) + +SPM identity keys on Keycloak UUIDs, which **churn on delete/recreate**. Because append-dedup keys on `role.id + scope.id`, a re-onboarded service whose Keycloak roles/scopes were recreated presents *new* UUIDs, so its edges are treated as new and pile up **beside** the superseded generations — nothing removes the old ones. (`override=True` does not close this: it purges by the *input* role's id, so a role whose UUID already churned out of the batch is never matched.) A live diagnostic once found a single agent SPM carrying 53 inbound edges across two role-id generations, retired `*-aud` scopes, an impossible self-reference, and duplicate same-name roles — all replayed into every regenerated APM/Rego. + +**Reconcile** closes this. After routing (step 2) and any override purge (step 3), and **before** persist (step 4), each **touched** SPM is pruned against the step-1 catalog. It **reuses that same `get_services()` result** — no additional IdP read, so the *only-runtime-IdP-read-is-`get_services()`* invariant holds. It runs under **both** merge modes and is **order-independent** — it removes *only* edges whose entity genuinely no longer exists, never a live edge, so both onboarding orders still converge. "Touched SPMs only": at that point the SPM cache holds exactly the routed + override-purged SPMs (agent-derive SPMs aren't loaded yet). -- **`override=False` (default — additive append):** existing `inbound_rules` and `outbound_rules` entries are preserved; new rules and map entries are appended. De-duplication compares rules by value (`role.id` + `scope.id`) and map list values by the entity's `id`. This is the incremental path (e.g. UC1 Service Onboarding, where existing roles receive a partial new mapping and must not lose their other access). -- **`override=True` (authoritative role-keyed replace):** before appending, the engine purges every input role from the stored model (both directions + `target_scopes` reconciliation, per algorithm step 5) so the fresh rules become that role's complete mapping. Used by role-scoped recomputes (UC3 Role Update) and full rebuilds (UC2 Rebuild), where the input already represents each role's complete intended scope set. +For each touched `SPM(X)` whose owner `X` **is present in the catalog** (a catalog **miss ⇒ skip pruning**, never wipe on a transient outage), an inbound edge is kept iff: -`override=True` provides **role-level** revocation — a role's stale mappings are removed before re-applying. Finer-grained single-rule revocation (removing one `PolicyRule` without replacing its whole role) is still **TBD**. +1. **Scope still exists** — `edge.scope.id ∈ {s.id for s in owned_scopes}` (X's current `aiac.managed` scopes, seeded from the catalog). Drops retired/churned scopes (kills the `*-aud` species and scope-model cruft). +2. **Agent role still exists** — for `role.kind == Agent`, `edge.role.id ∈` the catalog's `aiac.managed` role ids (all services). Drops retired/churned agent client roles (kills self-references and agent-role UUID churn). +3. **User-role churn collapse** — user realm roles are membership-derived, absent from the catalog, and the PCE must not read `get_subjects()`; so among surviving `User` edges grouped by `(scope.id, role.name)`, a stale edge is dropped only when **this batch** carries a *different* id for that same `(scope, name)` (the fresh id supersedes the old generation). Two *co-existing* same-name realm roles both currently held are both kept (realm hygiene, not accumulation — out of scope). + +#### Decommission (service offboard) + +Reconcile is passive and catalog-anchored: it prunes only **touched** SPMs and skips any whose owner is absent from `get_services()`. That leaves the **onboard→offboard** drift species uncovered — once a service `X` is decommissioned (its Keycloak client + roles/scopes deleted), `X` is gone from the catalog forever, so (1) `SPM(X)`'s own inbound edges linger; (2) `X`'s **outbound footprint** (`X_role → other_scope` edges on *other* SPMs) is never pruned; (3) if `X` was an agent, its **APM/Rego stays in the PDP**. `decommission(service_id)` is the **authoritative** teardown for exactly this — it acts on an explicit offboard signal, not the catalog-miss guard. + +**Keyed by the clientId, not the UUID.** An offboarded client is gone from `get_services()`, so UUID→clientId resolution is impossible; the offboard contract carries the clientId (`Service.serviceId`, the SPM key) directly. This is the documented asymmetry with onboard's `/apply/service/{uuid}`. + +Steps: + +1. **Catalog once** (`get_services()` — the same single allowed IdP read; `X` is absent, used only to seed/classify the still-live agents re-derived in step 8). +2. **Load `SPM(X)`.** **Content guard:** a 404 fresh-empty SPM (never onboarded / already removed) is a **no-op** — no spurious PDP delete. +3. **Targeters** — agents whose *outbound* loses `X`: the `actorIds` of every **Agent**-kind inbound edge on `SPM(X)` (they held `their_role → X_scope` on `SPM(X)`, deleted in step 5). +4. **Purge `X`'s outbound footprint.** For each `r ∈ SPM(X).owned_roles`, find the SPMs referencing it via `get_service_policies_by_role(r)`; on each such SPM `B` (skip `X`), drop edges where `edge.role.id == r.id`; mark `B` changed and, if `B` is an agent, affected (its inbound `source_roles[X]` vanished). +5. **Delete `SPM(X)`** (`delete_service_policy`) — removes every user→X and agent→X inbound edge at once — and evict it from the SPM cache so re-derive can't resurrect it. +6. **Persist** each changed (footprint-purged) SPM (`apply_service_policy`). +7. **Delete `APM(X)`** (`delete_agent_policy`) iff `SPM(X).service_type == Agent` (tools have an SPM but no APM). +8. **Re-derive** `affected = (targeters ∪ purged-agent-owners) − {X}`, filtered to agents; `apply_policy(PolicyModel(agents=…))` **once** if non-empty. Derivation is reused unchanged — it reads the freshly-persisted, `X`-deleted store, so `outbound` / `target_scopes` / `source_roles` referencing `X` drop automatically. + +**Invariants preserved:** still exactly one IdP read (`get_services()`); still a per-agent partial upsert. **Not covered** (follow-ups): NATS `aiac.apply.offboard.{id}` consumer wiring; dropped-target GC where the source service survives (via `override=True` re-onboard); batch offboard. ### Dependencies | Module | Purpose | |--------|---------| -| `aiac.policy.model` | `PolicyRule`, `AgentPolicyModel`, `PolicyModel` | -| `aiac.idp.configuration.library` | `Configuration` — `get_services` (catalog: type + own roles/scopes), `get_services_by_role`, `get_services_by_scope`, `get_subjects_by_role` | -| `aiac.policy.store.library` | `get_agent_policy`, `apply_agent_policy` | -| `aiac.pdp.policy.library` | `apply_policy` — push updated `PolicyModel` to OPA | +| `aiac.policy.model` | `PolicyRule`, `ServicePolicyModel`, `AgentPolicyModel`, `PolicyModel` | +| `aiac.idp.configuration` | `Configuration.get_services` — the **only** runtime IdP read (catalog: `service_type` + own roles/scopes for the P2 seed) | +| `aiac.policy.store.library` | `get_service_policy` / `get_service_policy_by_scope` (fetch SPM), `get_service_policies_by_role` (SPMs containing a role — override purge + outbound derivation), `apply_service_policy` (persist SPM), `delete_service_policy` (offboard) | +| `aiac.pdp.policy.library` | `apply_policy` — partial-upsert derived APMs to OPA; `delete_agent_policy` — remove an offboarded agent's APM/Rego | + +Note: the PCE no longer calls `get_services_by_role` / `get_services_by_scope` / `get_subjects_by_role` at routing or classification time — those facts arrive on the rules (input contract) and derivation reads SPMs. The single IdP read is `get_services()` for the identity seed. ### Not Called By -The PCE is **not** called by: -- PDP Policy Writer — it is the downstream consumer, not a caller -- Policy Store — the store is pure CRUD with no computation -- IdP Configuration Service — the IdP service has no awareness of this module +- PDP Policy Writer — the downstream consumer, not a caller. +- Policy Store — pure CRUD, no computation. +- IdP Configuration Service — no awareness of this module. ### Not Responsible For -- Rule revocation (TBD) -- Bootstrapping `AgentPolicyModel` records for new agents (the store returns a 404; the engine creates a fresh model in that case) -- Translating `PolicyModel` → Rego packages (responsibility of `aiac.pdp.policy.library` / PDP Policy Writer) +- Rule revocation beyond role-level `override=True` replace (single-rule revocation is TBD). +- Bootstrapping SPM records for brand-new services (the store returns 404; the engine seeds a fresh SPM from the catalog). +- Translating `PolicyModel` → Rego packages (responsibility of `aiac.pdp.policy.library` / PDP Policy Writer). --- ## Testing Decisions -Good tests assert external behavior — what the engine does to the Policy Store and PDP Policy Writer — not internal merge logic directly. +Good tests assert external behavior — what the engine writes to the Policy Store (SPMs) and pushes to the PDP (derived APMs) — not internal merge logic directly. **Seam:** mock all downstream dependencies at their module-level import boundary: -- `aiac.idp.configuration.library` — mock `Configuration.get_services` (the catalog, carrying `type` + each service's own roles/scopes), `Configuration.get_services_by_role`, `Configuration.get_services_by_scope`, and `Configuration.get_subjects_by_role` -- `aiac.policy.store.library` — mock `get_agent_policy`, `apply_agent_policy` -- `aiac.pdp.policy.library` — mock `apply_policy` + +- `aiac.idp.configuration` — mock `Configuration.get_services` (the catalog: `service_type` + each service's own roles/scopes for the P2 seed). +- `aiac.policy.store.library` — mock `get_service_policy` / `get_service_policy_by_scope`, `get_service_policies_by_role`, `apply_service_policy`, `delete_service_policy`. +- `aiac.pdp.policy.library` — mock `apply_policy`, `delete_agent_policy`. + +**Un-freeze `test/policy/computation/`.** These tests were excluded (frozen imports caused collection errors). With the SPM redesign landed, un-freeze the directory so the suite runs under `pytest test/ -m "not integration"`. Key behaviors to assert: -- **Routing by kind (P5b):** a (user role, agent scope) rule lands in the exposing agent's `inbound_rules`; a (agent role, tool scope) rule lands in the owning agent's `outbound_rules` with a `target_scopes[tool]` entry; a (user role, tool scope) rule lands in `outbound_subject_rules` of the agent whose `target_scopes` already exposes that tool scope. -- A (user role, tool scope) rule with no agent targeting that tool produces no model. -- **P2:** each written agent model carries its own `agent_roles` / `agent_scopes` read from the service catalog, filtered to AIAC-provisioned entities (the `aiac.managed` marker) so Keycloak built-ins are excluded; an agent with no AIAC-managed catalog roles/scopes keeps `[]`. -- **P4:** a pure-target **Tool** service is never written as its own model, even though it exposes a scope the agent reaches; the agent→tool `target_scopes` edge is still recorded. -- `get_subjects_by_role` records `subject_roles` keyed by the subject's string `username` with the typed `Role` in the value list (for both user→agent and user→tool rules). -- `target_scopes` on the written model is keyed by the target service's string `serviceId` with the typed `Scope` in the value list; every relationship map has string keys, so `model_dump(mode="json")` round-trips without a custom key serializer. -- The PCE does **not** flatten roles: `get_services_by_role` is called once per rule's role as-is (rules arrive pre-flattened from the UC); passing a rule with a composite role does not trigger per-child calls inside the PCE. -- With `override=False` (default), existing rules and map entries in the fetched `AgentPolicyModel` are preserved after merge (additive append). -- With `override=True`, every input role's stored mappings are purged from `inbound_rules`, `outbound_rules`, **and** `outbound_subject_rules` (and dropped from `source_roles` / `subject_roles`, with `target_scopes` reconciled from surviving outbound rules) before the fresh rules are applied; the distinct input-role set is purged once, up-front. -- Duplicate rules (same role + scope already present) are not appended twice; duplicate map list entries (same `id`) are not appended twice. -- `apply_policy` is called exactly once after all `apply_agent_policy` writes complete. -- An exception from any dependency is logged and does not propagate to the caller. - -**Prior art:** `3.14-unit-tests-write-api.md` (mock HTTP boundary pattern — apply the same approach at the library import boundary here). + +- **Original repro, both orders → identical `APM(A)`.** Onboard **A then T** and **T then A**; assert the derived `APM(A)` is identical (inbound `{UR→AS}`, outbound `{AR→TS, UR→TS}`), compared as order-independent `(role, scope)` sets. This is the headline regression guard. +- **Latent sibling bug (late UC3 user role).** After A+T exist, a later user-role rule `(UR2 → TS)` routes to `SPM(T)`, marks A affected, and A's re-derived subject gate includes `UR2`. +- **Agent → agent (`AR→BS`).** Stored on `SPM(B)`; A's derived APM has `AR→BS` in `outbound_rules` + `target_scopes[B]`; B's derived APM has `source_roles[A] += AR`. +- **Override role-level purge across SPMs.** `override=True` with an input role already present on multiple SPMs → that role is purged from **every** SPM (via `get_service_policies_by_role`) once, up-front, before the fresh rules are appended; a role shared across the input is not wiped after being added. +- **Append dedup.** A rule already present on the target SPM (same `role.id + scope.id`) is not appended twice; map list entries (same `id`) are not duplicated. +- **No flattening.** Rules arrive pre-flattened; the PCE issues at most one `get_service_policies_by_role` call **per distinct role** — a rule carrying a composite role does not trigger per-child calls inside the PCE. +- **Tool gets an SPM but no APM (P4).** A Tool service accrues durable `inbound_rules` on its SPM but is never emitted as an APM/Rego; the agent→tool `target_scopes` edge still appears on the agent's derived APM. +- **P2 identity from `owned_*`.** Each derived APM's `agent_roles` / `agent_scopes` come from `SPM(A).owned_roles` / `owned_scopes`, AIAC-managed-filtered; an agent with no AIAC-managed catalog roles/scopes keeps `[]`. +- **Directional relevance — no false outbound edge.** A user role shared between `AS` and `TS` does **not** by itself make A "target" T; A's outbound edge to T appears only if one of A's **agent** roles maps to a T scope. +- **Affected set from the batch, not a full scan.** The affected-agent set is computed from the batch roles/scopes; agents unrelated to the batch are never derived or upserted. +- **`apply_policy` called exactly once** after all `apply_service_policy` writes complete (partial upsert of only the affected agents). +- **Reconcile (drift GC).** A touched SPM carrying dangling edges (retired scope, churned scope UUID, churned/duplicate user role, retired agent-role self-reference) is pruned against the catalog on re-onboarding; live edges survive and the pass is idempotent; a catalog miss (owner absent) leaves the SPM untouched. +- **Decommission (service offboard).** Onboard an agent A targeting tool T, then `decommission(T)`: `SPM(T)` is deleted, no `delete_agent_policy` (tool has no APM), and A is re-derived with an empty outbound while its inbound survives. `decommission(A)`: `SPM(A)` deleted, `delete_agent_policy(A)` called, A's outbound footprint (`AR→TS` on `SPM(T)`) purged while T keeps its user grant, and no APM re-derived for the deleted agent. A never-onboarded / 404 service is a no-op. +- **Failures propagate.** An exception from any dependency is logged and **re-raised** (it propagates to the caller, which surfaces it — e.g. the Controller returns HTTP 500); on success `compute_and_apply` / `decommission` return `None`. + +**Prior art:** `3.14-unit-tests-write-api.md` (mock boundary pattern — apply the same approach at the library import boundary here). --- ## Out of Scope -- **Fine-grained rule revocation:** removing an individual `PolicyRule` without replacing its whole role. `override=True` covers role-level replace (see [Merge Semantics](#merge-semantics)); single-rule revocation is not yet designed — marked TBD. -- **Full policy rebuild:** the PCE handles incremental updates only. Full rebuilds (clear + reapply all) are driven by higher-level orchestration outside this module. -- **Direct Keycloak calls:** all IdP queries go through `aiac.idp.configuration.library.Configuration`. The PCE never calls Keycloak directly. -- **Persistence of `PolicyRule` inputs:** the PCE does not store the input rule list — only the merged `AgentPolicyModel` output is persisted. +- **Fine-grained rule revocation:** removing an individual `PolicyRule` without replacing its whole role. `override=True` covers role-level replace at the SPM layer (see [Merge Semantics](#merge-semantics)); single-rule revocation is not yet designed — **TBD**. (Full-service **decommission / package deletion** *is* now designed and implemented — see [Decommission (service offboard)](#decommission-service-offboard).) +- **Full policy rebuild orchestration:** the PCE handles incremental updates; full rebuilds (clear + reapply all) are driven by higher-level orchestration outside this module. +- **Direct Keycloak calls:** all IdP access goes through `aiac.idp.configuration.Configuration` (only `get_services()`). The PCE never calls Keycloak directly. +- **Persistence of `PolicyRule` inputs:** the PCE persists SPMs (source of truth); APMs are derived and pushed, never persisted as truth. The raw input rule list is not stored. +- **Model field definitions** (handoff 01), **IdP service / library** (handoffs 02/03), **store CRUD** (handoff 04). --- ## Further Notes -- The PCE is the **only** caller of `aiac.pdp.policy.library.apply_policy` from AIAC Agent sub-agents. Sub-agents no longer call the PDP Policy Library directly; they call `compute_and_apply` instead. -- `aiac/src/aiac/agent/policy/api.py` retains `role_to_scopes` / `roles_to_scope` helpers used by AIAC Agent sub-UC agents. `PolicyRule` (now at `aiac.policy.model`) is imported from there. These helpers are not used by the PCE. +- The PCE is the **only** caller of `aiac.pdp.policy.library.apply_policy` from AIAC Agent sub-agents. Sub-agents call `compute_and_apply`, not the PDP Policy Library directly. +- `aiac/src/aiac/agent/policy/api.py` retains `role_to_scopes` / `roles_to_scope` helpers used by AIAC Agent sub-UC agents; they now carry `scope.serviceId` / `role.kind` / `role.actorIds` through on each `PolicyRule` (input contract above). These helpers are not used by the PCE. + + diff --git a/aiac/docs/specs/components/policy-model.md b/aiac/docs/specs/components/policy-model.md index 55d194ab2..eae6a0432 100644 --- a/aiac/docs/specs/components/policy-model.md +++ b/aiac/docs/specs/components/policy-model.md @@ -10,13 +10,28 @@ Keeping the canonical model definitions inside a PDP-namespaced module (`aiac.pdp.library.models`) forces both the Policy Store library and the Policy Computation Engine to take a dependency on the PDP package — a wrong-layer coupling. Any of the three consumers importing from `aiac.pdp.library.models` would create a transitive dependency on an unrelated service namespace. -Additionally, the old `PolicyRule` used plain `str` for `role` and `scope`. The PCE algorithm requires typed `Role` and `Scope` objects (from `aiac.idp.configuration.models`) to invoke `Configuration.get_services_by_role` and `Configuration.get_services_by_scope`. +Additionally, the old `PolicyRule` used plain `str` for `role` and `scope`. The current SPM/APM PCE requires typed `Role` and `Scope` objects (from `aiac.idp.configuration.models`): it routes each rule to the owning service's SPM by `scope.serviceId`, classifies edges by `role.kind`, and reads `role.actorIds` when deriving each `AgentPolicyModel` — all fields that a plain `str` cannot carry. (These typed fields replaced the earlier reliance on `Configuration.get_services_by_role` / `get_services_by_scope`, which the SPM-based engine no longer calls.) -Finally, the original `source_roles`, `subject_roles`, and `scope_targets` maps used pydantic model objects (`Service`, `Subject`, `Scope`) as dict keys. Model-object keys do not round-trip through `model_dump(mode="json")` / JSON without custom key handling, and they couple `aiac.policy.model` to the id-only `__hash__`/`__eq__` of the IdP models. The outbound map was also keyed by scope (`scope → targets`), whereas consumers need the inverse (`target → scopes`) to emit per-target authorization directly. +The original `source_roles`, `subject_roles`, and `scope_targets` maps used pydantic model objects (`Service`, `Subject`, `Scope`) as dict keys. Model-object keys do not round-trip through `model_dump(mode="json")` / JSON without custom key handling, and they couple `aiac.policy.model` to the id-only `__hash__`/`__eq__` of the IdP models. The outbound map was also keyed by scope (`scope → targets`), whereas consumers need the inverse (`target → scopes`) to emit per-target authorization directly. + +### Order-dependence bug (the reason for the two-layer model) + +The Policy Computation Engine (PCE) merges `list[PolicyRule]` from onboarding sub-agents into per-agent policy. Historically `AgentPolicyModel` (APM) was the **only persisted entity** and stored rules **denormalised onto the agent**. That made policy **order-dependent**. + +Concretely, let `UR` be a user (realm) role mapped to agent `A`'s scope `AS` and tool `T`'s scope `TS`, and `AR` be agent `A`'s (client) role mapped to `TS`: + +- **A onboarded, then T:** at T-onboarding `A`'s model already exists, so the `(UR, TS)` rule attaches to `A`'s outbound-subject gate. Correct. +- **T onboarded, then A:** at T-onboarding no agent targets `TS`, so `UR→TS` is **dropped**; A-onboarding never re-emits it, so **`UR→TS` is lost forever.** + +The fix is a **two-layer model**: a per-service persistent source of truth (`ServicePolicyModel`) that stores every inbound edge durably on the service that owns the scope — so `UR→TS` lands on `SPM(T)` at tool-onboarding, no agent required, and can never be lost — with `AgentPolicyModel` demoted to a **pure derived projection** that is no longer persisted. ## Solution -A canonical, dependency-free model module at `aiac.policy.model` defines `PolicyRule`, `AgentPolicyModel`, and `PolicyModel` with typed fields. No HTTP client, no service code — importable by any consumer without side effects. `PolicyRule.role` and `PolicyRule.scope` are typed `Role` and `Scope` objects from `aiac.idp.configuration.models`. +A canonical, dependency-free model module at `aiac.policy.model` defines `ServicePolicyModel`, `PolicyRule`, `AgentPolicyModel`, and `PolicyModel` with typed fields. No HTTP client, no service code — importable by any consumer without side effects. `PolicyRule.role` and `PolicyRule.scope` are typed `Role` and `Scope` objects from `aiac.idp.configuration.models`. + +**Two-layer model.** `ServicePolicyModel` (SPM) is the **persistent source of truth**, one per **service** (agent *and* tool). It holds the service's **inbound** rules plus its own identity (owned roles and scopes). `AgentPolicyModel` (APM) becomes a **pure derived projection** built from the relevant SPMs by the PCE — it is **no longer persisted**. Its shape is unchanged so existing consumers (PDP Policy Library, Policy Store readers) keep working. + +**Canonical form.** *Every rule is an inbound edge on the SPM of the service that owns the rule's scope.* An agent's outbound edge is the target's inbound edge — `AR→TS` is stored on `SPM(T)`, not on `A`. The routing key is `Scope.serviceId`: a rule `(role, scope)` routes to `SPM(scope.serviceId)`. The relationship maps (`source_roles`, `subject_roles`, `target_scopes`) are keyed by the string `id` of the referenced entity rather than by a typed object, so they serialize to JSON natively and carry no hashability requirement into `aiac.policy.model`. Typed `Role` / `Scope` objects are retained as the map *values* (and in `PolicyRule`), preserving the typing the PCE needs for IdP queries. The outbound map is `target_scopes` (`target service id → scopes permitted`), the inverse of the former `scope_targets`. @@ -49,7 +64,7 @@ The relationship maps (`source_roles`, `subject_roles`, `target_scopes`) are key aiac/src/aiac/policy/ └── model/ ├── __init__.py # empty - └── models.py # PolicyRule, AgentPolicyModel, PolicyModel + └── models.py # ServicePolicyModel, PolicyRule, AgentPolicyModel, PolicyModel ``` ### Dependencies @@ -57,7 +72,7 @@ aiac/src/aiac/policy/ | Dependency | Purpose | |------------|---------| | `pydantic` | `BaseModel`, `ConfigDict` | -| `aiac.idp.configuration.models` | Typed `Role`, `Scope` (as map values and in `PolicyRule`) | +| `aiac.idp.configuration.models` | Typed `Role`, `Scope`, `ServiceType` (as map values, in `PolicyRule`, and in `ServicePolicyModel`) | No HTTP client dependency. No `requests`, no `python-dotenv`. @@ -65,6 +80,33 @@ No HTTP client dependency. No `requests`, no `python-dotenv`. All models use `model_config = ConfigDict(extra='ignore')`. +#### New `Role` / `Scope` fields (defined in `aiac.idp.configuration.models`) + +The two-layer model requires ownership and a user/agent distinction on the IdP types. These fields are **defined in `aiac.idp.configuration.models`** (the deep population from Keycloak is handoff 02's concern), but the policy model depends on them for SPM routing and APM derivation: + +- **`Scope.serviceId: str`** — the single owning service's `serviceId`. This is the SPM routing key: a rule `(role, scope)` routes to `SPM(scope.serviceId)`. See Assumption 2 (a scope has exactly one owner). +- **`RoleKind(str, Enum)`** — `USER = "User"`, `AGENT = "Agent"` (mirrors `ServiceType`'s style). +- **`Role.kind: RoleKind`** — whether the role is held by users or by agent service accounts. +- **`Role.actorIds: list[str]`** — context-dependent on `kind`: + - `kind == AGENT` ⇔ a Keycloak **client role** on the agent's client; `actorIds` = the owning **agent `serviceId`(s)** (usually one). + - `kind == USER` ⇔ a Keycloak **realm role**; `actorIds` = the **holder usernames**. + +A `model_validator` on `Role` enforces what it can locally (`kind` present/valid; `actorIds` is a `list[str]`). The **cross-kind** invariant (Assumption 1) and the **client/realm ⇔ agent/user** invariant (Assumption 3) are enforced **upstream at construction** (the Keycloak IdP boundary), because the raw Keycloak facts are only visible there — see handoff 02 for that enforcement and field population. + +#### `ServicePolicyModel` + +The persistent source of truth — one per service (agent *and* tool), keyed by `service_id`. Holds the service's inbound rules plus its own identity. + +| Field | Type | Description | +|-------|------|-------------| +| `service_id` | `str` | The owning service's id. | +| `service_type` | `ServiceType` | `Agent` or `Tool`. Drives derivation: only `Agent` services get an APM. | +| `owned_roles` | `list[Role]` | This service's own client roles (`aiac.managed` marker only). | +| `owned_scopes` | `list[Scope]` | This service's exposed scopes (`aiac.managed` marker only). | +| `inbound_rules` | `list[PolicyRule]` | Canonical: every edge granting access to `owned_scopes`. | + +`owned_roles` / `owned_scopes` are the service's own identity, filtered to the `aiac.managed` marker (this is where the PCE's P2 identity now lives). They are seeded from the catalog by the PCE; this module only defines the shape. `ServicePolicyModel` round-trips through `model_dump(mode="json")` / `model_validate()` with string keys only. + #### `PolicyRule` A single access rule pairing a typed role with a typed scope. Used in both inbound and outbound rule sets. @@ -78,6 +120,8 @@ A single access rule pairing a typed role with a typed scope. Used in both inbou Complete policy definition for a single agent (service). Inbound and outbound rule sets are typed collections. +> **Derived, not persisted.** `AgentPolicyModel` is now a **pure derived projection** built by the PCE from the relevant `ServicePolicyModel`s. It is **no longer a persisted entity** — the durable source of truth is `ServicePolicyModel`. Its shape is **unchanged** so existing consumers (PDP Policy Library, Policy Store readers) keep working; the docstring on the model states this explicitly. + | Field | Type | Description | |-------|------|-------------| | `agent_id` | `str` | Service ID from the AIAC trigger event (`aiac.apply.service.{id}`) | @@ -94,7 +138,7 @@ Complete policy definition for a single agent (service). Inbound and outbound ru **Outbound rule semantics:** this agent acting as realm role `role` is permitted to request the target scope `scope`. The PDP Policy Writer consumes `outbound_rules` as an agent-role → target-scope map; its outbound gate requires both the subject and the agent to be authorized. -**Outbound subject rule semantics:** `outbound_subject_rules` holds `(user role, tool scope)` pairs — the outbound subject gate; a user holding `role` may reach a tool exposing `scope`. It is the outbound counterpart of `inbound_rules` (which pairs a user role with an *agent* scope): where `inbound_rules` answers "may this user call the agent?", `outbound_subject_rules` answers "may this user reach the tool the agent targets?". The PDP Policy Writer groups it into `outbound_subject_role_scopes` (user role → tool-scope names) and matches against `target_scopes[input.target]`, not against `agent_scopes`. +**Outbound subject rule semantics:** `outbound_subject_rules` holds `(user role, tool scope)` pairs — the outbound subject gate; a user holding `role` may reach a tool exposing `scope`. It is the outbound counterpart of `inbound_rules` (which pairs a user role with an *agent* scope): where `inbound_rules` answers "may this user call the agent?", `outbound_subject_rules` answers "may this user reach the tool the agent targets?". The PDP Policy Writer groups it into `subject_role_scopes` (user role → tool-scope names) and matches against `target_scopes[input.target]`, not against `agent_scopes`. #### `PolicyModel` @@ -143,11 +187,23 @@ model = PolicyModel(agents=[agent_model]) --- +## Assumptions + +The two-layer model rests on three invariants. All three are **AIAC invariants**, not Keycloak guarantees, and the ones that require raw Keycloak facts are enforced upstream at the IdP boundary (handoff 02): + +1. **No role spans both kinds.** A role is held by users *or* by agent service accounts, never both. This is what lets `Role.actorIds` be a single list. Enforced upstream at construction (cross-kind invariant not visible to the local `Role` validator). +2. **No scope shared across services.** A scope has exactly one owner → a single `Scope.serviceId`. This reconciles with the existing `get_services_by_scope(scope) -> list[Service]` (plural, because Keycloak client scopes are realm-level and assignable to many clients): for AIAC-managed scopes that list is always length 1. +3. **Agent role ⇔ Keycloak client role; user role ⇔ Keycloak realm role.** The IdP config service sources agent roles from the agent's **client** roles (`Service.roles`), and `Role.kind` is populated from Keycloak's `clientRole` flag. Enforced upstream at construction. + +--- + ## Testing Decisions **Seam:** model instantiation and serialization — no HTTP boundary, no mocking required. Key behaviors to assert: +- `Scope.serviceId` is present; `Role.kind` (a `RoleKind`) and `Role.actorIds` (a `list[str]`) are present; the `Role` `model_validator` accepts a valid `kind` + `list[str]` `actorIds` and rejects a malformed one. +- `ServicePolicyModel` constructs with `service_id`, `service_type`, `owned_roles`, `owned_scopes`, `inbound_rules`, and round-trips via `model_dump(mode="json")` / `model_validate()` (string keys only) with typed `Role` / `Scope` / `PolicyRule` values preserved. - `PolicyRule` accepts typed `Role` and `Scope` objects; rejects plain `str` where `Role`/`Scope` is expected. - `AgentPolicyModel` with string-ID keys in `source_roles`, `subject_roles`, and `target_scopes` round-trips through `model_dump(mode="json")` / `model_validate()` with the typed `Role` / `Scope` list values preserved. - `target_scopes` maps a target service id to the list of `Scope` objects permitted on it (outbound direction is `target → scopes`, not `scope → targets`). diff --git a/aiac/docs/specs/components/policy-store.md b/aiac/docs/specs/components/policy-store.md index 2856f78b6..21fb181ed 100644 --- a/aiac/docs/specs/components/policy-store.md +++ b/aiac/docs/specs/components/policy-store.md @@ -2,34 +2,35 @@ ## Problem Statement -The AIAC Agent's Policy Computation Engine and Policy Builder sub-agent produce and merge `AgentPolicyModel` objects representing the access control policy for each service. The PDP Policy Writer translates these into Rego packages and writes them to an `AuthorizationPolicy` Kubernetes CR — but this derived artifact cannot be reverse-engineered back into structured `AgentPolicyModel` data. Without a durable structured policy store: +The AIAC Agent's Policy Computation Engine produces and merges `ServicePolicyModel` (SPM) objects — one per service, keyed by `serviceId` — representing the inbound/outbound access control policy for each service. The PDP Policy Writer translates these into Rego packages and writes them to an `AuthorizationPolicy` Kubernetes CR — but this derived artifact cannot be reverse-engineered back into structured SPM data. Without a durable structured policy store: - The Policy Computation Engine cannot read current policy state for additive merging — it must re-derive the full state from the PDP snapshot on every trigger. -- Off-boarded agents leave no structured record of their removal. +- Override-purge cannot find stale role→service mappings that the live IdP no longer reflects — there is no record of what was previously granted. - Pod restarts lose any in-flight policy construction context. +The `AgentPolicyModel` (APM) is **derived and never persisted** — it is computed on demand from SPMs. The store therefore has no per-agent surface; it persists SPMs only. + ## Solution -A dedicated **AIAC Policy Store** owns an in-memory `PolicyModel` cache backed by a SQLite database for durability. A companion library [`aiac.policy.store.library`](library-policy-store.md) exposes module-level typed functions matching the `aiac.pdp.policy.library` pattern, used by the Policy Computation Engine to read and write policy state without any storage-layer boilerplate. +A dedicated **AIAC Policy Store** owns an in-memory cache of `ServicePolicyModel` rows (keyed by `serviceId`) backed by a SQLite database for durability. A companion library [`aiac.policy.store.library`](library-policy-store.md) exposes module-level typed functions matching the `aiac.pdp.policy.library` pattern, used by the Policy Computation Engine to read and write SPM state without any storage-layer boilerplate. -The PDP Policy Writer retains sole ownership of the `AuthorizationPolicy` CR (Rego packages) and has no dependency on the Policy Store. The two persistence artifacts serve distinct purposes and are owned by distinct services: +The SPM is the **source of truth**. The PDP Policy Writer retains sole ownership of the `AuthorizationPolicy` CR (Rego packages) and has no dependency on the Policy Store. The two persistence artifacts serve distinct purposes and are owned by distinct services: | Artifact | Owner | Contents | |---|---|---| -| SQLite `agent_policies` table | Policy Store | Structured `AgentPolicyModel` — source of truth (cache-first, write-through) | +| SQLite `service_policies` table | Policy Store | Structured `ServicePolicyModel`, keyed by `serviceId` — source of truth (cache-first, write-through) | | `AuthorizationPolicy` CR (one total) | PDP Policy Writer | Derived Rego packages — OPA runtime artifact | --- ## User Stories -1. As the Policy Computation Engine, I want to read the current `AgentPolicyModel` for a specific agent, so that I can additively append new rules without overwriting existing ones. -2. As the Policy Computation Engine, I want to read the full `PolicyModel` (all agents), so that I can execute a whole-system policy rebuild. -3. As the Policy Computation Engine, I want to write an `AgentPolicyModel` to persistent storage, so that the current policy state survives pod restarts. -4. As the Policy Computation Engine, I want to delete a specific agent's policy on off-boarding, so that decommissioned services are removed from the structured policy store. -5. As the Policy Computation Engine, I want to clear all agent policies in a single call, so that a full policy rebuild can start from a clean state. -6. As a consumer of the Policy Store library, I want a typed Python library that returns `AgentPolicyModel` and `PolicyModel` objects directly, so that I can work with structured policy data without writing storage client code. -7. As an operator, I want the Policy Store deployed as its own single-replica StatefulSet with a dedicated PVC, so that its storage and restart lifecycle is decoupled from the stateless policy services. +1. As the Policy Computation Engine, I want to read the current `ServicePolicyModel` for a specific service by id, so that I can additively append new rules without overwriting existing ones — receiving a fresh empty SPM the first time a service is seen. +2. As the Policy Computation Engine, I want to resolve the single SPM that owns a given scope, so that I can attach outbound/inbound rules to the correct service. +3. As the Policy Computation Engine, I want to list every SPM whose inbound rules reference a given role — including stale mappings the IdP no longer reflects — so that override-purge can remove access that should no longer exist. +4. As the Policy Computation Engine, I want to upsert a `ServicePolicyModel`, so that the current policy state survives pod restarts. +5. As a consumer of the Policy Store library, I want a typed Python library that returns `ServicePolicyModel` objects directly, so that I can work with structured policy data without writing storage client code. +6. As an operator, I want the Policy Store deployed as its own single-replica StatefulSet with a dedicated PVC, so that its storage and restart lifecycle is decoupled from the stateless policy services. --- @@ -47,64 +48,83 @@ The PDP Policy Writer retains sole ownership of the `AuthorizationPolicy` CR (Re **Framework:** FastAPI + uvicorn. **Base image:** `python:3.12-slim`. -**Storage backend:** SQLite via `sqlite3` stdlib (zero extra dependency — `sqlite3` ships with the Python standard library). Database file: `AGENTPOLICY_DB_PATH` (default `/data/policy_model.db`). +**Storage backend:** SQLite via `sqlite3` stdlib (zero extra dependency — `sqlite3` ships with the Python standard library). Database file: `SERVICEPOLICY_DB_PATH` (default `/data/policy_model.db`). **Schema:** ```sql -CREATE TABLE IF NOT EXISTS agent_policies ( - agent_id TEXT PRIMARY KEY, - spec TEXT NOT NULL -- AgentPolicyModel.model_dump() as JSON +CREATE TABLE IF NOT EXISTS service_policies ( + service_id TEXT PRIMARY KEY, + spec TEXT NOT NULL -- ServicePolicyModel.model_dump_json() as JSON ); ``` -**In-memory cache:** the service owns a full `PolicyModel` in memory as the authoritative serving layer: -- All `GET` requests served from memory (storage never queried at runtime). +**In-memory cache:** the service owns all SPM rows in memory as the authoritative serving layer: +- All read requests served from memory (storage never queried at runtime). - Every mutation writes through to SQLite synchronously before returning `204`. - On pod restart: load all rows from SQLite → populate cache → begin serving. +**Concurrency (single write lock):** the mutating endpoints (`POST` / `DELETE /policy/services/{service_id}`) +serialize their cache **and** DB writes under a single process-wide write lock, so the SQLite row and the +in-memory cache entry are updated together as one critical section. This prevents interleaved +upsert/delete requests from leaving the cache and DB inconsistent (e.g. a cache entry present after its +row was deleted, or a lost update when two upserts for the same `service_id` race). Reads serve from the +cache and do not take the write lock; the single-writer discipline is what keeps concurrent mutations +consistent. + **Transaction strategy:** -- Full rebuild (`POST /policy`): `BEGIN` → `DELETE FROM agent_policies` → per-agent `INSERT` → `COMMIT`. Crash before `COMMIT` leaves the prior state intact (SQLite WAL rollback). -- Per-agent upsert (`POST /policy/agents/{id}`): `INSERT OR REPLACE INTO agent_policies VALUES (?, ?)`. +- Per-service upsert (`POST /policy/services/{service_id}`): `INSERT OR REPLACE INTO service_policies VALUES (?, ?)`. +- Per-service delete (`DELETE /policy/services/{service_id}`): `DELETE FROM service_policies WHERE service_id = ?`; evict the cache entry. + +**By-role query:** `GET /policy/services?role={role_id}` scans the cache and returns every SPM whose `inbound_rules` contains a rule referencing `role_id`. **Why a store query and not an IdP lookup:** the SPM is the source of truth, so this must return *stored* rows — including stale role→service mappings that the live IdP no longer reflects, which override-purge depends on to remove access that should no longer exist. It may start as a full scan; a `role.id -> {service_id}` index can be added later behind the same route/signature without changing callers. -**Future normalization:** migrate to `agent_policies` + `policy_rules(agent_id, role, scope)` tables once `AgentPolicyModel`/`PolicyRule` schema stabilizes — a future observability UI will need queryable columns. JSON column in the current schema avoids migration churn during active development. +**Future normalization:** migrate to `service_policies` + `policy_rules(service_id, role, scope)` tables once `ServicePolicyModel`/rule schema stabilizes — a future observability UI (and a native by-role index) will benefit from queryable columns. JSON column in the current schema avoids migration churn during active development. **Endpoints:** | Method | Path | Body | Returns | |---|---|---|---| -| `GET` | `/policy` | — | `PolicyModel` (all agents) | -| `GET` | `/policy/agents/{agent_id}` | — | `AgentPolicyModel` | -| `POST` | `/policy` | `PolicyModel` | `204 No Content` | -| `POST` | `/policy/agents/{agent_id}` | `AgentPolicyModel` | `204 No Content` | -| `DELETE` | `/policy/agents/{agent_id}` | — | `204 No Content` | -| `DELETE` | `/policy` | — | `204 No Content` | +| `GET` | `/policy/services/{service_id}` | — | `ServicePolicyModel` (from cache) | +| `GET` | `/policy/services?role={role_id}` | — | `list[ServicePolicyModel]` (SPMs referencing the role) | +| `POST` | `/policy/services/{service_id}` | `ServicePolicyModel` | `204 No Content` (upsert) | +| `DELETE` | `/policy/services/{service_id}` | — | `204 No Content` (off-board) | | `GET` | `/health` | — | `200` / `503` | +The by-scope lookup has **no dedicated route** — it collapses to the by-id read via `scope.serviceId` and is implemented entirely in the library. + +`service_id` is the Keycloak clientId, which is slash-bearing (`{ns}/{workload}`, or a SPIFFE URI under +SPIRE) and cannot be a single URL path segment as-is. The `{service_id}` path segment on the three +per-id routes above is base64url-encoded on the wire (`aiac.policy.store.keying.encode_service_id` / +`decode_service_id`); the service decodes it immediately on entry, and the cache/DB stays keyed by the +decoded real id — every `service_id` in a request/response *body* (including the by-role list) is +always the decoded, real form. The by-role query's `role={role_id}` param is unaffected (not a path +segment). + +`DELETE /policy/services/{service_id}` removes a single SPM row (SQLite `DELETE` + cache eviction) so a service can be off-boarded when it is decommissioned. Deleting a service that is not present is a no-op (`204`). Override-purge still edits `inbound_rules` in place via the upsert; the delete route is for whole-service removal, not per-rule purging. + **Error responses:** -- `404 Not Found` with `{"error": "agent {id} not found"}` when `GET /policy/agents/{agent_id}` finds no entry in cache. -- `502 Bad Gateway` with `{"error": "..."}` on SQLite write error for all write endpoints. +- `404 Not Found` with `{"error": "service {id} not found"}` when `GET /policy/services/{service_id}` finds no entry in cache. The library's `get_service_policy` catches this and returns a fresh empty SPM (per the "engine creates a fresh model on 404" convention); the by-role query never 404s (empty list on no match). +- `502 Bad Gateway` with `{"error": "..."}` on SQLite write error for the write and delete endpoints. - `503 Service Unavailable` if `GET /health` cannot open or query the SQLite file. **`main.py` functions:** -- `_get_db() -> sqlite3.Connection` — open `AGENTPOLICY_DB_PATH` with `check_same_thread=False`; run `CREATE TABLE IF NOT EXISTS` on first open. -- `_upsert_agent(agent_id: str, model: AgentPolicyModel)` — `INSERT OR REPLACE INTO agent_policies VALUES (?, ?)` with `model.model_dump_json()`. -- `_get_agent(agent_id: str) -> AgentPolicyModel` — read from in-memory cache; raise `404` if absent. -- `_list_all() -> PolicyModel` — return in-memory cache directly. -- `_delete_agent(agent_id: str)` — `DELETE FROM agent_policies WHERE agent_id = ?`; remove from cache. -- `_delete_all()` — `DELETE FROM agent_policies`; replace cache with empty `PolicyModel`. -- `_rebuild(model: PolicyModel)` — `BEGIN` → `DELETE FROM agent_policies` → per-agent `INSERT` → `COMMIT`; replace cache atomically. +- `_get_db() -> sqlite3.Connection` — open `SERVICEPOLICY_DB_PATH` with `check_same_thread=False`; run `CREATE TABLE IF NOT EXISTS` on first open. +- `_upsert_service(service_id: str, model: ServicePolicyModel)` — under the write lock: `INSERT OR REPLACE INTO service_policies VALUES (?, ?)` with `model.model_dump_json()`, then update cache (DB + cache write as one locked critical section). +- `_delete_service(service_id: str)` — under the write lock: `DELETE FROM service_policies WHERE service_id = ?`, then evict the cache entry (no-op if absent) — DB + cache eviction as one locked critical section. +- `_get_service(service_id: str) -> ServicePolicyModel` — read from in-memory cache; raise `404` if absent. +- `_list_by_role(role_id: str) -> list[ServicePolicyModel]` — return every cached SPM whose `inbound_rules` references `role_id`. +- `_load_cache()` — on startup, load all rows from SQLite into the in-memory cache. **Configuration:** | Variable | Source | Default | |---|---|---| -| `AGENTPOLICY_DB_PATH` | ConfigMap (`aiac-policy-store-config`) | `/data/policy_model.db` | +| `SERVICEPOLICY_DB_PATH` | ConfigMap (`aiac-policy-store-config`) | `/data/policy_model.db` | **Dependencies:** `fastapi`, `uvicorn[standard]`, `pydantic`. `sqlite3` is stdlib (no new dependency). -**Imports:** `from aiac.policy.model.models import PolicyModel, AgentPolicyModel` +**Imports:** `from aiac.policy.model.models import ServicePolicyModel, Scope, Role` **File structure:** @@ -130,16 +150,14 @@ Good tests assert external behavior at the system boundary — not internal impl ### Policy Store Service -**Seam:** SQLite `:memory:` database — pass an in-memory connection to the service on startup instead of opening `AGENTPOLICY_DB_PATH`. All behavioral assertions remain valid; only the storage seam changes. +**Seam:** SQLite `:memory:` database — pass an in-memory connection to the service on startup instead of opening `SERVICEPOLICY_DB_PATH`. All behavioral assertions remain valid; only the storage seam changes. Key behaviors to assert: -- `GET /policy/agents/{id}`: returns `AgentPolicyModel` deserialized from cache; `404` when agent not in cache. -- `GET /policy`: returns `PolicyModel` for all agents; empty `PolicyModel(agents=[])` when cache is empty. -- `POST /policy/agents/{id}`: `spec` stored in SQLite; cache updated; `204` returned. -- `POST /policy` (rebuild): SQLite `DELETE` + per-agent `INSERT` issued inside one transaction; cache replaced atomically; `204` returned. -- `DELETE /policy/agents/{id}`: row removed from SQLite; removed from cache; `204`. -- `DELETE /policy`: all rows removed from SQLite; cache empty; `204`. -- SQLite write error on any write endpoint → `502`. +- `GET /policy/services/{id}`: returns `ServicePolicyModel` deserialized from cache (hit); `404 {"error": "service {id} not found"}` when the service is not in cache (miss). +- `GET /policy/services?role={role_id}`: returns every SPM whose `inbound_rules` references the role; `[]` when none match; multiple when several match. +- `POST /policy/services/{id}`: `spec` stored in SQLite; cache updated; `204` returned. Upsert round-trip: a second `POST` for the same id replaces the row. +- `DELETE /policy/services/{id}`: row removed from SQLite; cache entry evicted; `204` returned. Deleting an absent service is a no-op (`204`). +- SQLite write error on the write or delete endpoint → `502`. - SQLite file cannot be opened/queried on `GET /health` → `503`. See [library-policy-store.md](library-policy-store.md) for the companion library testing decisions. @@ -148,8 +166,9 @@ See [library-policy-store.md](library-policy-store.md) for the companion library ## Out of Scope +- **APM persistence:** APMs are derived on demand and never stored; the store has no per-agent surface. - **Triggering Rego generation:** the Policy Store writes structured data only. Triggering Rego generation in the PDP Policy Writer is the responsibility of `aiac.pdp.policy.library` (called by `aiac.policy.computation`). -- **Pagination:** `GET /policy` returns all agents without pagination. At target scale (hundreds of agents), the full result fits within one SQLite query and one HTTP response. +- **Pagination:** the by-role query returns all matching SPMs without pagination. At target scale (hundreds of services), the full result fits within one query and one HTTP response. - **In-cluster mTLS between Policy Computation Engine and Policy Store:** secured by Kubernetes network policy; no application-layer auth. - **Multi-writer / replica scale-out:** the current design is single-writer (single-replica StatefulSet, RWO PVC, SQLite). Future migration to a shared DB (e.g. PostgreSQL) is a backend swap; the HTTP contract is unchanged. @@ -159,5 +178,6 @@ See [library-policy-store.md](library-policy-store.md) for the companion library - The K8s manifests issue must create the `aiac-policy-store` StatefulSet, its `volumeClaimTemplate` PVC (1 Gi, `ReadWriteOnce`), and a headless Service. No CRD or RBAC is needed — the service does not touch the Kubernetes API. - `spec` fields use snake_case (matching Pydantic's `model_dump()`) — consistent with the `AuthorizationPolicy` CR convention. The JSON column avoids a translation layer. -- `agent_id` is the SQLite `PRIMARY KEY`. The `aiac.apply.service.{id}` naming convention (lowercase alphanumeric + hyphens) should be maintained for consistency with trigger events. +- `service_id` is the SQLite `PRIMARY KEY`. The `aiac.apply.service.{id}` naming convention (lowercase alphanumeric + hyphens) should be maintained for consistency with trigger events. - K8s resource names: StatefulSet `aiac-policy-store`, ClusterIP Service `aiac-policy-store-service:7074`, env var `AIAC_POLICY_STORE_URL`. + diff --git a/aiac/docs/specs/demo/github-agent.md b/aiac/docs/specs/demo/github-agent.md index b97b95409..bc1f35e4c 100644 --- a/aiac/docs/specs/demo/github-agent.md +++ b/aiac/docs/specs/demo/github-agent.md @@ -27,8 +27,8 @@ This agent generalises it to the scenario's **two capability areas**. | Scenario element | This agent | |---|---| | Agent client `github-agent` (type **Agent**) | This A2A agent | -| Agent role `source-operator` → agent scope `source-access` | **Skill 1** — Source repository operations | -| Agent role `issues-operator` → agent scope `issues-access` | **Skill 2** — Issue & PR tracker operations | +| Agent role `source_operations` → agent scope `source-access` | **Skill 1** — Source repository operations | +| Agent role `issue_operations` → agent scope `issues-access` | **Skill 2** — Issue & PR tracker operations | | Tool `github-tool` scopes `source-read`/`source-write` | Source tool allow-list (see §5) | | Tool `github-tool` scopes `issues-read`/`issues-write` | Issue/PR tool allow-list (see §5) | @@ -42,7 +42,7 @@ RFC-8693 token exchange, and the `github-tool` MitM swaps the exchanged token fo - Existing (issue-only) agent card: [`../../analysis/github-agent-card.json`](../../analysis/github-agent-card.json) - `github-tool` MCP tool catalog (44 tools): [`../../analysis/github-mcp-tools-summary.json`](../../analysis/github-mcp-tools-summary.json) - Reference agent: `agent-examples/a2a/git_issue_agent/` -- Reference deployment: `cortex/authbridge/demos/github-issue/k8s/` +- Reference deployment: `kagenti-extensions/authbridge/demos/github-issue/k8s/` - **Sibling tool spec (UC-1 onboarding fixture):** [`github-tool.md`](github-tool.md) — a simplified 4-tool stub (`source-read`, `source-write`, `issues-read`, `issues-write`) deployed as Service `github-tool`. **This is not the tool this agent connects to.** The agent connects to the production @@ -57,7 +57,7 @@ Reuse the `git_issue_agent` stack verbatim — do not introduce a new framework: - **A2A SDK 1.x** server (route factories, `AgentInterface`, snake_case card fields). Binds `0.0.0.0` on `PORT` (default **8000**). `create_jsonrpc_routes(..., enable_v0_3_compat=True)` — **required** - because Rossoctl uses A2A 0.3 client libraries — plus the agent card at both + because Kagenti uses A2A 0.3 client libraries — plus the agent card at both `/.well-known/agent-card.json` and legacy `/.well-known/agent.json`. - **CrewAI** orchestration (`Agent`/`Crew`/`Task`, `Process.sequential`), LLM via **litellm** (`crewai.LLM`). - **`crewai-tools[mcp]` `MCPServerAdapter`** — per-request connection to the MCP tool over @@ -96,7 +96,7 @@ Built in `a2a_agent.py::get_agent_card()`. Fields: | id | name | description | tags | examples | |---|---|---|---|---| -| `source_operations` | Source repository operations | Browse and search code; read, create, and modify repository file contents, branches, and commits. | `git, github, source, repositories, files, branches, commits` | "Show the README of rossoctl/rossoctl", "List the branches of owner/repo", "Create a branch and commit a fix to owner/repo" | +| `source_operations` | Source repository operations | Browse and search code; read, create, and modify repository file contents, branches, and commits. | `git, github, source, repositories, files, branches, commits` | "Show the README of kagenti/kagenti", "List the branches of owner/repo", "Create a branch and commit a fix to owner/repo" | | `issue_operations` | Issue & PR tracker operations | Read, search, create, and update issues, comments, sub-issues, and pull requests. | `git, github, issues, pull-requests` | "List open issues in kubernetes/kubernetes", "Open an issue in owner/repo titled …", "Summarise PR #42 in owner/repo" | > The existing `git_issue_agent` card ([`../../analysis/github-agent-card.json`](../../analysis/github-agent-card.json)) @@ -199,25 +199,13 @@ Manifests live under `aiac/demo/agents/github_agent/k8s/`, adapted from the gith `team1` (installer-provided ConfigMaps/secrets assumed present). - **`github-agent-deployment.yaml`** — `ServiceAccount` + `Deployment` + `Service` + `AgentRuntime`: - - **Deployment labels** (on `metadata.labels` **and** pod template): `protocol.rossoctl.io/a2a: ""` - in addition to `app.kubernetes.io/name: github-agent`. The `protocol.rossoctl.io/a2a` label is - required by the `AgentCardSyncReconciler` (`shouldSyncWorkload` gate): the operator stamps - `rossoctl.io/type=agent` automatically via the `AgentRuntime`, but the protocol label must be set - in the manifest. Without it, no `AgentCard` CR is auto-created and `analyze_agent` falls back to - the default minimal scope. - - Pod labels `rossoctl.io/inject: enabled`, `rossoctl.io/spire: enabled`, `protocol.rossoctl.io/a2a: ""`. + - Pod labels `kagenti.io/inject: enabled`, `kagenti.io/spire: enabled`. - Container port `8000`; env `MCP_URL=http://github-tool-mcp:9090/mcp`, - `JWKS_URI=http://keycloak-service.keycloak.svc:8080/realms/rossoctl/protocol/openid-connect/certs`, + `JWKS_URI=http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/certs`, LLM vars, `PORT`, `LOG_LEVEL`; `/shared` `emptyDir` for operator-mounted client creds. - - `Service` (ClusterIP) with **two ports** — port order matters because - `AgentCardReconciler.getServicePort()` always takes `Ports[0]`: - 1. `agent: 8001 → 8001` (first) — direct path to the agent after authbridge port-stealing - (`8000 → 8001`); used by the operator's `AgentCardReconciler` to fetch `/.well-known/agent.json` - without going through the authbridge reverse proxy (which blocks unauthenticated requests). - 2. `proxy: 8080 → 8000` (second) — the public/authenticated path through the authbridge reverse - proxy; used by A2A clients that carry a valid JWT. + - `Service` `8080 → 8000` (ClusterIP). - `AgentRuntime{ type: agent, targetRef: this Deployment }` — enrolls the workload (operator applies - `rossoctl.io/type=agent`, registers a Keycloak client, injects the AuthBridge sidecar). + `kagenti.io/type=agent`, registers a Keycloak client, injects the AuthBridge sidecar). - Image `github-agent:latest`, `imagePullPolicy: IfNotPresent` (kind-load; name is a documented knob). - **`configmaps.yaml`** — `authbridge-config` (Keycloak URL/realm/issuer) + `authproxy-routes` with the outbound token-exchange route: @@ -228,7 +216,8 @@ Manifests live under `aiac/demo/agents/github_agent/k8s/`, adapted from the gith ``` - **Prerequisite (reused, not created here):** the existing **production** `github-tool` Deployment/Service (`authbridge/demos/github-issue/k8s/github-tool-deployment.yaml`, Service name `github-tool-mcp`) + - `github-tool-secrets`, a running Rossoctl cluster (Keycloak realm `rossoctl`, namespace `team1`). + `github-tool-secrets`, a running Kagenti cluster (Keycloak realm `kagenti`, namespace `team1` — + installer-provided and enrolled for AuthBridge injection). The sibling UC-1 stub at `demo/tools/github_tool/` (Service `github-tool`) is a separate deployment for AIAC onboarding discovery and is **not** a runtime dependency of this agent. @@ -248,13 +237,11 @@ Service name; exchanged audience (`github-tool`) == tool `AUDIENCE`. 4. (Optional; needs a GitHub PAT + reachable LLM) `GITHUB_TOKEN=… MCP_URL=https://api.githubcopilot.com/mcp/`, send an A2A `message/send` read query and confirm a grounded, tool-cited answer. -**Cluster (HITL — live Rossoctl + Keycloak + LLM + tool PAT):** -5. `kind load docker-image github-agent:latest --name rossoctl`. +**Cluster (HITL — live Kagenti + Keycloak + LLM + tool PAT):** +5. `kind load docker-image github-agent:latest --name kagenti`. 6. Ensure `github-tool` + `github-tool-secrets` exist in `team1`. 7. `kubectl apply -f k8s/configmaps.yaml -f k8s/github-agent-deployment.yaml`. -8. Confirm AuthBridge injection + `rossoctl.io/type=agent`; confirm the `AgentCard` CR was - auto-created (`kubectl get agentcard -n team1` → `github-agent-deployment-card`, `SYNCED=True`). - `kubectl port-forward svc/github-agent 8080:8080 -n team1` (proxy port); +8. Confirm AuthBridge injection + `kagenti.io/type=agent`; `kubectl port-forward svc/github-agent 8080:8080 -n team1`; send an authenticated A2A message; verify token exchange reaches `github-tool` and an answer returns. --- diff --git a/aiac/docs/specs/demo/github-tool.md b/aiac/docs/specs/demo/github-tool.md index ef36bc523..fa1f8b0e4 100644 --- a/aiac/docs/specs/demo/github-tool.md +++ b/aiac/docs/specs/demo/github-tool.md @@ -31,10 +31,10 @@ From `analyze_tool` (`../components/aiac-agent/uc1-service-onboarding.md`): 1. **`classify_service`** resolves identity from the Keycloak client's `client.name` (the operator sets it to `"{namespace}/{workload_name}"`), splits on the first `/` → `namespace` + `workload_name`, - LISTs pods in the namespace, finds the pod owned by `workload_name`, and reads the `rossoctl.io/type` + LISTs pods in the namespace, finds the pod owned by `workload_name`, and reads the `kagenti.io/type` pod label. It **must be `tool`** → routes to `analyze_tool`. 2. **`analyze_tool`** does `read_service(workload_name, namespace)` (the K8s Service named - `workload_name`), **requires the `protocol.rossoctl.io/mcp` label** on that Service (a deploy-time + `workload_name`), **requires the `protocol.kagenti.io/mcp` label** on that Service (a deploy-time prerequisite — the operator does **not** stamp it), takes the Service's **first port**, and POSTs JSON-RPC `tools/list` to `http://{workload_name}.{namespace}.svc.cluster.local:{port}/mcp`. @@ -68,7 +68,7 @@ Phase 1 only discovers and evaluates them. - Scenario fixture (`TOOL_SCOPES`): [`../../../test/integration/scenario.py`](../../../test/integration/scenario.py) - Scenario spec: [`../integration-test/policy-pipeline.md`](../integration-test/policy-pipeline.md) - Sibling agent spec: [`github-agent.md`](github-agent.md) -- Reference deployment: `cortex/authbridge/demos/github-issue/k8s/` +- Reference deployment: `kagenti-extensions/authbridge/demos/github-issue/k8s/` --- @@ -207,33 +207,33 @@ ConfigMaps/secrets assumed present), consistent with the agent spec. - **`Deployment`** named `github-tool`. Container port serves `/mcp` (default `9090`). Env `PORT`, `LOG_LEVEL`. Image `github-tool:latest`, `imagePullPolicy: IfNotPresent` (kind-load; name is a documented knob). No GitHub PAT / issuer / JWKS / audience env (unlike the github-issue tool). - - **Pod label `rossoctl.io/type: tool`** — this is what `classify_service` reads. Applied by the + - **Pod label `kagenti.io/type: tool`** — this is what `classify_service` reads. Applied by the operator via the `AgentRuntime` (see below); relying on the operator to stamp it, rather than hand-setting it, keeps it consistent with the operator's own discriminator. - **`Service`** named `github-tool` (ClusterIP), selecting the Deployment's pods. Its **first port** maps to the container's `/mcp` port (e.g. `port: 9090 → targetPort: 9090`). `analyze_tool` uses the Service's **first** port, so keep `/mcp`'s port first. - - **Service label `protocol.rossoctl.io/mcp` MUST be present** — a **deploy-time prerequisite** for + - **Service label `protocol.kagenti.io/mcp` MUST be present** — a **deploy-time prerequisite** for `analyze_tool` (the operator does **not** stamp it; `analyze_tool` returns `502` if absent). Set it - explicitly on the Service metadata (e.g. `protocol.rossoctl.io/mcp: "true"`). + explicitly on the Service metadata (e.g. `protocol.kagenti.io/mcp: "true"`). - **`AgentRuntime{ type: tool, targetRef: { apiVersion: apps/v1, kind: Deployment, name: github-tool } }`** - — enrolls the workload so the operator applies `rossoctl.io/type=tool` to the pod and + — enrolls the workload so the kagenti-operator applies `kagenti.io/type=tool` to the pod and registers a Keycloak client with `client.name = "team1/github-tool"`. Unlike the github-issue demo - (which omits `rossoctl.io/type` to skip AuthBridge entirely), this demo **needs** `type: tool` so the - pod carries `rossoctl.io/type=tool` for UC-1 `classify_service`. + (which omits `kagenti.io/type` to skip AuthBridge entirely), this demo **needs** `type: tool` so the + pod carries `kagenti.io/type=tool` for UC-1 `classify_service`. - **No `configmaps.yaml` needed here** — the tool has no `authbridge-config` / `authproxy-routes` dependency (it neither validates inbound JWTs nor does outbound token exchange in phase 1). The agent spec's `authproxy-routes` still targets the **production** `github-tool-mcp` host and is unrelated to this stand-in. -- **Prerequisite (reused, not created here):** a running Rossoctl cluster with the operator +- **Prerequisite (reused, not created here):** a running Kagenti cluster with the kagenti-operator (Keycloak realm as configured by the installer, namespace `team1`), so the `AgentRuntime` is reconciled into a Keycloak client + pod label. **Wiring invariant:** `AgentRuntime.targetRef.name` == `Deployment` name == `Service` name == -`github-tool`; the `Service` carries the `protocol.rossoctl.io/mcp` label and exposes `/mcp` as its first -port; the operator-applied pod label is `rossoctl.io/type=tool`; the operator-registered Keycloak +`github-tool`; the `Service` carries the `protocol.kagenti.io/mcp` label and exposes `/mcp` as its first +port; the operator-applied pod label is `kagenti.io/type=tool`; the operator-registered Keycloak `client.name` is `team1/github-tool`. --- @@ -254,13 +254,13 @@ port; the operator-applied pod label is `rossoctl.io/type=tool`; the operator-re 3. Confirm each returned tool's `description` is byte-identical to the matching `TOOL_SCOPES` entry in `scenario.py` (e.g. `jq '.result.tools[] | {name, description}'`). -**Cluster (HITL — live Rossoctl + Keycloak + operator):** -4. `kind load docker-image github-tool:latest --name rossoctl`. +**Cluster (HITL — live Kagenti + Keycloak + operator):** +4. `kind load docker-image github-tool:latest --name kagenti`. 5. `kubectl apply -f k8s/github-tool-deployment.yaml`. 6. Confirm the operator applied the pod label: `kubectl get pod -l app=github-tool -n team1 - -o jsonpath='{.items[0].metadata.labels.rossoctl\.io/type}'` → `tool`. + -o jsonpath='{.items[0].metadata.labels.kagenti\.io/type}'` → `tool`. 7. Confirm the Service carries the MCP label: `kubectl get svc github-tool -n team1 - -o jsonpath='{.metadata.labels.protocol\.rossoctl\.io/mcp}'` → present. + -o jsonpath='{.metadata.labels.protocol\.kagenti\.io/mcp}'` → present. 8. Confirm the operator registered a Keycloak client with `client.name = "team1/github-tool"` (via the Keycloak admin API / IdP `Configuration` library). 9. From inside the cluster (or via `kubectl port-forward svc/github-tool 9090:9090 -n team1`), POST diff --git a/aiac/docs/specs/integration-test/pdp-policy-writer.md b/aiac/docs/specs/integration-test/pdp-policy-writer.md index 53a49c75f..4b0cb8eeb 100644 --- a/aiac/docs/specs/integration-test/pdp-policy-writer.md +++ b/aiac/docs/specs/integration-test/pdp-policy-writer.md @@ -63,7 +63,7 @@ Role → access, as encoded by the model's inbound and outbound rules: - `tester` — issues read/write. This user→tool access is encoded in the model's `outbound_subject_rules` (`(user_role, tool_scope)` -pairs), which the outbound package renders as `outbound_subject_role_scopes`. The model's +pairs), which the outbound package renders as `subject_role_scopes`. The model's `inbound_rules` (user→agent-scope) and `outbound_rules` (agent-role→tool-scope) are unchanged. Applying this `PolicyModel` produces exactly two files in `REGO_OUTPUT_DIR`: @@ -76,7 +76,7 @@ Both must match the **ID-only** package shapes in (`{subject, source}` inbound, `{subject, target}` outbound); all role/scope maps are embedded in the package; the inbound gate is subject-mandatory + source-optional; the outbound gate requires both subject and agent to pass, but its **subject** gate is now user→**tool** — it reads -`outbound_subject_role_scopes` (grouped from `outbound_subject_rules`) and matches against +`subject_role_scopes` (grouped from `outbound_subject_rules`) and matches against `target_scopes[input.target]`, distinct from the inbound user→agent gate — while `target_ok` (agent→tool, from `agent_roles` × `agent_role_scopes`) is unchanged; and `target_scopes` is emitted verbatim (target id → scopes, no inversion). Because the input carries no per-request scope, the diff --git a/aiac/docs/specs/integration-test/policy-pipeline.md b/aiac/docs/specs/integration-test/policy-pipeline.md index fde5e4685..6b23c7f71 100644 --- a/aiac/docs/specs/integration-test/policy-pipeline.md +++ b/aiac/docs/specs/integration-test/policy-pipeline.md @@ -49,19 +49,22 @@ found. ### What it does The pipeline (provision → PRB → PCE → OPA) is driven **once per `policy.md` variant** — `explicit` and -`abstract` — each writing into its own `rego_out//` directory. `opa eval` then asserts the +`abstract` — each writing into its own `rego_out/policy_pipeline//` directory (a sibling of +the UC-1 ladder's `rego_out/uc1/`). `opa eval` then asserts the scenario truth table against **each** variant's Rego (step 7). Steps 1–6 below describe one such run. 1. **Set service URLs in env before importing the aiac libraries.** Export `AIAC_PDP_CONFIG_URL`, - `AIAC_POLICY_STORE_URL`, `AIAC_PDP_POLICY_URL`, and `AIAC_REALM` *before* importing the aiac + `AIAC_POLICY_STORE_URL`, `AIAC_PDP_POLICY_URL`, and `KEYCLOAK_REALM` *before* importing the aiac libraries — the libraries read env at import time. This is the pattern - `test/pdp/policy/generate_rego.py` already follows. + `test/pdp/policy/generate_rego.py` already follows. (The PCE resolves its realm via + `Configuration.for_default_realm()`, the single source of truth reading `KEYCLOAK_REALM`; the + former `AIAC_REALM` is retired.) 2. **Spawn the three services as `uvicorn` subprocesses** (no Docker) and poll each `GET /health` until ready, with a bounded timeout: - IdP Configuration Service — `aiac.idp.service.configuration.keycloak.main:app` on `7071`. - Policy Store — its ASGI app on `7074`, with `AGENTPOLICY_DB_PATH` pointed at a fresh temp dir. - OPA Policy Writer — `aiac.pdp.service.policy.opa.main:app` on `7072`, with `REGO_OUTPUT_DIR` - (pointed at the current variant's `rego_out//`) and the Policy Store DB path in its env. + (pointed at the current variant's `rego_out/policy_pipeline//`) and the Policy Store DB path in its env. 3. **Provision Keycloak** (idempotent — delete-if-exists the realm first, then create): - via **`python-keycloak` `KeycloakAdmin`** (test fixture): create realm `AIAC_TEST_REALM`; create users `dev-user`, `test-user`, and `devops-user`; create realm roles `developer`, `tester`, and @@ -76,7 +79,7 @@ scenario truth table against **each** variant's Rego (step 7). Steps 1–6 below **not** a list — a list fails the `in ("Agent","Tool")` check, resolves the type to `None`, and yields empty pipeline output. - via the **aiac IdP `Configuration` library** (the real product surface the PCE reads back): create - the client roles (`source-operator`, `issues-operator`) and scopes (`source-access`, `issues-access`, + the client roles (`source_operations`, `issue_operations`) and scopes (`source-access`, `issues-access`, `source-read`, `source-write`, `issues-read`, `issues-write`) with the descriptions in *[Scenario inputs](#scenario-inputs-prb-functional-inputs)*, and map roles→services and scopes→services so `get_services_by_role` / `get_services_by_scope` and `get_service().roles` / @@ -157,7 +160,7 @@ user→tool; the agent→tool gate covers all four scopes, so the user gate disc | devops-user | ❌ | ❌ | ❌ | ❌ | Alongside the assertions, each variant leaves exactly **two** files on disk in its -`rego_out//` for eyeballing: +`rego_out/policy_pipeline//` for eyeballing: - `github_agent.inbound.rego` — package `authz.github_agent.inbound`; the **user→agent** gate. `subject_roles` = `{dev-user: [developer], test-user: [tester]}`; `agent_scopes` populated. @@ -165,7 +168,7 @@ Alongside the assertions, each variant leaves exactly **two** files on disk in i denied inbound.) - `github_agent.outbound.rego` — package `authz.github_agent.outbound`; `allow if { subject_ok; target_ok }`. Its **`subject_ok`** is the new **user→tool** gate (mapping (b), grouped from - `outbound_subject_rules` into `outbound_subject_role_scopes`, matched against + `outbound_subject_rules` into `subject_role_scopes`, matched against `target_scopes[input.target]`); its **`target_ok`** is the **agent→tool** gate (mapping (c), over `agent_roles` × `agent_role_scopes`). `agent_roles` and `target_scopes` are populated. @@ -183,8 +186,8 @@ exercises the deny-by-default path. | Element | Value | |---------|-------| -| Realm | `AIAC_TEST_REALM` (default `aiac-e2e`) | -| Agent | `github-agent` (client roles `source-operator`, `issues-operator`; scopes `source-access`, `issues-access`) | +| Realm | `AIAC_TEST_REALM` (default `aiac-pp`) | +| Agent | `github-agent` (client roles `source_operations`, `issue_operations`; scopes `source-access`, `issues-access`) | | Tool | `github-tool` (scopes `source-read`, `source-write`, `issues-read`, `issues-write`) | | Users | `dev-user` (role `developer`), `test-user` (role `tester`), `devops-user` (role `devops`) | | `developer` | source read/write + issues read | @@ -207,12 +210,12 @@ Role → access (confirmed with the user; the fixed facts that both `policy.md` | `KEYCLOAK_URL` | External Keycloak base URL | — (required) | | `KEYCLOAK_ADMIN_REALM` | Realm the admin creds live in | `master` | | `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds | — (required) | -| `AIAC_TEST_REALM` | Realm the test provisions | `aiac-e2e` | -| `AIAC_REALM` | Realm the PCE reads back (= `AIAC_TEST_REALM`) | `aiac-e2e` | +| `AIAC_TEST_REALM` | Realm the test provisions | `aiac-pp` | +| `KEYCLOAK_REALM` | Realm the PCE reads back, via `Configuration.for_default_realm()` (single source of truth; = `AIAC_TEST_REALM`) | `aiac-pp` | | `AIAC_PDP_CONFIG_URL` | IdP Configuration Service base URL (set before import) | `http://127.0.0.1:7071` | | `AIAC_POLICY_STORE_URL` | Policy Store base URL (set before import) | `http://127.0.0.1:7074` | | `AIAC_PDP_POLICY_URL` | OPA Policy Writer base URL (set before import) | `http://127.0.0.1:7072` | -| `REGO_OUTPUT_DIR` | Base dir the OPA stub subprocess writes `.rego` to; the test points it at `rego_out//` per variant and leaves the files on disk | operator-chosen local dir | +| `REGO_OUTPUT_DIR` | Base dir the OPA stub subprocess writes `.rego` to; the test points it at `rego_out/policy_pipeline//` per variant and leaves the files on disk | operator-chosen local dir | | `AGENTPOLICY_DB_PATH` | Policy Store DB path for the subprocess (fresh temp dir) | temp | | `AIAC_POLICY_FILE` | PRB whole-file policy — path to the `policy.md` variant fed to the PRB; the test sets it per variant (`policy.explicit.md`, `policy.abstract.md`) | `/etc/aiac/policy.md` | | `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`) | — (required) | @@ -229,14 +232,14 @@ Runnable only once the pipeline fixes (handoffs 01 + 02, P1–P5) have landed, a Keycloak, a real LLM, and an `opa` binary on `PATH` (or `$OPA_BIN`). ```bash -# env: KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to aiac-e2e; opa on PATH or $OPA_BIN +# env: KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to aiac-pp; opa on PATH or $OPA_BIN .venv/bin/pytest test/integration/test_policy_pipeline.py -m integration -v # ~30 parametrized nodes (variant × subject inbound; variant × subject × function_name outbound). # A failing node names the exact cell, e.g.: # test_outbound[abstract-test-user-source-read] — expected deny, opa allowed # The generated Rego is left on disk per variant for eyeballing: -# rego_out/explicit/github_agent.{inbound,outbound}.rego -# rego_out/abstract/github_agent.{inbound,outbound}.rego +# rego_out/policy_pipeline/explicit/github_agent.{inbound,outbound}.rego +# rego_out/policy_pipeline/abstract/github_agent.{inbound,outbound}.rego # (no github_tool.*.rego in either) ``` @@ -339,8 +342,8 @@ Tracking issue for this test: `testing/5.3-policy-pipeline-integration-test.md`. - Two `policy.md` variants are shipped on purpose (see *Scenario inputs*): an **explicit** one and an **abstract** one. `AIAC_POLICY_FILE` selects which the PRB reads, so a reviewer can compare the PRB's output on explicit vs. abstract policy text against the same expected Rego. The abstract variant - carries **no** agent-capability bullet; it relies on the elaborated `source-operator` / - `issues-operator` role descriptions (provisioned into Keycloak) for mapping (c), so it survives + carries **no** agent-capability bullet; it relies on the elaborated `source_operations` / + `issue_operations` role descriptions (provisioned into Keycloak) for mapping (c), so it survives deny-by-default and both variants reproduce the same Rego. - Descriptions are ≤255 characters and written **verbatim** into Keycloak; there is no shortened / verbatim split. (Keycloak caps role and client descriptions at 255 chars, and the generic descriptions @@ -377,9 +380,9 @@ with the user; keep them in sync with the *Scenario* table (see *Further Notes*) The descriptions are **generic and keyword-free** — they describe what each entity/role/scope *does*, carry no policy grant ("Resolves to…") and no owning-client naming, and stay within Keycloak's 255-char cap so they are written verbatim (no shortened renderings). Client `type` is **not** inferred from -description prose: the test provisions each client's `client.type` attribute (the type UC1 -discovers from the agent card / `rossoctl.io/type` label) as a plain string `"Agent"` / `"Tool"`, so -`Service` type resolution ([../../../src/aiac/idp/configuration/models.py:79-87](../../../src/aiac/idp/configuration/models.py#L79-L87)) +description prose: the test sets each client's `client.type` attribute directly — as a plain string +`"Agent"` / `"Tool"` written onto the client — rather than discovering it from a `kagenti.io/type` +label, so `Service` type resolution ([../../../src/aiac/idp/configuration/models.py:79-87](../../../src/aiac/idp/configuration/models.py#L79-L87)) tags each client from the attribute without touching the TEMP description-keyword fallback. **`github-agent`** — client (Agent): @@ -413,9 +416,9 @@ tags each client from the attribute without touching the TEMP description-keywor **Client roles (agent):** -- `source-operator` — Covers read and write access to source repository contents — listing, reading, +- `source_operations` — Covers read and write access to source repository contents — listing, reading, creating, and modifying files. -- `issues-operator` — Covers read and write access to the issue tracker — reading, filing, updating, +- `issue_operations` — Covers read and write access to the issue tracker — reading, filing, updating, and commenting on issues and their threads. **Agent scopes:** @@ -452,15 +455,15 @@ policy supports it; deny by default. - tester may perform issues-read and issues-write. ## Agent roles → tool operations (outbound target; agent may reach the tool) -- source-operator may perform source-read and source-write. -- issues-operator may perform issues-read and issues-write. +- source_operations may perform source-read and source-write. +- issue_operations may perform issues-read and issues-write. ``` ### `policy.md` — Version 2 (abstract) Relies on the PRB / LLM to expand "read and modify source" into the concrete scopes. Encodes the same role→access facts as Version 1. It carries **no** agent-capability bullet; mapping (c) -(agent-role→tool-scope) is instead derived from the elaborated `source-operator` / `issues-operator` +(agent-role→tool-scope) is instead derived from the elaborated `source_operations` / `issue_operations` role descriptions (see *Role & scope descriptions*), so it survives the PRB's deny-by-default-on-silence rule and both variants reproduce the same Rego. diff --git a/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md b/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md index 7132f6060..419b9ff7a 100644 --- a/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md +++ b/aiac/docs/specs/integration-test/uc1-onboarding-pipeline.md @@ -1,175 +1,168 @@ -# Integration Test: uc1-onboarding-pipeline — `test_uc1_onboarding_pipeline.py` - -> **One spec among several.** This document specifies a **single** integration test. Integration-test -> specs live **one spec per test** under `docs/specs/integration-test/` (a sibling of -> `components/`), and the master PRD's *Integration test specifications* section ([../PRD.md](../PRD.md)) -> is the index of them. This is the **uc1-onboarding-pipeline** integration test — the phase-1 -> service-onboarding demo driven end-to-end through the **real UC-1 agent** against **really-deployed** -> demo workloads — not the definition of integration testing in general. - -> **Relationship to `policy-pipeline`.** This test is the **discovery-driven sibling** of -> [policy-pipeline.md](policy-pipeline.md). The *scenario facts and the truth tables are identical* (same -> three users, same role→access facts, same inbound/outbound matrices). The **difference** is provenance: -> where `policy-pipeline` *hand-provisions* the agent/tool roles and scopes with clean fixed names and -> then calls the PRB mappings directly, this test *infers them via real UC-1 onboarding* of deployed -> workloads. That inference is what makes the generated Rego **semantically similar but not byte-identical** -> to `policy-pipeline` (see *[Semantic similarity, not byte-identity](#semantic-similarity-not-byte-identity)*). +# Integration Test: uc1-onboarding-pipeline — a ladder of UC-1 onboarding tests + +> **One spec among several.** This document specifies the **UC-1 onboarding** integration tests. +> Integration-test specs live under `docs/specs/integration-test/` (a sibling of `components/`), indexed +> by the master PRD's *Integration test specifications* section ([../PRD.md](../PRD.md)). This is the +> phase-1 service-onboarding demo driven end-to-end through the **real UC-1 agent** against +> **really-deployed** demo workloads — not the definition of integration testing in general. + +> **Ladder, not one test.** This spec was previously a single "complete two-policy" test that assumed a +> **two-stack** topology (one AIAC stack per `policy.md` variant) which is **not deployed** and so could +> never run. It is now a **ladder** of three gradual, runnable tests against **one** AIAC stack, plus a +> **deferred** two-policy rung: +> +> | Rung | Issue | Onboards | Proves | +> |---|---|---|---| +> | 1 | `testing/5.4.1-uc1-onboard-agent-only.md` | agent only | agent discovery + inbound generation stand alone; outbound empty with no tool | +> | 2 | `testing/5.4.2-uc1-onboard-agent-then-tool.md` | agent → tool | onboarding the tool **after** the agent completes the agent's outbound (PCE additive merge) | +> | 3 | `testing/5.4.3-uc1-onboard-tool-then-agent.md` | tool → agent | the happy path; **and, vs rung 2, onboarding-order-independence** | +> | 4 | `testing/5.4.4-uc1-onboard-two-policies.md` | two policies | **deferred / TBD**; two-stack impl discarded | + +> **Relationship to `policy-pipeline`.** This is the **discovery-driven sibling** of +> [policy-pipeline.md](policy-pipeline.md). Identical *scenario facts and truth tables* (same three users, +> same role→access facts, same inbound/outbound matrices); the difference is provenance — `policy-pipeline` +> *hand-provisions* the agent/tool roles/scopes in process, this ladder *infers them via real UC-1 +> onboarding* of deployed workloads. That inference makes the generated Rego **semantically similar but not +> byte-identical** to `policy-pipeline` (see *[Semantic similarity](#semantic-similarity-not-byte-identity)*). ## Location -`aiac/test/integration/test_uc1_onboarding_pipeline.py` — a pytest module marked -`@pytest.mark.integration`. It imports two shared modules: +`aiac/test/integration/` — pytest modules marked `@pytest.mark.integration`, one per rung (or one module +with one test per rung). They import two shared modules: -- `aiac/test/integration/scenario_uc1.py` — a **new** scenario module (sibling of the existing - `scenario.py`, kept separate so `5.2`/`5.3` are unaffected). It carries the phase-1 users/roles, the two - `policy.md` variants, and the pair-lists expressed over the **discovered, workload-prefixed** names - (`github-tool.source-read`, `github-agent.source_operations`, …), which are what the generated Rego - actually contains. -- `aiac/test/integration/launcher.py` — the shared `kubectl`/port-forward + `opa` helpers (extended from - the `5.3` launcher; see *[Testing Decisions](#testing-decisions)*). +- `scenario_uc1.py` — the pure-data scenario (users/roles + the pair-lists expressed over the + **discovered, workload-prefixed** names `github-tool.source-read`, `github-agent.source_operations`, …). + The old two-variant machinery (`VARIANTS`, `POLICY_EXPLICIT`, per-variant URLs/pods) is removed; the + truth tables (`USERS`, `USER_ROLES`, `INBOUND_PAIRS`, `OUTBOUND_SUBJECT_PAIRS`, `TOOL_SCOPES`, + `AGENT_SCOPES`, `AGENT_ROLE`) and the single **abstract** `policy.md` remain. +- `launcher.py` — the shared `kubectl`/port-forward + `opa` helpers (`kubectl`, `kubectl_cp`, + `port_forward`, `resolve_pod`, `opa_eval`, `require_env`, …). -It also ships `aiac/test/integration/probe_uc1.rego` — a standalone Rego module used only as the outbound -verification query, adapted from `5.3`'s `probe.rego` to match against **full discovered scope names** -(no prefix-stripping soft match; see step 7). +They also ship `probe_uc1.rego` — the outbound verification query, matching `input.function_name` against +the generated data maps by **exact string equality** on full discovered names, binding **both** the user +(subject) gate and the agent capability gate (the per-scope two-gate AND). ## Description -A `@pytest.mark.integration` test that validates the **phase-1 deliverable** -([../../gh-issues/sub-issue-phase-1.md](../../gh-issues/sub-issue-phase-1.md)) and confirms the runnable -demo: it deploys the real **`github-agent`** and a simplified **`github-tool`** to a live Rossoctl/Kind -cluster, drives the **real UC-1 Service Onboarding agent** (`onboard_service` via the Controller's HTTP -trigger) for each, and then **asserts** the generated Rego decides correctly by running the standalone -`opa eval` binary as its verification oracle. +`@pytest.mark.integration` tests that validate the **phase-1 deliverable** and confirm the runnable demo: +they drive the **real UC-1 Service Onboarding agent** (`POST /apply/service/{id}` on the in-cluster AIAC +Controller) against **already-deployed** `github-agent` + simplified `github-tool`, and assert the +generated Rego decides correctly using the standalone `opa eval` binary as the oracle. Phase-1 is explicit that **live enforcement / live traffic is out of scope** — correctness is shown by -**evaluating the generated rules**, not by routing real requests. So this test is *Deploy + discover + -evaluate*: the agent and tool are really deployed and really discovered by UC-1 (classify from the -`rossoctl.io/type` label, read the AgentCard, read the MCP `tools/list`, provision roles/scopes into -Keycloak, model access, emit Rego), but **no A2A message is ever sent through the agent**. +**evaluating the generated rules**, not by routing requests. So each rung is *onboard + evaluate*: the +workloads are really deployed and really discovered by UC-1 (classify from the `kagenti.io/type` label, +read the AgentCard / MCP `tools/list`, provision roles/scopes into Keycloak, model access, emit Rego), but +**no A2A message is ever sent through the agent**. The generated Rego is the **artifact under test** — the LLM/PCE that produced it might be wrong — so the -test never trusts it. Expected verdicts are **computed from** the `scenario_uc1.py` pair-lists (the -intended policy), and the real Rego is asserted to admit/deny each scenario-derived request as the truth -table requires. A mismatch fails the test and names the exact cell. - -Because it needs a live Rossoctl cluster + operator + Keycloak + a real LLM, it is `@pytest.mark.integration` -and stays out of the default unit run (`-m "not integration"`); it additionally `pytest.skip`s when no -`opa` binary is found. - -### Topology - -- **AIAC runs on the host? No — in-cluster.** The pipeline is driven **inside the cluster** so UC-1's - `analyze_tool` can reach the tool's MCP endpoint at its cluster-internal DNS name - (`github-tool.{ns}.svc.cluster.local`). The test triggers onboarding over HTTP against the in-cluster - AIAC Controller (`POST /apply/service/{service_id}`), reachable via `kubectl port-forward`. -- **Two AIAC stacks, one per policy variant.** Because the PRB reads its policy from `AIAC_POLICY_FILE` - (a file/ConfigMap **baked into the AIAC pod**, not selectable per request), the two `policy.md` - variants (`explicit`, `abstract`) are served by **two independent in-cluster AIAC stacks** — each an - AIAC agent + its own Policy Store + its own OPA Policy Writer, mounting its own variant. The **IdP - Configuration Service and Keycloak are shared** (provisioning is variant-independent and idempotent). -- **Rego capture.** Each stack's OPA Policy Writer writes `.rego` (the filesystem stub — phase-1 emits to - a filesystem location, **not** the K8s CR) into a mounted volume. The test `kubectl cp`/`exec`s each - variant's `.rego` out to a host temp dir `rego_out//`, then runs the `opa eval` oracle on the - host. - -### What it does - -The pipeline (deploy → onboard tool → onboard agent → emit Rego) is driven **once per `policy.md` variant** -— against that variant's AIAC stack — each writing into its own `rego_out//`. `opa eval` then -asserts the truth table against **each** variant's Rego (step 7). Steps 1–6 describe the run. - -0. **Point the demo namespace at the dedicated realm (test fixture).** Before deploying workloads, set - `KEYCLOAK_REALM=` in the demo namespace's **`authbridge-config`** ConfigMap. The - operator reads the realm per-namespace from this key and **preserves an admin/CI-set value** - (operator issue #433), so the operator registers *this* namespace's clients into the test realm with - **no operator restart or cluster-wide change**. (Default without this is `rossoctl`.) -1. **Provision the realm's users + realm roles (test fixture).** UC-1 does **not** create users or realm - roles, so the fixture provisions them via **`python-keycloak` `KeycloakAdmin`** into the dedicated test - realm (`AIAC_TEST_REALM`), **before** onboarding (the Service Policy Builder reads the full realm-role - universe): users `dev-user`, `test-user`, `devops-user`; realm roles `developer`, `tester`, `devops` - (with the generic ≤255-char descriptions in *[Scenario inputs](#scenario-inputs)* — the PRB reads - them); role assignments (`devops-user`→`devops`, which maps to **no** scope — the deny case). - Provisioning is idempotent (create-or-get); state is **left in place** on teardown (the shared realm is - never deleted/recreated — reruns converge). - -2. **Deploy the demo workloads.** `kubectl apply` the simplified **`github-tool`** - ([../demo/github-tool.md](../demo/github-tool.md)) and the real **`github-agent`** - ([../demo/github-agent.md](../demo/github-agent.md)) into the demo namespace. Wait until: pods Ready; - the operator has **registered a Keycloak client** for each (with `client.name = - "{namespace}/{workload}"`) and applied the `rossoctl.io/type` pod label (`tool`/`agent`); the - `github-agent` **AgentCard CR** is present; the tool **Service** carries the `protocol.rossoctl.io/mcp` - label and answers `tools/list`. - -3. **Onboard the tool, then the agent (real UC-1), against each variant's AIAC stack.** Order matters — - the tool is onboarded first so its scopes exist when the agent's Service Policy Builder reads the scope - universe: - > **Resolving `{service_id}`.** The trigger takes the Keycloak **client id**, which is **not** the - > string `github-tool`: the operator sets `client.name = "{ns}/{workload}"` but the client *id* is - > `"{ns}/{workload}"` only when SPIRE is off — with `--spire-trust-domain` set it is a SPIFFE URI - > (`spiffe:///ns/{ns}/sa/{sa}`). The test resolves the id by looking up the client whose - > **name** is `"{ns}/github-tool"` (resp. `"{ns}/github-agent"`) via the Configuration library / - > Keycloak admin, then triggers with that id. - - - `POST /apply/service/{github-tool client id}` → UC-1 classifies it a **Tool** from the label, reads - the MCP manifest, provisions scopes `github-tool.{source-read, source-write, issues-read, - issues-write}` (verbatim tool descriptions), sets `client.type=Tool`. **No rules are written for the - tool directly.** - - `POST /apply/service/{github-agent client id}` → UC-1 classifies it an **Agent** from the label, - reads the AgentCard, provisions role `github-agent.agent` + scopes - `github-agent.{source_operations, issue_operations}` (from the two skills), sets `client.type=Agent`, - then the Service Policy Builder maps the realm roles→scopes via the real PRB (real LLM, - `temperature=0`) and the Controller calls `compute_and_apply(rules, override=False)` → the OPA writer - emits `github_agent.inbound.rego` + `github_agent.outbound.rego`. - -4. **Capture the Rego.** `kubectl cp`/`exec` each variant stack's OPA-writer output to - `rego_out//`. - -5. **Repeat steps 3–4 for the second variant** (the other AIAC stack). Provisioning re-runs are idempotent - and variant-independent; only the emitted Rego differs. - -6. **Teardown in `finally`.** Delete the demo workloads (operator de-registers the clients); leave the - realm, users, realm roles, and `.rego` files in place for eyeballing. AIAC stacks may be left running or - torn down per the harness. - -7. **Assert the truth table with `opa eval`.** For each variant's captured Rego, evaluate a matrix of - **(request JSON, rego file)** tuples with the standalone `opa` binary and hard-assert each decision: - - **`opa` discovery** — `$OPA_BIN` → `shutil.which("opa")` → `pytest.skip("opa not found")`. - - **Inbound** — one node per `(variant × subject)`. Request `{"subject": }` against - `data.authz.github_agent.inbound.allow`. Coarse existential "can this user reach the agent at all"; - the discovered agent-scope names (`github-agent.source_operations` / `.issue_operations`) do not need - to match anything — inbound has no function field. - - **Outbound (user gate only)** — one node per `(variant × subject × function_name)`, where - `function_name` is a **full discovered tool-scope name** (`github-tool.source-read`, …). Evaluated by - the probe query `data.probe.outbound.allow` (in `probe_uc1.rego`), which binds `input.function_name` - against the generated **user→tool** data maps (`subject_ok`) **only**. The agent→tool gate - (`target_ok`) is **not** probed — under UC-1's single generic `github-agent.agent` role it is - degenerate/empty (see *[The agent-to-tool gate](#the-agent-to-tool-gate-degenerate-by-design)*), matching - phase-1's "user-gating dimension only." - - **Name matching** — because `scenario_uc1.py` stores the **full discovered scope names**, the probe - matches `input.function_name` to a scope by **exact string equality** (no prefix-stripping token-set - soft match — that was `5.3`'s device for bare names; here both sides are already prefixed). - - The expected verdict for every cell is **computed from** the `scenario_uc1.py` pair-lists, not a - second hand-maintained copy. A failing node names the exact `variant / subject / function_name` cell. - -8. **Assert grant-set equivalence (semantic, from the Rego).** The pipeline runs inside the AIAC pod, so - the test never sees the intermediate `list[PolicyRule]`. Grant-set equivalence is therefore re-derived - from the **generated Rego data maps**: dump each variant's user→tool grant map (via `opa eval - data.authz.github_agent.outbound...`) and each variant's inbound `subject_roles`/`agent_scopes`, and - assert, as order-independent `(role, scope)` **sets**, that **each variant equals the `scenario_uc1.py` - truth table** and **the two variants equal each other**. This compares grant *sets*, not Rego text - (formatting/name-ordering may differ; the grant set may not) — the semantic-similarity guarantee this - test makes. It catches verdict-neutral over/under-grants the coarse `opa eval` oracle hides. +tests never trust it. Expected verdicts are **computed from** the `scenario_uc1.py` pair-lists (the +intended policy). A mismatch fails the test and names the exact cell. + +Because they need a live Kagenti cluster + operator + Keycloak + a real LLM, they are +`@pytest.mark.integration` (out of the default unit run, `-m "not integration"`) and additionally +`pytest.skip` when no `opa` binary is found. + +## Topology + +- **One in-cluster AIAC stack.** A single AIAC agent (Controller, `POST /apply/service/{id}`) + Policy + Store + **OPA Policy Writer (filesystem stub)**, mounting the **single abstract** `policy.md`. AIAC runs + in-cluster so UC-1's `analyze_tool` can reach the tool's MCP endpoint at its cluster-internal DNS name + (`github-tool.{ns}.svc.cluster.local`); the tests trigger over `kubectl port-forward`. +- **OPA filesystem-stub writer, not the legacy Keycloak composite writer.** The stack must run + `aiac-pdp-policy-opa` (the filesystem stub: writes `{slug}.inbound.rego` + `{slug}.outbound.rego` to + `REGO_OUTPUT_DIR`, default `/rego`) — this is what the K8s Phase 1 Interface Pod actually deploys. + **Not** the superseded `aiac-pdp-policy-keycloak` composite writer, which manages Keycloak composite + roles and emits **no Rego at all**, and is not deployed by any current manifest. The `.rego` files are + the artifact under test; without the OPA writer there is nothing to capture. +- **Rego capture.** `kubectl cp` the writer's `/rego` to a per-rung host dir (a `rung{1,2,3}` subfolder + under the gitignored `test/integration/rego_out/uc1/` tree, so artifacts stay in the project for + eyeballing but are never committed; each rung clears its dir first), then run `opa eval` on the host. + +## Preconditions (assumed, not performed by the tests) + +- **Workloads deployed + registered.** Both `github-agent` and simplified `github-tool` are **already + deployed** in `AIAC_DEMO_NAMESPACE` and **already registered as Keycloak clients** + (`client.name = "{ns}/{workload}"`) into `AIAC_TEST_REALM`. The tests do **not** `kubectl apply` + manifests or wait for operator registration / `kagenti.io/type` labels / AgentCard / `tools/list` — that + is deployment's job. + > **Resolving `{service_id}`.** The `POST /apply/service/{service_id}` route is a **single path + > segment**, and the Controller resolves the trigger via `admin.get_client(service_id)` — which keys + > on the Keycloak **internal client UUID** (a slash-free GUID). It is **not** the `clientId`: the + > operator sets `client.name = "{ns}/{workload}"`, and the `clientId` is slash-bearing either way + > (`"{ns}/{workload}"` with SPIRE off, a SPIFFE URI under `--spire-trust-domain`), so it cannot be a + > path segment. Resolve by looking up the client whose **name** is `"{ns}/github-tool"` / + > `"{ns}/github-agent"`, then trigger with that client's **`id`** (the UUID). +- **Users + realm roles.** The fixture provisions them (UC-1 does not) — see + *[Scenario](#scenario)* — via `KeycloakAdmin` into `AIAC_TEST_REALM`, **before** onboarding; idempotent; + left in place. + +## Per-rung flow + +**Keycloak cleanup → onboard (rung order) → validate end state → Keycloak cleanup.** + +1. **Cleanup** (before and after each rung). Unmap composites and delete the **agent's and tool's** + provisioned realm roles + client scopes, leaving the clients registered exactly as before the first + run; clear the writer's `/rego`. This gives every rung a clean slate and makes reruns converge. (With + the OPA writer there are no composites — the only Keycloak mutations are the roles/scopes the onboarding + agent provisions — but cleaning both is harmless and future-proofs against the Keycloak writer.) +2. **Onboard** in the rung's order via `POST /apply/service/{service_id}`, where `{service_id}` is the + internal Keycloak UUID (`Service.id`), **not** the clientId — the onboard route is keyed on the UUID. + - `POST /apply/service/{github-tool id}` → UC-1 classifies it a **Tool**, reads the MCP manifest, + provisions scopes `github-tool.{source-read, source-write, issues-read, issues-write}`, sets + `client.type=Tool`. **No rules are written for the tool directly.** + - `POST /apply/service/{github-agent id}` → UC-1 classifies it an **Agent**, reads the AgentCard, + provisions **one operator role per skill** `github-agent.{source_operations, issue_operations}` + (mirroring the scopes) + scopes `github-agent.{source_operations, issue_operations}`, sets + `client.type=Agent`; the Service Policy Builder maps roles→scopes via the real PRB (real LLM, + `temperature=0`) and the Controller calls `compute_and_apply(rules, override=False)`. +3. **Validate two outcomes at the end** (no intermediate checks): + 1. **Keycloak provisioning.** The expected realm role(s) + client scopes exist with the expected + names/descriptions (via `KeycloakAdmin` / the IdP Configuration read API). + 2. **Generated Rego decisions.** `kubectl cp` the `/rego` files to the host and `opa eval`: + - **`opa` discovery** — `$OPA_BIN` → `shutil.which("opa")` → `pytest.skip`. + - **Inbound** — per `subject`: `{"subject": }` vs `data.authz.team1_github_agent.inbound.allow`. + - **Outbound (per-scope two-gate AND)** — per `(subject × function_name)`, `function_name` a full + discovered tool-scope name, via the probe `data.probe.outbound.allow` in `probe_uc1.rego`, which + binds `input.function_name` against **both** the user (subject) gate and the agent capability + gate by exact string equality — a request is allowed iff both reach the same scope (see + *[The agent→tool gate](#the-agenttool-gate-capability-matched)*). + - **Grant sets** — re-derive the `(role, scope)` grant sets from the Rego data maps and compare, as + order-independent sets, to the `scenario_uc1.py` truth table. + - Verdicts are **computed from** `scenario_uc1.py`, never from the Rego. A failing node names the + exact cell. +4. **Cleanup** — restore the clients to their pre-run state. + +## Onboarding order is irrelevant (rungs 2 vs 3) + +The **final** policy must not depend on the order services are onboarded. This is a **requirement**: if +onboarding order changes the end state, that is a **bug** the ladder exists to catch — not an accepted +difference. (This corrects an earlier "order matters" note in this spec and the tracking issue.) + +Why it holds: `compute_and_apply` is **affected-agent** oriented and **additive** (`override=False`, see +[../components/policy-computation-engine.md](../components/policy-computation-engine.md)). When the **tool** +is onboarded, its Service Policy Builder pairs the tool's scopes against the rest of the role universe, +producing `(agent-role, tool-scope)` and `(user-role, tool-scope)` rules; the PCE resolves those roles to +the **agent** and merges them onto the agent's stored `AgentPolicyModel`, rewriting +`team1_github_agent.outbound.rego`. So: + +- **Rung 2 (agent → tool):** agent onboarding leaves outbound empty; **tool onboarding fills it in**. +- **Rung 3 (tool → agent):** the tool's scopes already exist, so **agent onboarding produces the full + gate** in one pass. +- **Both converge** to the same grant sets. Rung 3 asserts grant-set equivalence with **rung 2**; a + divergence fails and names the differing gate. + +Rung 1 (agent only) is the exception by construction: with no tool onboarded there are no tool scopes in +the universe, so the outbound user gate is **empty** (all deny). Inbound is unaffected. ## Expected output -The test passes when `opa eval` decides every cell of the scenario truth table as follows, for **both** -policy variants. Verdicts are **computed from** the `scenario_uc1.py` pair-lists, not a hand-maintained -copy — these tables are the human-readable rendering of them. They are **identical to policy-pipeline's** -(only the underlying scope-name strings differ). +Verdicts are **computed from** the `scenario_uc1.py` pair-lists (these tables are the human-readable +rendering). They are **identical to policy-pipeline's** (only the scope-name strings differ). `USERS`: `dev-user`→`developer`, `test-user`→`tester`, `devops-user`→`devops`. -**Inbound allow** (`data.authz.github_agent.inbound.allow`, user-role→agent-scope, existential): +**Inbound allow** (`data.authz.team1_github_agent.inbound.allow`; all rungs): | Subject | Inbound | |---|---| @@ -177,8 +170,9 @@ copy — these tables are the human-readable rendering of them. They are **ident | test-user | ✅ | | devops-user | ❌ | -**Outbound allow(subject, function)** (`data.probe.outbound.allow`, user→tool gate only; `function_name` -is the full discovered scope name, shown here by its bare suffix for readability): +**Outbound allow(subject, function)** (`data.probe.outbound.allow`, per-scope two-gate AND; the agent +reaches all four tool scopes, so the user gate discriminates; suffixes shown for readability) — +**rungs 2 and 3** (with a tool onboarded): | | github-tool.source-read | github-tool.source-write | github-tool.issues-read | github-tool.issues-write | |---|---|---|---|---| @@ -186,256 +180,184 @@ is the full discovered scope name, shown here by its bare suffix for readability | test-user | ❌ | ❌ | ✅ | ✅ | | devops-user | ❌ | ❌ | ❌ | ❌ | -Each variant leaves exactly **two** files on disk in its `rego_out//`: +**Rung 1 (agent only):** the outbound table is **entirely deny** (empty user gate — no tool scopes). -- `github_agent.inbound.rego` — package `authz.github_agent.inbound`; the **user→agent** gate. - `subject_roles` = `{dev-user: [developer], test-user: [tester]}`; `agent_scopes` populated with the - **discovered** names `[github-agent.source_operations, github-agent.issue_operations]`. (`devops-user` - holds `devops`, which maps to no agent scope, so it is absent and denied inbound.) -- `github_agent.outbound.rego` — package `authz.github_agent.outbound`; `allow if { subject_ok; target_ok - }`. Its **`subject_ok`** is the **user→tool** gate (populated: `subject_role_scopes` grouping - developer/tester → `github-tool.*` scopes). Its **`target_ok`** (agent→tool) is **degenerate/empty** - under the single generic `github-agent.agent` role — documented, not asserted (see below). - -Explicitly **no** `github_tool.*.rego` — the pipeline emits no tool model (the tool is a pure target; -phase-1 acceptance requires "no rules written for the tool alone"). +Each rung leaves on disk exactly `{AGENT_SLUG}.inbound.rego` + `{AGENT_SLUG}.outbound.rego`; explicitly +**no** `github_tool.*.rego` (the tool is a pure target; "no rules written for the tool alone"). +`AGENT_SLUG` is the Rego-package slug derived from the agent's clientId (`{namespace}/{name}`, +extracted from the SPIFFE URI under SPIRE) — `team1_github_agent` on the reference cluster's +`team1`/`github-agent` scenario, not a literal `github_agent` (see +[pdp-policy-writer-opa.md § Rego package structure](../components/pdp-policy-writer-opa.md#rego-package-structure) +for the slugify rule). ### Semantic similarity, not byte-identity -This test's Rego is **semantically similar** to `policy-pipeline`'s but **not byte-identical**, for two -baked-in reasons in the (frozen) UC-1 provisioning: +This ladder's Rego is **semantically similar** to `policy-pipeline`'s but **not byte-identical**, for two +baked-in reasons in UC-1 provisioning: 1. **Workload-prefixed names.** UC-1 names every scope `{workload}.{name}`, so the data maps hold - `github-tool.source-read` / `github-agent.source_operations` where `policy-pipeline` holds bare - `source-read` / `source-access`. Same **file set + package shapes**; different name strings. -2. **Degenerate `target_ok`.** UC-1 emits one generic `github-agent.agent` role (description `"Agent - role"`), which the PRB cannot map to specific tool scopes under deny-by-default, so the agent→tool gate - is empty — where `policy-pipeline`'s two operator roles populate it across all four scopes. - -Both are inherent to running real UC-1; making the Rego byte-identical would require unfreezing UC-1 -(dropping the prefix + deriving per-skill agent roles) or abandoning UC-1 discovery (which would collapse -this test into `policy-pipeline`). The test therefore asserts **same files + same decisions + equivalent -grant sets**, not identical text. - -### The agent-to-tool gate (degenerate by design) - -Phase-1 states outbound access is an **intersection** of the user→tool gate and the agent→tool gate, but -that "the agent holds all of `github-tool`'s scopes, so this demo exercises the **user-gating dimension -only**; the agent-role gate is not exercised here" (deferred to a future two-tool demo). Under real UC-1 -the single generic `github-agent.agent` role yields an **empty** `target_ok`, so the generated `allow` -(`subject_ok AND target_ok`) would deny everything. The probe therefore evaluates **`subject_ok` only**, -which is exactly the user-gating slice phase-1 validates. The empty `target_ok` is documented as a known -UC-1 limitation, not a test failure. + `github-tool.source-read` / `github-agent.source_operations` where `policy-pipeline` holds bare names. +2. **Capability-matched `target_ok`.** UC-1 provisions one **operator role per skill** + (`github-agent.source_operations` / `github-agent.issue_operations`), which the PRB maps to the tool + scopes by domain (capability-match), so the agent→tool gate is populated over all four tool scopes. + +The tests therefore assert **same file set + same decisions + equivalent grant sets**, not identical text. + +### The agent→tool gate (capability-matched) + +Phase-1 states outbound access is the **per-scope intersection** of the user→tool gate and the +agent→tool gate. UC-1 provisions **one operator role per skill** +(`github-agent.source_operations` / `github-agent.issue_operations`), and the PRB maps those operator +roles to the tool scopes by domain (capability-match under `generic_policy.md`), so `target_ok` is +**populated over all four tool scopes**. Because the agent reaches every tool scope, the **user gate +discriminates** — the probe binds the real per-scope AND (`subject_ok AND target_ok` on the same +`input.function_name`) and, for this scenario, its verdicts equal the user-gate slice. The AND is +genuine, not degenerate: if the agent reached only a subset of the tool's scopes, the request would be +denied for the scopes it does not reach. ## Scenario -The phase-1 scenario — identical role→access facts to `policy-pipeline`, driven end-to-end through real -UC-1 onboarding of deployed workloads rather than a hand-built provisioning step. +Identical role→access facts to `policy-pipeline`, driven through real UC-1 onboarding of deployed +workloads. | Element | Value | |---------|-------| -| Realm | `AIAC_TEST_REALM` (dedicated; default `aiac-uc1-e2e`) | -| Agent | `github-agent` — **discovered** role `github-agent.agent`; scopes `github-agent.source_operations`, `github-agent.issue_operations` (from AgentCard skills) | -| Tool | `github-tool` (simplified) — **discovered** scopes `github-tool.source-read`, `github-tool.source-write`, `github-tool.issues-read`, `github-tool.issues-write` (from MCP `tools/list`) | -| Users | `dev-user` (role `developer`), `test-user` (role `tester`), `devops-user` (role `devops`) | +| Realm | `AIAC_TEST_REALM` (must match the deployed stack's `KEYCLOAK_REALM`; default `kagenti`) | +| Agent | `github-agent` — **discovered** per-skill operator roles `github-agent.source_operations`, `github-agent.issue_operations` (mirroring the scopes); scopes `github-agent.source_operations`, `github-agent.issue_operations` (from AgentCard skills) | +| Tool | `github-tool` (simplified) — **discovered** scopes `github-tool.{source-read, source-write, issues-read, issues-write}` (from MCP `tools/list`) | +| Users | `dev-user` (`developer`), `test-user` (`tester`), `devops-user` (`devops`) | | `developer` | source read/write + issues read | | `tester` | issues read/write | -| `devops` | no access (inbound deny; denied every outbound function) | - -Role → access (the fixed facts both `policy.md` variants and the `scenario_uc1.py` pair-lists must agree -with; the generic descriptions are not part of this triad): - -- `developer` — source read/write, issues read. -- `tester` — issues read/write. -- `devops` — no access. Conveyed by the **role description only** — absent from every pair-list and both - `policy.md` variants (deny-by-default), so denied inbound and on every outbound function. +| `devops` | no access (inbound deny; denied every outbound function) — conveyed by **role description only**, absent from the `policy.md` (deny-by-default) | ## Configuration (env) | Variable | Purpose | Default | |----------|---------|---------| -| `KUBECONFIG` | Kubeconfig for the live Rossoctl/Kind cluster | — (required) | -| `AIAC_DEMO_NAMESPACE` | Namespace the demo workloads deploy into | `team1` | +| `KUBECONFIG` | Kubeconfig for the live Kagenti/Kind cluster | — (required) | +| `AIAC_DEMO_NAMESPACE` | Namespace the demo workloads are deployed in (precondition) | `team1` | | `KEYCLOAK_URL` | External Keycloak base URL | — (required) | | `KEYCLOAK_ADMIN_REALM` | Realm the admin creds live in | `master` | -| `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds (for user/realm-role provisioning) | — (required) | -| `AIAC_TEST_REALM` | Dedicated realm the test uses; the fixture sets `KEYCLOAK_REALM` on the demo namespace's `authbridge-config` ConfigMap so the operator registers this namespace's clients into it (per-namespace, no cluster-wide change) | `aiac-uc1-e2e` | -| `AIAC_REALM` | Realm the AIAC stacks read back (= `AIAC_TEST_REALM`) | `aiac-uc1-e2e` | -| `AIAC_EXPLICIT_URL` / `AIAC_ABSTRACT_URL` | Base URL of each variant's in-cluster AIAC Controller (via port-forward) for `POST /apply/service/{id}` | `http://127.0.0.1:7070` / `:7080` | -| `AIAC_OPA_POD_EXPLICIT` / `AIAC_OPA_POD_ABSTRACT` | OPA-writer pod (or PVC) per variant to `kubectl cp` `.rego` from | — (resolved from labels) | -| `REGO_OUTPUT_DIR` | Host base dir the captured `.rego` is copied to; `rego_out//` per variant, left on disk | operator-chosen local dir | -| `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`); consumed by the in-cluster AIAC pods | — (required) | -| `OPA_BIN` | Path to the standalone `opa` binary (oracle); else `PATH`, else `pytest.skip` | — (optional; PATH lookup) | - -> When the test is written, confirm: the Controller trigger path and port; and the OPA writer's output -> path / how the two variant stacks are deployed and addressed. The realm mechanism (per-namespace -> `authbridge-config` `KEYCLOAK_REALM`, preserved by the operator — issue #433) and the `{service_id}` -> resolution (look up the client by `client.name = "{ns}/{workload}"`; the id is a SPIFFE URI under SPIRE) -> are **confirmed** against the operator source and reflected above. +| `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin creds (user/realm-role provisioning + cleanup) | — (required) | +| `AIAC_TEST_REALM` | Realm the tests resolve/provision against. **Must match the deployed AIAC stack's `KEYCLOAK_REALM`** — the in-cluster Controller resolves the onboarding trigger in *its own* realm, so a harness on a different realm resolves a client UUID the Controller can't find (404 → onboard 502). The demo namespace's clients are registered into it. | `kagenti` | +| `AIAC_CONTROLLER_URL` | Base URL of the in-cluster AIAC Controller (via port-forward) for `POST /apply/service/{id}` | `http://127.0.0.1:7070` | +| `AIAC_OPA_POD` / `AIAC_OPA_SELECTOR` | OPA-writer pod (or label selector) to `kubectl cp` `.rego` from | — (resolved from labels) | +| `AIAC_OPA_REGO_PATH` | Writer output dir inside the pod | `/rego` | +| `REGO_OUTPUT_DIR` | Base dir the captured `.rego` is copied to (one `rung{1,2,3}` subfolder per rung) | `test/integration/rego_out/uc1/` (gitignored) | +| `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY` | PRB LLM (pinned `temperature=0`); consumed by the in-cluster AIAC pod | — (required) | +| `OPA_BIN` | Path to the standalone `opa` binary (oracle); else `PATH`, else `pytest.skip` | — (optional) | + +> Single stack — one Controller URL, one OPA pod, one policy. The two-variant env +> (`AIAC_EXPLICIT_URL`/`AIAC_ABSTRACT_URL`, `AIAC_OPA_POD_EXPLICIT`/`_ABSTRACT`) is removed with the +> two-stack topology. ## Runbook -Runnable only against a live Rossoctl/Kind cluster (operator + Keycloak + SPIRE) **configured to register -clients into `AIAC_TEST_REALM`**, with the two AIAC variant stacks deployable, a real LLM, the demo agent -(GA-1…9) and simplified tool images built + kind-loaded, and an `opa` binary on `PATH` (or `$OPA_BIN`). +Runnable against a live Kagenti/Kind cluster (operator + Keycloak + SPIRE) with the AIAC stack running the +**OPA filesystem-stub writer**, `github-agent` + `github-tool` **already deployed and registered** into +`AIAC_TEST_REALM`, a real LLM, and an `opa` binary on `PATH` (or `$OPA_BIN`). ```bash -# env: KUBECONFIG + KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to aiac-uc1-e2e; opa on PATH or $OPA_BIN -.venv/bin/pytest test/integration/test_uc1_onboarding_pipeline.py -m integration -v -# Parametrized nodes: (variant × subject) inbound + (variant × subject × function_name) outbound + grant-set equivalence. +# env: KUBECONFIG + KEYCLOAK_URL + admin creds + LLM_* set; realm defaults to kagenti (match the stack's KEYCLOAK_REALM); opa on PATH or $OPA_BIN +.venv/bin/pytest test/integration/ -m integration -k uc1_onboard -v # A failing node names the exact cell, e.g.: -# test_outbound[abstract-test-user-github-tool.source-read] — expected deny, opa allowed -# The generated Rego is left on disk per variant for eyeballing: -# rego_out/explicit/github_agent.{inbound,outbound}.rego -# rego_out/abstract/github_agent.{inbound,outbound}.rego -# (no github_tool.*.rego in either) +# test_outbound[test-user-github-tool.source-read] — expected deny, opa allowed ``` -The suite `pytest.skip`s when no `opa` binary is found. Eyeball the persisted Rego against the ID-only -package shapes in [../components/pdp-policy-writer-opa.md](../components/pdp-policy-writer-opa.md); -optionally inspect the provisioned Keycloak realm and the discovered scopes. +The suite `pytest.skip`s when no `opa` binary is found. ## Testing Decisions -- **Highest seam available, verified by a real oracle.** Real deployed workloads + real operator - + real UC-1 onboarding + real PRB/PCE + real Keycloak + real LLM. The test drives the pipeline through - its production trigger (`POST /apply/service/{id}`) and verifies the real filesystem Rego with the - standalone `opa eval` binary. A good test here asserts only **external behavior** — the *decisions* the - generated Rego makes — never internal Rego structure. -- **Rego is the artifact under test; the scenario is the oracle.** Expected verdicts are computed from - `scenario_uc1.py`, not from the Rego. A wrong role→scope mapping fails the test at the exact cell. -- **Deploy + discover + evaluate, no live traffic.** Per phase-1, enforcement/token-exchange/live A2A is - out of scope; correctness is shown by evaluating the generated rules. The agent pod need only exist - (labelled `rossoctl.io/type=agent`, AgentCard present); the simplified tool need only answer `tools/list` - — neither is driven with real requests. -- **In-cluster AIAC for MCP reachability.** UC-1's `analyze_tool` posts `tools/list` to a - cluster-internal DNS name, so the pipeline runs in-cluster (triggered over HTTP) rather than in-process - on the host, which cannot resolve `*.svc.cluster.local`. -- **Two AIAC stacks for two variants.** The PRB policy is baked into the pod (`AIAC_POLICY_FILE`), so each - `policy.md` variant is served by its own AIAC stack; Keycloak + the IdP Configuration Service are shared - (provisioning is variant-independent and idempotent). -- **User gate only.** UC-1's single generic agent role yields an empty `target_ok`; the outbound probe - evaluates `subject_ok` alone, matching phase-1's user-gating-only intent. The agent-role gate is - deferred (needs a second tool the agent is not mapped to). -- **Semantic similarity, from the Rego.** Since the pipeline runs in-pod, grant-set equivalence is - re-derived from the generated Rego data maps (not an in-process `list[PolicyRule]`), and compares grant - *sets* across variants and vs the scenario — the semantic-similarity guarantee, not byte-identity. -- **Dedicated realm, leave-in-place.** A dedicated test realm (`AIAC_TEST_REALM`) the operator is - configured for; the shared realm is never deleted/recreated, so cleanup is idempotent leave-in-place - (users/roles create-or-get; UC-1 writes idempotent; demo workloads deleted so the operator de-registers - the clients). -- **LLM nondeterminism, contained.** The PRB LLM is pinned `temperature=0`; both variants are asserted - cell-by-cell (`opa eval`) and at the grant-set level. Some model-dependence remains, which is why the - suite is `@pytest.mark.integration`, out of default CI. -- **Prior art, shared not copied.** Reuses the `5.3` shape (`@pytest.mark.integration`, `opa` - discovery/skip, per-variant `rego_out/`, scenario-as-oracle, probe query) via the shared - `launcher.py`/`scenario_uc1.py`, adapted from in-process/subprocess to deploy/port-forward/`kubectl cp`. +- **Highest seam available, verified by a real oracle.** Real deployed workloads + real operator + real + UC-1 onboarding + real PRB/PCE + real Keycloak + real LLM, driven through the production trigger + (`POST /apply/service/{id}`), verified by the standalone `opa eval` binary. Assert only **external + behavior** — the decisions the Rego makes — never internal Rego structure. +- **Rego is the artifact under test; the scenario is the oracle.** Verdicts computed from `scenario_uc1.py`. +- **Onboard + evaluate, no live traffic.** Enforcement / token-exchange / live A2A is out of scope. +- **Deployment is a precondition.** The tests do not deploy or wait for registration; they cleanup → + onboard → validate → cleanup, so reruns are hermetic and cheap. +- **One stack, one policy, OPA filesystem stub.** Rungs 1–3 need only one AIAC stack; the OPA writer's + `/rego` output is what makes the pipeline observable. The Keycloak composite writer emits no Rego and is + not used. +- **Onboarding-order-independence is asserted, not assumed** (rungs 2 vs 3). A divergence is a bug. +- **Per-scope two-gate AND.** UC-1's per-skill operator roles are mapped to the tool scopes by + capability-match, so `target_ok` is populated; the outbound probe binds the real per-scope AND + (`subject_ok AND target_ok` on the same `input.function_name`). The agent reaches all four tool + scopes, so the user gate discriminates. +- **Grant sets, semantic.** Equivalence is re-derived from the Rego data maps and compared as sets — the + semantic-similarity guarantee, not byte-identity. +- **Stack's realm, leave-in-place; per-rung cleanup.** UC-1 resolves/provisions against the deployed + stack's `KEYCLOAK_REALM` (default `kagenti`) and **never deletes** the realm/users/roles; only the + provisioned agent/tool roles/scopes are cleaned up per rung so onboarding runs from a clean slate. + (Contrast `5.3 policy-pipeline`, which owns a **throwaway** realm it `delete_realm`s + recreates each + run — that suite must never point `AIAC_TEST_REALM` at `kagenti`, or it destroys the demo clients.) +- **LLM nondeterminism, contained.** PRB LLM pinned `temperature=0`; both cell-level and grant-set + assertions; `@pytest.mark.integration`, out of default CI. +- **Prior art, shared not copied.** Reuses the `5.3` shape (`opa` discovery/skip, scenario-as-oracle, + probe query) via `launcher.py`/`scenario_uc1.py`, adapted to deploy-precondition + port-forward + + `kubectl cp`. ## Relationship to other integration tests -This is **one** integration-test spec among several indexed by the master PRD -([../PRD.md](../PRD.md), § *Integration test specifications*). - - **Discovery-driven sibling of `policy-pipeline`** ([policy-pipeline.md](policy-pipeline.md), - `testing/5.3-policy-pipeline-integration-test.md`): identical scenario facts and truth tables, but this - test *infers* the agent/tool entities via **real UC-1 onboarding of deployed workloads** instead of - hand-provisioning them, so its Rego is semantically similar (not byte-identical) and it validates the - **phase-1** deliverable end-to-end. -- Same `@pytest.mark.integration` + `opa eval` flavor as the live-Keycloak pytest tests - (`testing/5.1-integration-tests.md`) and `policy-pipeline`; skips when `opa` is absent. + `testing/5.3-policy-pipeline-integration-test.md`): identical scenario facts/tables, but this ladder + *infers* the entities via real UC-1 onboarding of deployed workloads. `5.3` also already asserts the + **cross-variant** (explicit vs abstract) grant-set equivalence in process, which covers the deferred + rung 4's core guarantee until an in-cluster two-policy approach is designed. +- Same `@pytest.mark.integration` + `opa eval` flavor as `testing/5.1-integration-tests.md` and + `policy-pipeline`; skips when `opa` is absent. -Tracking issue for this test: `testing/5.4-uc1-onboarding-integration-test.md`. +Tracking issues: `testing/5.4-uc1-onboarding-integration-test.md` (epic) + `5.4.1`/`5.4.2`/`5.4.3` (rungs) ++ `5.4.4` (deferred two-policy). ## Out of Scope -- **Writing `test_uc1_onboarding_pipeline.py`, `probe_uc1.rego`, `scenario_uc1.py`** — this spec - *describes* the test; the test is written in a later session (tracked by - `testing/5.4-uc1-onboarding-integration-test.md`). -- **The UC-1 agent, PRB, PCE, OPA writer, and demo `github-agent` implementations** — specified/tested by - their own components/issues, not here. In particular UC-1's discovery naming and single-generic-role - behavior are **fixed**; this test observes them, it does not change them. -- **Building the simplified `github-tool`** — its own build issue (a `blocked-by` of this test); the tool - is specified in [../demo/github-tool.md](../demo/github-tool.md). -- **Live enforcement / A2A traffic / token exchange / K8s-CR Policy Writer** — Phase-2+; this test targets - the filesystem stub and evaluates rules, per phase-1 out-of-scope. +- **Writing the rung tests + `probe_uc1.rego` + `scenario_uc1.py` edits** — this spec *describes* them; + they are written under the `5.4.x` issues. +- **The UC-1 agent, PRB, PCE, OPA writer, and demo `github-agent`** — specified/tested by their own + components/issues. UC-1's discovery naming and per-skill operator-role behavior are **fixed**; these + tests observe them. +- **Deploying / registering the workloads** — a precondition, not part of the tests. +- **Two-policy (rung 4)** — deferred; the two-stack topology is discarded and the in-cluster approach is + TBD (`testing/5.4.4-uc1-onboard-two-policies.md`). +- **Live enforcement / A2A / token exchange / K8s-CR Policy Writer** — Phase-2+; these tests target the + filesystem stub and evaluate rules. - **Default-CI wiring** — `@pytest.mark.integration`; runs on demand. -## Further Notes - -- **Fact triad.** The role→access facts are owned by three artefacts that must agree: the *Scenario* - table, **both** `policy.md` variants, and the `scenario_uc1.py` pair-lists. Entity/role/scope - descriptions are generic and functional and must not contradict the facts. -- **Two variants in the discovered world** (see *Scenario inputs*): an **explicit** variant that - enumerates each `(user-role → discovered scope)` pair by its full prefixed name, and an **abstract** - variant (phase-1's intent-only prose). **Neither names the agent role** — doing so would populate - `target_ok` and break both the user-gate-only decision and cross-variant equivalence. Both are - user-intent-only, both leave `target_ok` degenerate, and both must produce the **same discovered grant - set**. -- **`devops` zero access** is conveyed by its role description only; it is absent from every pair-list and - both `policy.md` variants (deny-by-default denies it inbound and on every outbound function). -- **Descriptions ≤255 chars, verbatim into Keycloak.** The tool-scope descriptions are the verbatim - scenario descriptions the simplified tool returns from `tools/list`; the agent-scope descriptions come - from the AgentCard skills; the realm-role descriptions are provisioned by the fixture. - -## Blocked-by - -- Simplified `github-tool` build issue (see [../demo/github-tool.md](../demo/github-tool.md)). -- Demo `github-agent` implementation — `demo/GA-1…GA-9` (deployable agent + AgentCard). -- UC-1 Service Onboarding — `agent/service-onboarding/3.6-service-onboarding-orchestrator.md` (**done**). -- The PRB / PCE / OPA-writer / Policy-Store prerequisites shared with `5.3`. -- A live Rossoctl/Kind cluster + operator (registers clients into `AIAC_TEST_REALM` via the demo - namespace's `authbridge-config` `KEYCLOAK_REALM`, set by the fixture — per-namespace, confirmed against - the operator source); the `protocol.rossoctl.io/mcp` Service label applied at deploy time - (`../../gh-issues/operator-mcp-label-stamping.md`); an `opa` binary at test time. - ## Scenario inputs -These are **functional** inputs — the PRB reads the descriptions and the `policy.md` to produce the -role→scope mappings. The entity/role/scope descriptions are **generic and keyword-free**; client `type` -is set by UC-1 from the `rossoctl.io/type` label (not description prose). +**Functional** inputs — the PRB reads the descriptions and the `policy.md` to produce the role→scope +mappings. Descriptions are **generic and keyword-free**; client `type` is set by UC-1 from the +`kagenti.io/type` label. ### Discovered entities (what UC-1 provisions) -- **`github-tool`** (Tool) → scopes, from the simplified tool's MCP `tools/list` (verbatim descriptions): +- **`github-tool`** (Tool) → scopes, from MCP `tools/list` (verbatim descriptions): - `github-tool.source-read` — "Read source repository contents: file listings and file bodies. Read-only." - `github-tool.source-write` — "Create, modify, or delete source repository contents; commit file changes." - `github-tool.issues-read` — "Read issues and their comment threads. Read-only." - `github-tool.issues-write` — "Create and update issues: open, edit, comment, and close." -- **`github-agent`** (Agent) → role `github-agent.agent` (description "Agent role") + scopes from the - AgentCard skills: +- **`github-agent`** (Agent) → **one operator role per skill** (name + description mirror each scope) + + scopes from the AgentCard skills: - `github-agent.source_operations` — "Browse and search code; read, create, and modify repository file contents, branches, and commits." - `github-agent.issue_operations` — "Read, search, create, and update issues, comments, sub-issues, and pull requests." + The operator roles `github-agent.source_operations` / `github-agent.issue_operations` carry the same + descriptions as the scopes they mirror; those descriptions drive the PRB capability-match. (This + replaces the prior single generic `github-agent.agent` role.) + ### Realm roles (provisioned by the fixture) - `developer` — "Developer — an engineering user who develops the source codebase (writing and maintaining code) and fixes code defects reported in the issue tracker; works primarily in source and consults issues for defect reports." - `tester` — "Tester — a quality-assurance user who verifies software quality and tracks defects through the issue tracker: filing, triaging, and updating issue reports; works in the issue tracker, not in source." - `devops` — "DevOps — an operations user who manages deployment infrastructure and runtime environments; does not author source code and does not manage the issue tracker." -### `policy.md` — Version 1 (explicit) +### `policy.md` — the single (abstract) variant -Enumerates each `(user-role → discovered scope)` pair by name. **No agent-role→tool-scope section** -(target_ok is deferred). Deny by default. - -```markdown -# Access Control Policy — github-agent / github-tool - -Grant access on a least-privilege basis. Only grant a (role, scope) pair when this -policy supports it; deny by default. - -## Users → agent capabilities (inbound; user may call the agent) -- developer may use github-agent.source_operations and github-agent.issue_operations. -- tester may use github-agent.issue_operations. - -## Users → tool operations (outbound subject; user may reach the tool) -- developer may perform github-tool.source-read, github-tool.source-write, and github-tool.issues-read. -- tester may perform github-tool.issues-read and github-tool.issues-write. -``` - -### `policy.md` — Version 2 (abstract) - -Phase-1's intent-only prose. Encodes the same facts; relies on the PRB/LLM to expand intent into the -discovered scopes via the entity/role descriptions. +Phase-1's intent-only prose. The PRB/LLM expands intent into the discovered scopes via the entity/role +descriptions. It stays **user-intent-only** and **does not name the agent's operator roles** — the +agent's capability gate comes from the generic rubric (`generic_policy.md`) matching the operator-role +descriptions to the tool-scope descriptions, not from the policy naming them. Deny by default. ```markdown Grant access on a least-privilege basis: allow only what this policy states; deny by default. @@ -443,3 +365,7 @@ Grant access on a least-privilege basis: allow only what this policy states; den - Developers may read and modify source, and read issues. - Testers may read and modify issues. ``` + +> The **explicit** enumerated variant and cross-variant equivalence are deferred to rung 4 +> (`testing/5.4.4-uc1-onboard-two-policies.md`); the two-stack topology that served both variants is +> discarded. diff --git a/aiac/k8s/agent-deployment.yaml b/aiac/k8s/agent-deployment.yaml new file mode 100644 index 000000000..3b9b68a71 --- /dev/null +++ b/aiac/k8s/agent-deployment.yaml @@ -0,0 +1,116 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: aiac-agent-config + namespace: aiac-system +data: + # OpenAI-compatible LLM endpoint for the Policy Rules Builder (ChatOpenAI: + # base_url=LLM_BASE_URL, model=LLM_MODEL, api_key from the aiac-agent-secret). + # These are PLACEHOLDERS — do not commit real endpoints/keys. Supply the real + # values per-environment on the LIVE objects at deploy time (guide step 4): + # kubectl patch configmap aiac-agent-config -n aiac-system --type merge \ + # -p '{"data":{"LLM_BASE_URL":"https:///v1","LLM_MODEL":""}}' + # kubectl create secret generic aiac-agent-secret -n aiac-system \ + # --from-literal=LLM_API_KEY= --dry-run=client -o yaml | kubectl apply -f - + # then: kubectl rollout restart deployment/aiac-agent -n aiac-system + LLM_BASE_URL: "https://openai-compatible-endpoint.example.com/v1" + LLM_MODEL: "your-model-name" + AIAC_AC_MODEL: "RBAC" + +--- +# Prerequisites: create this Secret before applying this manifest: +# +# kubectl create secret generic aiac-agent-secret \ +# -n aiac-system \ +# --from-literal=LLM_API_KEY= +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aiac-agent + namespace: aiac-system + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: aiac-agent +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get"] + - apiGroups: ["agent.kagenti.dev"] + resources: ["agentcards"] + verbs: ["list"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: aiac-agent +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: aiac-agent +subjects: + - kind: ServiceAccount + name: aiac-agent + namespace: aiac-system + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aiac-agent + namespace: aiac-system +spec: + replicas: 1 + selector: + matchLabels: + app: aiac-agent + template: + metadata: + labels: + app: aiac-agent + spec: + serviceAccountName: aiac-agent + # aiac-init init container is deferred to Phase 2 (issue 4.21). + # It will gate startup on NATS + IdP Config + PDP Policy + RAG Ingest health + # and provision the aiac-events JetStream stream. + containers: + - name: aiac-agent + image: localhost/aiac-agent:local + imagePullPolicy: Never + ports: + - containerPort: 7070 + # The Controller exposes only /apply/* routes (no /health endpoint), + # so readiness is gated on the port accepting TCP connections. + readinessProbe: + tcpSocket: + port: 7070 + initialDelaySeconds: 5 + periodSeconds: 10 + envFrom: + - configMapRef: + name: aiac-pdp-config + - configMapRef: + name: aiac-agent-config + - secretRef: + name: aiac-agent-secret + +--- +apiVersion: v1 +kind: Service +metadata: + name: aiac-agent-service + namespace: aiac-system +spec: + selector: + app: aiac-agent + ports: + - name: agent + port: 7070 + targetPort: 7070 diff --git a/aiac/k8s/aiac-deployment-guide.md b/aiac/k8s/aiac-deployment-guide.md new file mode 100644 index 000000000..fd8900b6e --- /dev/null +++ b/aiac/k8s/aiac-deployment-guide.md @@ -0,0 +1,252 @@ +# AIAC — Kubernetes Installation Guide + +This guide covers the full AIAC deployment in the `aiac-system` namespace. + +## Components deployed + +| Manifest | Contents | Port(s) | +|---|---|---| +| `pdp-interface-deployment.yaml` | Kagenti Interface Pod (IdP Configuration Service + PDP Policy Writer **Phase 1 rego-file mock** `aiac-pdp-policy-opa`) + 2 ClusterIP Services | 7071, 7072 | +| `policy-store-statefulset.yaml` | Policy Store StatefulSet + 1 Gi PVC + headless Service + ClusterIP Service | 7074 | +| `agent-deployment.yaml` | Agent Pod Deployment (AIAC Agent) + ClusterIP Service | 7070 | + +## Prerequisites + +- Kubernetes cluster with `kubectl` configured for the target cluster. +- Keycloak reachable from within the cluster (default: `http://keycloak-service.keycloak.svc:8080`). +- For local Kind clusters: `kind` CLI and `docker`. + +## 1 — Build the images + +Run from the repo root (`kagenti-extensions/`): + +```bash +# IdP Configuration Service (Interface Pod container 1) +# Build context is the component directory (Dockerfile copies requirements.txt + main.py from there) +docker build -f aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile \ + -t localhost/aiac-pdp-config:local \ + aiac/src/aiac/idp/service/configuration/keycloak/ + +# PDP Policy Writer — Phase 1 OPA rego-file mock (Interface Pod container 2, writes .rego to filesystem) +# Build context is aiac/src/ (the OPA Dockerfile COPYs the whole tree and sets PYTHONPATH) +docker build -f aiac/src/aiac/pdp/service/policy/opa/Dockerfile \ + -t localhost/aiac-pdp-policy-opa:local \ + aiac/src/ + +# Policy Store +docker build -f aiac/src/aiac/policy/store/service/Dockerfile \ + -t localhost/aiac-policy-store:local aiac/src/ + +# AIAC Agent +docker build -f aiac/src/aiac/agent/controller/Dockerfile \ + -t localhost/aiac-agent:local aiac/src/ +``` + +## 2 — Load images into the cluster + +**Kind (local development)** + +```bash +kind load docker-image localhost/aiac-pdp-config:local --name +kind load docker-image localhost/aiac-pdp-policy-opa:local --name +kind load docker-image localhost/aiac-policy-store:local --name +kind load docker-image localhost/aiac-agent:local --name +``` + +**Remote registry** — tag, push, then update the `image:` fields in the manifests to match. + +> **Note:** the manifests set `imagePullPolicy: Never` because images are side-loaded +> into a local Kind cluster (dev only). For a real cluster that pulls from a registry, +> change these to `imagePullPolicy: IfNotPresent` (or `Always`). + +## 3 — Create the admin secret + +The Interface Pod requires a `keycloak-admin-secret` Secret. Create it once per cluster before applying the manifests: + +```bash +kubectl create secret generic keycloak-admin-secret \ + -n aiac-system \ + --from-literal=KEYCLOAK_ADMIN_USERNAME= \ + --from-literal=KEYCLOAK_ADMIN_PASSWORD= +``` + +> `pdp-interface-deployment.yaml` contains placeholder credentials for reference only. +> For any non-local environment, create the secret manually and remove the `stringData` block. + +## 3b — Configure the Agent LLM (ConfigMap + Secret) + +The AIAC Agent's Policy Rules Builder calls an **OpenAI-compatible** LLM endpoint +(`ChatOpenAI(base_url=LLM_BASE_URL, model=LLM_MODEL, api_key=LLM_API_KEY)`). This configuration is +split across two objects the Agent consumes via `envFrom`, and `agent-deployment.yaml` ships only +**placeholders** — you must supply the real values per environment: + +| Key | Object | Notes | +|-----|--------|-------| +| `LLM_BASE_URL` | `aiac-agent-config` ConfigMap | OpenAI-compatible base URL (e.g. a litellm proxy). Placeholder in the manifest. | +| `LLM_MODEL` | `aiac-agent-config` ConfigMap | Model the endpoint serves. Placeholder in the manifest. | +| `LLM_API_KEY` | `aiac-agent-secret` Secret | **Not** defined in any manifest — the Deployment only references it. | + +```bash +# API key — create the Secret BEFORE applying agent-deployment.yaml (step 5); the manifest only +# references it. (To update an existing one, append: --dry-run=client -o yaml | kubectl apply -f -) +kubectl create secret generic aiac-agent-secret -n aiac-system \ + --from-literal=LLM_API_KEY= + +# Endpoint + model — patch the LIVE ConfigMap AFTER step 5 (agent-deployment.yaml creates it with +# placeholders). Do not commit real endpoints/keys to the manifest. +kubectl patch configmap aiac-agent-config -n aiac-system --type merge \ + -p '{"data":{"LLM_BASE_URL":"https:///v1","LLM_MODEL":""}}' +``` + +Both are read by the Agent at startup, so a change to either takes effect on the next (re)start: +`kubectl rollout restart deployment/aiac-agent -n aiac-system`. + +## 4 — Configure the environment + +Edit the `aiac-pdp-config` ConfigMap in `pdp-interface-deployment.yaml` to match your environment: + +| Key | Default | Used by | +|-----|---------|---------| +| `KEYCLOAK_URL` | `http://keycloak-service.keycloak.svc:8080` | IdP Configuration Service, PDP Policy Writer | +| `KEYCLOAK_REALM` | `kagenti` | PDP Policy Writer | +| `KEYCLOAK_ADMIN_REALM` | `master` | IdP Configuration Service | +| `AIAC_PDP_CONFIG_URL` | `http://aiac-pdp-config-service:7071` | Agent | +| `AIAC_PDP_POLICY_URL` | `http://aiac-pdp-policy-service:7072` | Agent | +| `AIAC_POLICY_STORE_URL` | `http://aiac-policy-store-service:7074` | Agent | +| `SERVICEPOLICY_DB_PATH` | `/data/policy_model.db` | Policy Store | +| `NATS_URL` | `nats://aiac-event-broker-service:4222` | Agent — **added in Phase 2** (Event Broker, issue 4.19) | +| `AIAC_RAG_INGEST_URL` | `http://aiac-rag-service:7073` | Init container — **added in Phase 3** (RAG Pod, issue 4.20) | +| `AIAC_CHROMADB_URL` | `http://aiac-rag-service:8000` | Agent — **added in Phase 3** (RAG Pod, issue 4.20) | + +## 5 — Deploy + +Apply in dependency order: + +```bash +# 1. Interface Pod — creates the namespace, ConfigMap, Secret, and ClusterIP Services +kubectl apply -f aiac/k8s/pdp-interface-deployment.yaml + +# 2. Policy Store — needs the aiac-system namespace +kubectl apply -f aiac/k8s/policy-store-statefulset.yaml + +# 3. Agent — depends on the Interface Pod + Policy Store already being healthy +kubectl apply -f aiac/k8s/agent-deployment.yaml +``` + +Wait for all pods to be ready: + +```bash +kubectl wait deployment/aiac-interface -n aiac-system --for=condition=Available --timeout=120s +kubectl wait statefulset/aiac-policy-store -n aiac-system --for=jsonpath='{.status.readyReplicas}'=1 --timeout=120s +kubectl wait deployment/aiac-agent -n aiac-system --for=condition=Available --timeout=120s +``` + +## 6 — Verify + +Port-forward each service and check its health endpoint: + +```bash +# IdP Configuration Service +kubectl port-forward svc/aiac-pdp-config-service 7071:7071 -n aiac-system & +curl http://localhost:7071/health +# {"status":"ok"} + +# PDP Policy Writer +kubectl port-forward svc/aiac-pdp-policy-service 7072:7072 -n aiac-system & +curl http://localhost:7072/health +# {"status":"ok"} + +# Policy Store +kubectl port-forward svc/aiac-policy-store-service 7074:7074 -n aiac-system & +curl http://localhost:7074/health +# {"status":"ok"} + +# AIAC Agent +kubectl port-forward svc/aiac-agent-service 7070:7070 -n aiac-system & +curl http://localhost:7070/health +# {"status":"ok"} + +pkill -f "port-forward" +``` + +Run the IdP data smoke test: + +```bash +kubectl port-forward svc/aiac-pdp-config-service 7071:7071 -n aiac-system & +cd aiac +.venv/bin/python test/idp/configuration/show_keycloak_data.py +pkill -f "port-forward.*7071" +``` + +## Redeploying after a code change + +```bash +# Rebuild the changed image, e.g. IdP Configuration Service: +docker build -f aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile \ + -t localhost/aiac-pdp-config:local aiac/src/ +kind load docker-image localhost/aiac-pdp-config:local --name + +# Restart the affected deployment: +kubectl rollout restart deployment/aiac-interface -n aiac-system +``` + +--- + +## Phase 2: Upgrading the OPA PDP Policy Writer to the CR-backed implementation + +Phase 1 already deploys the OPA PDP Policy Writer (`aiac-pdp-policy-opa`) as a filesystem +stub that writes `.rego` files to `REGO_OUTPUT_DIR`. Phase 2 upgrades that same container +in place to the CR-backed implementation, which writes Rego packages to an +`AuthorizationPolicy` Kubernetes CR instead. The image name, ClusterIP Service name, and +port are unchanged — no image swap and no Agent reconfiguration required. + +See issue [4.18 — K8s: OPA PDP Policy Writer AuthorizationPolicy CR + RBAC upgrade](../docs/issues/deployment/4.18-k8s-opa-authorizationpolicy-rbac.md) for the full procedure (ServiceAccount, ClusterRole, ClusterRoleBinding, CR instance). + +```bash +# Rebuild the OPA PDP Policy Writer image with the Phase 2 (CR-backed) implementation +docker build -f aiac/src/aiac/pdp/service/policy/opa/Dockerfile \ + -t localhost/aiac-pdp-policy-opa:local aiac/src/ +kind load docker-image localhost/aiac-pdp-policy-opa:local --name +``` + +--- + +## Isolated dev: IdP Configuration Service only + +To test the IdP Configuration Service in isolation without deploying the full stack, use the standalone dev pod manifest: + +```bash +kubectl apply -f aiac/k8s/idp-configuration-keycloak-pod.yaml +kubectl wait pod/idp-configuration-keycloak-pod -n aiac-system \ + --for=condition=Ready --timeout=60s +``` + +See [idp-configuration-keycloak-pod.yaml](idp-configuration-keycloak-pod.yaml) for the minimal ConfigMap and pod spec. + +--- + +## IdP Configuration Service API reference + +All endpoints accept a `?realm=` query parameter. `/health` uses `KEYCLOAK_ADMIN_REALM` directly. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/subjects` | List users | +| GET | `/roles` | List realm roles | +| GET | `/services` | List clients | +| GET | `/scopes` | List client scopes | +| GET | `/subjects/{subject_id}/assignments` | Realm and service role mappings for a user | +| GET | `/services/{service_id}/permissions` | Client roles for a service | +| GET | `/roles/{role_name}/composites` | Composite roles for a realm role | +| GET | `/health` | Readiness probe — `200 ok` or `503 unavailable` | + +All list endpoints return a JSON array. `/subjects/{id}/assignments` returns: + +```json +{ + "realmMappings": [...], + "serviceMappings": { "": { "mappings": [...] } } +} +``` + +Errors from Keycloak are returned as `502` with `{"error": ""}`. diff --git a/aiac/k8s/idp-configuration-keycloak-pod.yaml b/aiac/k8s/idp-configuration-keycloak-pod.yaml new file mode 100644 index 000000000..4fcf3566d --- /dev/null +++ b/aiac/k8s/idp-configuration-keycloak-pod.yaml @@ -0,0 +1,57 @@ +# DEV / ISOLATED TESTING ONLY +# +# This manifest deploys the IdP Configuration Service as a standalone Pod for +# isolated development and testing. It does NOT reflect the production topology. +# +# In production both interface services run as containers in a single Kagenti Interface Pod +# defined in pdp-interface-deployment.yaml. + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: aiac-system + +--- +# NOTE: standalone-specific ConfigMap name. It deliberately does NOT reuse +# `aiac-pdp-config` (the name used by pdp-interface-deployment.yaml), which +# carries additional keys (KEYCLOAK_REALM, AIAC_PDP_*_URL). Sharing the name in +# the same namespace would let `kubectl apply` of this isolated manifest clobber +# the interface ConfigMap and break a running interface Pod on its next restart. +apiVersion: v1 +kind: ConfigMap +metadata: + name: aiac-pdp-config-standalone + namespace: aiac-system +data: + KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" + KEYCLOAK_ADMIN_REALM: "master" + +--- +# keycloak-admin-secret is NOT defined here — it must be pre-provisioned +# out-of-band before applying this manifest (credentials must NEVER be committed +# to git). The Pod below references it via secretRef. Create it once: +# +# kubectl create secret generic keycloak-admin-secret \ +# -n aiac-system \ +# --from-literal=KEYCLOAK_ADMIN_USERNAME= \ +# --from-literal=KEYCLOAK_ADMIN_PASSWORD= +apiVersion: v1 +kind: Pod +metadata: + name: idp-configuration-keycloak-pod + namespace: aiac-system + labels: + app: idp-configuration-keycloak-pod +spec: + containers: + - name: aiac-pdp-config + image: localhost/aiac-pdp-config:local + imagePullPolicy: Never + ports: + - containerPort: 7071 + envFrom: + - configMapRef: + name: aiac-pdp-config-standalone + - secretRef: + name: keycloak-admin-secret diff --git a/aiac/k8s/pdp-interface-deployment.yaml b/aiac/k8s/pdp-interface-deployment.yaml new file mode 100644 index 000000000..5a192b204 --- /dev/null +++ b/aiac/k8s/pdp-interface-deployment.yaml @@ -0,0 +1,125 @@ +# PHASE 1 LOCAL / KIND DEV TOPOLOGY +# +# This manifest uses node-local images (`localhost/...:local` + +# `imagePullPolicy: Never`), so it only runs on a cluster where every target +# node has been preloaded with those images (e.g. `kind load` / `podman save`). +# A production deployment must publish immutable, versioned images to the +# deployment registry and use `imagePullPolicy: IfNotPresent` — layer that in +# via a separate production overlay rather than editing this dev manifest. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: aiac-pdp-config + namespace: aiac-system +data: + KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" + KEYCLOAK_REALM: "kagenti" + KEYCLOAK_ADMIN_REALM: "master" + AIAC_PDP_CONFIG_URL: "http://aiac-pdp-config-service:7071" + AIAC_PDP_POLICY_URL: "http://aiac-pdp-policy-service:7072" + AIAC_POLICY_STORE_URL: "http://aiac-policy-store-service:7074" + # NATS_URL is added to this ConfigMap in Phase 2 (Event Broker, issue 4.19). + # AIAC_RAG_INGEST_URL and AIAC_CHROMADB_URL are added in Phase 3 (RAG Pod, issue 4.20). + +--- +# Preconditions (NOT created by this manifest): +# +# * The target namespace `aiac-system` must already exist +# (`kubectl create namespace aiac-system`). +# * The `keycloak-admin-secret` Secret must be pre-provisioned out-of-band — +# credentials must NEVER be committed to git. The Deployment below references +# it via secretRef. Create it once per cluster: +# +# kubectl create secret generic keycloak-admin-secret \ +# -n aiac-system \ +# --from-literal=KEYCLOAK_ADMIN_USERNAME= \ +# --from-literal=KEYCLOAK_ADMIN_PASSWORD= +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aiac-interface + namespace: aiac-system +spec: + replicas: 1 + selector: + matchLabels: + app: aiac-interface + template: + metadata: + labels: + app: aiac-interface + spec: + containers: + - name: aiac-pdp-config + image: localhost/aiac-pdp-config:local + imagePullPolicy: Never + ports: + - containerPort: 7071 + readinessProbe: + httpGet: + path: /health + port: 7071 + initialDelaySeconds: 5 + periodSeconds: 10 + envFrom: + - configMapRef: + name: aiac-pdp-config + - secretRef: + name: keycloak-admin-secret + # Phase 1 PDP Policy Writer: OPA rego-file mock. Generates Rego + # packages and writes them as .rego files to REGO_OUTPUT_DIR. It is a + # pure filesystem stub — no Keycloak, no k8s API — so it mounts neither + # keycloak-admin-secret nor any Keycloak credentials. The AuthorizationPolicy + # CR-backed variant (RBAC + env vars) is layered on later per issue 4.18. + - name: aiac-pdp-policy-opa + image: localhost/aiac-pdp-policy-opa:local + imagePullPolicy: Never + ports: + - containerPort: 7072 + readinessProbe: + httpGet: + path: /health + port: 7072 + initialDelaySeconds: 5 + periodSeconds: 10 + envFrom: + - configMapRef: + name: aiac-pdp-config + env: + - name: REGO_OUTPUT_DIR + value: /rego + volumeMounts: + - name: rego-output + mountPath: /rego + volumes: + - name: rego-output + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: aiac-pdp-config-service + namespace: aiac-system +spec: + selector: + app: aiac-interface + ports: + - name: idp-config + port: 7071 + targetPort: 7071 + +--- +apiVersion: v1 +kind: Service +metadata: + name: aiac-pdp-policy-service + namespace: aiac-system +spec: + selector: + app: aiac-interface + ports: + - name: pdp-policy + port: 7072 + targetPort: 7072 diff --git a/aiac/k8s/policy-store-statefulset.yaml b/aiac/k8s/policy-store-statefulset.yaml new file mode 100644 index 000000000..a13a77929 --- /dev/null +++ b/aiac/k8s/policy-store-statefulset.yaml @@ -0,0 +1,83 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: aiac-policy-store-config + namespace: aiac-system +data: + SERVICEPOLICY_DB_PATH: "/data/policy_model.db" + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: aiac-policy-store + namespace: aiac-system +spec: + replicas: 1 + serviceName: aiac-policy-store-headless + selector: + matchLabels: + app: aiac-policy-store + template: + metadata: + labels: + app: aiac-policy-store + spec: + containers: + - name: aiac-policy-store + image: localhost/aiac-policy-store:local + imagePullPolicy: Never + ports: + - containerPort: 7074 + readinessProbe: + httpGet: + path: /health + port: 7074 + initialDelaySeconds: 5 + periodSeconds: 10 + envFrom: + - configMapRef: + name: aiac-policy-store-config + volumeMounts: + - name: policy-store-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: policy-store-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +--- +# Headless Service for StatefulSet DNS +apiVersion: v1 +kind: Service +metadata: + name: aiac-policy-store-headless + namespace: aiac-system +spec: + clusterIP: None + selector: + app: aiac-policy-store + ports: + - name: policy-store + port: 7074 + targetPort: 7074 + +--- +apiVersion: v1 +kind: Service +metadata: + name: aiac-policy-store-service + namespace: aiac-system +spec: + selector: + app: aiac-policy-store + ports: + - name: policy-store + port: 7074 + targetPort: 7074 diff --git a/aiac/pyproject.toml b/aiac/pyproject.toml new file mode 100644 index 000000000..da102f1e1 --- /dev/null +++ b/aiac/pyproject.toml @@ -0,0 +1,15 @@ +[tool.ruff] +line-length = 120 +target-version = "py312" +# Apply excludes even to paths passed explicitly (e.g. by pre-commit / CI). +force-exclude = true + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.pytest.ini_options] +testpaths = ["test"] +pythonpath = ["src"] +markers = [ + "integration: tests that need live infra — a running Keycloak, Keycloak admin credentials, a real LLM endpoint, and the `opa` binary on PATH (see test/integration/.env)", +] diff --git a/aiac/pyrightconfig.json b/aiac/pyrightconfig.json new file mode 100644 index 000000000..603ec5e8a --- /dev/null +++ b/aiac/pyrightconfig.json @@ -0,0 +1,20 @@ +{ + "include": [ + "src", + "test" + ], + "extraPaths": [ + "src" + ], + "pythonVersion": "3.12", + "typeCheckingMode": "basic", + "executionEnvironments": [ + { + "root": "src", + "pythonVersion": "3.12", + "extraPaths": [ + "src" + ] + } + ] +} diff --git a/aiac/src/aiac/__init__.py b/aiac/src/aiac/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/__init__.py b/aiac/src/aiac/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/controller/Dockerfile b/aiac/src/aiac/agent/controller/Dockerfile new file mode 100644 index 000000000..3af54a6d8 --- /dev/null +++ b/aiac/src/aiac/agent/controller/Dockerfile @@ -0,0 +1,14 @@ +# Build context: aiac/src/ +FROM python:3.12-slim + +WORKDIR /app + +COPY aiac/agent/controller/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/src +ENV PYTHONPATH=/app/src + +EXPOSE 7070 + +CMD ["uvicorn", "aiac.agent.controller.routes:app", "--host", "0.0.0.0", "--port", "7070"] diff --git a/aiac/src/aiac/agent/controller/__init__.py b/aiac/src/aiac/agent/controller/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/controller/requirements.txt b/aiac/src/aiac/agent/controller/requirements.txt new file mode 100644 index 000000000..72fccbfcc --- /dev/null +++ b/aiac/src/aiac/agent/controller/requirements.txt @@ -0,0 +1,10 @@ +langgraph +langchain-openai +chromadb +tenacity +fastapi +uvicorn[standard] +requests +python-dotenv +kubernetes +nats-py diff --git a/aiac/src/aiac/agent/controller/routes.py b/aiac/src/aiac/agent/controller/routes.py new file mode 100644 index 000000000..e028cf19c --- /dev/null +++ b/aiac/src/aiac/agent/controller/routes.py @@ -0,0 +1,76 @@ +"""AIAC Agent Controller — FastAPI app factory + the four ``/apply/*`` routes. + +The Controller is stateless. Each route dispatches to its use-case handler +(orchestrator or sub-agent), receives the ``(list[PolicyRule], override)`` tuple +the handler returns, and makes the **single** ``compute_and_apply(rules, override)`` +call to the Policy Computation Engine. No per-use-case business logic, retry +handling, or state assembly lives here. + +Responses are bare HTTP status codes: ``200 OK`` on success (no body). Upstream +failures are raised as FastAPI ``HTTPException``s by the handlers; the status +code is authoritative (the accompanying default JSON error body is incidental). +""" + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import Response + +from aiac.agent.uc.offboarding.offboard import offboard_service +from aiac.agent.uc.onboarding.orchestrator import onboard_service +from aiac.agent.uc.policy_update.build import build_policy +from aiac.agent.uc.policy_update.rebuild import rebuild_policy +from aiac.agent.uc.role_update.role import update_role +from aiac.policy.computation import compute_and_apply, decommission + +app = FastAPI() + + +@app.post("/apply/service/{service_id}") +def apply_service(service_id: str) -> Response: + rules, override = onboard_service(service_id) + compute_and_apply(rules, override) + return Response(status_code=200) + + +@app.post("/apply/policy/build") +def apply_policy_build() -> Response: + rules, override = build_policy() + compute_and_apply(rules, override) + return Response(status_code=200) + + +@app.post("/apply/policy/rebuild") +def apply_policy_rebuild() -> Response: + rules, override = rebuild_policy() + compute_and_apply(rules, override) + return Response(status_code=200) + + +@app.post("/apply/role/{role_id}") +def apply_role(role_id: str) -> Response: + rules, override = update_role(role_id) + compute_and_apply(rules, override) + return Response(status_code=200) + + +# Offboard is keyed by the clientId (the SPM key), NOT the Keycloak internal UUID that +# /apply/service/{service_id} carries: an offboarded client is gone from get_services(), so +# UUID→clientId resolution is impossible. The {service_id:path} converter carries slash-bearing +# SPIFFE-URI clientIds. Decommission is a whole-service teardown, so — BY DESIGN — it does NOT +# go through the (rules, override) → compute_and_apply path the other /apply/* routes share. +# compute_and_apply folds *incremental* rule updates into the SPM store; decommission is an +# authoritative offboard that must tear down a service's entire policy footprint (its SPM, its +# outbound edges on other SPMs, and its APM), which is not expressible as a rule delta. So this +# route intentionally calls the PCE's decommission() directly. See the implementation plan. +@app.post("/apply/offboard/{service_id:path}") +def apply_offboard(service_id: str) -> Response: + decommission(offboard_service(service_id)) + return Response(status_code=200) + + +def main() -> None: + uvicorn.run(app, host="0.0.0.0", port=7070) + + +if __name__ == "__main__": + main() diff --git a/aiac/src/aiac/agent/policy_rules_builder/__init__.py b/aiac/src/aiac/agent/policy_rules_builder/__init__.py new file mode 100644 index 000000000..41e55d591 --- /dev/null +++ b/aiac/src/aiac/agent/policy_rules_builder/__init__.py @@ -0,0 +1,3 @@ +from .graph import build_role_rules, build_scope_rules + +__all__ = ["build_role_rules", "build_scope_rules"] diff --git a/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md b/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md new file mode 100644 index 000000000..31370a265 --- /dev/null +++ b/aiac/src/aiac/agent/policy_rules_builder/generic_policy.md @@ -0,0 +1,6 @@ +# Generic Access Control Policy + +This baseline policy applies to every policy decision, on top of the +scenario-specific policy that follows it. Read both together as one policy. + +- The agent's internal operator roles are each confined to their own domain: grant every operator role exactly the target operations — where a target is a tool the agent calls, or another agent it calls — within the domain it is responsible for, and nothing outside that domain. diff --git a/aiac/src/aiac/agent/policy_rules_builder/graph.py b/aiac/src/aiac/agent/policy_rules_builder/graph.py new file mode 100644 index 000000000..1576867bf --- /dev/null +++ b/aiac/src/aiac/agent/policy_rules_builder/graph.py @@ -0,0 +1,262 @@ +"""Policy Rules Builder graph: fetch -> propose -> precheck -> audit -> build. + +Hides LangGraph behind two plain functions (build_role_rules / build_scope_rules) +that return list[PolicyRule]. The LLM is built lazily (never at import) and every +structured call is transport-retried via a call-time tenacity Retrying. On failure +the builder RAISES (policy-source failure, LLM failure after retries, audit-budget +exhaustion) -- never a silent []. An auditor-approved empty selection is a valid []. +""" + +import logging +import os +from typing import Any, TypedDict, TypeVar, cast + +from langchain_core.messages import BaseMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, StateGraph +from pydantic import BaseModel, SecretStr +from tenacity import Retrying, retry_if_exception, stop_after_attempt, wait_exponential + +from aiac.idp.configuration.models import Role, Scope +from aiac.policy.model.models import PolicyRule +from aiac.shared.upstream import is_transient, max_retries + +from .policy_source import get_policy_source +from .prompts import build_auditor_messages, build_proposer_messages + +logger = logging.getLogger(__name__) +MAX_AUDIT_RETRIES = 3 + + +class _Selection(BaseModel): + """Common proposer-output shape; the direction-specific names field is read + by name (names_field) while reasoning is accessed directly.""" + + reasoning: str + + +class RoleSelection(_Selection): + granted_scope_names: list[str] + + +class ScopeSelection(_Selection): + roles_with_access_names: list[str] + + +class AuditVerdict(BaseModel): + approved: bool + reason: str | None = None + + +class PolicyRulesBuilderError(RuntimeError): ... + + +class _PRBWorking(TypedDict): + policy_text: str + selected_names: list[str] + reasoning: str + approved: bool + audit_feedback: str | None + retry_count: int + rules: list[PolicyRule] + + +class RoleRulesState(_PRBWorking): + role: Role + scopes: list[Scope] + + +class ScopeRulesState(_PRBWorking): + roles: list[Role] + scope: Scope + + +def _build_llm() -> ChatOpenAI: # lazy -- NEVER called at import + return ChatOpenAI( + base_url=os.getenv("LLM_BASE_URL"), + model=os.getenv("LLM_MODEL", ""), + api_key=SecretStr(os.getenv("LLM_API_KEY", "")), + temperature=0, + ) + + +T = TypeVar("T", bound=BaseModel) + + +def _structured_call(schema: type[T], messages: list[BaseMessage]) -> T: + """THE seam. Behavior tests patch this. Transport-retries each .invoke() via call-time Retrying. + + Only transient failures (connection errors / timeouts / 5xx) are retried — a permanent + failure (e.g. a bad request or a validation error) fails identically on every attempt, so + it is surfaced immediately (consistent with ``aiac.shared.upstream``).""" + runnable = _build_llm().with_structured_output(schema) + retryer = Retrying( + retry=retry_if_exception(is_transient), + stop=stop_after_attempt(max_retries()), + wait=wait_exponential(multiplier=1, min=1, max=30), + reraise=True, + ) + return cast(T, retryer(runnable.invoke, messages)) + + +# shared node helpers (typed against _PRBWorking; direction specifics passed as kwargs) +def _fetch(state: _PRBWorking) -> dict[str, Any]: + return {"policy_text": get_policy_source().fetch()} + + +def _propose( + state: _PRBWorking, + *, + focal: str, + candidates: str, + contract: str, + schema: type[_Selection], + names_field: str, +) -> dict[str, Any]: + msgs = build_proposer_messages(state["policy_text"], focal, candidates, contract, state["audit_feedback"]) + sel = _structured_call(schema, msgs) + return {"selected_names": list(getattr(sel, names_field)), "reasoning": sel.reasoning} + + +def _precheck(state: _PRBWorking, *, candidate_names: set[str]) -> dict[str, Any]: + keep = [n for n in state["selected_names"] if n in candidate_names] + dropped = [n for n in state["selected_names"] if n not in candidate_names] + if dropped: + logger.warning("PRB precheck dropped hallucinated names: %s", dropped) + return {"selected_names": keep} + + +def _audit(state: _PRBWorking, *, focal: str, candidates: str) -> dict[str, Any]: + verdict = _structured_call( + AuditVerdict, + build_auditor_messages(state["policy_text"], focal, candidates, state["selected_names"]), + ) + if verdict.approved: + return {"approved": True} + if state["retry_count"] >= MAX_AUDIT_RETRIES: + raise PolicyRulesBuilderError(f"Auditor rejected after {MAX_AUDIT_RETRIES} retries: {verdict.reason}") + return {"approved": False, "audit_feedback": verdict.reason, "retry_count": state["retry_count"] + 1} + + +def _route(state: _PRBWorking) -> str: + return "approved" if state["approved"] else "rejected" + + +def _role_focal(r: Role) -> str: + return f"role name={r.name}: {r.description or ''}" + + +def _scope_focal(s: Scope) -> str: + return f"scope name={s.name}: {s.description or ''}" + + +def _scope_cands(ss: list[Scope]) -> str: + return "\n".join(_scope_focal(s) for s in ss) + + +def _role_cands(rs: list[Role]) -> str: + return "\n".join(_role_focal(r) for r in rs) + + +_ROLE_CONTRACT = "Return granted_scope_names (subset of candidate scope names) + reasoning." +_SCOPE_CONTRACT = "Return roles_with_access_names (subset of candidate role names) + reasoning." + + +def _assemble(state_type: type, propose, precheck, audit, build): + """Wire the shared fetch -> propose -> precheck -> audit -> build shape with the + audit -> propose retry edge. Both directions differ only in their four closures.""" + g = StateGraph(state_type) + g.add_node("fetch", _fetch) + g.add_node("propose", propose) + g.add_node("precheck", precheck) + g.add_node("audit", audit) + g.add_node("build", build) + g.add_edge(START, "fetch") + g.add_edge("fetch", "propose") + g.add_edge("propose", "precheck") + g.add_edge("precheck", "audit") + g.add_conditional_edges("audit", _route, {"approved": "build", "rejected": "propose"}) + g.add_edge("build", END) + return g.compile() + + +def build_role_graph(): + def propose(s: RoleRulesState) -> dict[str, Any]: + return _propose( + s, + focal=_role_focal(s["role"]), + candidates=_scope_cands(s["scopes"]), + contract=_ROLE_CONTRACT, + schema=RoleSelection, + names_field="granted_scope_names", + ) + + def precheck(s: RoleRulesState) -> dict[str, Any]: + return _precheck(s, candidate_names={sc.name for sc in s["scopes"]}) + + def audit(s: RoleRulesState) -> dict[str, Any]: + return _audit(s, focal=_role_focal(s["role"]), candidates=_scope_cands(s["scopes"])) + + def build(s: RoleRulesState) -> dict[str, Any]: + granted = set(s["selected_names"]) + return {"rules": [PolicyRule(role=s["role"], scope=sc) for sc in s["scopes"] if sc.name in granted]} + + return _assemble(RoleRulesState, propose, precheck, audit, build) + + +def build_scope_graph(): + def propose(s: ScopeRulesState) -> dict[str, Any]: + return _propose( + s, + focal=_scope_focal(s["scope"]), + candidates=_role_cands(s["roles"]), + contract=_SCOPE_CONTRACT, + schema=ScopeSelection, + names_field="roles_with_access_names", + ) + + def precheck(s: ScopeRulesState) -> dict[str, Any]: + return _precheck(s, candidate_names={r.name for r in s["roles"]}) + + def audit(s: ScopeRulesState) -> dict[str, Any]: + return _audit(s, focal=_scope_focal(s["scope"]), candidates=_role_cands(s["roles"])) + + def build(s: ScopeRulesState) -> dict[str, Any]: + granted = set(s["selected_names"]) + return {"rules": [PolicyRule(role=r, scope=s["scope"]) for r in s["roles"] if r.name in granted]} + + return _assemble(ScopeRulesState, propose, precheck, audit, build) + + +ROLE_GRAPH = build_role_graph() # module-level compile is safe (never builds the LLM) +SCOPE_GRAPH = build_scope_graph() + + +def build_role_rules(role: Role, scopes: list[Scope]) -> list[PolicyRule]: + state: RoleRulesState = { + "role": role, + "scopes": scopes, + "policy_text": "", + "selected_names": [], + "reasoning": "", + "approved": False, + "audit_feedback": None, + "retry_count": 0, + "rules": [], + } + return ROLE_GRAPH.invoke(state)["rules"] + + +def build_scope_rules(roles: list[Role], scope: Scope) -> list[PolicyRule]: + state: ScopeRulesState = { + "roles": roles, + "scope": scope, + "policy_text": "", + "selected_names": [], + "reasoning": "", + "approved": False, + "audit_feedback": None, + "retry_count": 0, + "rules": [], + } + return SCOPE_GRAPH.invoke(state)["rules"] diff --git a/aiac/src/aiac/agent/policy_rules_builder/policy_source.py b/aiac/src/aiac/agent/policy_rules_builder/policy_source.py new file mode 100644 index 000000000..97d1cec3b --- /dev/null +++ b/aiac/src/aiac/agent/policy_rules_builder/policy_source.py @@ -0,0 +1,27 @@ +"""Policy-text sources for the Policy Rules Builder. + +Phase 1 reads the whole policy file from ``AIAC_POLICY_FILE``. The ``PolicySource`` +protocol is the seam Phase 2 (ChromaDB RAG) plugs a ``ChromaPolicySource`` into, +without changing callers. +""" + +import os +from pathlib import Path +from typing import Protocol + +_DEFAULT_POLICY_FILE = "/etc/aiac/policy.md" + + +class PolicySource(Protocol): + def fetch(self) -> str: ... + + +class FilePolicySource: + def fetch(self) -> str: + path = Path(os.getenv("AIAC_POLICY_FILE", _DEFAULT_POLICY_FILE)) + # Missing / unreadable -> OSError propagates (no retry, no silent []). + return path.read_text(encoding="utf-8") + + +def get_policy_source() -> PolicySource: + return FilePolicySource() diff --git a/aiac/src/aiac/agent/policy_rules_builder/prompts.py b/aiac/src/aiac/agent/policy_rules_builder/prompts.py new file mode 100644 index 000000000..83155c4b1 --- /dev/null +++ b/aiac/src/aiac/agent/policy_rules_builder/prompts.py @@ -0,0 +1,108 @@ +"""Lean proposer/auditor message builders for both PRB directions. + +No worked examples, no scenario domain content. The static system message carries +the task framing plus two shared safety meta-rules (deny-by-default / policy-silence, +and stay strictly scoped to the single focal entity) and two shared mapping rules +(capability projection and relationship scoping — see _MAPPING_RULES). Both the +proposer and the auditor reason under the same rules because both make the same +grant decision. Everything variable — policy text, focal entity, candidates, and +any auditor feedback — goes in the user message so it is observable in tests. + +The user message's POLICY block is built from three layers, in order: the +least-privilege deny-by-default directive (``_GRANT_ACCESS`` here in the prompt), +the bundled generic baseline policy (``generic_policy.md`` — agent operator-role +domain confinement, applies to every policy decision), and finally the scenario policy +text. Factoring the universal clauses out of the file means a scenario policy file +only has to state what is specific to that scenario; the baseline is always present. +""" + +from pathlib import Path + +from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage + +# Least-privilege framing that heads every POLICY block, before any file-based policy. +_GRANT_ACCESS = "Grant access on a least-privilege basis: allow only what this policy states; deny by default." + +# Generic baseline policy, bundled next to this module and prepended to every scenario policy. +# Loaded once at import (static, domain-agnostic — safe to read eagerly). +_GENERIC_POLICY = (Path(__file__).parent / "generic_policy.md").read_text(encoding="utf-8").strip() + + +def _policy_block(policy_text: str) -> str: + """Compose the POLICY block: least-privilege directive, then the generic baseline, then the + scenario policy.""" + return f"{_GRANT_ACCESS}\n\n{_GENERIC_POLICY}\n\n{policy_text}" + +_SAFETY = ( + "Rules:\n" + "1) Deny by default: grant a pair only when it is supported by evidence about the focal entity " + "itself — either the provided policy, OR the focal entity's and the candidate's own descriptions " + "establishing that the candidate performs an operation the scope covers (see rule 3). Policy " + "silence is not by itself a reason to deny a pair the descriptions already establish, nor a " + "license to grant one they do not; when neither the policy nor those descriptions support the " + "pair, do not grant. A statement about any OTHER entity is never support (see rule 4).\n" + "2) Stay strictly scoped to the single focal entity described below; ignore anything else." +) + +# Shared mapping rules appended to BOTH system messages so the proposer and the auditor decide grants +# under the same reasoning (a proposer-only or auditor-only rule lets the two sides diverge). +# +# Rule 3 (capability projection): a scope/capability names a SET of operations; any one covered +# operation established for a candidate — by the policy OR by the focal/candidate descriptions — +# grants the whole scope (read-only still counts). Without this, a candidate with partial access to +# a capability's domain is wrongly dropped — e.g. a read-only "consults issues" subject failing to +# earn the issue-management agent scope. +# Rule 4 (relationship scoping): a policy may state several distinct access relationships over the +# same entities. Each grant is judged by what the policy/descriptions establish for THAT candidate +# in relation to the focal entity; a statement about an entity that is NEITHER the focal entity NOR +# a candidate describes a different relationship and is never evidence. The "neither focal nor +# candidate" scoping matters in BOTH directions: focal=scope/candidates=roles (mapping a/b) and +# focal=role/candidates=scopes (mapping c) — in the latter the focal role's OWN description must +# still count, so it must not be treated as a non-candidate to ignore. Without this a scope that +# appears in two relationships bleeds across them — wrongly rejecting a single-subject grant, or +# inventing an agent-role grant from an unrelated subject statement. +_MAPPING_RULES = ( + "\n3) A scope or capability names a set of operations (see its description). Grant it to a " + "candidate when the policy — or the focal entity's and the candidate's own descriptions — shows " + "that candidate performs ANY operation the scope covers; partial access (e.g. read-only) still " + "grants the scope. A candidate shown to perform no covered operation is denied (rule 1).\n" + "4) A policy may describe several different access relationships over the same entities. Judge " + "each candidate independently, by what the policy or the descriptions establish for THAT candidate " + "in relation to the focal entity. Base each grant only on evidence about that specific candidate " + "and the focal entity; a statement about any OTHER entity — even one sharing the same domain or " + "theme (e.g. a differently-named role or subject with related access) — concerns a different " + "relationship and is never evidence for or against the grant, even when it names the focal entity " + "or the scope." +) + +_PROPOSER_SYSTEM = "You map access policy to concrete grants.\n" + _SAFETY + _MAPPING_RULES +_AUDITOR_SYSTEM = ( + "You audit a proposed set of grants. Approve only if every granted pair is " + "policy-supported and nothing unsupported slipped in.\n" + _SAFETY + _MAPPING_RULES +) + + +def build_proposer_messages( + policy_text: str, + focal: str, + candidates: str, + contract: str, + audit_feedback: str | None, +) -> list[BaseMessage]: + body = f"POLICY:\n{_policy_block(policy_text)}\n\nFOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n{contract}" + if audit_feedback: + body += f"\n\nA prior proposal was REJECTED. Fix per this feedback:\n{audit_feedback}" + return [SystemMessage(content=_PROPOSER_SYSTEM), HumanMessage(content=body)] + + +def build_auditor_messages( + policy_text: str, + focal: str, + candidates: str, + selected_names: list[str], +) -> list[BaseMessage]: + body = ( + f"POLICY:\n{_policy_block(policy_text)}\n\nFOCAL ENTITY:\n{focal}\n\nCANDIDATES:\n{candidates}\n\n" + f"PROPOSED SELECTION (names): {selected_names}" + ) + return [SystemMessage(content=_AUDITOR_SYSTEM), HumanMessage(content=body)] diff --git a/aiac/src/aiac/agent/shared/__init__.py b/aiac/src/aiac/agent/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/shared/roles.py b/aiac/src/aiac/agent/shared/roles.py new file mode 100644 index 000000000..c5f92db8f --- /dev/null +++ b/aiac/src/aiac/agent/shared/roles.py @@ -0,0 +1,28 @@ +"""Shared agent helpers for working with IdP roles.""" + +from aiac.idp.configuration.models import Role + + +def flatten_role(role: Role) -> list[Role]: + """Closure of a role: the role itself plus all descendants. + + Recurses via ``role.childRoles``, de-duplicated by ``role.id``. ``Role`` is + not hashable, so seen ids are tracked rather than adding ``Role`` objects to + a set. A non-composite role yields ``[role]``. + + Pure function — no IdP call. ``role.childRoles`` is already populated on the + ``Role`` objects read from ``aiac.idp.configuration``. + """ + closure: list[Role] = [] + seen: set[str] = set() + + def _visit(current: Role) -> None: + if current.id in seen: + return + seen.add(current.id) + closure.append(current) + for child in current.childRoles: + _visit(child) + + _visit(role) + return closure diff --git a/aiac/src/aiac/agent/uc/__init__.py b/aiac/src/aiac/agent/uc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/offboarding/__init__.py b/aiac/src/aiac/agent/uc/offboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/offboarding/offboard.py b/aiac/src/aiac/agent/uc/offboarding/offboard.py new file mode 100644 index 000000000..e19f6b829 --- /dev/null +++ b/aiac/src/aiac/agent/uc/offboarding/offboard.py @@ -0,0 +1,19 @@ +"""Service Offboarding sub-agent (UC4) — stub. + +Counterpart to Service Onboarding. Where onboarding *adds* a service's policy +footprint, offboarding *removes* it: the Controller's ``/apply/offboard`` route +resolves the service key here and then calls the PCE's ``decommission`` directly +(no ``compute_and_apply`` / ``(rules, override)`` tuple — decommission is a +whole-service teardown, not a rule fold). + +Identity asymmetry with onboard. Onboarding is keyed by the Keycloak **internal +UUID** (``Service.id``) and resolves it to the clientId via ``get_services()``. +Offboarding cannot: an offboarded client is gone from ``get_services()``, so +UUID→clientId resolution is impossible. The offboard contract therefore carries +the **clientId (the SPM key)** directly, and this stub returns it unchanged. +Full validation/resolution lands with the UC4 implementation (issue 3.21). +""" + + +def offboard_service(service_id: str) -> str: + return service_id diff --git a/aiac/src/aiac/agent/uc/onboarding/__init__.py b/aiac/src/aiac/agent/uc/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/onboarding/orchestrator.py b/aiac/src/aiac/agent/uc/onboarding/orchestrator.py new file mode 100644 index 000000000..1144082fd --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/orchestrator.py @@ -0,0 +1,32 @@ +"""Service Onboarding Orchestrator (UC1). + +The only use case with an Orchestrator, because it is a **two-stage** pipeline. Invoked by +the Controller for the ``aiac.apply.service.{id}`` / ``POST /apply/service/{service_id}`` +trigger, it sequences the two sub-agents and returns ``(list[PolicyRule], override=False)``: + + 1. Service Provision — classifies the service and writes its roles/scopes into the IdP, + producing the discovered ``service_type``. + 2. Service Policy Builder — reads the excluded-self IdP universe and builds the rules. + +There is **no Apply stage** here: the Controller makes the single +``compute_and_apply(rules, override=False)`` (PCE) call. UC1 is incremental, so ``override`` +is always ``False`` (append; existing roles keep their other access). + +**Replay safety (at-least-once delivery):** Provision IdP writes are idempotent and the PCE +reconcile is idempotent, so a crash between stages simply re-runs the full pipeline to +convergence on NATS redelivery. No rollback logic. +""" + +from aiac.agent.uc.onboarding.policy_builder.builder import ServicePolicyBuilder +from aiac.agent.uc.onboarding.provision.graph import build_provision_graph +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger +from aiac.policy.model.models import PolicyRule + + +def onboard_service(service_id: str) -> tuple[list[PolicyRule], bool]: + provision = build_provision_graph().invoke( + OnboardingProvisionState(trigger=Trigger(entity_id=service_id)) + ) + service_type = provision["service_type"] + rules = ServicePolicyBuilder.build(service_id, service_type) + return rules, False diff --git a/aiac/src/aiac/agent/uc/onboarding/policy_builder/__init__.py b/aiac/src/aiac/agent/uc/onboarding/policy_builder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/onboarding/policy_builder/builder.py b/aiac/src/aiac/agent/uc/onboarding/policy_builder/builder.py new file mode 100644 index 000000000..f0a536c28 --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/policy_builder/builder.py @@ -0,0 +1,118 @@ +"""Service Policy Builder sub-agent (UC1). + +Second of the two stages sequenced by the Service Onboarding Orchestrator, run after +Service Provision. Deterministic (non-LLM): sources candidates from the same worldview as +the Policy Computation Engine — ``get_services()`` for correct ``kind``/ownership, plus +``get_subjects()`` for membership-derived user roles — flattens roles to their closure via +``flatten_role`` (3.2) before any PRB call, invokes the Policy Rules Builder for each +applicable pair, and returns a single ``list[PolicyRule]``. It applies nothing — the +Orchestrator/Controller make the single ``compute_and_apply`` (PCE) call afterwards. + +IdP access is via the **idp-library** ``Configuration`` (the ``_config`` seam), never the +IdP Configuration Service directly. The focus service is resolved from ``get_services()`` +by ``id`` (the Keycloak internal client UUID the ``/apply/service/{id}`` route and +``Trigger.entity_id`` carry — **not** ``serviceId``/clientId, which may be a slash-bearing +SPIFFE URI), so its own roles/scopes are id-bearing ``Role``/``Scope`` usable as PRB inputs +and flattenable. + +Candidates are excluded/included by **ownership** (role id / ``scope.serviceId``), never by +name: the focus service's own ``aiac.managed`` roles/scopes are never candidates; other +services' ``aiac.managed`` roles carry ``kind=Agent``; realm roles held by at least one user +(composite-expanded, and not owned by any service) carry ``kind=User``. This keeps +``subject_roles``/``source_roles`` routing correct downstream in the PCE. +""" + +from fastapi import HTTPException + +from aiac.agent.policy_rules_builder.graph import build_role_rules, build_scope_rules +from aiac.agent.shared.roles import flatten_role +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import Role, ServiceType +from aiac.policy.model.models import PolicyRule + + +def _config() -> Configuration: + return Configuration.for_default_realm() + + +def _flatten_dedup(roles): + """Union of every role's closure, de-duplicated by ``role.id``.""" + out = [] + seen: set[str] = set() + for role in roles: + for member in flatten_role(role): + if member.id not in seen: + seen.add(member.id) + out.append(member) + return out + + +class ServicePolicyBuilder: + @staticmethod + def build(service_id: str, service_type: ServiceType) -> list[PolicyRule]: + config = _config() + + try: + services = config.get_services() + subjects = config.get_subjects() + except Exception as e: + raise HTTPException( + 502, f"IdP Configuration Service unavailable for service {service_id!r}: {e}" + ) + + # The trigger id is the Keycloak internal client UUID (Service.id), not the human-readable + # clientId (Service.serviceId): the /apply/service/{id} route is keyed on the UUID because a + # clientId can be a slash-bearing SPIFFE URI the single-segment route cannot carry. + focus = next((s for s in services if s.id == service_id), None) + if focus is None: + raise HTTPException(404, f"service {service_id!r} not found in IdP catalog") + + own_roles = [r for r in focus.roles if r.aiac_managed] + own_scopes = [s for s in focus.scopes if s.aiac_managed] + + # kind=Agent rides through unchanged from get_services() → routes to source_roles in the PCE. + other_agent_roles = [ + r + for other in services + if other.serviceId != focus.serviceId + for r in other.roles + if r.aiac_managed + ] + + # User roles are membership-derived, not aiac.managed: a realm role qualifies iff a user + # holds it directly or via a composite parent they hold, and no service owns it. + service_owned_ids = {r.id for s in services for r in s.roles} + user_roles_by_id: dict[str, Role] = {} + for subject in subjects: + for role in subject.roles: + # NB: flatten_role on an agent composite role would yield children whose kind + # defaults to User (composites endpoint doesn't carry per-service kind) — a latent + # edge case if a user is ever assigned a composite agent role. Not hit here. + for member in flatten_role(role): + if member.id not in service_owned_ids: + user_roles_by_id[member.id] = member + user_roles = list(user_roles_by_id.values()) + + # Other services' aiac.managed scopes, sourced from get_services() (mirroring + # other_agent_roles) so each scope carries its owning serviceId — the SPM routing key the + # PCE needs. The global get_scopes() endpoint returns scopes with an empty serviceId, which + # would both (a) fail to exclude the focus's own scopes (``"" != focus.serviceId`` is always + # true) and (b) route any resulting rule to ``SPM("")``, a 422 dead-end. + other_scopes = [ + s + for other in services + if other.serviceId != focus.serviceId + for s in other.scopes + if s.aiac_managed + ] + + candidate_roles = _flatten_dedup(user_roles + other_agent_roles) + + rules: list[PolicyRule] = [] + for scope in own_scopes: + rules.extend(build_scope_rules(candidate_roles, scope)) + if service_type is ServiceType.AGENT: + for own_role in own_roles: + for role in flatten_role(own_role): + rules.extend(build_role_rules(role, other_scopes)) + return rules diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/__init__.py b/aiac/src/aiac/agent/uc/onboarding/provision/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/graph.py b/aiac/src/aiac/agent/uc/onboarding/provision/graph.py new file mode 100644 index 000000000..a11e64b60 --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/provision/graph.py @@ -0,0 +1,37 @@ +"""Compiled StateGraph for the Service Provision sub-agent (UC1). + + START -> classify_service -> [analyze_agent | analyze_tool] -> provision_service -> END + +The conditional edge routes on `service_type` (set by `classify_service`). All nodes are +non-LLM; no LLM is built here. +""" + +from langgraph.graph import END, START, StateGraph + +from aiac.idp.configuration.models import ServiceType + +from .nodes import analyze_agent, analyze_tool, classify_service, provision_service +from .state import OnboardingProvisionState + + +def _route(state: OnboardingProvisionState) -> str: + return "analyze_agent" if state.service_type is ServiceType.AGENT else "analyze_tool" + + +def build_provision_graph(): + g = StateGraph(OnboardingProvisionState) + g.add_node("classify_service", classify_service) + g.add_node("analyze_agent", analyze_agent) + g.add_node("analyze_tool", analyze_tool) + g.add_node("provision_service", provision_service) + + g.add_edge(START, "classify_service") + g.add_conditional_edges( + "classify_service", + _route, + {"analyze_agent": "analyze_agent", "analyze_tool": "analyze_tool"}, + ) + g.add_edge("analyze_agent", "provision_service") + g.add_edge("analyze_tool", "provision_service") + g.add_edge("provision_service", END) + return g.compile() diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/kube.py b/aiac/src/aiac/agent/uc/onboarding/provision/kube.py new file mode 100644 index 000000000..fb10177a4 --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/provision/kube.py @@ -0,0 +1,63 @@ +"""Kubernetes access seam for the Service Provision sub-agent (UC1). + +Owns the `kubernetes` client seams (`_core_v1`, `_custom_objects`, `_load_kube_config`) and +exposes the small set of read operations the provision nodes need. Each operation wraps its +client call in ``run_upstream`` so transient API failures are retried at the transport +boundary — the nodes call these plainly and only map the final failure to +``HTTPException(502)``. Unit tests patch the ``_core_v1`` / ``_custom_objects`` seams here. +""" + +from kubernetes import client, config + +from aiac.shared.upstream import run_upstream + +_AGENTCARD_GROUP = "agent.kagenti.dev" +_AGENTCARD_VERSION = "v1alpha1" +_AGENTCARD_PLURAL = "agentcards" + + +# --------------------------------------------------------------------------- # +# Seams (patched in unit tests) # +# --------------------------------------------------------------------------- # +def _load_kube_config() -> None: + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + + +def _core_v1(): + """CoreV1Api client (pods, services).""" + _load_kube_config() + return client.CoreV1Api() + + +def _custom_objects(): + """CustomObjectsApi client (AgentCard CRs).""" + _load_kube_config() + return client.CustomObjectsApi() + + +# --------------------------------------------------------------------------- # +# Retrying operations # +# --------------------------------------------------------------------------- # +def list_pods(namespace: str | None): + """Pods in ``namespace`` (the ``.items`` list), with bounded transport retries.""" + return run_upstream(lambda: _core_v1().list_namespaced_pod(namespace).items) + + +def read_service(name: str | None, namespace: str | None): + """A single Service by name, with bounded transport retries.""" + return run_upstream(lambda: _core_v1().read_namespaced_service(name, namespace)) + + +def list_agentcards(namespace: str | None) -> dict: + """List AgentCard CRs in ``namespace`` (raw dict response), with bounded transport retries.""" + return run_upstream( + lambda: _custom_objects().list_namespaced_custom_object( + group=_AGENTCARD_GROUP, + version=_AGENTCARD_VERSION, + namespace=namespace, + plural=_AGENTCARD_PLURAL, + ) + ) diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/nodes.py b/aiac/src/aiac/agent/uc/onboarding/provision/nodes.py new file mode 100644 index 000000000..d2ec7687a --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/provision/nodes.py @@ -0,0 +1,289 @@ +"""Nodes for the Service Provision sub-agent (UC1). + +All nodes are **non-LLM**. Graph: + + START -> classify_service -> [analyze_agent | analyze_tool] -> provision_service -> END + +IdP access is via the **idp-library** `Configuration` (the `_config` seam), never the IdP +service directly. Kubernetes access is via the `kube` seam module (`list_pods`, +`read_service`, `list_agentcards`), which retries internally. Any upstream failure surfaces +as an `HTTPException(502, ...)` whose message names the workload and the specific +missing/invalid label — actionable, never silent. +""" + +from fastapi import HTTPException + +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import ServiceType +from aiac.shared.upstream import run_upstream + +from .kube import list_agentcards, list_pods, read_service +from .state import OnboardingProvisionState +from .types import RoleDefinition, ScopeDefinition, ServiceProvision + +_TYPE_LABEL = "kagenti.io/type" +_MCP_LABEL = "protocol.kagenti.io/mcp" + + +# --------------------------------------------------------------------------- # +# Seams (patched in unit tests) # +# --------------------------------------------------------------------------- # +def _config() -> Configuration: + return Configuration.for_default_realm() + + +def _discovery_token(service_id: str) -> str: + """Mint a tool-audienced discovery bearer token via the idp-library `Configuration` seam + (never Keycloak directly). The config service holds the admin creds and does the minting.""" + return _config().mint_discovery_token(service_id) + + +# (connect, read) timeouts for the MCP discovery probe — an unreachable/hanging tool must not +# block the onboarding request indefinitely (there was previously no timeout). +_MCP_TIMEOUT = (5, 30) + + +def _mcp_tools_list(endpoint: str, token: str | None = None) -> list[dict]: + """POST a JSON-RPC `tools/list` to an MCP endpoint and return the tool manifest list. + Each tool is a dict with `name` and (optional) `description`. When `token` is provided it is + sent as an `Authorization: Bearer` header (the tool's MCP endpoint is fronted by an AuthBridge + sidecar that validates inbound JWTs). Bounded transport retries are applied here so callers + just map the final failure to a 502.""" + import requests + + def _do(): + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + resp = requests.post( + endpoint, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + headers=headers, + timeout=_MCP_TIMEOUT, + ) + resp.raise_for_status() + return (resp.json().get("result") or {}).get("tools", []) + + return run_upstream(_do) + + +def _select_pod(pods, workload_name: str): + """The pod owned by ``workload_name``: a Deployment's ReplicaSet (name prefix + ``{workload}-``), or a StatefulSet / Sandbox whose name equals ``workload``.""" + for pod in pods: + for owner in getattr(pod.metadata, "owner_references", None) or []: + if owner.kind == "ReplicaSet" and owner.name.startswith(f"{workload_name}-"): + return pod + if owner.kind in ("StatefulSet", "Sandbox") and owner.name == workload_name: + return pod + return None + + +# --------------------------------------------------------------------------- # +# Nodes # +# --------------------------------------------------------------------------- # +def classify_service(state: OnboardingProvisionState) -> dict: + """Resolve identity and determine service type from the operator's `kagenti.io/type` + pod label (authoritative — not the entity_id format).""" + service_id = state.trigger.entity_id + + try: + service = _config().get_service(service_id) + except Exception as e: + raise HTTPException(502, f"IdP config unavailable resolving service {service_id!r}: {e}") + + name = service.name or "" + if "/" not in name: + raise HTTPException( + 502, + f"client.name {name!r} for service {service_id!r} has no '/': " + "namespace/workload_name unrecoverable", + ) + namespace, workload_name = name.split("/", 1) + + try: + pods = list_pods(namespace) + except Exception as e: + raise HTTPException(502, f"Kubernetes pod LIST failed in namespace {namespace!r}: {e}") + + pod = _select_pod(pods, workload_name) + if pod is None: + raise HTTPException( + 502, f"no pod owned by workload {workload_name!r} in namespace {namespace!r}" + ) + + label = (getattr(pod.metadata, "labels", None) or {}).get(_TYPE_LABEL) + try: + service_type = ServiceType((label or "").capitalize()) + except ValueError: + raise HTTPException( + 502, + f"workload {workload_name!r}: {_TYPE_LABEL} label missing or invalid " + f"(got {label!r}, expected 'agent' or 'tool')", + ) + + return { + "service_id": service_id, + "namespace": namespace, + "workload_name": workload_name, + "service_type": service_type, + } + + +def analyze_agent(state: OnboardingProvisionState) -> dict: + """Derive an agent's roles + scopes from its AgentCard CR (non-LLM). + + The operator fetches the agent's A2A card and syncs it onto the CR's ``status.card``; each skill + there carries a machine ``id`` (a stable identifier, e.g. ``source_operations``) plus a display + ``name`` (which may contain spaces). Scope names are built from the skill ``id`` so they are + usable Keycloak scope names, and each skill also gets a **per-skill operator role** mirroring the + scope (same name + description): the role's description is what the PRB capability-match reads to + confine and grant the agent's outbound access on a domain basis. Falls back to a default access + scope + a default operator role when there is no AgentCard CR (legacy deployments) or the CR has + no synced skills yet.""" + namespace, workload = state.namespace, state.workload_name + + try: + resp = list_agentcards(namespace) + except Exception as e: + raise HTTPException(502, f"Kubernetes AgentCard LIST failed in namespace {namespace!r}: {e}") + + # Link the card to the workload by its ``spec.targetRef`` (the Deployment it describes), since the + # operator names the CR after the Deployment (e.g. ``-deployment-card``), not the + # workload. Fall back to ``metadata.name == workload`` for hand-authored/legacy cards. + def _targets_workload(c: dict) -> bool: + target = ((c.get("spec") or {}).get("targetRef") or {}).get("name") + return target == workload or (c.get("metadata") or {}).get("name") == workload + + card = next((c for c in resp.get("items", []) if _targets_workload(c)), None) + skills = (((card or {}).get("status") or {}).get("card") or {}).get("skills", []) + if not skills: + provision = ServiceProvision( + roles=[RoleDefinition(name=f"{workload}.access", description="Default access scope")], + scopes=[ScopeDefinition(name=f"{workload}.access", description="Default access scope")], + reasoning=( + "partial: no AgentCard found, default scope assigned" + if card is None + else "partial: AgentCard has no synced skills, default scope assigned" + ), + ) + return {"service_provision": provision} + + # One operator role per skill, mirroring the scope (same name + description). The role name == + # scope name is fine — a realm role and a client scope are distinct Keycloak objects. The role's + # description drives the PRB capability-match (see generic_policy.md). + def _skill_key(s: dict) -> str: + key = s.get("id") or s.get("name") + if not key: + raise HTTPException( + 502, + f"AgentCard for workload {workload!r} in namespace {namespace!r} has a skill " + f"with neither 'id' nor 'name'; cannot derive a scope/role name (skill: {s!r})", + ) + return key + + scopes = [ + ScopeDefinition(name=f"{workload}.{_skill_key(s)}", description=s.get("description", "")) + for s in skills + ] + roles = [ + RoleDefinition(name=f"{workload}.{_skill_key(s)}", description=s.get("description", "")) + for s in skills + ] + provision = ServiceProvision( + roles=roles, + scopes=scopes, + reasoning=f"derived from AgentCard: {len(skills)} skills", + ) + return {"service_provision": provision} + + +def analyze_tool(state: OnboardingProvisionState) -> dict: + """Discover a tool's scopes from its MCP `tools/list` manifest (non-LLM). Endpoint is + resolved via the hybrid Keycloak->K8s strategy (issue 6.2): identity from `classify_service`, + reachable endpoint from the K8s Service.""" + namespace, workload = state.namespace, state.workload_name + + try: + svc = read_service(workload, namespace) + except Exception as e: + raise HTTPException( + 502, f"Kubernetes Service GET failed for {workload!r} in namespace {namespace!r}: {e}" + ) + + labels = getattr(svc.metadata, "labels", None) or {} + if _MCP_LABEL not in labels: + raise HTTPException( + 502, + f"Service {workload!r} in namespace {namespace!r} is missing the {_MCP_LABEL!r} " + "label (deploy-time prerequisite for MCP tool discovery)", + ) + + ports = getattr(svc.spec, "ports", None) or [] + if not ports: + raise HTTPException( + 502, + f"Service {workload!r} in namespace {namespace!r} exposes no ports; " + "cannot resolve an MCP endpoint", + ) + port = ports[0].port + endpoint = f"http://{workload}.{namespace}.svc.cluster.local:{port}/mcp" + + # The MCP endpoint is fronted by the tool's AuthBridge sidecar, which validates inbound JWTs + # against the tool's own clientId as the audience. Mint a tool-audienced discovery token first; + # a failure here surfaces as an actionable 502 rather than a downstream 401. + try: + token = _discovery_token(state.service_id) + except Exception as e: + raise HTTPException( + 502, f"discovery token minting failed for service {state.service_id!r}: {e}" + ) + + try: + tools = _mcp_tools_list(endpoint, token=token) + except Exception as e: + raise HTTPException(502, f"MCP tools/list failed at {endpoint}: {e}") + + def _tool_name(t: dict) -> str: + name = t.get("name") + if not name: + raise HTTPException( + 502, + f"MCP tools/list at {endpoint} returned a tool with no 'name'; " + f"cannot derive a scope name (tool: {t!r})", + ) + return name + + scopes = [ + ScopeDefinition(name=f"{workload}.{_tool_name(t)}", description=t.get("description", "")) + for t in tools + ] + provision = ServiceProvision( + roles=[], + scopes=scopes, + reasoning=f"derived from MCP manifest: {len(tools)} tools", + ) + return {"service_provision": provision} + + +def provision_service(state: OnboardingProvisionState) -> dict: + """Write the derived roles + scopes into the IdP (idempotent create-or-get + map) and + persist the discovered service type onto the Keycloak client, via the idp-library. + Returns the `ServiceProvision` + `service_type` to the Orchestrator.""" + config = _config() + provision = state.service_provision + service_id = state.service_id + + try: + for role in provision.roles: + config.create_service_role(service_id, role) + for scope in provision.scopes: + config.create_service_scope(service_id, scope) + service = config.get_service(service_id) + config.set_service_type(service, state.service_type) + except HTTPException: + raise + except Exception as e: + raise HTTPException(502, f"IdP Configuration Service unavailable provisioning {service_id!r}: {e}") + + return {"service_provision": provision, "service_type": state.service_type} diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/state.py b/aiac/src/aiac/agent/uc/onboarding/provision/state.py new file mode 100644 index 000000000..aa3cda45a --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/provision/state.py @@ -0,0 +1,25 @@ +"""LangGraph state for the Service Provision sub-agent (UC1). + +``Trigger`` is the minimal trigger context carried into the graph: the entity id +(Keycloak ``client_id``) that originated the ``aiac.apply.service.{id}`` event / HTTP call. +""" + +from pydantic import BaseModel + +from aiac.idp.configuration.models import ServiceType + +from .types import ServiceProvision + + +class Trigger(BaseModel): + entity_id: str + + +class OnboardingProvisionState(BaseModel): + trigger: Trigger + + service_id: str | None = None # Keycloak internal client UUID (Service.id) = trigger.entity_id (not clientId) + namespace: str | None = None # from client.name split in classify_service + workload_name: str | None = None # from client.name split in classify_service + service_type: ServiceType | None = None + service_provision: ServiceProvision | None = None diff --git a/aiac/src/aiac/agent/uc/onboarding/provision/types.py b/aiac/src/aiac/agent/uc/onboarding/provision/types.py new file mode 100644 index 000000000..14428ea5e --- /dev/null +++ b/aiac/src/aiac/agent/uc/onboarding/provision/types.py @@ -0,0 +1,32 @@ +"""Types for the Service Provision sub-agent (UC1). + +``ServiceType`` is intentionally *not* redefined here — it is reused from +``aiac.idp.configuration.models`` (the same enum backing ``Service.type``), so the +sub-agent, the IdP library, and the IdP service share one vocabulary. + +``RoleDefinition`` / ``ScopeDefinition`` are deliberately distinct from the IdP +``Role`` / ``Scope`` models: a derived role/scope is a pre-persistence *name + description* +with no Keycloak ``id`` yet, so it cannot be an idp model until ``provision_service`` writes it. +""" + +from pydantic import BaseModel + +from aiac.idp.configuration.models import ServiceType + +__all__ = ["ServiceType", "RoleDefinition", "ScopeDefinition", "ServiceProvision"] + + +class RoleDefinition(BaseModel): + name: str + description: str + + +class ScopeDefinition(BaseModel): + name: str + description: str + + +class ServiceProvision(BaseModel): + roles: list[RoleDefinition] + scopes: list[ScopeDefinition] + reasoning: str # machine-generated provenance string diff --git a/aiac/src/aiac/agent/uc/policy_update/__init__.py b/aiac/src/aiac/agent/uc/policy_update/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/policy_update/build.py b/aiac/src/aiac/agent/uc/policy_update/build.py new file mode 100644 index 000000000..5e4e14217 --- /dev/null +++ b/aiac/src/aiac/agent/uc/policy_update/build.py @@ -0,0 +1,11 @@ +"""Policy Update — Build sub-agent (UC2) — stub. + +Full incremental build lands in 3.7. Its ``override`` value is resolved in 6.4; +until then the stub returns ``override=False`` (additive merge). +""" + +from aiac.policy.model.models import PolicyRule + + +def build_policy() -> tuple[list[PolicyRule], bool]: + return [], False diff --git a/aiac/src/aiac/agent/uc/policy_update/rebuild.py b/aiac/src/aiac/agent/uc/policy_update/rebuild.py new file mode 100644 index 000000000..10f4a6ae9 --- /dev/null +++ b/aiac/src/aiac/agent/uc/policy_update/rebuild.py @@ -0,0 +1,11 @@ +"""Policy Update — Rebuild sub-agent (UC2) — stub. + +Full authoritative rebuild lands in 3.8. Rebuild is authoritative, so it +returns ``override=True`` (role-keyed replace in the PCE). HTTP-only trigger. +""" + +from aiac.policy.model.models import PolicyRule + + +def rebuild_policy() -> tuple[list[PolicyRule], bool]: + return [], True diff --git a/aiac/src/aiac/agent/uc/role_update/__init__.py b/aiac/src/aiac/agent/uc/role_update/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/agent/uc/role_update/role.py b/aiac/src/aiac/agent/uc/role_update/role.py new file mode 100644 index 000000000..ba8958609 --- /dev/null +++ b/aiac/src/aiac/agent/uc/role_update/role.py @@ -0,0 +1,11 @@ +"""Role sub-agent (UC3) — stub. + +Full role-change handling lands in 3.11. A role change is authoritative for +that role, so it returns ``override=True`` (role-keyed replace in the PCE). +""" + +from aiac.policy.model.models import PolicyRule + + +def update_role(role_id: str) -> tuple[list[PolicyRule], bool]: + return [], True diff --git a/aiac/src/aiac/idp/__init__.py b/aiac/src/aiac/idp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/idp/configuration/__init__.py b/aiac/src/aiac/idp/configuration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/idp/configuration/api.py b/aiac/src/aiac/idp/configuration/api.py new file mode 100644 index 000000000..9e6bea14a --- /dev/null +++ b/aiac/src/aiac/idp/configuration/api.py @@ -0,0 +1,251 @@ +import os +from pathlib import Path +from typing import Protocol + +import requests +from dotenv import load_dotenv + +from aiac.idp.configuration.models import Role, Scope, Service, ServiceType, Subject +from aiac.shared.upstream import run_upstream + + +class _NamedDefinition(Protocol): + """Structural type for a not-yet-persisted role/scope: just a name + description. + Lets ``create_service_role`` / ``create_service_scope`` accept the agent layer's + ``RoleDefinition`` / ``ScopeDefinition`` without the IdP library importing the agent.""" + + name: str + description: str + +load_dotenv(Path(__file__).resolve().parent / ".env") + + +# Single source of truth for the Keycloak realm the whole AIAC pipeline operates on. Provisioning, +# the Service Policy Builder, and the Policy Computation Engine all resolve the realm through +# ``Configuration.for_default_realm()`` so they can never diverge onto different env vars. +REALM_ENV_VAR = "KEYCLOAK_REALM" + + +class Configuration: + def __init__(self, realm: str) -> None: + self.realm = realm + + @classmethod + def for_realm(cls, realm: str) -> "Configuration": + return cls(realm) + + @classmethod + def for_default_realm(cls) -> "Configuration": + """Build a ``Configuration`` for the realm named by ``$KEYCLOAK_REALM`` — the single source + of truth shared by provisioning, the policy builder, and the computation engine. + + Fails fast if the env var is unset or empty: an empty realm would silently target the + wrong Keycloak realm rather than surface the misconfiguration.""" + realm = os.getenv(REALM_ENV_VAR, "").strip() + if not realm: + raise RuntimeError( + f"{REALM_ENV_VAR} is unset or empty; set it to the Keycloak realm the AIAC " + "pipeline operates on" + ) + return cls.for_realm(realm) + + def _base_url(self) -> str: + return os.getenv("AIAC_PDP_CONFIG_URL", "http://127.0.0.1:7071") + + def _params(self) -> dict[str, str]: + return {"realm": self.realm} + + def _check(self, resp) -> None: + if not resp.ok: + raise RuntimeError(f"HTTP {resp.status_code}: {resp.text}") + + def _request(self, method: str, path: str, **kwargs): + """Issue an HTTP request to the config service with bounded transport retries. + + Dispatches to the named ``requests.get`` / ``requests.post`` (not ``requests.request``) + so callers and tests keep a stable, mockable surface. Retries transient failures via + ``run_upstream`` and raises on a non-OK response (``_check``), so callers just consume + ``resp.json()``. Retrying at this leaf boundary means composite methods + (``create_service_role`` / ``create_service_scope``) retry each sub-request without + compounding. + """ + caller = getattr(requests, method.lower()) + + def _do(): + resp = caller(f"{self._base_url()}{path}", **kwargs) + self._check(resp) + return resp + + return run_upstream(_do) + + def _build_subject(self, raw: dict, all_roles: dict[str, Role]) -> Subject: + subject_id = raw["id"] + assignments_resp = self._request( + "GET", f"/subjects/{subject_id}/assignments", params=self._params() + ) + realm_role_ids = {r["id"] for r in assignments_resp.json().get("realmMappings", [])} + roles = [r.model_dump() for r in all_roles.values() if r.id in realm_role_ids] + return Subject.model_validate({**raw, "roles": roles}) + + def get_subjects(self) -> list[Subject]: + resp = self._request("GET", "/subjects", params=self._params()) + all_roles = self._all_roles_map() + return [self._build_subject(raw, all_roles) for raw in resp.json()] + + def get_roles(self) -> list[Role]: + resp = self._request("GET", "/roles", params=self._params()) + roles = [] + for raw in resp.json(): + role_data = dict(raw) + if raw.get("composite"): + composites_resp = self._request( + "GET", f"/roles/{raw['name']}/composites", params=self._params() + ) + role_data["childRoles"] = composites_resp.json() + roles.append(Role.model_validate(role_data)) + return roles + + def _build_service(self, raw: dict, all_roles: dict[str, Role], all_scopes: dict[str, Scope]) -> Service: + service_id = raw["id"] + roles_resp = self._request( + "GET", f"/services/{service_id}/roles", params=self._params() + ) + scopes_resp = self._request( + "GET", f"/services/{service_id}/scopes", params=self._params() + ) + # The per-service roles response carries the authoritative kind/actorIds for each role + # (e.g. kind=Agent + actorIds=[serviceId] for agent-owned roles). Merge those fields into + # the fully-validated all_roles objects (which carry composite/attributes/etc.) so Role + # validation succeeds and kind/actorIds are not reverted to their defaults. + service_roles_by_id = {r["id"]: r for r in roles_resp.json()} + roles = [] + for role_id, svc_role in service_roles_by_id.items(): + base = all_roles.get(role_id) + if base is not None: + merged = {**base.model_dump(), **{k: v for k, v in svc_role.items() if k in ("kind", "actorIds")}} + else: + merged = svc_role # not in all_roles (e.g. client role not in realm roles) + roles.append(merged) + client_id = raw.get("clientId") or raw.get("serviceId") or service_id + service_scope_ids = {s["id"] for s in scopes_resp.json()} + scopes = [{**s.model_dump(), "serviceId": client_id} for s in all_scopes.values() if s.id in service_scope_ids] + # Type resolution is handled entirely by Service._resolve_keycloak_fields + # (client.type attribute → None); the library does not infer it here. + return Service.model_validate({**raw, "roles": roles, "scopes": scopes}) + + def _all_roles_map(self) -> dict[str, Role]: + return {r.id: r for r in self.get_roles()} + + def _all_scopes_map(self) -> dict[str, Scope]: + return {s.id: s for s in self.get_scopes()} + + def get_services(self) -> list[Service]: + resp = self._request("GET", "/services", params=self._params()) + all_roles = self._all_roles_map() + all_scopes = self._all_scopes_map() + return [self._build_service(raw, all_roles, all_scopes) for raw in resp.json()] + + def get_service(self, service_id: str) -> Service: + resp = self._request("GET", f"/services/{service_id}", params=self._params()) + return self._build_service(resp.json(), self._all_roles_map(), self._all_scopes_map()) + + def mint_discovery_token(self, service_id: str) -> str: + """Mint a bearer token whose ``aud`` contains the tool's clientId, for authenticating UC-1 + tool discovery against the tool's AuthBridge sidecar. The config service (which holds the + Keycloak admin) does the minting; this returns the raw ``access_token`` string. Raises + ``RuntimeError`` on a non-OK response (via ``_check``).""" + resp = self._request( + "GET", f"/services/{service_id}/discovery-token", params=self._params() + ) + return resp.json()["access_token"] + + def get_services_by_role(self, role: Role) -> list[Service]: + """Services whose service-account holds ``role`` (client-side filter of get_services).""" + return [s for s in self.get_services() if any(r.id == role.id for r in s.roles)] + + def get_subjects_by_role(self, role: Role) -> list[Subject]: + resp = self._request( + "GET", "/subjects", params={"role_id": role.id, "realm": self.realm} + ) + return [Subject.model_validate(s) for s in resp.json()] + + def get_services_by_scope(self, scope: Scope) -> list[Service]: + """Services exposing ``scope`` as a default client scope (client-side filter of get_services).""" + return [s for s in self.get_services() if any(sc.id == scope.id for sc in s.scopes)] + + def get_scopes(self) -> list[Scope]: + resp = self._request("GET", "/scopes", params=self._params()) + return [Scope.model_validate(s) for s in resp.json()] + + def create_scope(self, scope_name: str, scope_description: str) -> Scope: + resp = self._request( + "POST", + "/scopes", + json={"name": scope_name, "description": scope_description}, + params=self._params(), + ) + return Scope.model_validate(resp.json()) + + def map_scope_to_service(self, service: Service, scope: Scope) -> Service: + self._request( + "POST", f"/services/{service.id}/scopes/{scope.id}", params=self._params() + ) + get_resp = self._request("GET", f"/services/{service.id}", params=self._params()) + return Service.model_validate(get_resp.json()) + + def set_service_type(self, service: Service, service_type: ServiceType) -> Service: + """Persist a service's type onto the Keycloak client as the ``client.type`` attribute. + + The value is stored capitalized (``Agent``/``Tool`` — ``ServiceType``'s values) so + ``Service._resolve_keycloak_fields`` resolves it back on read. Returns the updated + ``Service``. A bare ``"Agent"``/``"Tool"`` string is accepted too (``ServiceType`` is a + ``str`` enum). + """ + value = service_type.value if isinstance(service_type, ServiceType) else service_type + resp = self._request( + "POST", + f"/services/{service.id}/type", + json={"type": value}, + params=self._params(), + ) + return Service.model_validate(resp.json()) + + def create_service_role(self, service_id: str, role: _NamedDefinition) -> Role: + """Idempotent create-or-get of a realm role by name, then map it to ``service_id``. + + If a realm role with ``role.name`` already exists it is reused (no duplicate create); + otherwise it is created. The role is then mapped to the service's service-account + (``map_role_to_service`` is itself idempotent). Returns the resolved ``Role``. + """ + existing = next((r for r in self.get_roles() if r.name == role.name), None) + resolved = existing or self.create_role(role.name, role.description) + self.map_role_to_service(self.get_service(service_id), resolved) + return resolved + + def create_service_scope(self, service_id: str, scope: _NamedDefinition) -> Scope: + """Idempotent create-or-get of a client scope by name, then map it to ``service_id``. + + If a client scope with ``scope.name`` already exists it is reused; otherwise it is + created. The scope is then mapped to the service as a default client scope + (``map_scope_to_service`` is itself idempotent). Returns the resolved ``Scope``. + """ + existing = next((s for s in self.get_scopes() if s.name == scope.name), None) + resolved = existing or self.create_scope(scope.name, scope.description) + self.map_scope_to_service(self.get_service(service_id), resolved) + return resolved + + def create_role(self, role_name: str, role_description: str) -> Role: + resp = self._request( + "POST", + "/roles", + json={"name": role_name, "description": role_description}, + params=self._params(), + ) + return Role.model_validate(resp.json()) + + def map_role_to_service(self, service: Service, role: Role) -> Service: + self._request( + "POST", f"/services/{service.id}/roles/{role.id}", params=self._params() + ) + get_resp = self._request("GET", f"/services/{service.id}", params=self._params()) + return Service.model_validate(get_resp.json()) diff --git a/aiac/src/aiac/idp/configuration/models.py b/aiac/src/aiac/idp/configuration/models.py new file mode 100644 index 000000000..d34be6352 --- /dev/null +++ b/aiac/src/aiac/idp/configuration/models.py @@ -0,0 +1,169 @@ +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, model_validator + + +class ServiceType(str, Enum): + """Canonical service-type vocabulary, shared by the IdP library, the IdP service, and the + AIAC agent sub-agents. Values are capitalized (``Agent``/``Tool``) to match the Keycloak + ``client.type`` attribute; as a ``str`` enum, ``ServiceType.AGENT == "Agent"`` holds, so it + is a drop-in for the former ``Literal["Agent", "Tool"]``. The operator's ``kagenti.io/type`` + pod label is lowercase (``agent``/``tool``) and is normalized to a member via + ``ServiceType(label.capitalize())`` at classification time.""" + + AGENT = "Agent" + TOOL = "Tool" + + +class RoleKind(str, Enum): + """Whether a role is held by users or by agent service accounts. Mirrors ``ServiceType``'s + capitalized ``str``-enum style, so ``RoleKind.USER == "User"`` holds. + + - ``USER`` ⇔ a Keycloak **realm** role; ``Role.actorIds`` are the holder usernames. + - ``AGENT`` ⇔ a Keycloak **client** role on an agent's client; ``Role.actorIds`` are the + owning agent ``serviceId``(s) (usually one). + + The client/realm ⇔ agent/user invariant (Assumption 3) and the cross-kind invariant + (Assumption 1) are enforced upstream at the Keycloak IdP construction boundary (handoff 02), + not by the local ``Role`` validator.""" + + USER = "User" + AGENT = "Agent" + + +# AIAC naming convention: every role and client scope AIAC provisions carries the Keycloak +# attribute ``aiac.managed`` with value ``true``. Keycloak's own built-ins (default client +# scopes, ``default-roles-``) never carry it, so consumers filter on this marker to +# distinguish AIAC-provisioned entities. Realm-role attribute values are lists of strings +# (``{"aiac.managed": ["true"]}``); client-scope attribute values are plain strings +# (``{"aiac.managed": "true"}``) — the helper below tolerates both shapes. +AIAC_MANAGED_ATTRIBUTE = "aiac.managed" + +# Keycloak attribute that carries a service's (client's) type. AIAC calls the concept +# "service type" everywhere (``Service.type`` ∈ {``Agent``,``Tool``}); the underlying Keycloak +# client attribute is named ``client.type``. The value is a plain string (``"Agent"``/``"Tool"``, +# capitalized to match ``ServiceType``) — a list value fails resolution → type ``None``. +SERVICE_TYPE_ATTRIBUTE = "client.type" + + +def _is_aiac_managed(attributes: dict[str, Any]) -> bool: + value = attributes.get(AIAC_MANAGED_ATTRIBUTE) + if isinstance(value, list): + return "true" in value + return value == "true" + + +class Subject(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + username: str + email: str | None = None + firstName: str | None = None + lastName: str | None = None + enabled: bool + roles: list["Role"] = [] + + +class Role(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + name: str + description: str | None = None + composite: bool + childRoles: list["Role"] = [] + attributes: dict[str, Any] = {} + # SPM routing metadata. ``kind`` distinguishes a user-held realm role from an agent-held + # client role; ``actorIds`` are the holder usernames (USER) or owning agent serviceId(s) + # (AGENT). Populated deeply from Keycloak at the IdP construction boundary (handoff 02); + # defaulted here so Wave 1 stays backward-compatible with existing Role construction sites. + kind: RoleKind = RoleKind.USER + actorIds: list[str] = [] + + @property + def aiac_managed(self) -> bool: + """True when this role carries the ``aiac.managed`` provisioning marker.""" + return _is_aiac_managed(self.attributes) + + @model_validator(mode="after") + def _validate_kind_and_actor_ids(self) -> "Role": + """Enforce the invariants visible locally: ``kind`` is a valid ``RoleKind`` and + ``actorIds`` is a ``list[str]``. The cross-kind invariant (Assumption 1) and the + client/realm ⇔ agent/user invariant (Assumption 3) require raw Keycloak facts and are + enforced upstream at construction (handoff 02), not here.""" + if not isinstance(self.kind, RoleKind): + raise ValueError("Role.kind must be a RoleKind") + if not isinstance(self.actorIds, list) or not all(isinstance(a, str) for a in self.actorIds): + raise ValueError("Role.actorIds must be a list[str]") + return self + + +class Service(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + serviceId: str + name: str | None = None + description: str | None = None + enabled: bool + type: ServiceType | None = None + roles: list["Role"] = [] + scopes: list["Scope"] = [] + + @model_validator(mode="before") + @classmethod + def _resolve_keycloak_fields(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + updates: dict[str, Any] = {} + + # Keycloak uses clientId as the identifier; name is a display name + # that is often a localisation placeholder like ${client_account}. + client_id = data.get("clientId") + name = data.get("name") + if client_id and (not name or str(name).startswith("${")): + updates["name"] = client_id + + # Surface Keycloak's human-readable clientId as serviceId (id stays the UUID). + if client_id and not data.get("serviceId"): + updates["serviceId"] = client_id + + # Resolve service type. Precedence: explicit ``type`` (already set, skipped here) → + # Keycloak ``client.type`` attribute (plain string ∈ {Agent,Tool}) → None. A + # list-valued or unrecognized attribute fails the string check → None. clientId + # shape (e.g. ``spiffe://``) is not consulted: it signals SPIRE-enablement, not + # agent-vs-tool — the operator's ``kagenti.io/type`` label (persisted as + # ``client.type``) is the authoritative type signal. + if data.get("type") is None: + attrs = data.get("attributes") or {} + stored_type = attrs.get(SERVICE_TYPE_ATTRIBUTE) + if stored_type in ("Agent", "Tool"): + updates["type"] = stored_type + + return {**data, **updates} if updates else data + + +class Scope(BaseModel): + model_config = ConfigDict(extra="ignore") + + id: str + name: str + description: str | None = None + attributes: dict[str, Any] = {} + # The single owning service's serviceId — the SPM routing key: a rule ``(role, scope)`` + # routes to ``SPM(scope.serviceId)`` (Assumption 2: a scope has exactly one owner). + # Populated at the IdP construction boundary (handoff 02); defaulted here so Wave 1 stays + # backward-compatible with existing Scope construction sites. + serviceId: str = "" + + @property + def aiac_managed(self) -> bool: + """True when this scope carries the ``aiac.managed`` provisioning marker.""" + return _is_aiac_managed(self.attributes) + + +Subject.model_rebuild() +Role.model_rebuild() +Service.model_rebuild() diff --git a/aiac/src/aiac/idp/service/__init__.py b/aiac/src/aiac/idp/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/idp/service/configuration/__init__.py b/aiac/src/aiac/idp/service/configuration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile b/aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile new file mode 100644 index 000000000..7a6859b16 --- /dev/null +++ b/aiac/src/aiac/idp/service/configuration/keycloak/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 7071 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7071"] diff --git a/aiac/src/aiac/idp/service/configuration/keycloak/__init__.py b/aiac/src/aiac/idp/service/configuration/keycloak/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/idp/service/configuration/keycloak/keycloak_admin_methods.md b/aiac/src/aiac/idp/service/configuration/keycloak/keycloak_admin_methods.md new file mode 100644 index 000000000..206844bf4 --- /dev/null +++ b/aiac/src/aiac/idp/service/configuration/keycloak/keycloak_admin_methods.md @@ -0,0 +1,113 @@ +# KeycloakAdmin — Available Methods + +Source: `python-keycloak` library (`keycloak/keycloak_admin.py`) + +## Realm +- `get_realms`, `get_realm`, `create_realm`, `update_realm`, `delete_realm` +- `import_realm`, `partial_import_realm`, `export_realm` +- `get_current_realm`, `change_current_realm` +- `get_realm_users_profile`, `update_realm_users_profile` +- `get_server_info` + +## Users +- `get_users`, `create_user`, `get_user`, `get_user_id`, `update_user`, `delete_user` +- `users_count` +- `disable_user`, `enable_user`, `disable_all_users`, `enable_all_users` +- `set_user_password` +- `get_credentials`, `delete_credential` +- `user_logout`, `user_consents`, `revoke_consent` +- `get_user_groups` +- `get_user_social_logins`, `add_user_social_login`, `delete_user_social_login` +- `send_update_account`, `send_verify_email` +- `get_sessions` +- `sync_users` +- `get_bruteforce_detection_status`, `clear_bruteforce_attempts_for_user`, `clear_all_bruteforce_attempts` + +## Realm Roles +- `get_realm_roles`, `get_realm_role`, `get_realm_role_by_id`, `get_realm_role_groups`, `get_realm_role_members` +- `create_realm_role`, `update_realm_role`, `delete_realm_role` +- `get_default_realm_role_id`, `get_realm_default_roles`, `add_realm_default_roles`, `remove_realm_default_roles` +- `add_composite_realm_roles_to_role`, `remove_composite_realm_roles_to_role`, `get_composite_realm_roles_of_role` +- `get_role_by_id`, `update_role_by_id`, `delete_role_by_id`, `get_role_composites_by_id` +- `assign_realm_roles`, `delete_realm_roles_of_user`, `get_realm_roles_of_user` +- `get_available_realm_roles_of_user`, `get_composite_realm_roles_of_user` +- `assign_group_realm_roles`, `delete_group_realm_roles`, `get_group_realm_roles` + +## Clients +- `get_clients`, `get_client`, `get_client_id`, `create_client`, `update_client`, `delete_client` +- `get_client_installation_provider` +- `create_initial_access_token` +- `generate_client_secrets`, `get_client_secrets` +- `get_client_service_account_user` +- `get_client_all_sessions`, `get_client_sessions_stats` +- `get_client_management_permissions`, `update_client_management_permissions` +- `get_mappers_from_client`, `add_mapper_to_client`, `update_client_mapper`, `remove_client_mapper` + +## Client Roles +- `get_client_roles`, `get_client_role`, `get_client_role_id` +- `create_client_role`, `update_client_role`, `delete_client_role` +- `add_composite_client_roles_to_role`, `remove_composite_client_roles_from_role` +- `get_composite_client_roles_of_role`, `get_composite_client_roles_of_group` +- `assign_client_role`, `get_client_role_members`, `get_client_role_groups` +- `get_client_roles_of_user`, `get_available_client_roles_of_user`, `get_composite_client_roles_of_user`, `delete_client_roles_of_user` +- `assign_group_client_roles`, `get_group_client_roles`, `delete_group_client_roles` +- `get_all_roles_of_user` +- `get_role_client_level_children` + +## Client Scopes +- `get_client_scopes`, `get_client_scope`, `get_client_scope_by_name` +- `create_client_scope`, `update_client_scope`, `delete_client_scope` +- `get_mappers_from_client_scope`, `add_mapper_to_client_scope`, `delete_mapper_from_client_scope`, `update_mapper_in_client_scope` +- `get_all_roles_of_client_scope` +- `get_client_default_client_scopes`, `add_client_default_client_scope`, `delete_client_default_client_scope` +- `get_client_optional_client_scopes`, `add_client_optional_client_scope`, `delete_client_optional_client_scope` +- `get_default_default_client_scopes`, `add_default_default_client_scope`, `delete_default_default_client_scope` +- `get_default_optional_client_scopes`, `add_default_optional_client_scope`, `delete_default_optional_client_scope` +- `assign_realm_roles_to_client_scope`, `delete_realm_roles_of_client_scope`, `get_realm_roles_of_client_scope` +- `assign_client_roles_to_client_scope`, `delete_client_roles_of_client_scope`, `get_client_roles_of_client_scope` +- `add_client_specific_roles_to_client_scope`, `remove_client_specific_roles_of_client_scope`, `get_client_specific_roles_of_client_scope` + +## Client Authorization +- `get_client_authz_settings`, `import_client_authz_config` +- `get_client_authz_resources`, `get_client_authz_resource`, `create_client_authz_resource`, `update_client_authz_resource`, `delete_client_authz_resource` +- `get_client_authz_scopes`, `create_client_authz_scopes` +- `get_client_authz_permissions`, `get_client_authz_policy_resources`, `get_client_authz_policy_scopes` +- `get_client_authz_policies`, `get_client_authz_policy`, `delete_client_authz_policy`, `get_client_authz_permission_associated_policies` +- `create_client_authz_role_based_policy`, `create_client_authz_policy`, `create_client_authz_resource_based_permission` +- `get_client_authz_scope_permission`, `create_client_authz_scope_permission`, `update_client_authz_scope_permission`, `update_client_authz_resource_permission` +- `get_client_authz_client_policies`, `create_client_authz_client_policy` +- `get_client_certificate_key_info`, `upload_certificate` + +## Groups +- `get_groups`, `get_group`, `get_subgroups`, `get_group_children`, `get_group_by_path` +- `create_group`, `update_group`, `delete_group` +- `groups_count` +- `get_group_members` +- `group_set_permissions`, `group_user_add`, `group_user_remove` +- `get_composite_client_roles_of_group` + +## Identity Providers +- `get_idps`, `get_idp`, `create_idp`, `update_idp`, `delete_idp` +- `add_mapper_to_idp`, `update_mapper_in_idp`, `get_idp_mappers` + +## Authentication Flows +- `get_authentication_flows`, `get_authentication_flow_for_id`, `create_authentication_flow`, `update_authentication_flow`, `copy_authentication_flow`, `delete_authentication_flow` +- `get_authentication_flow_executions`, `update_authentication_flow_executions`, `get_authentication_flow_execution`, `create_authentication_flow_execution`, `delete_authentication_flow_execution` +- `change_execution_priority` +- `create_authentication_flow_subflow` +- `get_authenticator_providers`, `get_authenticator_provider_config_description`, `get_authenticator_config`, `create_execution_config`, `update_authenticator_config`, `delete_authenticator_config` +- `get_required_actions`, `get_required_action_by_alias`, `update_required_action` + +## Organizations +- `get_organizations`, `get_organization`, `create_organization`, `update_organization`, `delete_organization` +- `get_organization_idps`, `organization_idp_add`, `organization_idp_remove` +- `get_organization_members`, `get_organization_members_count`, `organization_user_add`, `organization_user_remove` +- `get_user_organizations` + +## Components & Events +- `get_components`, `create_component`, `get_component`, `update_component`, `delete_component` +- `get_keys` +- `get_admin_events`, `get_events`, `set_events` + +## Cache +- `clear_keys_cache`, `clear_realm_cache`, `clear_user_cache` diff --git a/aiac/src/aiac/idp/service/configuration/keycloak/main.py b/aiac/src/aiac/idp/service/configuration/keycloak/main.py new file mode 100644 index 000000000..fae16704f --- /dev/null +++ b/aiac/src/aiac/idp/service/configuration/keycloak/main.py @@ -0,0 +1,515 @@ +import base64 +import json +import os +import threading +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Literal + +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, Query +from keycloak import KeycloakAdmin, KeycloakOpenID +from keycloak.exceptions import KeycloakError +from pydantic import BaseModel +from starlette.responses import JSONResponse + +_cache: dict[str, KeycloakAdmin] = {} +_lock = threading.Lock() + +# AIAC naming convention: stamp every role/scope this service provisions with the +# ``aiac.managed=true`` Keycloak attribute so downstream consumers (the Policy Computation +# Engine) can keep only AIAC-provisioned entities and drop Keycloak built-ins. Defined locally +# because this service image ships only ``main.py`` (the aiac library is not on its path); it +# mirrors ``AIAC_MANAGED_ATTRIBUTE`` in ``aiac.idp.configuration.models``. Realm-role attribute +# values are lists of strings; client-scope attribute values are plain strings. +_AIAC_MANAGED_ATTRIBUTE = "aiac.managed" + +# Keycloak client attribute carrying a service's type. AIAC calls the concept "service type" +# (``Agent``/``Tool``); the Keycloak attribute is named ``client.type`` and its value is a plain +# string. Mirrors ``SERVICE_TYPE_ATTRIBUTE`` in ``aiac.idp.configuration.models`` (the aiac library +# is not on this service image's path, so it is redefined locally). +_SERVICE_TYPE_ATTRIBUTE = "client.type" + +# Name of the idempotent audience protocol-mapper AIAC adds to a tool's Keycloak client so the +# client's own access tokens carry its clientId in ``aud``. UC-1 tool discovery mints such a token +# and presents it to the tool's AuthBridge sidecar, whose jwt-validation plugin expects the tool's +# own clientId as the audience (it defaults ``audience_file`` to ``/shared/client-id.txt``). +_DISCOVERY_AUDIENCE_MAPPER = "aiac-discovery-audience" + +# Optional hard assertion of the public issuer the tool's sidecar validates against. When set, the +# discovery-token endpoint refuses to emit a token whose ``iss`` differs (frontend-URL pinning is a +# Keycloak deployment property, not something we can force here); when unset it only records the +# observed ``iss`` for the caller / rollout gate. +_EXPECTED_ISSUER_ENV = "AIAC_KEYCLOAK_ISSUER" + + +class _InvariantViolation(Exception): + """Raised when Keycloak facts violate an AIAC assumption the IdP boundary must hold + (e.g. Assumption 1 — no cross-kind role). Surfaced as HTTP 409, distinct from the 502 + used for upstream KeycloakError, so callers can tell an invariant breach from an outage.""" + + +def _assert_single_kind(members: list[dict], role_name: str) -> None: + """Assumption 1 (no cross-kind role): a role must be held by human users *or* by agent + service accounts, never both — otherwise a single ``actorIds`` list cannot represent it. + Keycloak names an agent service account ``service-account-``.""" + has_agent = any(m.get("username", "").startswith("service-account-") for m in members) + has_user = any(not m.get("username", "").startswith("service-account-") for m in members) + if has_agent and has_user: + raise _InvariantViolation( + f"role '{role_name}' is held by both human users and agent service accounts " + f"(Assumption 1: no cross-kind role)" + ) + + +def _build_scope_owner_index(admin: "KeycloakAdmin") -> dict[str, list[str]]: + """Map each client-scope id -> the clientIds exposing it as a default scope, from a single + pass over all clients. Building this once per request turns the Assumption-2 check from an + O(scopes × clients) nested rescan (a full ``get_clients`` + per-client default-scope fetch + for every scope) into a single scan plus O(1) lookups.""" + index: dict[str, list[str]] = {} + for client in admin.get_clients(): + for s in admin.get_client_default_client_scopes(client["id"]): + index.setdefault(s["id"], []).append(client["id"]) + return index + + +def _assert_single_owner(scope: dict, owner_index: dict[str, list[str]]) -> None: + """Assumption 2 (single scope owner): an AIAC-managed client scope must be exposed as a + default scope by exactly one client. Keycloak client scopes are realm-level and assignable + to many clients, so this is not a Keycloak guarantee — look the scope up in the precomputed + ``owner_index`` and fail loud on more than one owner, since a single ``Scope.serviceId`` + cannot represent it.""" + scope_id = scope["id"] + owners = owner_index.get(scope_id, []) + if len(owners) > 1: + raise _InvariantViolation( + f"AIAC-managed scope '{scope.get('name', scope_id)}' is exposed by {len(owners)} " + f"clients (Assumption 2: a scope has exactly one owner)" + ) + + +def _is_aiac_managed(attributes: dict | None) -> bool: + """True when a Keycloak role/scope carries the ``aiac.managed`` provisioning marker. + Realm-role attribute values are lists of strings; client-scope values are plain strings — + tolerate both (mirrors ``_is_aiac_managed`` in ``aiac.idp.configuration.models``).""" + value = (attributes or {}).get(_AIAC_MANAGED_ATTRIBUTE) + if isinstance(value, list): + return "true" in value + return value == "true" + + +def _get_or_create_admin(realm: str) -> KeycloakAdmin: + if realm not in _cache: + with _lock: + if realm not in _cache: + _cache[realm] = KeycloakAdmin( + server_url=os.environ["KEYCLOAK_URL"], + realm_name=realm, + user_realm_name=os.environ["KEYCLOAK_ADMIN_REALM"], + username=os.environ["KEYCLOAK_ADMIN_USERNAME"], + password=os.environ["KEYCLOAK_ADMIN_PASSWORD"], + ) + return _cache[realm] + + +def get_admin(realm: str = Query(...)) -> KeycloakAdmin: + return _get_or_create_admin(realm) + + +def _decode_jwt_payload(token: str) -> dict: + """Decode a JWT's payload segment WITHOUT verifying its signature — AuthBridge verifies the + signature at the tool; here we only read ``iss``/``aud`` to assert the discovery-token invariants.""" + segment = token.split(".")[1] + segment += "=" * (-len(segment) % 4) # restore base64 padding + return json.loads(base64.urlsafe_b64decode(segment)) + + +def _ensure_audience_mapper(admin: KeycloakAdmin, service_id: str, client_id: str) -> None: + """Idempotently ensure the tool's client emits its own clientId in the access-token ``aud``. + + A client's client-credentials token does not carry its own clientId in ``aud`` by default, but + AuthBridge's jwt-validation expects exactly that. Add an ``oidc-audience-mapper`` (named + ``_DISCOVERY_AUDIENCE_MAPPER``) once; a second call is a no-op. + """ + mappers = admin.get_mappers_from_client(service_id) + if any(m.get("name") == _DISCOVERY_AUDIENCE_MAPPER for m in mappers): + return + admin.add_mapper_to_client( + service_id, + { + "name": _DISCOVERY_AUDIENCE_MAPPER, + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": client_id, + "access.token.claim": "true", + "id.token.claim": "false", + }, + }, + ) + + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + load_dotenv(Path(__file__).parent / ".env") + yield + + +app = FastAPI(lifespan=_lifespan) + + +class _ScopeCreate(BaseModel): + name: str + description: str = "" + + +class _RoleCreate(BaseModel): + name: str + description: str = "" + + +class _ServiceTypeUpdate(BaseModel): + type: Literal["Agent", "Tool"] + + +class _DiscoveryToken(BaseModel): + access_token: str + client_id: str # the tool clientId placed in the token's aud + issuer: str # observed iss (for the caller / rollout gate to verify) + audience: list[str] # observed aud, normalized to a list + + +@app.get("/subjects") +def list_subjects( + realm: str = Query(...), + role_id: str | None = Query(default=None), + admin: KeycloakAdmin = Depends(get_admin), +): + try: + if role_id is not None: + role = admin.get_realm_role_by_id(role_id) + role_name = role["name"] + users = admin.get_realm_role_members(role_name) + result = [] + for user in users: + raw = admin.get_all_roles_of_user(user["id"]) + result.append({ + **user, + "realmMappings": raw.get("realmMappings", []), + "serviceMappings": raw.get("clientMappings", {}), + }) + return result + return admin.get_users() + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/subjects/{subject_id}/assignments") +def get_subject_assignments(subject_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + raw = admin.get_all_roles_of_user(subject_id) + # Remap clientMappings → serviceMappings for PDP naming + return { + "realmMappings": raw.get("realmMappings", []), + "serviceMappings": raw.get("clientMappings", {}), + } + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/services") +def list_services(admin: KeycloakAdmin = Depends(get_admin)): + try: + return admin.get_clients() + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/services/{service_id}") +def get_service(service_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + return admin.get_client(service_id) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/services/{service_id}/discovery-token") +def mint_discovery_token( + service_id: str, + realm: str = Query(...), + admin: KeycloakAdmin = Depends(get_admin), +): + """Mint a bearer token for UC-1 tool discovery whose ``aud`` contains the tool's own clientId. + + The tool's MCP endpoint sits behind an AuthBridge sidecar that validates inbound JWTs against + the tool's own clientId as the audience. Mint AS the tool's own client (client-credentials), + ensuring an idempotent self-audience mapper first, and refuse to emit a token the sidecar would + reject — ``aud`` missing the clientId, or (when ``AIAC_KEYCLOAK_ISSUER`` is set) a mismatched + ``iss`` — so discovery fails loud here rather than with an opaque 401 at the tool. + """ + try: + client = admin.get_client(service_id) + client_id = client["clientId"] + secret = client.get("secret") or (admin.get_client_secrets(service_id) or {}).get("value") + if not secret: + return JSONResponse( + status_code=502, + content={ + "error": f"client {client_id!r} has no readable secret (a confidential client " + "with service accounts enabled is required to mint a discovery token)" + }, + ) + _ensure_audience_mapper(admin, service_id, client_id) + + oid = KeycloakOpenID( + server_url=os.environ["KEYCLOAK_URL"], + realm_name=realm, + client_id=client_id, + client_secret_key=secret, + ) + access_token = oid.token(grant_type="client_credentials")["access_token"] + + # Verification gate (Direction A invariant): the token must satisfy the sidecar or we do + # not hand it out. aud MUST contain the tool clientId; iss must match when pinned. + payload = _decode_jwt_payload(access_token) + aud = payload.get("aud") + aud_list = aud if isinstance(aud, list) else [aud] if aud else [] + iss = payload.get("iss", "") + if client_id not in aud_list: + return JSONResponse( + status_code=502, + content={"error": f"minted token aud={aud_list} does not contain {client_id!r}"}, + ) + expected_iss = os.environ.get(_EXPECTED_ISSUER_ENV) + if expected_iss and iss != expected_iss: + return JSONResponse( + status_code=502, + content={"error": f"minted token iss={iss!r} != expected {expected_iss!r}"}, + ) + + return _DiscoveryToken( + access_token=access_token, client_id=client_id, issuer=iss, audience=aud_list + ).model_dump() + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/type", status_code=200) +def set_service_type( + service_id: str, body: _ServiceTypeUpdate, admin: KeycloakAdmin = Depends(get_admin) +): + try: + client = admin.get_client(service_id) + # Merge into the existing attributes so we don't clobber other client attributes; + # Keycloak replaces the whole attributes map on update. + attributes = dict(client.get("attributes") or {}) + attributes[_SERVICE_TYPE_ATTRIBUTE] = body.type # capitalized plain string + admin.update_client(service_id, {"attributes": attributes}) + return admin.get_client(service_id) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/services/{service_id}/roles") +def list_service_roles(service_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + client = admin.get_client(service_id) + service_client_id = client["clientId"] + roles: list[dict] = [] + + # Client roles on the agent's own client (original path): each carries + # clientRole=true and containerId, used to classify + resolve owner. + client_roles = admin.get_client_roles(service_id) + owners: dict[str, str] = {} + for role in client_roles: + container_id = role.get("containerId") or service_id + if container_id not in owners: + owners[container_id] = admin.get_client(container_id)["clientId"] + role["kind"] = "Agent" + role["actorIds"] = [owners[container_id]] + roles.extend(client_roles) + + # Realm roles assigned to the service account (provisioning path used by the + # Configuration library). These are aiac-managed realm roles mapped to the service + # via assign_realm_roles on the service-account user. Classify them as Agent roles + # owned by this service, so the PCE sees them as outbound-capable roles. + # NOTE: get_realm_roles_of_user returns role stubs without attributes, so re-fetch + # each role's full representation to check the aiac.managed marker. + try: + sa_user = admin.get_client_service_account_user(service_id) + sa_realm_roles = admin.get_realm_roles_of_user(sa_user["id"]) + seen_ids = {r["id"] for r in client_roles} + for stub in sa_realm_roles: + if stub["id"] in seen_ids: + continue + full_role = admin.get_realm_role_by_id(stub["id"]) + if _is_aiac_managed(full_role.get("attributes")): + full_role["kind"] = "Agent" + full_role["actorIds"] = [service_client_id] + roles.append(full_role) + except KeycloakError as e: + # Only "the client has no service account" (Keycloak answers 400/404 on the + # service-account-user lookup) means skip. Any other Keycloak failure is a real + # error and must propagate to the outer 502 handler, not be silently swallowed. + if getattr(e, "response_code", None) not in (400, 404): + raise + + return roles + except KeycloakError as e: + if e.response_code == 400: + return [] + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/roles/{role_id}", status_code=201) +def assign_role_to_service(service_id: str, role_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + sa_user = admin.get_client_service_account_user(service_id) + user_id = sa_user["id"] + # Keycloak's role-mappings endpoint needs the full role representation (id + name), + # not just the id, so resolve the role before assigning it to the service account. + role = admin.get_realm_role_by_id(role_id) + admin.assign_realm_roles(user_id, [role]) + return JSONResponse(status_code=201, content={}) + except KeycloakError as e: + if e.response_code == 409: + return JSONResponse(status_code=409, content={"error": str(e)}) + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/services/{service_id}/scopes") +def list_service_scopes(service_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + scopes = admin.get_client_default_client_scopes(service_id) + if scopes: + # Scope.serviceId = the owning client. For a per-service listing the owner is this + # service; resolve its serviceId (clientId). AIAC-managed scopes must have exactly + # one owner (Assumption 2) — enforce fail-loud. Built-ins are shared by design, so + # they are exempt from the single-owner scan. + owner = admin.get_client(service_id)["clientId"] + # Build the scope->owners reverse index at most once per request, only when there is + # an AIAC-managed scope to check (built-ins are exempt). + owner_index: dict[str, list[str]] | None = None + for scope in scopes: + if _is_aiac_managed(scope.get("attributes")): + if owner_index is None: + owner_index = _build_scope_owner_index(admin) + _assert_single_owner(scope, owner_index) # Assumption 2, fail loud + scope["serviceId"] = owner + return scopes + except _InvariantViolation as e: + return JSONResponse(status_code=409, content={"error": str(e)}) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/scopes", status_code=201) +def create_scope(service_id: str, body: _ScopeCreate, admin: KeycloakAdmin = Depends(get_admin)): + try: + scope_id = admin.create_client_scope({ + "name": body.name, + "description": body.description, + "protocol": "openid-connect", + "attributes": {_AIAC_MANAGED_ATTRIBUTE: "true"}, # AIAC provisioning marker + }) + admin.add_client_default_client_scope(service_id, scope_id, {}) + return admin.get_client_scope(scope_id) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/scopes/{scope_id}", status_code=201) +def assign_scope_to_service(service_id: str, scope_id: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + admin.add_client_default_client_scope(service_id, scope_id, {}) + return JSONResponse(status_code=201, content={}) + except KeycloakError as e: + if e.response_code == 409: + return JSONResponse(status_code=409, content={"error": str(e)}) + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/roles") +def list_roles(realm: str = Query(...), admin: KeycloakAdmin = Depends(get_admin)): + try: + # brief_representation=False so realm-role attributes (incl. the aiac.managed marker) + # are returned; the brief representation Keycloak returns by default omits them. + roles = admin.get_realm_roles(brief_representation=False) + # Keycloak's auto-assigned default composite is the sole path to built-ins + # (offline_access, uma_authorization, view-profile, account roles) -- drop it so + # those built-ins never surface in AIAC policy (see handoff 01). + default_composite = f"default-roles-{realm}" + roles = [r for r in roles if r.get("name") != default_composite] + for role in roles: + # Realm roles are user roles (clientRole == false) -> kind=User (Assumption 3). + role["kind"] = "User" + # For AIAC-managed user roles, actorIds = the member usernames — the same values + # GET /subjects?role_id= returns for this role (SPM/APM alignment, 1.12). Built-ins + # are left unenriched (no member scan). + if _is_aiac_managed(role.get("attributes")): + members = admin.get_realm_role_members(role["name"]) + _assert_single_kind(members, role["name"]) # Assumption 1, fail loud + role["actorIds"] = [m["username"] for m in members] + return roles + except _InvariantViolation as e: + return JSONResponse(status_code=409, content={"error": str(e)}) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/roles", status_code=201) +def create_role(body: _RoleCreate, admin: KeycloakAdmin = Depends(get_admin)): + try: + admin.create_realm_role({ + "name": body.name, + "description": body.description, + "attributes": {_AIAC_MANAGED_ATTRIBUTE: ["true"]}, # AIAC provisioning marker + }) + return admin.get_realm_role(body.name) + except KeycloakError as e: + if e.response_code == 409: + return JSONResponse(status_code=409, content={"error": str(e)}) + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/roles/{role_name}/composites") +def list_role_composites(role_name: str, admin: KeycloakAdmin = Depends(get_admin)): + try: + return admin.get_composite_realm_roles_of_role(role_name=role_name) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/scopes") +def list_scopes(admin: KeycloakAdmin = Depends(get_admin)): + try: + return admin.get_client_scopes() + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/scopes", status_code=201) +def create_scope_standalone(body: _ScopeCreate, admin: KeycloakAdmin = Depends(get_admin)): + try: + scope_id = admin.create_client_scope({ + "name": body.name, + "description": body.description, + "protocol": "openid-connect", + "attributes": {_AIAC_MANAGED_ATTRIBUTE: "true"}, # AIAC provisioning marker + }) + return admin.get_client_scope(scope_id) + except KeycloakError as e: + if e.response_code == 409: + return JSONResponse(status_code=409, content={"error": str(e)}) + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/health") +def health(): + admin = _get_or_create_admin(os.environ["KEYCLOAK_ADMIN_REALM"]) + try: + admin.get_server_info() + return {"status": "ok"} + except KeycloakError as e: + return JSONResponse(status_code=503, content={"status": "unavailable", "error": str(e)}) diff --git a/aiac/src/aiac/idp/service/configuration/keycloak/requirements.txt b/aiac/src/aiac/idp/service/configuration/keycloak/requirements.txt new file mode 100644 index 000000000..056a4a863 --- /dev/null +++ b/aiac/src/aiac/idp/service/configuration/keycloak/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +python-keycloak +python-dotenv +pydantic diff --git a/aiac/src/aiac/pdp/__init__.py b/aiac/src/aiac/pdp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/library/__init__.py b/aiac/src/aiac/pdp/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/library/read_api_from_config.py b/aiac/src/aiac/pdp/library/read_api_from_config.py new file mode 100644 index 000000000..638ad5ba9 --- /dev/null +++ b/aiac/src/aiac/pdp/library/read_api_from_config.py @@ -0,0 +1,201 @@ +import os +from pathlib import Path + +import yaml +from dotenv import load_dotenv + +from aiac.idp.configuration.models import Role, Scope, Service, Subject + +load_dotenv(Path(__file__).resolve().parent / ".env") + +_CONFIG_ENV_VAR = "AIAC_PDP_CONFIG_PATH" + + +class Configuration: + def __init__(self, realm: str) -> None: + self.realm = realm + + @classmethod + def for_realm(cls, realm: str) -> "Configuration": + return cls(realm) + + def _load(self) -> dict: + env_val = os.getenv(_CONFIG_ENV_VAR) + if not env_val: + raise RuntimeError(f"{_CONFIG_ENV_VAR} is not set") + with open(Path(env_val)) as f: + return yaml.safe_load(f) + + def get_subjects(self) -> list[Subject]: + config = self._load() + subjects_raw = config.get("subjects", config.get("users", [])) + + realm_roles_map = {role.name: role for role in self.get_roles()} + + result = [] + for subject in subjects_raw: + if not isinstance(subject, dict): + continue + subject_id = subject.get("id") or subject.get("username") + username = subject.get("username") or subject_id + if not subject_id or not username: + continue + + roles = [ + realm_roles_map[role_name] + for role_name in subject.get("roles", []) + if isinstance(role_name, str) and role_name in realm_roles_map + ] + + result.append( + Subject( + id=subject_id, + username=username, + email=subject.get("email"), + firstName=subject.get("firstName"), + lastName=subject.get("lastName"), + enabled=subject.get("enabled", True), + roles=roles, + ) + ) + return result + + def get_roles(self) -> list[Role]: + roles_raw = self._load().get("realm_roles", []) + result = [] + for role in roles_raw: + if isinstance(role, dict): + name = role["name"] + description = role.get("description") or None + else: + name = str(role) + description = None + result.append( + Role( + id=name, + name=name, + description=description, + composite=False, + ) + ) + return result + + def get_services(self) -> list[Service]: + services_raw = self._load().get("services", []) + result = [] + for service in services_raw: + if not isinstance(service, dict): + service_id = str(service) + result.append( + Service( + id=service_id, + serviceId=service_id, + enabled=True, + ) + ) + continue + + service_id = service.get("id") or "" + + roles = [] + for role in service.get("roles", []): + if isinstance(role, dict): + role_name = role.get("name", "") + role_description = role.get("description") or None + else: + role_name = str(role) + role_description = None + if role_name: + roles.append( + Role( + id=role_name, + name=role_name, + description=role_description, + composite=False, + ) + ) + + # Scopes are explicit if provided; otherwise each role maps to a scope of the same name. + scopes_raw = service.get("scopes") + if scopes_raw is not None: + scopes = [ + Scope( + id=s["name"] if isinstance(s, dict) else str(s), + name=s["name"] if isinstance(s, dict) else str(s), + description=s.get("description") if isinstance(s, dict) else None, + ) + for s in scopes_raw + ] + else: + scopes = [ + Scope(id=r.name, name=r.name, description=r.description) + for r in roles + ] + + result.append( + Service( + id=service_id, + serviceId=service_id, + name=service.get("name") or None, + description=service.get("description") or None, + enabled=service.get("enabled", True), + type=service.get("type") or None, + roles=roles, + scopes=scopes, + ) + ) + return result + + def get_scopes(self) -> list[Scope]: + config = self._load() + scopes_raw = config.get("scopes", []) + if scopes_raw: + return [ + Scope( + id=s["name"] if isinstance(s, dict) else str(s), + name=s["name"] if isinstance(s, dict) else str(s), + description=s.get("description") if isinstance(s, dict) else None, + ) + for s in scopes_raw + ] + + # Derive from service roles when no top-level scopes section exists. + seen: set[str] = set() + result = [] + for service in config.get("services", []): + if not isinstance(service, dict): + continue + for role in service.get("roles", service.get("permissions", [])): + if isinstance(role, dict): + role_name = role.get("name") + description = role.get("description") + else: + role_name = str(role) + description = None + if role_name and role_name not in seen: + seen.add(role_name) + result.append(Scope(id=role_name, name=role_name, description=description)) + return result + + def create_scope(self, scope_name: str, scope_description: str) -> Scope: + raise NotImplementedError( + "create_scope is not supported when reading from a static config file. " + "Use the HTTP-based Configuration class instead." + ) + + +# Backward compatibility: module-level functions that delegate to Configuration class +def get_subjects(realm: str) -> list[Subject]: + return Configuration.for_realm(realm).get_subjects() + + +def get_roles(realm: str) -> list[Role]: + return Configuration.for_realm(realm).get_roles() + + +def get_services(realm: str) -> list[Service]: + return Configuration.for_realm(realm).get_services() + + +def get_scopes(realm: str) -> list[Scope]: + return Configuration.for_realm(realm).get_scopes() diff --git a/aiac/src/aiac/pdp/policy/__init__.py b/aiac/src/aiac/pdp/policy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/policy/library/__init__.py b/aiac/src/aiac/pdp/policy/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/policy/library/api.py b/aiac/src/aiac/pdp/policy/library/api.py new file mode 100644 index 000000000..a9f5a43d8 --- /dev/null +++ b/aiac/src/aiac/pdp/policy/library/api.py @@ -0,0 +1,66 @@ +"""HTTP client for the PDP Policy Writer (OPA) REST API. + +Module-level functions wrapping ``{AIAC_PDP_POLICY_URL}/policy...`` endpoints. +The PDP Policy Writer operates on a Kubernetes CR, not a Keycloak realm, so +none of these functions take or send a ``realm`` parameter. +""" + +import os +import re +from pathlib import Path +from urllib.parse import quote + +import requests +from dotenv import load_dotenv + +from aiac.policy.model.models import AgentPolicyModel, PolicyModel + +load_dotenv(Path(__file__).resolve().parent / ".env") + +# ``quote(..., safe="")`` emits only unreserved characters (``A-Za-z0-9-._~``) and ``%XX`` escapes. +# Asserting the encoded value against this set before it is spliced into a request URL proves it is +# a single, inert path segment — no scheme, host, ``/`` or ``..`` can be injected (closes the +# partial-SSRF vector). The fullmatch barrier is what CodeQL recognises; ``quote`` alone does not. +_URL_SEGMENT_RE = re.compile(r"[A-Za-z0-9._~%-]+") + + +def _base_url() -> str: + return os.getenv("AIAC_PDP_POLICY_URL", "http://127.0.0.1:7072") + + +def _agent_id_segment(agent_id: str) -> str: + """URL-encode ``agent_id`` as a single, validated path segment. + + ``agent_id`` is the Keycloak clientId (``{ns}/{name}`` or a SPIFFE URI), so it can carry + slashes and other reserved characters; ``safe=""`` escapes them all. The fullmatch check + then guarantees the result cannot alter the request target. + """ + segment = quote(agent_id, safe="") + if not _URL_SEGMENT_RE.fullmatch(segment): + raise ValueError(f"agent_id {agent_id!r} does not yield a safe URL path segment") + return segment + + +def _check(resp: requests.Response) -> None: + if not resp.ok: + raise RuntimeError(f"HTTP {resp.status_code}: {resp.text}") + + +def apply_policy(model: PolicyModel) -> None: + _check(requests.post(f"{_base_url()}/policy", json=model.model_dump())) + + +def apply_agent_policy(agent_id: str, model: AgentPolicyModel) -> None: + _check( + requests.post( + f"{_base_url()}/policy/agents/{_agent_id_segment(agent_id)}", json=model.model_dump() + ) + ) + + +def delete_agent_policy(agent_id: str) -> None: + _check(requests.delete(f"{_base_url()}/policy/agents/{_agent_id_segment(agent_id)}")) + + +def delete_policy() -> None: + _check(requests.delete(f"{_base_url()}/policy")) diff --git a/aiac/src/aiac/pdp/service/__init__.py b/aiac/src/aiac/pdp/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/service/policy/__init__.py b/aiac/src/aiac/pdp/service/policy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/service/policy/keycloak/Dockerfile b/aiac/src/aiac/pdp/service/policy/keycloak/Dockerfile new file mode 100644 index 000000000..6430018cc --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/keycloak/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 7072 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7072"] diff --git a/aiac/src/aiac/pdp/service/policy/keycloak/__init__.py b/aiac/src/aiac/pdp/service/policy/keycloak/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/service/policy/keycloak/main.py b/aiac/src/aiac/pdp/service/policy/keycloak/main.py new file mode 100644 index 000000000..82f6b05ee --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/keycloak/main.py @@ -0,0 +1,125 @@ +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from fastapi import Body, Depends, FastAPI, Query +from keycloak import KeycloakAdmin +from keycloak.exceptions import KeycloakError +from starlette.responses import JSONResponse, Response + +_admin: KeycloakAdmin | None = None + + +def get_admin(realm: str | None = Query(None)) -> KeycloakAdmin: + if realm is None: + return _admin + return KeycloakAdmin( + server_url=os.environ["KEYCLOAK_URL"], + realm_name=realm, + username=os.environ["KEYCLOAK_ADMIN_USERNAME"], + password=os.environ["KEYCLOAK_ADMIN_PASSWORD"], + ) + + +@asynccontextmanager +async def _lifespan(application: FastAPI): + global _admin + load_dotenv(Path(__file__).parent / ".env") + _admin = KeycloakAdmin( + server_url=os.environ["KEYCLOAK_URL"], + realm_name=os.environ["KEYCLOAK_REALM"], + username=os.environ["KEYCLOAK_ADMIN_USERNAME"], + password=os.environ["KEYCLOAK_ADMIN_PASSWORD"], + ) + yield + + +app = FastAPI(lifespan=_lifespan) + + +@app.post("/roles/{role_name}/composites", status_code=204) +def add_role_composites( + role_name: str, + roles: list[Any] = Body(...), + admin: KeycloakAdmin = Depends(get_admin), +): + try: + admin.add_composite_realm_roles_to_role(role_name, roles) + return Response(status_code=204) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.delete("/roles/{role_name}/composites", status_code=204) +def remove_role_composites( + role_name: str, + roles: list[Any] = Body(...), + admin: KeycloakAdmin = Depends(get_admin), +): + try: + admin.remove_composite_realm_roles_to_role(role_name, roles) + return Response(status_code=204) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.delete("/composites", status_code=204) +def clear_all_composites(admin: KeycloakAdmin = Depends(get_admin)): + try: + for role in admin.get_realm_roles(): + composites = admin.get_composite_realm_roles_of_role(role_name=role["name"]) + if composites: + admin.remove_composite_realm_roles_to_role(role["name"], composites) + return Response(status_code=204) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/permissions", status_code=201) +def create_service_permission( + service_id: str, + body: dict[str, Any] = Body(...), + admin: KeycloakAdmin = Depends(get_admin), +): + try: + created = admin.create_client_role(service_id, body) + return JSONResponse(status_code=201, content=created) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.post("/services/{service_id}/scopes", status_code=201) +def create_service_scope( + service_id: str, + body: dict[str, Any] = Body(...), + admin: KeycloakAdmin = Depends(get_admin), +): + try: + scope_payload = { + "name": body["name"], + "description": body.get("description", ""), + "protocol": "openid-connect", + } + created = admin.create_client_scope(scope_payload) + # ``add_default_default_client_scope`` is not a real KeycloakAdmin method; the correct API + # is ``add_client_default_client_scope(client_id, client_scope_id, payload)``. + scope_id = created["id"] + admin.add_client_default_client_scope( + service_id, + scope_id, + {"realm": admin.connection.realm_name, "client": service_id, "clientScopeId": scope_id}, + ) + return JSONResponse(status_code=201, content=created) + except KeycloakError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +@app.get("/health") +def health(admin: KeycloakAdmin = Depends(get_admin)): + try: + admin.get_server_info() + return {"status": "ok"} + except KeycloakError as e: + return JSONResponse(status_code=503, content={"status": "unavailable", "error": str(e)}) diff --git a/aiac/src/aiac/pdp/service/policy/keycloak/requirements.txt b/aiac/src/aiac/pdp/service/policy/keycloak/requirements.txt new file mode 100644 index 000000000..0464cb432 --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/keycloak/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn[standard] +python-keycloak +python-dotenv diff --git a/aiac/src/aiac/pdp/service/policy/opa/Dockerfile b/aiac/src/aiac/pdp/service/policy/opa/Dockerfile new file mode 100644 index 000000000..99b5d0aa0 --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/opa/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY aiac/pdp/service/policy/opa/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/src +ENV PYTHONPATH=/app/src + +EXPOSE 7072 + +CMD ["uvicorn", "aiac.pdp.service.policy.opa.main:app", "--host", "0.0.0.0", "--port", "7072"] diff --git a/aiac/src/aiac/pdp/service/policy/opa/__init__.py b/aiac/src/aiac/pdp/service/policy/opa/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/pdp/service/policy/opa/main.py b/aiac/src/aiac/pdp/service/policy/opa/main.py new file mode 100644 index 000000000..11cf21de6 --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/opa/main.py @@ -0,0 +1,100 @@ +"""PDP Policy Writer (OPA) — filesystem stub. + +Generates Rego packages via ``rego.py`` and writes them as ``.rego`` files to +a configurable output directory. No Kubernetes client — the ``rego.py`` module +is shared with the final (1.13) implementation. +""" + +import os +from pathlib import Path + +from fastapi import Depends, FastAPI +from starlette.responses import JSONResponse, Response + +from aiac.pdp.service.policy.opa.rego import ( + generate_inbound_rego, + generate_outbound_rego, + slugify, +) +from aiac.policy.model.models import AgentPolicyModel, PolicyModel + + +def get_output_dir() -> Path: + return Path(os.environ.get("REGO_OUTPUT_DIR", "/rego")) + + +def _upsert_agent(out_dir: Path, model: AgentPolicyModel) -> None: + slug = slugify(model.agent_id) + (out_dir / f"{slug}.inbound.rego").write_text(generate_inbound_rego(model)) + (out_dir / f"{slug}.outbound.rego").write_text(generate_outbound_rego(model)) + + +def _delete_agent(out_dir: Path, agent_id: str) -> None: + slug = slugify(agent_id) + (out_dir / f"{slug}.inbound.rego").unlink(missing_ok=True) + (out_dir / f"{slug}.outbound.rego").unlink(missing_ok=True) + + +def _delete_all(out_dir: Path) -> None: + for rego_file in out_dir.glob("*.rego"): + rego_file.unlink(missing_ok=True) + + +def _run_write(op) -> Response: + """Run a filesystem write op, mapping any OSError to a 502 response.""" + try: + op() + return Response(status_code=204) + except OSError as e: + return JSONResponse(status_code=502, content={"error": str(e)}) + + +app = FastAPI() + + +@app.post("/policy", status_code=204) +def upsert_policy( + policy: PolicyModel, + out_dir: Path = Depends(get_output_dir), +): + def _op(): + for agent in policy.agents: + _upsert_agent(out_dir, agent) + + return _run_write(_op) + + +@app.post("/policy/agents/{agent_id}", status_code=204) +def upsert_agent( + agent_id: str, + model: AgentPolicyModel, + out_dir: Path = Depends(get_output_dir), +): + return _run_write(lambda: _upsert_agent(out_dir, model)) + + +@app.delete("/policy/agents/{agent_id}", status_code=204) +def delete_agent( + agent_id: str, + out_dir: Path = Depends(get_output_dir), +): + return _run_write(lambda: _delete_agent(out_dir, agent_id)) + + +@app.delete("/policy", status_code=204) +def delete_all(out_dir: Path = Depends(get_output_dir)): + return _run_write(lambda: _delete_all(out_dir)) + + +@app.get("/health") +def health(): + # Resolve the output dir from server config directly rather than as a route parameter: a + # FastAPI handler parameter is treated as request-controlled input, and feeding that into a + # filesystem path check reads as path injection. The dir is operator config, never a request. + out_dir = get_output_dir() + if out_dir.is_dir() and os.access(out_dir, os.W_OK): + return {"status": "ok"} + return JSONResponse( + status_code=503, + content={"status": "unavailable", "error": f"{out_dir} is not a writable directory"}, + ) diff --git a/aiac/src/aiac/pdp/service/policy/opa/rego.py b/aiac/src/aiac/pdp/service/policy/opa/rego.py new file mode 100644 index 000000000..ecd7f1ad0 --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/opa/rego.py @@ -0,0 +1,182 @@ +"""Rego package generation for the PDP Policy Writer (OPA). + +Translates an ``AgentPolicyModel`` into Rego package strings ready to be +written to disk by the PDP Policy Writer. + +The generated packages are **ID-only**: the Rego ``input`` carries only +``{subject, source, target}`` identifiers. All role/scope mappings are +embedded in the package, and ``allow`` resolves IDs -> roles -> scopes +internally. +""" + +import json +import re + +from aiac.policy.model.models import AgentPolicyModel, PolicyRule + +__all__ = ["slugify", "generate_inbound_rego", "generate_outbound_rego"] + +_SPIFFE_RE = re.compile(r"^spiffe://[^/]+/ns/(?P[^/]+)/sa/(?P[^/]+)$") + +# A valid slug is a single filename/package segment: only [a-z0-9_], non-empty. +_SLUG_RE = re.compile(r"[a-z0-9_]+") + + +def _short_id(agent_id: str) -> str: + """Reduce a clientId to ``{namespace}/{name}``, dropping the SPIFFE trust domain. + + Under SPIRE, agent_id is a SPIFFE URI (``spiffe://host/ns/{ns}/sa/{name}``); without + SPIRE it's already ``{ns}/{name}``. Either way the trust domain/host is not part of a + stable identity, so the slug must not depend on it. + """ + match = _SPIFFE_RE.match(agent_id) + return f"{match['ns']}/{match['name']}" if match else agent_id + + +def slugify(agent_id: str) -> str: + """Turn an agent id into a valid Rego package name segment / filename. + + Predictable regardless of whether SPIRE is enabled: derived from ``{ns}/{name}``, + not the full slash/colon-bearing clientId or SPIFFE URI. + """ + slug = re.sub(r"[^a-z0-9]+", "_", _short_id(agent_id).lower()).strip("_") + # The slug becomes a filename in the PDP Policy Writer's output dir, so it must be a single + # inert segment: reject anything that isn't [a-z0-9_] (blocks ``/`` / ``..`` path traversal + # from a hostile agent_id) or that collapses to empty (would yield ``.inbound.rego``). + if not _SLUG_RE.fullmatch(slug): + raise ValueError(f"agent_id {agent_id!r} does not yield a valid slug") + return slug + + +def _render_list(var: str, values: list[str]) -> str: + """Render ``{var} := ["a", "b"]`` as Rego (empty-safe: ``[]``). + + Each value is emitted via ``json.dumps`` so quotes/newlines/backslashes are escaped — + Rego string syntax is JSON-compatible, and this prevents Rego injection / broken output.""" + inner = ", ".join(json.dumps(v) for v in values) + return f"{var} := [{inner}]" + + +def _render_map(var: str, mapping: dict[str, list[str]]) -> str: + """Render ``{var} := { "key": ["a", "b"], ... }`` as Rego (empty-safe: ``{}``). + + Keys and values are emitted via ``json.dumps`` so quotes/newlines/backslashes are escaped + (JSON-compatible Rego string syntax) — this prevents Rego injection / broken output.""" + if not mapping: + return f"{var} := {{}}" + lines = [f"{var} := {{"] + for key, values in mapping.items(): + inner = ", ".join(json.dumps(v) for v in values) + lines.append(f" {json.dumps(key)}: [{inner}],") + lines.append("}") + return "\n".join(lines) + + +def _group_rules(rules: list[PolicyRule]) -> dict[str, list[str]]: + """Group rules into ``{role.name: [scope.name, ...]}`` preserving first-seen order.""" + grouped: dict[str, list[str]] = {} + for rule in rules: + scopes = grouped.setdefault(rule.role.name, []) + if rule.scope.name not in scopes: + scopes.append(rule.scope.name) + return grouped + + +def _names(items) -> list[str]: + """Extract the ``.name`` of each entity in a list.""" + return [item.name for item in items] + + +def _name_map(mapping) -> dict[str, list[str]]: + """Turn ``{id: [entity, ...]}`` into ``{id: [entity.name, ...]}``.""" + return {key: _names(values) for key, values in mapping.items()} + + +# The subject gate is identical in both packages: the subject holds a role +# that grants at least one of the agent's own scopes. +_SUBJECT_OK = ( + "subject_ok if {\n" + " some role in subject_roles[input.subject]\n" + " some scope in role_scopes[role]\n" + " scope in agent_scopes\n" + "}" +) + + +def generate_inbound_rego(model: AgentPolicyModel) -> str: + """Render the ``authz.{slug}.inbound`` Rego package for an agent. + + Input is ``{subject, source}`` (ids only). ``subject`` is mandatory; + ``source`` is optional (absent source passes). The gate is coarse: it + passes when the principal holds a role granting >=1 of ``agent_scopes``. + """ + slug = slugify(model.agent_id) + parts = [ + f"package authz.{slug}.inbound", + _render_list("agent_scopes", _names(model.agent_scopes)), + _render_map("subject_roles", _name_map(model.subject_roles)), + _render_map("source_roles", _name_map(model.source_roles)), + _render_map("role_scopes", _group_rules(model.inbound_rules)), + _SUBJECT_OK, + ( + "source_ok if { not input.source }\n" + "source_ok if {\n" + " some role in source_roles[input.source]\n" + " some scope in role_scopes[role]\n" + " scope in agent_scopes\n" + "}" + ), + "default allow := false\nallow if { subject_ok; source_ok }", + ] + return "\n\n".join(parts) + "\n" + + +# The outbound decision is a per-scope two-gate AND, both keyed on the requested scope +# ``input.function_name`` (user->target, where a target is a tool or another agent): +# subject gate — the user (subject) is granted the requested scope +# capability gate — the agent reaches the requested scope on the requested target +# Testing the SAME function_name in both gates makes ``allow`` a genuine per-scope intersection: +# an A/B mismatch (user reaches A, agent reaches B) denies both A and B. +_OUTBOUND_SUBJECT_OK = ( + "subject_ok if {\n" + " some role in subject_roles[input.subject]\n" + " input.function_name in subject_role_scopes[role]\n" + "}" +) + + +def generate_outbound_rego(model: AgentPolicyModel) -> str: + """Render the ``authz.{slug}.outbound`` Rego package for an agent. + + Input is ``{subject, target, function_name}`` (ids only). ``function_name`` is the + requested target scope. ``allow`` is a **per-scope AND** on that scope: the subject + gate passes iff the subject holds a role granted ``function_name`` (user->target, via + ``subject_role_scopes`` from ``outbound_subject_rules``), AND the capability + gate passes iff the agent reaches ``function_name`` on the requested ``target`` + (``target_scopes[input.target]``, built from the agent's outbound rules). Because both + gates test the *same* ``function_name``, ``allow`` is a genuine per-scope intersection. + + ``target_scopes`` is used directly (target id -> scopes) -- no inversion. A target is a + tool the agent calls or another agent it calls. ``agent_roles`` / ``agent_role_scopes`` + are still emitted (informational / debugging) but are not referenced by ``allow`` -- + ``target_scopes[input.target]`` already *is* the per-scope capability gate. + """ + slug = slugify(model.agent_id) + parts = [ + f"package authz.{slug}.outbound", + _render_list("agent_roles", _names(model.agent_roles)), + _render_map("subject_roles", _name_map(model.subject_roles)), + _render_map( + "subject_role_scopes", _group_rules(model.outbound_subject_rules) + ), + _render_map("agent_role_scopes", _group_rules(model.outbound_rules)), + _render_map("target_scopes", _name_map(model.target_scopes)), + _OUTBOUND_SUBJECT_OK, + ( + "target_ok if {\n" + " input.function_name in target_scopes[input.target]\n" + "}" + ), + "default allow := false\nallow if { subject_ok; target_ok }", + ] + return "\n\n".join(parts) + "\n" diff --git a/aiac/src/aiac/pdp/service/policy/opa/requirements.txt b/aiac/src/aiac/pdp/service/policy/opa/requirements.txt new file mode 100644 index 000000000..50ac7c0f3 --- /dev/null +++ b/aiac/src/aiac/pdp/service/policy/opa/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn[standard] +pydantic diff --git a/aiac/src/aiac/policy/__init__.py b/aiac/src/aiac/policy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/policy/computation/__init__.py b/aiac/src/aiac/policy/computation/__init__.py new file mode 100644 index 000000000..4b51b593c --- /dev/null +++ b/aiac/src/aiac/policy/computation/__init__.py @@ -0,0 +1,3 @@ +from aiac.policy.computation.engine import compute_and_apply, decommission + +__all__ = ["compute_and_apply", "decommission"] diff --git a/aiac/src/aiac/policy/computation/engine.py b/aiac/src/aiac/policy/computation/engine.py new file mode 100644 index 000000000..b2e2b4c12 --- /dev/null +++ b/aiac/src/aiac/policy/computation/engine.py @@ -0,0 +1,417 @@ +"""Policy Computation Engine (SPM-based, order-independent). + +A pure library that folds partial ``list[PolicyRule]`` updates into the persistent, +per-service source of truth — ``ServicePolicyModel`` (SPM) — and then **derives** each +affected agent's ``AgentPolicyModel`` (APM) entirely from those SPMs before partial-upserting +them to the PDP Policy Writer. + +Why SPMs. The previous design persisted only per-agent APMs with rules denormalised onto the +agent, which made the merge outcome depend on onboarding order (``UR→TS`` was dropped when the +tool onboarded before any agent targeted it). Here every rule ``(role → scope)`` is stored as an +inbound edge on ``SPM(scope.serviceId)`` — the service that *owns* the scope — so the fact +survives regardless of which services already exist, and both onboarding orders converge to the +same derived ``APM(A)``. + +Input contract. Each ``PolicyRule`` arrives with ``scope.serviceId``, ``role.kind`` and +``role.actorIds`` already populated and with roles already flattened to their closure. The PCE +performs no IdP lookup for routing/classification and no role flattening — the only runtime IdP +read is ``Configuration.get_services()`` for the identity (P2) seed. + +Drift GC. Because Keycloak UUIDs churn on delete/recreate, an append-only merge would let stale +edges pile up beside their superseded generations. So after routing, ``_reconcile`` prunes each +*touched* SPM against that same ``get_services()`` catalog (no extra IdP read) — dropping edges +whose scope or agent-role no longer exists and collapsing churned/duplicate user-role generations. +It removes only edges whose entity is gone, so order-independence is preserved. + +Offboard / decommission. Reconcile is passive and catalog-anchored: it never wipes an SPM whose +owning service is absent from ``get_services()`` (a transient miss must not destroy state). That +leaves the *decommission* drift species uncovered — once a service's Keycloak client is deleted it +falls out of the catalog forever, so its own ``SPM(X)`` and its outbound footprint (``X_role → +other_scope`` edges on *other* SPMs) would linger, and its ``APM(X)`` would stay in the PDP. +``decommission(service_id)`` is the authoritative counterpart: it acts on an explicit offboard +signal (not the catalog-miss guard), tears down X's entire footprint, and re-derives every agent +whose policy changed. It is keyed by the **clientId (SPM key)**, not the Keycloak UUID — after the +client is deleted, ``get_services()`` can no longer resolve UUID→clientId. + +Fire-and-forget — ``compute_and_apply`` and ``decommission`` re-raise dependency failures. +""" + +import logging +from typing import TypeVar + +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import Role, RoleKind, Scope, Service, ServiceType +from aiac.pdp.policy.library.api import apply_policy, delete_agent_policy +from aiac.policy.model.models import ( + AgentPolicyModel, + PolicyModel, + PolicyRule, + ServicePolicyModel, +) +from aiac.policy.store.library.api import ( + apply_service_policy, + delete_service_policy, + get_service_policies_by_role, + get_service_policy, +) + +logger = logging.getLogger(__name__) + +_Entity = TypeVar("_Entity", Role, Scope) + + +def _add_rule(rules: list[PolicyRule], rule: PolicyRule) -> None: + """Append ``rule`` unless one with the same ``role.id`` + ``scope.id`` is present.""" + if any(r.role.id == rule.role.id and r.scope.id == rule.scope.id for r in rules): + return + rules.append(rule) + + +def _add_by_id(items: list[_Entity], item: _Entity) -> None: + """Append ``item`` unless one with the same ``.id`` is already present.""" + if any(existing.id == item.id for existing in items): + return + items.append(item) + + +def _reconcile( + model: ServicePolicyModel, + catalog: dict[str, Service], + catalog_agent_role_ids: set[str], + batch_user_role_ids: set[str], +) -> bool: + """Drop dangling inbound edges from a touched SPM against current IdP truth. + + Prevents cross-run drift accumulation (Keycloak UUIDs churn on delete/recreate, so an + append-only merge grows stale edges beside the superseded ones). Uses only the ``get_services()`` + catalog the PCE already loads — no additional IdP read — so the ``get_services()``-only invariant + holds. Order-independent: it removes **only** edges whose entity no longer exists, never a live + edge, so onboarding-order convergence is preserved. + + An edge on ``SPM(X)`` is kept iff: + + 1. its scope is still one of ``X``'s current ``aiac.managed`` scopes (``model.owned_scopes``, + seeded from the catalog) — drops retired/churned scopes (e.g. ``*-aud``); + 2. for an ``Agent``-kind role, the role id is still in the catalog — drops retired/churned agent + client roles (e.g. a focus-agent self-reference the current builder can no longer emit); + 3. for a ``User``-kind role, it is not a superseded generation: user realm roles are + membership-derived (absent from the catalog, and the PCE must not read ``get_subjects()``), so + among the ``User`` edges sharing ``(scope.id, role.name)`` a stale edge is dropped only when + this batch carries a *different* id for that same ``(scope, name)`` — the fresh batch's + current-generation id supersedes the old one. + + Skips pruning entirely when ``X`` is absent from the catalog (a transient miss must never wipe an + SPM). Returns ``True`` iff it removed at least one edge. + """ + if catalog.get(model.service_id) is None: + return False + + owner_scope_ids = {s.id for s in model.owned_scopes} + + # (1)+(2) existence prune. + survivors = [ + edge + for edge in model.inbound_rules + if edge.scope.id in owner_scope_ids + and not (edge.role.kind == RoleKind.AGENT and edge.role.id not in catalog_agent_role_ids) + ] + + # (3) user-role churn collapse: a stale generation is dropped only when this batch carries a + # different id for the same (scope, name). + batch_ids_by_key: dict[tuple[str, str], set[str]] = {} + for edge in survivors: + if edge.role.kind == RoleKind.USER and edge.role.id in batch_user_role_ids: + batch_ids_by_key.setdefault((edge.scope.id, edge.role.name), set()).add(edge.role.id) + + kept = [ + edge + for edge in survivors + if not ( + edge.role.kind == RoleKind.USER + and edge.role.id not in batch_ids_by_key.get((edge.scope.id, edge.role.name), set()) + and batch_ids_by_key.get((edge.scope.id, edge.role.name)) + ) + ] + + if len(kept) != len(model.inbound_rules): + model.inbound_rules = kept + return True + return False + + +def _spm_cache(catalog: dict[str, Service]): + """Build a store-backed SPM cache seeded from the ``get_services()`` catalog. + + Returns ``(spms, spm, is_agent)`` shared by ``_run`` and ``_decommission``. ``spm(id)`` fetches + each SPM from the store at most once (``get_service_policy`` returns a fresh empty SPM on 404, so + a brand-new — or already-deleted — service is handled), seeds its identity (type + own + ``aiac.managed`` roles/scopes) from the catalog when the service is still present, and mutates in + place. ``is_agent(id)`` prefers the catalog and falls back to the cached SPM's ``service_type`` + (so a decommissioned service, absent from the catalog, is still classifiable from its persisted + SPM). + """ + spms: dict[str, ServicePolicyModel] = {} + + def spm(service_id: str) -> ServicePolicyModel: + if service_id not in spms: + model = get_service_policy(service_id) + svc = catalog.get(service_id) + if svc is not None: + if svc.type is not None: + model.service_type = svc.type + model.owned_roles = [r for r in svc.roles if r.aiac_managed] + model.owned_scopes = [s for s in svc.scopes if s.aiac_managed] + spms[service_id] = model + return spms[service_id] + + def is_agent(service_id: str) -> bool: + svc = catalog.get(service_id) + if svc is not None: + return svc.type == ServiceType.AGENT + model = spms.get(service_id) + return model is not None and model.service_type == ServiceType.AGENT + + return spms, spm, is_agent + + +def _fresh_apm(agent_id: str) -> AgentPolicyModel: + return AgentPolicyModel( + agent_id=agent_id, + agent_roles=[], + agent_scopes=[], + source_roles={}, + subject_roles={}, + target_scopes={}, + inbound_rules=[], + outbound_rules=[], + outbound_subject_rules=[], + ) + + +def compute_and_apply(rules: list[PolicyRule], override: bool = False) -> None: + """Route, persist, derive, and apply ``rules`` — fire-and-forget. + + ``override`` selects the merge mode at the SPM layer. ``False`` (default) appends each rule + additively to ``SPM(scope.serviceId).inbound_rules`` (dedup by ``role.id`` + ``scope.id``). + ``True`` authoritatively replaces every input role's mappings: the distinct input-role set is + purged from **every** SPM containing it, once, up-front, before the fresh rules are appended + (role-level revocation). + + Exceptions from any dependency (IdP, Policy Store, PDP) are logged and **re-raised** so the + caller (the Controller) surfaces the failure — e.g. as a 500 — instead of returning success + while silently applying nothing. + """ + try: + _run(rules, override) + except Exception: + logger.exception("compute_and_apply failed for %d rule(s)", len(rules)) + raise + + +def decommission(service_id: str) -> None: + """Authoritatively remove a decommissioned service's entire policy footprint. + + ``service_id`` is the **clientId (the SPM key)**, not the Keycloak internal UUID: an offboarded + client is gone from ``get_services()``, so UUID→clientId resolution is impossible — the offboard + contract carries the clientId directly (the documented asymmetry with onboard's + ``/apply/service/{uuid}``). + + Tears down everything reconcile's catalog-anchored GC cannot: deletes ``SPM(X)`` (removing every + user→X and agent→X inbound edge), purges X's **outbound footprint** (``X_role → other_scope`` + edges stored on other services' SPMs), deletes ``APM(X)`` from the PDP if X was an agent, and + re-derives every agent whose policy changed (agents that targeted X; agents X sourced into) in a + single partial upsert. A never-onboarded / already-removed service is a no-op. + + Exceptions from any dependency (IdP, Policy Store, PDP) are logged and **re-raised** so the + Controller surfaces the failure instead of reporting a phantom success. + """ + try: + _decommission(service_id) + except Exception: + # Strip CR/LF before logging so a hostile service_id cannot forge log records. + safe_id = service_id.replace("\r", "").replace("\n", "") + logger.exception("decommission failed for service %r", safe_id) + raise + + +def _run(rules: list[PolicyRule], override: bool) -> None: + config = Configuration.for_default_realm() + + # (1) Catalog once — the only runtime IdP read. Carries each service's type (agent vs tool, + # for P4) and its own roles/scopes (embedded on the APM for P2, filtered to aiac.managed). + catalog = {svc.serviceId: svc for svc in config.get_services()} + + # SPM cache: fetch each SPM from the store at most once, seed its identity from the catalog, + # mutate in place, and persist the changed ones. + spms, spm, is_agent = _spm_cache(catalog) + + # Distinct input roles (dedup by id) — the set purged under override and the seed of the + # affected-agent set. + distinct_roles: dict[str, Role] = {} + for rule in rules: + distinct_roles.setdefault(rule.role.id, rule.role) + + changed: set[str] = set() + + # (3) Override — role-level revocation, once up-front, BEFORE any fresh append (so a role + # shared across the input is not wiped after being added). + if override: + for role in distinct_roles.values(): + for stored in get_service_policies_by_role(role): + model = spm(stored.service_id) + kept = [r for r in model.inbound_rules if r.role.id != role.id] + if len(kept) != len(model.inbound_rules): + model.inbound_rules = kept + changed.add(model.service_id) + + # (2) Route each rule to the SPM of the service that owns its scope. Append-dedup by + # role.id + scope.id. No write-time classification — kind only matters at derive time. + for rule in rules: + model = spm(rule.scope.serviceId) + before = len(model.inbound_rules) + _add_rule(model.inbound_rules, rule) + if len(model.inbound_rules) != before or override: + changed.add(model.service_id) + + # (3.5) Reconcile touched SPMs against current IdP truth (get_services()-only — no extra IdP + # read) so drift cannot accumulate across re-onboarding. At this point ``spms`` holds exactly + # the touched SPMs (routed + override-purged); agent-derive SPMs are not loaded yet. Runs under + # both merge modes; order-independent (drops only edges whose entity no longer exists). + catalog_agent_role_ids = {r.id for svc in catalog.values() for r in svc.roles if r.aiac_managed} + batch_user_role_ids = {rule.role.id for rule in rules if rule.role.kind == RoleKind.USER} + for service_id, model in list(spms.items()): + if _reconcile(model, catalog, catalog_agent_role_ids, batch_user_role_ids): + changed.add(service_id) + + # (4) Persist every changed SPM. + for service_id in changed: + apply_service_policy(service_id, spms[service_id]) + + # (5) Affected-agent set — from the batch's roles plus every owner whose stored SPM was + # modified during this run (routed, override-purged, or reconciled), never a full scan. + # Folding ``changed`` into the seed is what makes revocation propagate: under override an + # owner that only *lost* edges appears in ``changed`` but carries no fresh rule, so driving + # the recompute off the incoming ``rules`` alone would leave that owner's APM (and the APMs + # of agents that targeted it) stale. + touched_owners = {rule.scope.serviceId for rule in rules} | changed + + affected: set[str] = set() + for role in distinct_roles.values(): + if role.kind == RoleKind.AGENT: + affected.update(role.actorIds) # owning agents — their outbound changed + for owner in touched_owners: + if is_agent(owner): + affected.add(owner) # the touched owner is an agent — its inbound changed + # every agent targeting a scope on this touched SPM: owners of its Agent-kind inbound + # rules (a superset of the exact-scope match — re-deriving is idempotent, so safe). + for edge in spm(owner).inbound_rules: + if edge.role.kind == RoleKind.AGENT: + affected.update(edge.role.actorIds) + + # (6) Derive each affected agent's APM (zero IdP) and partial-upsert once. Tools get an SPM + # but no APM (P4). + derived = [_derive(agent_id, spm) for agent_id in sorted(affected) if is_agent(agent_id)] + if derived: + apply_policy(PolicyModel(agents=derived)) + + +def _decommission(service_id: str) -> None: + config = Configuration.for_default_realm() + + # (1) Catalog once — the only runtime IdP read. X itself is absent (it was offboarded); the + # catalog is used to seed/classify the still-live agents we re-derive. + catalog = {svc.serviceId: svc for svc in config.get_services()} + spms, spm, is_agent = _spm_cache(catalog) + + # (2) Load SPM(X) — X is gone from the catalog, so spm() does not reseed it; the persisted SPM + # carries the roles/scopes X owned when it was onboarded. Content guard: a 404 fresh-empty SPM + # (never onboarded / already removed) is a no-op — no spurious PDP delete. + spm_x = spm(service_id) + if not (spm_x.owned_roles or spm_x.owned_scopes or spm_x.inbound_rules): + return + + # (3) Targeters — agents whose outbound loses X: they hold an Agent-kind inbound edge on SPM(X) + # (their_role → X_scope), which vanishes when SPM(X) is deleted in step 5. + affected: set[str] = { + actor + for edge in spm_x.inbound_rules + if edge.role.kind == RoleKind.AGENT + for actor in edge.role.actorIds + } + + changed: set[str] = set() + + # (4) Purge X's outbound footprint — X_role → other_scope edges stored on OTHER services' SPMs. + for role in spm_x.owned_roles: + for stored in get_service_policies_by_role(role): + if stored.service_id == service_id: + continue + model = spm(stored.service_id) + kept = [e for e in model.inbound_rules if e.role.id != role.id] + if len(kept) != len(model.inbound_rules): + model.inbound_rules = kept + changed.add(model.service_id) + if is_agent(model.service_id): + affected.add(model.service_id) # its inbound source_roles[X] vanished + + # (5) Delete SPM(X) — removes every user→X and agent→X inbound edge in one shot — and evict it + # from the cache so re-derive cannot resurrect it. + was_agent = spm_x.service_type == ServiceType.AGENT + delete_service_policy(service_id) + spms.pop(service_id, None) + + # (6) Persist every changed (footprint-purged) SPM. + for changed_id in changed: + apply_service_policy(changed_id, spms[changed_id]) + + # (7) Delete APM(X) from the PDP iff X was an agent (tools have an SPM but no APM). + if was_agent: + delete_agent_policy(service_id) + + # (8) Re-derive every affected agent (X excluded) from the freshly-persisted, X-deleted store — + # outbound/target_scopes/source_roles referencing X drop automatically. One partial upsert. + affected.discard(service_id) + derived = [_derive(agent_id, spm) for agent_id in sorted(affected) if is_agent(agent_id)] + if derived: + apply_policy(PolicyModel(agents=derived)) + + +def _derive(agent_id, spm) -> AgentPolicyModel: + """Build ``APM(agent_id)`` entirely from the persisted SPMs (zero IdP).""" + sa = spm(agent_id) + apm = _fresh_apm(agent_id) + + # Identity (P2) — the agent's own aiac.managed roles/scopes, seeded from the catalog. + apm.agent_roles = list(sa.owned_roles) + apm.agent_scopes = list(sa.owned_scopes) + + # Inbound — every edge on SPM(A), split by role.kind. + for edge in sa.inbound_rules: + _add_rule(apm.inbound_rules, edge) + if edge.role.kind == RoleKind.USER: + for username in edge.role.actorIds: + _add_by_id(apm.subject_roles.setdefault(username, []), edge.role) + else: # Agent + for source_id in edge.role.actorIds: + _add_by_id(apm.source_roles.setdefault(source_id, []), edge.role) + + # Outbound — for each of A's own roles, the edges on other services' SPMs that reference it. + # Relevance is directional: only A's *agent* roles confer an outbound edge, so a merely + # shared user role never creates a false edge to a service A does not target. + for role in sa.owned_roles: + for stored in get_service_policies_by_role(role): + for edge in stored.inbound_rules: + if edge.role.id != role.id: + continue + scope = edge.scope + _add_rule(apm.outbound_rules, edge) + _add_by_id(apm.target_scopes.setdefault(scope.serviceId, []), scope) + # Outbound subject gate — the User-kind inbound rules on the SAME owning SPM + # whose scope is this target scope (the users allowed to reach it through A). + for user_edge in stored.inbound_rules: + if user_edge.scope.id == scope.id and user_edge.role.kind == RoleKind.USER: + _add_rule(apm.outbound_subject_rules, user_edge) + for username in user_edge.role.actorIds: + _add_by_id(apm.subject_roles.setdefault(username, []), user_edge.role) + + return apm diff --git a/aiac/src/aiac/policy/model/__init__.py b/aiac/src/aiac/policy/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/policy/model/models.py b/aiac/src/aiac/policy/model/models.py new file mode 100644 index 000000000..8fbd25f4d --- /dev/null +++ b/aiac/src/aiac/policy/model/models.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, ConfigDict + +from aiac.idp.configuration.models import Role, Scope, ServiceType + + +class PolicyRule(BaseModel): + model_config = ConfigDict(extra="ignore") + + role: Role + scope: Scope + + +class ServicePolicyModel(BaseModel): + """The persistent source of truth — one per service (agent *and* tool), keyed by + ``service_id``. Holds the service's own identity (owned roles/scopes) plus every inbound + edge that grants access to its ``owned_scopes``. + + Canonical form: *every rule is an inbound edge on the SPM of the service that owns the + rule's scope.* An agent's outbound edge is the target's inbound edge (``AR→TS`` is stored on + ``SPM(T)``, not on ``A``). Because ``UR→TS`` lands durably on ``SPM(T)`` at tool-onboarding — + no agent required — it can never be lost, which fixes the order-dependence bug that motivated + the two-layer model. + + ``owned_roles`` / ``owned_scopes`` are the service's own identity, filtered to the + ``aiac.managed`` marker; they are seeded from the catalog by the PCE (this module only + defines the shape).""" + + model_config = ConfigDict(extra="ignore") + + service_id: str + service_type: ServiceType # Agent | Tool — only Agents get a derived APM + owned_roles: list[Role] # this service's own client roles (aiac.managed only) + owned_scopes: list[Scope] # this service's exposed scopes (aiac.managed only) + inbound_rules: list[PolicyRule] # canonical: every edge granting access to owned_scopes + + +class AgentPolicyModel(BaseModel): + """Complete policy definition for a single agent (service). + + **Derived, not persisted.** ``AgentPolicyModel`` is a pure derived projection built by the + PCE from the relevant ``ServicePolicyModel``s — it is **no longer a persisted entity** (the + durable source of truth is ``ServicePolicyModel``). Its shape is unchanged so existing + consumers (PDP Policy Library, Policy Store readers) keep working.""" + + model_config = ConfigDict(extra="ignore") + + agent_id: str + agent_roles: list[Role] + agent_scopes: list[Scope] + # Relationship maps are keyed by the referenced entity's string id, so they + # serialize to JSON natively (no custom key handling needed). + source_roles: dict[str, list[Role]] # source service id -> roles granted + subject_roles: dict[str, list[Role]] # subject id -> roles held on behalf of + target_scopes: dict[str, list[Scope]] # target service id -> scopes permitted + inbound_rules: list[PolicyRule] + outbound_rules: list[PolicyRule] + # (user role, tool scope) pairs — the outbound subject gate; a user holding + # ``role`` may reach a tool exposing ``scope``. Outbound counterpart of + # ``inbound_rules`` (which pairs a user role with an *agent* scope). + outbound_subject_rules: list[PolicyRule] = [] + + +class PolicyModel(BaseModel): + model_config = ConfigDict(extra="ignore") + + agents: list[AgentPolicyModel] diff --git a/aiac/src/aiac/policy/store/__init__.py b/aiac/src/aiac/policy/store/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/policy/store/keying.py b/aiac/src/aiac/policy/store/keying.py new file mode 100644 index 000000000..66fe89a1f --- /dev/null +++ b/aiac/src/aiac/policy/store/keying.py @@ -0,0 +1,26 @@ +"""Slash-safe encoding of a Policy Store ``service_id`` for use as a URL path segment. + +A service_id is the Keycloak clientId, which contains slashes (``ns/workload``, or a SPIFFE URI +under SPIRE) and cannot be a single path segment. The library encodes it with URL-safe base64 +(no ``/``, ``:``, or ``=`` padding) before putting it in the path; the service decodes it back. +The decoded id stays the cache/DB key and the ``service_id`` in every SPM body. +""" +import base64 +import re + +# URL-safe base64 (minus ``=`` padding) yields only these characters. Asserting it before the +# value is spliced into a request URL proves the encoded id is a single, inert path segment — +# no scheme, host, ``/``, ``.`` or ``..`` can be injected (closes the partial-SSRF vector). +_SEGMENT_RE = re.compile(r"[A-Za-z0-9_-]+") + + +def encode_service_id(service_id: str) -> str: + encoded = base64.urlsafe_b64encode(service_id.encode("utf-8")).decode("ascii").rstrip("=") + if not _SEGMENT_RE.fullmatch(encoded): # unreachable given the alphabet above; defends the invariant + raise ValueError("encoded service_id is not a safe URL path segment") + return encoded + + +def decode_service_id(encoded: str) -> str: + padding = "=" * (-len(encoded) % 4) + return base64.urlsafe_b64decode(encoded + padding).decode("utf-8") diff --git a/aiac/src/aiac/policy/store/library/__init__.py b/aiac/src/aiac/policy/store/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/policy/store/library/api.py b/aiac/src/aiac/policy/store/library/api.py new file mode 100644 index 000000000..95b2ed828 --- /dev/null +++ b/aiac/src/aiac/policy/store/library/api.py @@ -0,0 +1,92 @@ +import os + +import requests +from dotenv import load_dotenv + +# Scope / Role / ServiceType are re-exported by aiac.policy.model.models (it imports them +# from aiac.idp.configuration.models), so the whole model surface comes from one module. +from aiac.policy.model.models import Role, Scope, ServicePolicyModel, ServiceType +from aiac.policy.store.keying import encode_service_id + +load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env")) + +# Per-request timeout (seconds) for every Policy Store HTTP call. Without it a hung store +# would block the caller indefinitely. Tunable via ``AIAC_HTTP_TIMEOUT``. +_HTTP_TIMEOUT = float(os.getenv("AIAC_HTTP_TIMEOUT", "10")) + + +def _base_url() -> str: + return os.getenv("AIAC_POLICY_STORE_URL", "http://127.0.0.1:7074") + + +def _check(response: requests.Response) -> None: + if not response.ok: + raise RuntimeError(f"Policy Store error {response.status_code}") + + +def _fresh_empty(service_id: str) -> ServicePolicyModel: + # The "engine creates a fresh model on 404" convention: the first time a service is + # seen the store has no row, so callers get an empty SPM to append to. ``service_type`` + # is required by the model but genuinely unknown here; the PCE re-seeds it (along with + # ``owned_roles`` / ``owned_scopes``) from the IdP catalog before it is ever consulted, + # so the placeholder below never reaches a policy decision. + return ServicePolicyModel( + service_id=service_id, + service_type=ServiceType.AGENT, + owned_roles=[], + owned_scopes=[], + inbound_rules=[], + ) + + +def get_service_policy(service_id: str) -> ServicePolicyModel: + resp = requests.get( + f"{_base_url()}/policy/services/{encode_service_id(service_id)}", timeout=_HTTP_TIMEOUT + ) + if resp.status_code == 404: + return _fresh_empty(service_id) + _check(resp) + return ServicePolicyModel.model_validate(resp.json()) + + +def get_service_policy_by_scope(scope: Scope) -> ServicePolicyModel | None: + # Singular: a scope has exactly one owning service (Assumption 2). Pure sugar over the + # by-id read — no dedicated HTTP route. A scope with no resolved owner has no SPM. + if not scope.serviceId: + return None + return get_service_policy(scope.serviceId) + + +def get_service_policies_by_role(role: Role) -> list[ServicePolicyModel]: + # The one genuinely new route. Returns every SPM whose inbound_rules reference role.id — + # including stale role->service mappings the live IdP no longer reflects (which + # override-purge needs). [] when none match. + resp = requests.get( + f"{_base_url()}/policy/services", params={"role": role.id}, timeout=_HTTP_TIMEOUT + ) + _check(resp) + return [ServicePolicyModel.model_validate(item) for item in resp.json()] + + +def apply_service_policy(service_id: str, spm: ServicePolicyModel) -> None: + resp = requests.post( + f"{_base_url()}/policy/services/{encode_service_id(service_id)}", + json=spm.model_dump(), + timeout=_HTTP_TIMEOUT, + ) + _check(resp) + + +def delete_service_policy(service_id: str) -> None: + resp = requests.delete( + f"{_base_url()}/policy/services/{encode_service_id(service_id)}", timeout=_HTTP_TIMEOUT + ) + _check(resp) + + +def clear_service_policies() -> None: + # Drop every SPM in the store. The collection-root DELETE (no service_id segment) — + # distinct from delete_service_policy's by-id path. Intended for test harnesses that + # need a clean slate before a run; clearing an already-empty store is a no-op. + resp = requests.delete(f"{_base_url()}/policy/services", timeout=_HTTP_TIMEOUT) + _check(resp) diff --git a/aiac/src/aiac/policy/store/service/Dockerfile b/aiac/src/aiac/policy/store/service/Dockerfile new file mode 100644 index 000000000..6ada0e34b --- /dev/null +++ b/aiac/src/aiac/policy/store/service/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY aiac/policy/store/service/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/src +ENV PYTHONPATH=/app/src + +# Directory for the SQLite backend. Must match the dir of SERVICEPOLICY_DB_PATH's +# default (/data/policy_model.db in aiac.policy.store.service.main); in production this +# is typically a mounted PV, but create it so a default-path start does not fail. +RUN mkdir -p /data + +CMD ["uvicorn", "aiac.policy.store.service.main:app", "--host", "0.0.0.0", "--port", "7074"] diff --git a/aiac/src/aiac/policy/store/service/__init__.py b/aiac/src/aiac/policy/store/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/policy/store/service/main.py b/aiac/src/aiac/policy/store/service/main.py new file mode 100644 index 000000000..21d0150c5 --- /dev/null +++ b/aiac/src/aiac/policy/store/service/main.py @@ -0,0 +1,156 @@ +import logging +import os +import sqlite3 +import threading +from contextlib import asynccontextmanager +from typing import Annotated + +import uvicorn +from fastapi import Depends, FastAPI, HTTPException +from fastapi.responses import JSONResponse, Response + +from aiac.policy.model.models import ServicePolicyModel +from aiac.policy.store.keying import decode_service_id + +logger = logging.getLogger(__name__) + +DB_PATH = os.getenv("SERVICEPOLICY_DB_PATH", "/data/policy_model.db") + +# Returned to the client on any SQLite failure. The concrete exception (which can carry +# schema/path internals) is logged server-side instead of echoed in the response body — +# never expose stack-trace / driver detail to an external caller. +_DB_ERROR_BODY = {"error": "database error"} + +# In-memory cache of ServicePolicyModel rows keyed by service_id — the authoritative +# serving layer. All reads are served from here; SQLite is the durable write-through backend. +_cache: dict[str, ServicePolicyModel] = {} +_db_conn: sqlite3.Connection | None = None + +# Serializes the read-modify-write of the cache + SQLite backend across the mutating +# endpoints. The cache dict and the shared connection are mutated by every request thread, +# so without this a concurrent create/update/delete/clear can interleave and leave the cache +# out of sync with the durable rows. Held only around the local SQLite write + cache mutation +# (never across network I/O). +_write_lock = threading.Lock() + + +def get_db() -> sqlite3.Connection: + assert _db_conn is not None + return _db_conn + + +def _init_db(conn: sqlite3.Connection) -> None: + conn.execute("CREATE TABLE IF NOT EXISTS service_policies (service_id TEXT PRIMARY KEY, spec TEXT NOT NULL)") + + +def _load_cache(conn: sqlite3.Connection) -> None: + global _cache + rows = conn.execute("SELECT service_id, spec FROM service_policies").fetchall() + _cache = {service_id: ServicePolicyModel.model_validate_json(spec) for service_id, spec in rows} + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _db_conn + _db_conn = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None) + _init_db(_db_conn) + _load_cache(_db_conn) + yield + if _db_conn: + _db_conn.close() + _db_conn = None + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/policy/services", response_model=None) +def list_service_policies_by_role(role: str) -> list[ServicePolicyModel]: + # Return every cached SPM whose inbound_rules reference the given role id. This must + # be answered from the store (not the IdP): the SPM is the source of truth, so stale + # role->service mappings the live IdP no longer reflects still show up here — which is + # exactly what override-purge needs. Never 404s; empty list on no match. + return [spm for spm in _cache.values() if any(rule.role.id == role for rule in spm.inbound_rules)] + + +@app.delete("/policy/services", response_model=None) +def clear_service_policies( + conn: Annotated[sqlite3.Connection, Depends(get_db)], +) -> Response: + # Drop every SPM — durable row and cache entry alike. Distinct path from the by-id + # DELETE (``/policy/services`` vs ``/policy/services/{service_id}``), so FastAPI routes + # unambiguously. Intended for test harnesses that need a clean slate: without it the + # StatefulSet PV outlives image redeploys, so pre-fix cruft accumulates across runs + # (override=False appends). Never 404s; clearing an already-empty store is a no-op 204. + with _write_lock: + try: + conn.execute("DELETE FROM service_policies") + except sqlite3.Error: + logger.exception("clear_service_policies: SQLite error") + return JSONResponse(status_code=502, content=_DB_ERROR_BODY) + _cache.clear() + return Response(status_code=204) + + +@app.get("/policy/services/{service_id}", response_model=None) +def get_service_policy(service_id: str): + service_id = decode_service_id(service_id) + if service_id not in _cache: + return JSONResponse(status_code=404, content={"error": f"service {service_id} not found"}) + return _cache[service_id] + + +@app.post("/policy/services/{service_id}", response_model=None) +def upsert_service_policy( + service_id: str, + body: ServicePolicyModel, + conn: Annotated[sqlite3.Connection, Depends(get_db)], +) -> Response: + service_id = decode_service_id(service_id) + if body.service_id != service_id: + # The body's own service_id must agree with the path; a mismatch would silently + # persist a row under the wrong key. Fail loud with a 422 instead. + raise HTTPException( + status_code=422, + detail=f"body.service_id {body.service_id!r} does not match path service_id {service_id!r}", + ) + with _write_lock: + try: + conn.execute( + "INSERT OR REPLACE INTO service_policies (service_id, spec) VALUES (?, ?)", + (service_id, body.model_dump_json()), + ) + except sqlite3.Error: + logger.exception("upsert_service_policy: SQLite error") + return JSONResponse(status_code=502, content=_DB_ERROR_BODY) + _cache[service_id] = body + return Response(status_code=204) + + +@app.delete("/policy/services/{service_id}", response_model=None) +def delete_service_policy( + service_id: str, + conn: Annotated[sqlite3.Connection, Depends(get_db)], +) -> Response: + service_id = decode_service_id(service_id) + with _write_lock: + try: + conn.execute("DELETE FROM service_policies WHERE service_id = ?", (service_id,)) + except sqlite3.Error: + logger.exception("delete_service_policy: SQLite error") + return JSONResponse(status_code=502, content=_DB_ERROR_BODY) + _cache.pop(service_id, None) + return Response(status_code=204) + + +@app.get("/health", response_model=None) +def health(conn: Annotated[sqlite3.Connection, Depends(get_db)]): + try: + conn.execute("SELECT 1") + return {"status": "ok"} + except Exception: + return JSONResponse(status_code=503, content={"status": "unavailable"}) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=7074) diff --git a/aiac/src/aiac/policy/store/service/requirements.txt b/aiac/src/aiac/policy/store/service/requirements.txt new file mode 100644 index 000000000..50ac7c0f3 --- /dev/null +++ b/aiac/src/aiac/policy/store/service/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn[standard] +pydantic diff --git a/aiac/src/aiac/shared/__init__.py b/aiac/src/aiac/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/src/aiac/shared/upstream.py b/aiac/src/aiac/shared/upstream.py new file mode 100644 index 000000000..86d02e310 --- /dev/null +++ b/aiac/src/aiac/shared/upstream.py @@ -0,0 +1,77 @@ +"""Shared upstream-call helper (project-level). + +`run_upstream` wraps a zero-arg callable in a bounded retry loop for transient upstream +failures (IdP Configuration Service, Kubernetes API, MCP endpoints). It is deliberately +transport-agnostic: it retries then **re-raises the original exception**, leaving each +caller to map the failure to the right HTTP status at the service boundary (e.g. +``HTTPException(502)`` for IdP/Kubernetes — see the Error Handling table in +``aiac-agent.md``). The retry budget is read from ``UPSTREAM_MAX_RETRIES`` (default 3) at +call time, so tests can tune it via the environment. + +Lives at the project root (``aiac.shared``) so any layer can reuse it without importing from +the ``agent`` package. Retry now lives at the transport boundary: the idp-library +``Configuration`` methods, the ``_mcp_tools_list`` helper, and the provision k8s seam +(``provision/kube.py``) each call it internally so the agent nodes no longer orchestrate +retries. +""" + +import os +from typing import Callable, TypeVar + +from tenacity import Retrying, retry_if_exception, stop_after_attempt, wait_exponential + +T = TypeVar("T") + +_DEFAULT_MAX_RETRIES = 3 + + +def max_retries() -> int: + """Retry budget from ``UPSTREAM_MAX_RETRIES`` (default 3), tolerant of an unset or + non-numeric value — a bad value must not crash the request, it falls back to the default.""" + try: + value = int(os.getenv("UPSTREAM_MAX_RETRIES", str(_DEFAULT_MAX_RETRIES))) + except (TypeError, ValueError): + return _DEFAULT_MAX_RETRIES + return value if value > 0 else _DEFAULT_MAX_RETRIES + + +def _status_code(exc: BaseException) -> int | None: + """Best-effort HTTP status of an exception: ``requests`` HTTPError carries it on + ``.response.status_code``; a Kubernetes ``ApiException`` carries it on ``.status``.""" + resp = getattr(exc, "response", None) + for candidate in (getattr(resp, "status_code", None), getattr(exc, "status", None)): + try: + return int(candidate) # type: ignore[arg-type] + except (TypeError, ValueError): + continue + return None + + +def is_transient(exc: BaseException) -> bool: + """Only transient upstream failures are worth retrying: connection resets, timeouts, or a + 5xx server response. Permanent failures (4xx, value/validation errors) fail identically on + every attempt, so they are surfaced immediately rather than retried. Duck-typed so it + covers ``requests`` and Kubernetes client errors without importing either here.""" + if isinstance(exc, (ConnectionError, TimeoutError)): # builtins (also OSError subclasses) + return True + # requests.exceptions.ConnectionError / Timeout are NOT subclasses of the builtins above; + # match them structurally by class name so this module stays transport-agnostic. + name = type(exc).__name__ + if name in ("ConnectionError", "Timeout", "ConnectTimeout", "ReadTimeout", "ConnectionResetError"): + return True + status = _status_code(exc) + return status is not None and status >= 500 + + +def run_upstream(fn: Callable[[], T]) -> T: + """Run ``fn`` with bounded retries (``UPSTREAM_MAX_RETRIES``, default 3) and exponential + backoff, retrying **only transient failures** (``is_transient``) and reraising the last + error so the caller can convert it to a 502. Returns whatever ``fn`` returns (the return + type is preserved for callers).""" + retryer = Retrying( + retry=retry_if_exception(is_transient), + stop=stop_after_attempt(max_retries()), + wait=wait_exponential(multiplier=1, min=1, max=30), + reraise=True, + ) + return retryer(fn) diff --git a/aiac/test/__init__.py b/aiac/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/__init__.py b/aiac/test/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/controller/test_routes.py b/aiac/test/agent/controller/test_routes.py new file mode 100644 index 000000000..649671367 --- /dev/null +++ b/aiac/test/agent/controller/test_routes.py @@ -0,0 +1,162 @@ +"""Unit tests for aiac.agent.controller.routes (the Controller). + +The orchestrator/sub-agent handlers and the Policy Computation Engine are +mocked at the routes module boundary — no live services, no real graphs. +""" + +from unittest.mock import patch + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from aiac.agent.controller.routes import app +from aiac.idp.configuration.models import Role, Scope +from aiac.policy.model.models import PolicyRule + +client = TestClient(app) + + +def _rule(role_id: str = "r-1", scope_id: str = "s-1") -> PolicyRule: + return PolicyRule( + role=Role(id=role_id, name="editor", composite=False), + scope=Scope(id=scope_id, name="write"), + ) + + +def test_apply_service_dispatches_to_orchestrator_and_calls_pce_once(): + with ( + patch("aiac.agent.controller.routes.onboard_service", return_value=([], False)) as orch, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/service/svc-123") + + assert resp.status_code == 200 + orch.assert_called_once_with("svc-123") + pce.assert_called_once_with([], False) + + +def test_apply_policy_build_dispatches_to_build_subagent(): + with ( + patch("aiac.agent.controller.routes.build_policy", return_value=([], False)) as build, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/policy/build") + + assert resp.status_code == 200 + build.assert_called_once_with() + pce.assert_called_once_with([], False) + + +def test_apply_policy_rebuild_dispatches_to_rebuild_subagent(): + with ( + patch("aiac.agent.controller.routes.rebuild_policy", return_value=([], True)) as rebuild, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/policy/rebuild") + + assert resp.status_code == 200 + rebuild.assert_called_once_with() + pce.assert_called_once_with([], True) + + +def test_apply_role_dispatches_to_role_subagent_with_role_id(): + with ( + patch("aiac.agent.controller.routes.update_role", return_value=([], True)) as role, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/role/role-42") + + assert resp.status_code == 200 + role.assert_called_once_with("role-42") + pce.assert_called_once_with([], True) + + +def test_apply_offboard_dispatches_to_decommission_with_client_id(): + # Offboard resolves the service key through the UC stub then calls decommission directly + # (no compute_and_apply — it is a whole-service teardown, not a rule fold). + with ( + patch("aiac.agent.controller.routes.offboard_service", side_effect=lambda s: s) as off, + patch("aiac.agent.controller.routes.decommission") as dec, + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/offboard/github-tool") + + assert resp.status_code == 200 + off.assert_called_once_with("github-tool") + dec.assert_called_once_with("github-tool") + pce.assert_not_called() + + +def test_apply_offboard_carries_slash_bearing_spiffe_client_id(): + # The {service_id:path} converter must pass a slash-bearing SPIFFE-URI clientId through intact. + spiffe_id = "spiffe://cluster.local/ns/team1/sa/github-tool" + with ( + patch("aiac.agent.controller.routes.offboard_service", side_effect=lambda s: s), + patch("aiac.agent.controller.routes.decommission") as dec, + ): + resp = client.post(f"/apply/offboard/{spiffe_id}") + + assert resp.status_code == 200 + dec.assert_called_once_with(spiffe_id) + + +def test_controller_forwards_handler_rules_and_override_verbatim(): + rules = [_rule("r-a"), _rule("r-b")] + with ( + patch("aiac.agent.controller.routes.onboard_service", return_value=(rules, False)), + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/service/svc-9") + + assert resp.status_code == 200 + # Exactly one PCE call, with the handler's own rules object and flag — not a rebuilt/empty one. + pce.assert_called_once_with(rules, False) + forwarded_rules, forwarded_override = pce.call_args.args + assert forwarded_rules is rules + assert forwarded_override is False + + +def test_handler_upstream_error_surfaces_status_and_skips_pce(): + with ( + patch( + "aiac.agent.controller.routes.onboard_service", + side_effect=HTTPException(status_code=502), + ), + patch("aiac.agent.controller.routes.compute_and_apply") as pce, + ): + resp = client.post("/apply/service/svc-boom") + + assert resp.status_code == 502 + pce.assert_not_called() + + +# --------------------------------------------------------------------------- # +# Stub contract: the per-route override each handler returns (no mocks). # +# --------------------------------------------------------------------------- # +def test_build_policy_stub_returns_no_rules_and_override_false(): + from aiac.agent.uc.policy_update.build import build_policy + + assert build_policy() == ([], False) + + +def test_rebuild_policy_stub_returns_no_rules_and_override_true(): + from aiac.agent.uc.policy_update.rebuild import rebuild_policy + + assert rebuild_policy() == ([], True) + + +def test_update_role_stub_returns_no_rules_and_override_true(): + from aiac.agent.uc.role_update.role import update_role + + assert update_role("role-1") == ([], True) + + +def test_offboard_service_stub_returns_client_id_unchanged(): + from aiac.agent.uc.offboarding.offboard import offboard_service + + # Keyed by the clientId (SPM key), returned verbatim — including slash-bearing SPIFFE URIs. + assert offboard_service("github-tool") == "github-tool" + assert ( + offboard_service("spiffe://cluster.local/ns/team1/sa/github-tool") + == "spiffe://cluster.local/ns/team1/sa/github-tool" + ) diff --git a/aiac/test/agent/policy_rules_builder/__init__.py b/aiac/test/agent/policy_rules_builder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py b/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py new file mode 100644 index 000000000..ba1f10643 --- /dev/null +++ b/aiac/test/agent/policy_rules_builder/test_auditor_dimension_integration.py @@ -0,0 +1,91 @@ +"""Integration regression for the relationship-scoping mapping rule (prompts._MAPPING_RULES, rule 4). + +Reproduces a field failure: a policy that grants a tool operation to exactly one subject AND names +that same operation under a *different* relationship (agent-role -> tool operations) made the LLM +auditor conflate the two relationships and wrongly deny the subject's grant. The trigger was the +singular actor-noun agent role name ``issue-operator``, which the auditor read as competing with the +``tester`` subject; it rejected the valid ``(tester, issues-write)`` grant, aborting the whole PRB run. + +The fix is a relationship-scoping meta-rule: a policy statement about an entity that is NOT among the +candidates describes a different relationship and is not evidence for or against a candidate's grant. +Originally auditor-only; now shared by the proposer and auditor (``_MAPPING_RULES``) so the two sides +decide grants under the same reasoning. This test pins the known-bad singular name so the collision +cannot silently return. + +Requires a live LLM (``@pytest.mark.integration``); skips when ``LLM_BASE_URL`` is unset. It does not +touch Keycloak or any service — it calls ``build_scope_rules`` directly with a temp policy file. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import pytest + +from aiac.idp.configuration.models import Role, Scope + +pytestmark = pytest.mark.integration + +# Section 3 deliberately uses the SINGULAR ``issue-operator`` that historically tripped the auditor. +# ``tester`` is granted ``issues-write`` ONLY under "Users -> tool operations"; ``issue-operator`` is a +# different (agent-role) relationship and must not count against tester's grant. +_POLICY = """\ +# Access Control Policy + +Grant access on a least-privilege basis; deny by default. + +## Users -> tool operations (subject may reach the tool) +- developer may perform source-read, source-write, and issues-read. +- tester may perform issues-read and issues-write. + +## Agent roles -> tool operations (agent may reach the tool) +- source-operator may perform source-read and source-write. +- issue-operator may perform issues-read and issues-write. +""" + +_USER_ROLES = { + "developer": "Developer — an engineering user who writes and maintains source code.", + "tester": "Tester — a quality-assurance user who files, triages, and updates issue reports.", + "devops": "DevOps — an operations user who manages deployment infrastructure and runtime environments.", +} +_ISSUES_WRITE_DESC = "Create and update issues: open, edit, comment, and close." + + +@pytest.fixture +def _bad_name_policy(): + """Point AIAC_POLICY_FILE at the known-bad policy; skip when no live LLM is configured.""" + if not os.getenv("LLM_BASE_URL"): + pytest.skip("LLM_BASE_URL unset — PRB auditor regression needs a live LLM") + f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) + f.write(_POLICY) + f.close() + prev = os.environ.get("AIAC_POLICY_FILE") + os.environ["AIAC_POLICY_FILE"] = f.name + try: + yield + finally: + Path(f.name).unlink(missing_ok=True) + if prev is None: + os.environ.pop("AIAC_POLICY_FILE", None) + else: + os.environ["AIAC_POLICY_FILE"] = prev + + +def test_auditor_admits_single_subject_grant_despite_competing_relation(_bad_name_policy): + """(tester, issues-write) must be granted even though the singular ``issue-operator`` agent role + names issues-write under a different relationship. Pre-fix, the auditor rejected and the builder + raised PolicyRulesBuilderError; post-fix it returns exactly the tester grant.""" + from aiac.agent.policy_rules_builder.graph import build_scope_rules + + user_roles = [ + Role(id=f"role-{name}", name=name, description=desc, composite=False) + for name, desc in _USER_ROLES.items() + ] + issues_write = Scope(id="scope-issues-write", name="issues-write", description=_ISSUES_WRITE_DESC) + + rules = build_scope_rules(user_roles, issues_write) + + granted = {r.role.name for r in rules} + assert granted == {"tester"}, f"expected only tester granted issues-write, got {sorted(granted)}" diff --git a/aiac/test/agent/policy_rules_builder/test_graph.py b/aiac/test/agent/policy_rules_builder/test_graph.py new file mode 100644 index 000000000..7a1ec21bf --- /dev/null +++ b/aiac/test/agent/policy_rules_builder/test_graph.py @@ -0,0 +1,219 @@ +"""Unit tests for aiac.agent.policy_rules_builder.graph. + +The LLM is mocked at the module's structured-call boundary +(aiac.agent.policy_rules_builder.graph._structured_call) so no live endpoint is +touched; the policy source is stubbed at graph.get_policy_source. Transport +retries (slice 9) patch graph._build_llm + time.sleep instead. +""" + +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +import pytest + +from aiac.agent.policy_rules_builder.graph import ( + AuditVerdict, + PolicyRulesBuilderError, + RoleSelection, + ScopeSelection, + build_role_rules, + build_scope_rules, +) +from aiac.idp.configuration.models import Role, Scope +from aiac.policy.model.models import PolicyRule + + +# --------------------------------------------------------------------------- # +# builders (mirror test/policy/computation/test_engine.py) # +# --------------------------------------------------------------------------- # +def _role(id="r-edit", name="editor", composite=False, children=None) -> Role: + return Role(id=id, name=name, composite=composite, childRoles=children or []) + + +def _scope(id="s-write", name="write") -> Scope: + return Scope(id=id, name=name) + + +class _Source: + """Stub PolicySource whose fetch() returns a fixed policy string.""" + + def __init__(self, text="POLICY"): + self.text = text + + def fetch(self) -> str: + return self.text + + +# --------------------------------------------------------------------------- # +# Slice 1 — tracer: build_role_rules happy path. The proposer grants one # +# candidate scope by name and the auditor approves; a single PolicyRule for # +# that (role, scope) pair comes back. # +# --------------------------------------------------------------------------- # +def test_build_role_rules_happy_path(): + role = _role() + write = _scope("s-write", "write") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=["write"], reasoning="r"), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [write]) + + assert rules == [PolicyRule(role=role, scope=write)] + + +# --------------------------------------------------------------------------- # +# Slice 2 — build_scope_rules happy path (mirror). Scope is focal, roles are # +# the candidates; the proposer names one role, the auditor approves. # +# --------------------------------------------------------------------------- # +def test_build_scope_rules_happy_path(): + editor = _role("r-edit", "editor") + scope = _scope("s-write", "write") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + ScopeSelection(roles_with_access_names=["editor"], reasoning="r"), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_scope_rules([editor], scope) + + assert rules == [PolicyRule(role=editor, scope=scope)] + + +# --------------------------------------------------------------------------- # +# Slice 3 — precheck drops proposer names not in the candidate set BEFORE the # +# auditor sees them: the proposer hallucinates "ghost", so the auditor audits # +# only the real "write" selection and just the write rule is built. # +# --------------------------------------------------------------------------- # +def test_precheck_drops_hallucinated_names(): + role = _role() + write = _scope("s-write", "write") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=["write", "ghost"], reasoning="r"), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [write]) + + assert rules == [PolicyRule(role=role, scope=write)] + # The auditor (2nd structured call) must see the cleaned selection, not "ghost". + auditor_msg = sc.call_args_list[1].args[1][1].content + assert "write" in auditor_msg and "ghost" not in auditor_msg + + +# --------------------------------------------------------------------------- # +# Slice 4 — an auditor-approved empty selection is a valid [] (deny-by-default) # +# and NOT an error. The proposer grants nothing; the auditor approves. # +# --------------------------------------------------------------------------- # +def test_approved_empty_selection_returns_empty(): + role = _role() + write = _scope("s-write", "write") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=[], reasoning="policy is silent"), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [write]) + + assert rules == [] + + +# --------------------------------------------------------------------------- # +# Slice 5 — auditor rejects the first proposal, the builder re-proposes carrying # +# the rejection reason, then the auditor approves. Rules come back AND the 2nd # +# proposer call was threaded the prior reason. # +# --------------------------------------------------------------------------- # +def test_auditor_reject_then_approve_threads_feedback(): + role = _role() + write = _scope("s-write", "write") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=["write"], reasoning="r"), + AuditVerdict(approved=False, reason="scope X unsupported"), + RoleSelection(granted_scope_names=["write"], reasoning="r2"), + AuditVerdict(approved=True), + ], + ) + ) + rules = build_role_rules(role, [write]) + + assert rules == [PolicyRule(role=role, scope=write)] + # 3rd structured call is the re-proposal; its user message must carry the reason. + reproposal_msg = sc.call_args_list[2].args[1][1].content + assert "scope X unsupported" in reproposal_msg + + +# --------------------------------------------------------------------------- # +# Slice 6 — a persistently-rejecting auditor exhausts the audit budget and the # +# builder RAISES PolicyRulesBuilderError rather than returning a silent []. # +# --------------------------------------------------------------------------- # +def test_auditor_rejects_past_budget_raises(): + role = _role() + write = _scope("s-write", "write") + + def se(schema, messages): + if schema is AuditVerdict: + return AuditVerdict(approved=False, reason="never ok") + return RoleSelection(granted_scope_names=["write"], reasoning="r") + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph._structured_call", side_effect=se)) + with pytest.raises(PolicyRulesBuilderError): + build_role_rules(role, [write]) + + +# --------------------------------------------------------------------------- # +# Slice 9 — a persistently-unavailable LLM is transport-retried UPSTREAM_MAX_ # +# RETRIES times, then the original transport error propagates (never swallowed). # +# time.sleep is patched so tenacity's backoff waits are skipped. # +# --------------------------------------------------------------------------- # +def test_llm_unavailable_raises_after_upstream_max_retries(monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "2") + + invoke = MagicMock(side_effect=ConnectionError("down")) + runnable = MagicMock() + runnable.invoke = invoke + llm = MagicMock() + llm.with_structured_output.return_value = runnable + + with ExitStack() as stack: + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph.get_policy_source", return_value=_Source())) + stack.enter_context(patch("aiac.agent.policy_rules_builder.graph._build_llm", return_value=llm)) + stack.enter_context(patch("time.sleep")) # NOT tenacity.nap.sleep (ineffective) + with pytest.raises(ConnectionError): + build_role_rules(_role(), [_scope("s-write", "write")]) + + assert invoke.call_count == 2 diff --git a/aiac/test/agent/policy_rules_builder/test_isolation.py b/aiac/test/agent/policy_rules_builder/test_isolation.py new file mode 100644 index 000000000..84898703f --- /dev/null +++ b/aiac/test/agent/policy_rules_builder/test_isolation.py @@ -0,0 +1,26 @@ +"""Architecture guard: the PRB must not import the PDP/Policy-Store libraries. + +Only the PCE touches aiac.pdp.policy.library / aiac.policy.store.library. A +sys.modules check gives false positives (other imported modules pull those in), +so we AST-scan the PRB package's own source instead. +""" + +import ast +import pathlib + +import aiac.agent.policy_rules_builder as prb + +FORBIDDEN = ("aiac.pdp.policy.library", "aiac.policy.store.library") + + +def test_prb_imports_no_pdp_or_store_library(): + pkg_dir = pathlib.Path(prb.__file__).parent + seen: set[str] = set() + for py in pkg_dir.rglob("*.py"): + for node in ast.walk(ast.parse(py.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + seen.update(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + seen.add(node.module) + leaked = [m for m in seen for f in FORBIDDEN if m == f or m.startswith(f + ".")] + assert not leaked, f"PRB leaked forbidden imports: {leaked}" diff --git a/aiac/test/agent/policy_rules_builder/test_policy_source.py b/aiac/test/agent/policy_rules_builder/test_policy_source.py new file mode 100644 index 000000000..4b84d394a --- /dev/null +++ b/aiac/test/agent/policy_rules_builder/test_policy_source.py @@ -0,0 +1,58 @@ +"""Unit tests for the Phase 1 policy source (real FilePolicySource, real files). + +These exercise FilePolicySource end-to-end via AIAC_POLICY_FILE -- get_policy_source +is NOT patched here, so the file read is real. The LLM is still mocked at +graph._structured_call. +""" + +from contextlib import ExitStack +from unittest.mock import patch + +import pytest + +from aiac.agent.policy_rules_builder.graph import AuditVerdict, RoleSelection, build_role_rules +from aiac.idp.configuration.models import Role, Scope + + +def _role(id="r-edit", name="editor", composite=False, children=None) -> Role: + return Role(id=id, name=name, composite=composite, childRoles=children or []) + + +def _scope(id="s-write", name="write") -> Scope: + return Scope(id=id, name=name) + + +# --------------------------------------------------------------------------- # +# Slice 7 — Phase 1 reads the whole policy file at AIAC_POLICY_FILE and the # +# proposer's user message carries that policy text (real FilePolicySource). # +# --------------------------------------------------------------------------- # +def test_policy_file_reaches_proposer(tmp_path, monkeypatch): + policy = tmp_path / "policy.md" + policy.write_text("EDITORS MAY WRITE", encoding="utf-8") + monkeypatch.setenv("AIAC_POLICY_FILE", str(policy)) + + with ExitStack() as stack: + sc = stack.enter_context( + patch( + "aiac.agent.policy_rules_builder.graph._structured_call", + side_effect=[ + RoleSelection(granted_scope_names=["write"], reasoning="r"), + AuditVerdict(approved=True), + ], + ) + ) + build_role_rules(_role(), [_scope()]) + + proposer_msg = sc.call_args_list[0].args[1][1].content + assert "EDITORS MAY WRITE" in proposer_msg + + +# --------------------------------------------------------------------------- # +# Slice 8 — a missing/unreadable policy file raises (OSError), never a silent # +# []; the builder does not swallow policy-source failure. # +# --------------------------------------------------------------------------- # +def test_missing_policy_file_raises(tmp_path, monkeypatch): + monkeypatch.setenv("AIAC_POLICY_FILE", str(tmp_path / "does-not-exist.md")) + + with pytest.raises(OSError): + build_role_rules(_role(), [_scope()]) diff --git a/aiac/test/agent/shared/test_roles.py b/aiac/test/agent/shared/test_roles.py new file mode 100644 index 000000000..b589a6dac --- /dev/null +++ b/aiac/test/agent/shared/test_roles.py @@ -0,0 +1,68 @@ +"""Unit tests for aiac.agent.shared.roles.flatten_role — pure function, no mocks. + +``flatten_role(role)`` returns the closure of a role: the role itself plus all +descendants (recursively via ``role.childRoles``), de-duplicated by ``role.id``. +""" + +from unittest.mock import patch + +from aiac.agent.shared.roles import flatten_role +from aiac.idp.configuration.models import Role + + +def _role(id: str, name: str = "", *, composite: bool = False, children=None) -> Role: + return Role( + id=id, + name=name or id, + composite=composite, + childRoles=children or [], + ) + + +def test_non_composite_role_yields_itself_only(): + role = _role("r-leaf", composite=False) + + assert flatten_role(role) == [role] + + +def test_composite_with_two_children_yields_self_plus_descendants(): + child1 = _role("r-c1") + child2 = _role("r-c2") + parent = _role("r-parent", composite=True, children=[child1, child2]) + + assert flatten_role(parent) == [parent, child1, child2] + + +def test_nested_composite_returns_full_recursive_closure(): + grandchild = _role("r-gc") + child = _role("r-c", composite=True, children=[grandchild]) + parent = _role("r-parent", composite=True, children=[child]) + + assert flatten_role(parent) == [parent, child, grandchild] + + +def test_diamond_shared_child_is_deduplicated_by_id(): + shared = _role("r-shared") + branch_a = _role("r-a", composite=True, children=[shared]) + branch_b = _role("r-b", composite=True, children=[shared]) + root = _role("r-root", composite=True, children=[branch_a, branch_b]) + + result = flatten_role(root) + + # The shared child is reachable via two parents but appears exactly once. + assert [r.id for r in result] == ["r-root", "r-a", "r-shared", "r-b"] + assert sum(1 for r in result if r.id == "r-shared") == 1 + + +def test_flatten_role_makes_no_idp_config_call(): + child = _role("r-c") + parent = _role("r-parent", composite=True, children=[child]) + + # If flatten_role ever reached for the IdP, this patched Configuration would + # register a call. It operates purely on the in-memory childRoles graph. + with patch("aiac.idp.configuration.api.Configuration") as config: + result = flatten_role(parent) + + assert result == [parent, child] + config.assert_not_called() + config.for_realm.assert_not_called() diff --git a/aiac/test/agent/uc/__init__.py b/aiac/test/agent/uc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/uc/onboarding/__init__.py b/aiac/test/agent/uc/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/uc/onboarding/policy_builder/__init__.py b/aiac/test/agent/uc/onboarding/policy_builder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/uc/onboarding/policy_builder/test_builder.py b/aiac/test/agent/uc/onboarding/policy_builder/test_builder.py new file mode 100644 index 000000000..56918240f --- /dev/null +++ b/aiac/test/agent/uc/onboarding/policy_builder/test_builder.py @@ -0,0 +1,489 @@ +"""Unit tests for the Service Policy Builder sub-agent (UC1, issue 4.4). + +The idp-library `Configuration` is mocked via the `_config` seam, and the PRB entry +points (`build_scope_rules` / `build_role_rules`) are patched on the builder module — +no live services, no LLM. The sub-agent is deterministic and applies nothing. + +Candidates are sourced from `get_services()` / `get_subjects()` — the same worldview as +the Policy Computation Engine — and excluded/included by **ownership** (role id / +`scope.serviceId`), never by name. Both roles AND scopes come from `get_services()`, so +every scope carries its owning `serviceId` (the SPM routing key); the global `get_scopes()` +catalog is not a candidate source (it returns scopes with an empty `serviceId`). Fixtures +below always give non-focus services distinct `serviceId`s and mark AIAC-provisioned +roles/scopes with the `aiac.managed` attribute, so ownership-based routing is exercised for +real. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding.policy_builder import builder +from aiac.idp.configuration.models import RoleKind, Scope, Service, ServiceType, Subject +from aiac.idp.configuration.models import Role as RoleModel +from aiac.policy.model.models import PolicyRule + +FOCUS_ID = "svc-focus" +OTHER_ID = "svc-other" +THIRD_ID = "svc-third" + + +def _role(name, *, role_id=None, composite=False, children=None, kind=RoleKind.USER, aiac_managed=True): + return RoleModel( + id=role_id or f"{name}-id", + name=name, + description=name, + composite=composite, + childRoles=children or [], + attributes={"aiac.managed": ["true"]} if aiac_managed else {}, + kind=kind, + ) + + +def _scope(name, *, scope_id=None, service_id="", aiac_managed=True): + return Scope( + id=scope_id or f"{name}-id", + name=name, + description=name, + attributes={"aiac.managed": "true"} if aiac_managed else {}, + serviceId=service_id, + ) + + +def _service(service_id, *, ref=None, roles=None, scopes=None, service_type=ServiceType.TOOL): + # `service_id` is the internal client UUID (Service.id — the /apply/service/{id} route key); + # `ref` is the human-readable clientId (Service.serviceId), which defaults to the UUID for the + # tests that don't care but is set distinctly where the id-shape distinction matters. + return Service( + id=service_id, + serviceId=ref or service_id, + enabled=True, + type=service_type, + roles=roles or [], + scopes=scopes or [], + ) + + +def _subject(username, *, roles=None, subject_id=None): + return Subject(id=subject_id or f"{username}-id", username=username, enabled=True, roles=roles or []) + + +def _rule(role, scope): + return PolicyRule(role=role, scope=scope) + + +def _invoke( + service_type, + *, + services, + all_scopes, + subjects, + service_id=FOCUS_ID, + scope_rules=None, + role_rules=None, + get_services_exc=None, + get_subjects_exc=None, +): + """Run ServicePolicyBuilder.build with all IdP + PRB calls mocked. + + `services` / `subjects` back `get_services()` / `get_subjects()` respectively. Both + candidate roles and scopes are sourced from `get_services()`; `all_scopes` is retained + only to describe the global scope catalog in each fixture and is not consulted by the + builder. `scope_rules` / `role_rules` are optional side_effect callables; default to + returning an empty list so calls are counted without inventing rule content. `get_*_exc` + injects an IdP-read failure on the corresponding call. + """ + with ( + patch.object(builder, "_config") as cfg, + patch.object(builder, "build_scope_rules") as bsr, + patch.object(builder, "build_role_rules") as brr, + ): + conf = MagicMock() + if get_services_exc is not None: + conf.get_services.side_effect = get_services_exc + else: + conf.get_services.return_value = services + if get_subjects_exc is not None: + conf.get_subjects.side_effect = get_subjects_exc + else: + conf.get_subjects.return_value = subjects + cfg.return_value = conf + bsr.side_effect = scope_rules or (lambda roles, scope: []) + brr.side_effect = role_rules or (lambda role, scopes: []) + result = builder.ServicePolicyBuilder.build(service_id, service_type) + return result, bsr, brr, conf + + +class TestTool: + def test_single_scope_calls_build_scope_rules_once_and_merges(self): + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + other_role = _role("github.agent", kind=RoleKind.AGENT) + focus = _service(FOCUS_ID, scopes=[own_scope]) + other = _service(OTHER_ID, roles=[other_role]) + rule = _rule(other_role, own_scope) + + result, bsr, brr, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[], + scope_rules=lambda roles, scope: [rule], + ) + + assert bsr.call_count == 1 + passed_roles, passed_scope = bsr.call_args.args + assert passed_scope.name == "weather.forecast" + assert [r.name for r in passed_roles] == ["github.agent"] + brr.assert_not_called() + assert result == [rule] + + def test_build_scope_rules_once_per_own_scope_and_results_merged(self): + s1 = _scope("weather.forecast", service_id=FOCUS_ID) + s2 = _scope("weather.history", service_id=FOCUS_ID) + other_role = _role("github.agent", kind=RoleKind.AGENT) + focus = _service(FOCUS_ID, scopes=[s1, s2]) + other = _service(OTHER_ID, roles=[other_role]) + r1, r2 = _rule(other_role, s1), _rule(other_role, s2) + + result, bsr, brr, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[s1, s2], + subjects=[], + scope_rules=lambda roles, scope: [r1] if scope.name == s1.name else [r2], + ) + + assert bsr.call_count == 2 + assert {c.args[1].name for c in bsr.call_args_list} == {s1.name, s2.name} + brr.assert_not_called() + assert result == [r1, r2] + + +class TestAgent: + def test_scope_rules_per_own_scope_and_role_rules_per_own_role(self): + own_role = _role("weather.agent") + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + other_role = _role("github.agent", kind=RoleKind.AGENT) + other_scope = _scope("github.issue", service_id=OTHER_ID) + focus = _service(FOCUS_ID, roles=[own_role], scopes=[own_scope], service_type=ServiceType.AGENT) + other = _service(OTHER_ID, roles=[other_role], scopes=[other_scope]) + scope_rule = _rule(other_role, own_scope) + role_rule = _rule(own_role, other_scope) + + result, bsr, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus, other], + all_scopes=[own_scope, other_scope], + subjects=[], + scope_rules=lambda roles, scope: [scope_rule], + role_rules=lambda role, scopes: [role_rule], + ) + + # scope side: once per own scope, roles list is the other-agent-role universe + assert bsr.call_count == 1 + s_roles, s_scope = bsr.call_args.args + assert s_scope.name == "weather.forecast" + assert [r.name for r in s_roles] == ["github.agent"] + + # role side: once per own role, scopes list is the other-scope universe + assert brr.call_count == 1 + r_role, r_scopes = brr.call_args.args + assert r_role.name == "weather.agent" + assert [s.name for s in r_scopes] == ["github.issue"] + + assert result == [scope_rule, role_rule] + + +class TestFlattening: + def test_composite_other_role_expanded_to_closure_deduped_by_id(self): + reader = _role("github.reader", role_id="reader-id", kind=RoleKind.AGENT) + # composite whose closure includes reader, which also appears standalone + admin = _role( + "github.admin", role_id="admin-id", composite=True, children=[reader], kind=RoleKind.AGENT + ) + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + focus = _service(FOCUS_ID, scopes=[own_scope]) + other = _service(OTHER_ID, roles=[admin, reader]) + + _, bsr, _, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[], + ) + + passed_roles = bsr.call_args.args[0] + # closure of admin (admin + reader) unioned with standalone reader, deduped by id + assert [r.name for r in passed_roles] == ["github.admin", "github.reader"] + assert [r.id for r in passed_roles] == ["admin-id", "reader-id"] + + def test_composite_own_agent_role_calls_build_role_rules_per_closure_member(self): + sub = _role("weather.reader", role_id="wr-id") + own_role = _role("weather.admin", role_id="wa-id", composite=True, children=[sub]) + other_scope = _scope("github.issue", service_id=OTHER_ID) + focus = _service(FOCUS_ID, roles=[own_role], service_type=ServiceType.AGENT) + other = _service(OTHER_ID, scopes=[other_scope]) + + _, _, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus, other], + all_scopes=[other_scope], + subjects=[], + ) + + assert brr.call_count == 2 + assert {c.args[0].name for c in brr.call_args_list} == {"weather.admin", "weather.reader"} + + +class TestSelfExclusion: + """Exclusion is by ownership (role id / scope.serviceId), never by name — the other + service's role/scope below intentionally shares a name with the focus's own, to prove + that name is not what drives exclusion.""" + + def test_own_role_and_scope_excluded_from_other_universe_even_when_name_matches(self): + own_role = _role("shared.name", role_id="own-role-id") + own_scope = _scope("shared.scope", scope_id="own-scope-id", service_id=FOCUS_ID) + other_role = _role("shared.name", role_id="other-role-id", kind=RoleKind.AGENT) + other_scope = _scope("shared.scope", scope_id="other-scope-id", service_id=OTHER_ID) + focus = _service(FOCUS_ID, roles=[own_role], scopes=[own_scope], service_type=ServiceType.AGENT) + other = _service(OTHER_ID, roles=[other_role], scopes=[other_scope]) + + _, bsr, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus, other], + all_scopes=[own_scope, other_scope], + subjects=[], + ) + + # own role never in the roles list handed to build_scope_rules — only the other + # service's same-named-but-differently-owned role is present + assert [r.id for r in bsr.call_args.args[0]] == ["other-role-id"] + # own scope never in the scopes list handed to build_role_rules + assert [s.id for s in brr.call_args.args[1]] == ["other-scope-id"] + + +class TestSelfMappingInvariant: + def test_no_own_role_in_any_scope_call_and_no_own_scope_in_any_role_call(self): + own_roles = [_role("weather.agent"), _role("weather.admin")] + own_scopes = [ + _scope("weather.forecast", service_id=FOCUS_ID), + _scope("weather.history", service_id=FOCUS_ID), + ] + other_roles = [_role("github.agent", kind=RoleKind.AGENT), _role("slack.bot", kind=RoleKind.AGENT)] + other_scopes = [ + _scope("github.issue", service_id=OTHER_ID), + _scope("slack.post", service_id=OTHER_ID), + ] + own_role_ids = {r.id for r in own_roles} + own_scope_ids = {s.id for s in own_scopes} + + focus = _service(FOCUS_ID, roles=own_roles, scopes=own_scopes, service_type=ServiceType.AGENT) + other = _service(OTHER_ID, roles=other_roles, scopes=other_scopes) + + _, bsr, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus, other], + all_scopes=own_scopes + other_scopes, + subjects=[], + ) + + # across ALL build_scope_rules calls, no roles list contains an own role + for c in bsr.call_args_list: + assert own_role_ids.isdisjoint({r.id for r in c.args[0]}) + # across ALL build_role_rules calls, no scopes list contains an own scope + for c in brr.call_args_list: + assert own_scope_ids.isdisjoint({s.id for s in c.args[1]}) + + +class TestOwnershipBeatsMembership: + def test_role_owned_by_focus_and_held_by_user_is_excluded_from_candidates(self): + own_role = _role("weather.admin", role_id="own-role-id") + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + # a user also holds the focus's own role — ownership must still exclude it + subject = _subject("alice", roles=[own_role]) + focus = _service(FOCUS_ID, roles=[own_role], scopes=[own_scope]) + other = _service(OTHER_ID) + + _, bsr, _, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[subject], + ) + + assert bsr.call_args.args[0] == [] + + +class TestKindRouting: + def test_other_agent_role_carries_agent_kind_and_user_role_carries_user_kind(self): + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + other_role = _role("github.agent", kind=RoleKind.AGENT) + user_role = _role("realm.viewer", kind=RoleKind.USER, aiac_managed=False) + subject = _subject("alice", roles=[user_role]) + focus = _service(FOCUS_ID, scopes=[own_scope]) + other = _service(OTHER_ID, roles=[other_role]) + + result, bsr, _, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[subject], + scope_rules=lambda roles, scope: [_rule(r, scope) for r in roles], + ) + + by_name = {rule.role.name: rule.role.kind for rule in result} + assert by_name["github.agent"] == RoleKind.AGENT + assert by_name["realm.viewer"] == RoleKind.USER + + +class TestBuiltInScopeDropped: + def test_non_aiac_managed_own_scope_never_reaches_build_scope_rules(self): + managed_scope = _scope("weather.forecast", service_id=FOCUS_ID) + builtin_scope = _scope("profile", service_id=FOCUS_ID, aiac_managed=False) + focus = _service(FOCUS_ID, scopes=[managed_scope, builtin_scope]) + other = _service(OTHER_ID) + + _, bsr, _, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[managed_scope, builtin_scope], + subjects=[], + ) + + assert bsr.call_count == 1 + assert bsr.call_args.args[1].name == "weather.forecast" + + +class TestMissingFocus: + def test_service_id_not_in_catalog_raises_http_exception_not_stopiteration(self): + other = _service(OTHER_ID) + + with pytest.raises(HTTPException) as ei: + _invoke( + ServiceType.TOOL, + services=[other], + all_scopes=[], + subjects=[], + service_id="svc-does-not-exist", + ) + + assert ei.value.status_code == 404 + + +class TestFocusResolvedByInternalId: + """The trigger id handed to build() is the Keycloak internal client UUID (Service.id), not the + human-readable clientId (Service.serviceId) — the /apply/service/{id} route is keyed on the UUID + because a clientId can be a slash-bearing SPIFFE URI. Regression for the id-shape mismatch that + made every onboard 404 (focus matched on serviceId instead of id).""" + + UUID = "f5592be1-uuid" + CLIENT_ID = "spiffe://localtest.me/ns/team1/sa/github-agent" + + def test_focus_resolved_by_uuid_when_serviceid_differs(self): + own_scope = _scope("weather.forecast", service_id=self.UUID) + focus = _service(self.UUID, ref=self.CLIENT_ID, scopes=[own_scope]) + other = _service(OTHER_ID, ref="svc-other-client") + + result, bsr, _, _ = _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[], + service_id=self.UUID, # the route/trigger id is the UUID, not the clientId + ) + + # resolved (no 404); the focus's own scope reached build_scope_rules + assert bsr.call_count == 1 + assert bsr.call_args.args[1].name == "weather.forecast" + + def test_passing_the_clientid_does_not_resolve_focus(self): + focus = _service(self.UUID, ref=self.CLIENT_ID) + other = _service(OTHER_ID, ref="svc-other-client") + + with pytest.raises(HTTPException) as ei: + _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[], + subjects=[], + service_id=self.CLIENT_ID, # clientId is NOT the route key → not found + ) + + assert ei.value.status_code == 404 + + +class TestEmptyUniverse: + def test_no_other_services_or_subjects_yields_no_rules_without_error(self): + focus = _service(FOCUS_ID) + + result, bsr, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus], + all_scopes=[], + subjects=[], + ) + + bsr.assert_not_called() + brr.assert_not_called() + assert result == [] + + def test_own_entities_only_invokes_prb_with_empty_lists(self): + own_role, own_scope = _role("weather.agent"), _scope("weather.forecast", service_id=FOCUS_ID) + focus = _service(FOCUS_ID, roles=[own_role], scopes=[own_scope], service_type=ServiceType.AGENT) + + result, bsr, brr, _ = _invoke( + ServiceType.AGENT, + services=[focus], # no other services, no subjects + all_scopes=[own_scope], + subjects=[], + ) + + assert bsr.call_count == 1 + assert bsr.call_args.args[0] == [] # empty candidate-roles universe + assert brr.call_count == 1 + assert brr.call_args.args[1] == [] # empty other-scopes universe + assert result == [] + + +class TestErrors: + def test_get_services_unavailable_is_502(self): + with pytest.raises(HTTPException) as ei: + _invoke( + ServiceType.TOOL, + services=[], + all_scopes=[], + subjects=[], + get_services_exc=RuntimeError("HTTP 503"), + ) + assert ei.value.status_code == 502 + + def test_get_subjects_unavailable_is_502(self): + focus = _service(FOCUS_ID) + with pytest.raises(HTTPException) as ei: + _invoke( + ServiceType.TOOL, + services=[focus], + all_scopes=[], + subjects=[], + get_subjects_exc=RuntimeError("HTTP 500"), + ) + assert ei.value.status_code == 502 + + def test_prb_exception_propagates_no_partial_apply(self): + own_scope = _scope("weather.forecast", service_id=FOCUS_ID) + other_role = _role("github.agent", kind=RoleKind.AGENT) + focus = _service(FOCUS_ID, scopes=[own_scope]) + other = _service(OTHER_ID, roles=[other_role]) + + def _boom(roles, scope): + raise RuntimeError("LLM/ChromaDB failure") + + with pytest.raises(RuntimeError, match="LLM/ChromaDB failure"): + _invoke( + ServiceType.TOOL, + services=[focus, other], + all_scopes=[own_scope], + subjects=[], + scope_rules=_boom, + ) diff --git a/aiac/test/agent/uc/onboarding/provision/__init__.py b/aiac/test/agent/uc/onboarding/provision/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/agent/uc/onboarding/provision/test_analyze_agent.py b/aiac/test/agent/uc/onboarding/provision/test_analyze_agent.py new file mode 100644 index 000000000..38e4b72bb --- /dev/null +++ b/aiac/test/agent/uc/onboarding/provision/test_analyze_agent.py @@ -0,0 +1,109 @@ +"""Unit tests for the Service Provision `analyze_agent` node (UC1, issue 4.3). + +Kubernetes access (AgentCard CRs) is mocked via the `_custom_objects` seam. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding.provision import kube, nodes +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger + +NS = "team-a" +WORKLOAD = "weather" + + +def _state(): + return OnboardingProvisionState( + trigger=Trigger(entity_id="svc-123"), namespace=NS, workload_name=WORKLOAD + ) + + +def _card(name=WORKLOAD, skills=None): + """An AgentCard CR as the operator syncs it: the fetched A2A card lives under ``status.card``, + and each skill carries a machine ``id`` (a stable identifier) plus a display ``name``.""" + return {"metadata": {"name": name}, "status": {"card": {"skills": skills or []}}} + + +def _run(items=None, list_exc=None): + with patch.object(kube, "_custom_objects") as co: + client = MagicMock() + if list_exc is not None: + client.list_namespaced_custom_object.side_effect = list_exc + else: + client.list_namespaced_custom_object.return_value = {"items": items or []} + co.return_value = client + return nodes.analyze_agent(_state()) + + +class TestAnalyzeAgentFound: + def test_one_operator_role_and_one_scope_per_skill(self): + # Scope names come from each skill's machine ``id`` — not its display ``name`` (which may + # contain spaces) — so they are stable identifiers usable as Keycloak scope names. Each skill + # also yields a per-skill operator role mirroring the scope (same name + description); the + # role's description drives the PRB capability-match. No generic ``{workload}.agent`` role. + skills = [ + {"id": "forecast", "name": "Weather forecast operations", "description": "Get forecast"}, + {"id": "history", "name": "Historical data operations", "description": "Historical data"}, + ] + provision = _run(items=[_card(skills=skills)])["service_provision"] + + assert [r.name for r in provision.roles] == [f"{WORKLOAD}.forecast", f"{WORKLOAD}.history"] + assert provision.roles[0].description == "Get forecast" + assert [s.name for s in provision.scopes] == [f"{WORKLOAD}.forecast", f"{WORKLOAD}.history"] + assert provision.scopes[0].description == "Get forecast" + # role name == scope name per skill (distinct Keycloak objects: realm role vs client scope) + assert [r.name for r in provision.roles] == [s.name for s in provision.scopes] + assert f"{WORKLOAD}.agent" not in [r.name for r in provision.roles] + assert "derived from AgentCard: 2 skills" == provision.reasoning + + + def test_agentcard_matched_by_targetref_not_metadata_name(self): + # The operator names the card after the Deployment (e.g. "-deployment-card") and + # points spec.targetRef at the workload; provision must link the card by targetRef, not by + # metadata.name == workload. + card = { + "metadata": {"name": f"{WORKLOAD}-deployment-card"}, + "spec": {"targetRef": {"kind": "Deployment", "name": WORKLOAD}}, + "status": {"card": {"skills": [{"id": "forecast", "name": "F", "description": "d"}]}}, + } + provision = _run(items=[card])["service_provision"] + + assert [s.name for s in provision.scopes] == [f"{WORKLOAD}.forecast"] + assert "derived from AgentCard: 1 skills" == provision.reasoning + + +class TestAnalyzeAgentLegacyFallback: + def test_no_agentcard_yields_default_access_scope_and_partial_reasoning(self): + provision = _run(items=[])["service_provision"] + + # No-skills fallback: a default operator role mirrors the default access scope. + assert [r.name for r in provision.roles] == [f"{WORKLOAD}.access"] + assert provision.roles[0].description == "Default access scope" + assert [s.name for s in provision.scopes] == [f"{WORKLOAD}.access"] + assert provision.scopes[0].description == "Default access scope" + assert "partial: no AgentCard found" in provision.reasoning + + def test_agentcard_present_but_no_name_match_is_legacy_fallback(self): + provision = _run( + items=[_card(name="other-agent", skills=[{"id": "x", "name": "X", "description": "y"}])] + )["service_provision"] + assert [s.name for s in provision.scopes] == [f"{WORKLOAD}.access"] + assert "partial: no AgentCard found" in provision.reasoning + + def test_agentcard_present_but_no_synced_skills_is_fallback(self): + # The CR exists but its ``status.card`` has not synced any skills yet (e.g. the operator has + # not fetched the A2A card): fall back to a default access scope rather than provision none. + provision = _run(items=[_card(skills=[])])["service_provision"] + assert [s.name for s in provision.scopes] == [f"{WORKLOAD}.access"] + assert "no synced skills" in provision.reasoning + + +class TestAnalyzeAgent502: + def test_k8s_agentcards_list_failure_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with pytest.raises(HTTPException) as ei: + _run(list_exc=RuntimeError("apiserver down")) + assert ei.value.status_code == 502 diff --git a/aiac/test/agent/uc/onboarding/provision/test_analyze_tool.py b/aiac/test/agent/uc/onboarding/provision/test_analyze_tool.py new file mode 100644 index 000000000..2079afb88 --- /dev/null +++ b/aiac/test/agent/uc/onboarding/provision/test_analyze_tool.py @@ -0,0 +1,158 @@ +"""Unit tests for the Service Provision `analyze_tool` node (UC1, issue 4.3). + +Kubernetes Service lookup (`_core_v1` seam) and the MCP `tools/list` call (`_mcp_tools_list` +seam) are mocked. `namespace` + `workload_name` are pre-set on state by `classify_service`. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding.provision import kube, nodes +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger + +NS = "team-a" +WORKLOAD = "github-tool" +MCP_LABEL = "protocol.kagenti.io/mcp" + + +def _state(): + return OnboardingProvisionState( + trigger=Trigger(entity_id="svc-9"), namespace=NS, workload_name=WORKLOAD + ) + + +def _svc(labels, port=8080): + return SimpleNamespace( + metadata=SimpleNamespace(labels=labels), + spec=SimpleNamespace(ports=[SimpleNamespace(port=port)]), + ) + + +def _run(svc=None, read_exc=None, tools=None, mcp_exc=None): + with ( + patch.object(kube, "_core_v1") as core_v1, + patch.object(nodes, "_mcp_tools_list") as mcp, + patch.object(nodes, "_discovery_token", return_value="disco-tok") as disco, + ): + core = MagicMock() + if read_exc is not None: + core.read_namespaced_service.side_effect = read_exc + else: + core.read_namespaced_service.return_value = svc + core_v1.return_value = core + if mcp_exc is not None: + mcp.side_effect = mcp_exc + else: + mcp.return_value = tools or [] + result = nodes.analyze_tool(_state()) + return result, mcp, disco + + +class TestAnalyzeToolFound: + def test_scopes_one_per_tool_roles_empty_and_endpoint_built(self): + tools = [ + {"name": "create_issue", "description": "Open an issue"}, + {"name": "list_repos", "description": "List repos"}, + ] + result, mcp, disco = _run(svc=_svc({MCP_LABEL: ""}), tools=tools) + provision = result["service_provision"] + + assert provision.roles == [] + assert [s.name for s in provision.scopes] == [ + f"{WORKLOAD}.create_issue", + f"{WORKLOAD}.list_repos", + ] + assert provision.scopes[0].description == "Open an issue" + assert "derived from MCP manifest: 2 tools" == provision.reasoning + mcp.assert_called_once_with( + f"http://{WORKLOAD}.{NS}.svc.cluster.local:8080/mcp", token="disco-tok" + ) + + def test_endpoint_uses_services_first_port(self): + _run(svc=_svc({MCP_LABEL: ""}, port=9000), tools=[])[1].assert_called_once_with( + f"http://{WORKLOAD}.{NS}.svc.cluster.local:9000/mcp", token="disco-tok" + ) + + def test_authenticated_discovery_uses_minted_token(self): + # The token comes from the _discovery_token seam (config service mints it) and is threaded + # through to the MCP probe as the Bearer credential. + _, mcp, disco = _run(svc=_svc({MCP_LABEL: ""}), tools=[]) + disco.assert_called_once() + assert mcp.call_args.kwargs["token"] == "disco-tok" + + +class TestAnalyzeToolEmpty: + def test_empty_tools_list_yields_no_roles_no_scopes(self): + provision = _run(svc=_svc({MCP_LABEL: ""}), tools=[])[0]["service_provision"] + assert provision.roles == [] + assert provision.scopes == [] + + +class TestAnalyzeTool502: + def test_service_without_mcp_label_is_502_naming_workload_and_label(self): + with pytest.raises(HTTPException) as ei: + _run(svc=_svc({"app": "x"})) + assert ei.value.status_code == 502 + assert WORKLOAD in ei.value.detail + assert MCP_LABEL in ei.value.detail + + def test_service_get_failure_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with pytest.raises(HTTPException) as ei: + _run(read_exc=RuntimeError("404 not found")) + assert ei.value.status_code == 502 + + def test_mcp_call_failure_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with pytest.raises(HTTPException) as ei: + _run(svc=_svc({MCP_LABEL: ""}), mcp_exc=RuntimeError("connection refused")) + assert ei.value.status_code == 502 + + def test_discovery_token_failure_is_502_and_skips_tools_list(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with ( + patch.object(kube, "_core_v1") as core_v1, + patch.object(nodes, "_mcp_tools_list") as mcp, + patch.object(nodes, "_discovery_token", side_effect=RuntimeError("mint failed")), + ): + core = MagicMock() + core.read_namespaced_service.return_value = _svc({MCP_LABEL: ""}) + core_v1.return_value = core + with pytest.raises(HTTPException) as ei: + nodes.analyze_tool(_state()) + assert ei.value.status_code == 502 + assert "discovery token minting failed" in ei.value.detail + mcp.assert_not_called() + + +class TestMcpToolsList: + """Exercises the real `_mcp_tools_list` (not the seam) — the load-bearing auth change.""" + + def _resp(self, tools=None): + resp = MagicMock() + resp.json.return_value = {"result": {"tools": tools or []}} + resp.raise_for_status = MagicMock() + return resp + + def test_sends_bearer_and_timeout_when_token_present(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with patch("requests.post", return_value=self._resp()) as post: + nodes._mcp_tools_list("http://x/mcp", token="abc") + kwargs = post.call_args.kwargs + assert kwargs["headers"]["Authorization"] == "Bearer abc" + assert kwargs["timeout"] == nodes._MCP_TIMEOUT + + def test_no_auth_header_when_token_absent(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with patch("requests.post", return_value=self._resp()) as post: + nodes._mcp_tools_list("http://x/mcp") + assert "Authorization" not in post.call_args.kwargs["headers"] + + def test_returns_tools_from_result(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + tools = [{"name": "t1", "description": "d"}] + with patch("requests.post", return_value=self._resp(tools)): + assert nodes._mcp_tools_list("http://x/mcp", token="abc") == tools diff --git a/aiac/test/agent/uc/onboarding/provision/test_classify_service.py b/aiac/test/agent/uc/onboarding/provision/test_classify_service.py new file mode 100644 index 000000000..ccdd9bac5 --- /dev/null +++ b/aiac/test/agent/uc/onboarding/provision/test_classify_service.py @@ -0,0 +1,115 @@ +"""Unit tests for the Service Provision `classify_service` node (UC1, issue 4.3). + +The idp-library `Configuration` (via the `_config` seam) and the Kubernetes API (via the +`_core_v1` seam) are mocked — no live services. All provision nodes are non-LLM. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding.provision import kube, nodes +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger +from aiac.idp.configuration.models import Service, ServiceType + +ENTITY = "svc-123" + + +def _state(): + return OnboardingProvisionState(trigger=Trigger(entity_id=ENTITY)) + + +def _service(name="team-a/weather"): + return Service.model_validate({"id": ENTITY, "clientId": ENTITY, "name": name, "enabled": True}) + + +def _pod(labels, owner_kind="ReplicaSet", owner_name="weather-abc123"): + return SimpleNamespace( + metadata=SimpleNamespace( + labels=labels, + owner_references=[SimpleNamespace(kind=owner_kind, name=owner_name)], + ) + ) + + +def _core(pods): + core = MagicMock() + core.list_namespaced_pod.return_value = SimpleNamespace(items=pods) + return core + + +def _run(service=None, pods=None, get_service_exc=None, list_pods_exc=None): + with patch.object(nodes, "_config") as cfg, patch.object(kube, "_core_v1") as core_v1: + if get_service_exc is not None: + cfg.return_value.get_service.side_effect = get_service_exc + else: + cfg.return_value.get_service.return_value = service + core = _core(pods or []) + if list_pods_exc is not None: + core.list_namespaced_pod.side_effect = list_pods_exc + core_v1.return_value = core + return nodes.classify_service(_state()) + + +class TestClassifyServiceHappyPaths: + def test_agent_label_routes_to_agent_and_sets_identity(self): + result = _run(service=_service(), pods=[_pod({"kagenti.io/type": "agent"})]) + assert result["service_id"] == ENTITY + assert result["namespace"] == "team-a" + assert result["workload_name"] == "weather" + assert result["service_type"] is ServiceType.AGENT + + def test_tool_label_routes_to_tool(self): + result = _run(service=_service(), pods=[_pod({"kagenti.io/type": "tool"})]) + assert result["service_type"] is ServiceType.TOOL + assert result["namespace"] == "team-a" + assert result["workload_name"] == "weather" + + def test_service_id_stored_from_trigger_entity_id(self): + result = _run(service=_service(), pods=[_pod({"kagenti.io/type": "agent"})]) + assert result["service_id"] == ENTITY + + def test_statefulset_owner_matched_by_exact_name(self): + pod = _pod({"kagenti.io/type": "tool"}, owner_kind="StatefulSet", owner_name="weather") + result = _run(service=_service(), pods=[pod]) + assert result["service_type"] is ServiceType.TOOL + + +class TestClassifyService502s: + def test_label_absent_is_502_naming_workload_and_label(self): + with pytest.raises(HTTPException) as ei: + _run(service=_service(), pods=[_pod({})]) + assert ei.value.status_code == 502 + assert "weather" in ei.value.detail + assert "kagenti.io/type" in ei.value.detail + + def test_label_unknown_value_is_502(self): + with pytest.raises(HTTPException) as ei: + _run(service=_service(), pods=[_pod({"kagenti.io/type": "sidecar"})]) + assert ei.value.status_code == 502 + + def test_client_name_without_slash_is_502(self): + with pytest.raises(HTTPException) as ei: + _run(service=_service(name="no-slash-name"), pods=[_pod({"kagenti.io/type": "agent"})]) + assert ei.value.status_code == 502 + + def test_config_api_down_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with pytest.raises(HTTPException) as ei: + _run(get_service_exc=RuntimeError("HTTP 503"), pods=[]) + assert ei.value.status_code == 502 + + def test_k8s_pod_list_failure_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + with pytest.raises(HTTPException) as ei: + _run(service=_service(), list_pods_exc=RuntimeError("boom")) + assert ei.value.status_code == 502 + + def test_no_pod_owned_by_workload_is_502(self): + unrelated = _pod({"kagenti.io/type": "agent"}, owner_name="other-xyz") + with pytest.raises(HTTPException) as ei: + _run(service=_service(), pods=[unrelated]) + assert ei.value.status_code == 502 + assert "weather" in ei.value.detail diff --git a/aiac/test/agent/uc/onboarding/provision/test_graph.py b/aiac/test/agent/uc/onboarding/provision/test_graph.py new file mode 100644 index 000000000..01a2f9bce --- /dev/null +++ b/aiac/test/agent/uc/onboarding/provision/test_graph.py @@ -0,0 +1,104 @@ +"""Unit tests for the Service Provision graph wiring (UC1, issue 3.4). + +Covers the conditional routing on `service_type` and end-to-end node wiring with all +external seams (idp-library `Configuration`, Kubernetes, MCP) mocked. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from aiac.agent.uc.onboarding.provision import graph as graph_mod +from aiac.agent.uc.onboarding.provision import kube, nodes +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger +from aiac.idp.configuration.models import Service, ServiceType + +ENTITY = "svc-1" + + +def _get(result, key): + return result[key] if isinstance(result, dict) else getattr(result, key) + + +def _pod(type_value): + return SimpleNamespace( + metadata=SimpleNamespace( + labels={"kagenti.io/type": type_value}, + owner_references=[SimpleNamespace(kind="ReplicaSet", name="weather-abc")], + ) + ) + + +def _service(): + return Service.model_validate( + {"id": ENTITY, "clientId": ENTITY, "name": "team-a/weather", "enabled": True} + ) + + +class TestRoute: + def test_agent_routes_to_analyze_agent(self): + state = OnboardingProvisionState(trigger=Trigger(entity_id=ENTITY), service_type=ServiceType.AGENT) + assert graph_mod._route(state) == "analyze_agent" + + def test_tool_routes_to_analyze_tool(self): + state = OnboardingProvisionState(trigger=Trigger(entity_id=ENTITY), service_type=ServiceType.TOOL) + assert graph_mod._route(state) == "analyze_tool" + + +class TestGraphCompiles: + def test_build_returns_compiled_graph(self): + assert graph_mod.build_provision_graph() is not None + + +class TestEndToEnd: + def _invoke(self): + return graph_mod.build_provision_graph().invoke( + OnboardingProvisionState(trigger=Trigger(entity_id=ENTITY)) + ) + + def test_agent_path_end_to_end(self): + with ( + patch.object(nodes, "_config") as cfg, + patch.object(kube, "_core_v1") as core_v1, + patch.object(kube, "_custom_objects") as co, + ): + cfg.return_value.get_service.return_value = _service() + core = MagicMock() + core.list_namespaced_pod.return_value = SimpleNamespace(items=[_pod("agent")]) + core_v1.return_value = core + co.return_value.list_namespaced_custom_object.return_value = { + "items": [ + { + "metadata": {"name": "weather"}, + "status": {"card": {"skills": [{"id": "forecast", "name": "F", "description": "d"}]}}, + } + ] + } + result = self._invoke() + + assert _get(result, "service_type") is ServiceType.AGENT + provision = _get(result, "service_provision") + # one per-skill operator role, mirroring the scope (no generic weather.agent role) + assert [r.name for r in provision.roles] == ["weather.forecast"] + assert [s.name for s in provision.scopes] == ["weather.forecast"] + cfg.return_value.set_service_type.assert_called_once() + + def test_tool_path_end_to_end(self): + with ( + patch.object(nodes, "_config") as cfg, + patch.object(kube, "_core_v1") as core_v1, + patch.object(nodes, "_mcp_tools_list", return_value=[{"name": "t1", "description": "d"}]), + ): + cfg.return_value.get_service.return_value = _service() + core = MagicMock() + core.list_namespaced_pod.return_value = SimpleNamespace(items=[_pod("tool")]) + core.read_namespaced_service.return_value = SimpleNamespace( + metadata=SimpleNamespace(labels={"protocol.kagenti.io/mcp": ""}), + spec=SimpleNamespace(ports=[SimpleNamespace(port=8080)]), + ) + core_v1.return_value = core + result = self._invoke() + + assert _get(result, "service_type") is ServiceType.TOOL + provision = _get(result, "service_provision") + assert provision.roles == [] + assert [s.name for s in provision.scopes] == ["weather.t1"] diff --git a/aiac/test/agent/uc/onboarding/provision/test_provision_service.py b/aiac/test/agent/uc/onboarding/provision/test_provision_service.py new file mode 100644 index 000000000..be11d8a7f --- /dev/null +++ b/aiac/test/agent/uc/onboarding/provision/test_provision_service.py @@ -0,0 +1,112 @@ +"""Unit tests for the Service Provision `provision_service` node (UC1, issue 4.3). + +The idp-library `Configuration` is mocked via the `_config` seam — no service HTTP layer. +""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding.provision import nodes +from aiac.agent.uc.onboarding.provision.state import OnboardingProvisionState, Trigger +from aiac.agent.uc.onboarding.provision.types import ( + RoleDefinition, + ScopeDefinition, + ServiceProvision, +) +from aiac.idp.configuration.models import Service, ServiceType + +SERVICE_ID = "svc-123" + + +def _state(roles, scopes, service_type=ServiceType.AGENT, reasoning="r"): + return OnboardingProvisionState( + trigger=Trigger(entity_id=SERVICE_ID), + service_id=SERVICE_ID, + namespace="team-a", + workload_name="weather", + service_type=service_type, + service_provision=ServiceProvision(roles=roles, scopes=scopes, reasoning=reasoning), + ) + + +def _service(): + return Service.model_validate({"id": SERVICE_ID, "clientId": SERVICE_ID, "enabled": True}) + + +def _run(state, *, get_service_exc=None): + with patch.object(nodes, "_config") as cfg: + conf = MagicMock() + if get_service_exc is not None: + conf.get_service.side_effect = get_service_exc + else: + conf.get_service.return_value = _service() + cfg.return_value = conf + result = nodes.provision_service(state) + return result, conf + + +class TestProvisionServiceWrites: + def test_create_service_role_called_once_per_role(self): + roles = [ + RoleDefinition(name="weather.agent", description="Agent role"), + RoleDefinition(name="weather.admin", description="Admin role"), + ] + _, conf = _run(_state(roles, [])) + assert conf.create_service_role.call_count == 2 + conf.create_service_role.assert_has_calls( + [call(SERVICE_ID, roles[0]), call(SERVICE_ID, roles[1])] + ) + + def test_create_service_scope_called_once_per_scope(self): + scopes = [ + ScopeDefinition(name="weather.forecast", description="Forecast"), + ScopeDefinition(name="weather.history", description="History"), + ] + _, conf = _run(_state([], scopes)) + assert conf.create_service_scope.call_count == 2 + conf.create_service_scope.assert_has_calls( + [call(SERVICE_ID, scopes[0]), call(SERVICE_ID, scopes[1])] + ) + + def test_no_entries_makes_no_create_calls(self): + _, conf = _run(_state([], [])) + conf.create_service_role.assert_not_called() + conf.create_service_scope.assert_not_called() + + +class TestProvisionServicePersistsType: + def test_set_service_type_called_with_resolved_service_type(self): + _, conf = _run(_state([], [], service_type=ServiceType.TOOL)) + assert conf.set_service_type.call_count == 1 + passed_service, passed_type = conf.set_service_type.call_args.args + assert passed_type is ServiceType.TOOL + assert passed_service is conf.get_service.return_value + + def test_agent_type_persisted_as_capitalized_value(self): + _, conf = _run(_state([], [], service_type=ServiceType.AGENT)) + assert conf.set_service_type.call_args.args[1] == "Agent" + + +class TestProvisionServiceReturn: + def test_returns_service_provision_and_service_type_to_orchestrator(self): + roles = [RoleDefinition(name="weather.agent", description="Agent role")] + state = _state(roles, [], service_type=ServiceType.AGENT) + result, _ = _run(state) + assert result["service_provision"] is state.service_provision + assert result["service_type"] is ServiceType.AGENT + + +class TestProvisionService502: + def test_idp_unavailable_is_502(self, monkeypatch): + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + roles = [RoleDefinition(name="weather.agent", description="Agent role")] + state = _state(roles, []) + with patch.object(nodes, "_config") as cfg: + conf = MagicMock() + conf.create_service_role.side_effect = RuntimeError("HTTP 503") + cfg.return_value = conf + with pytest.raises(HTTPException) as ei: + nodes.provision_service(state) + assert ei.value.status_code == 502 diff --git a/aiac/test/agent/uc/onboarding/test_orchestrator.py b/aiac/test/agent/uc/onboarding/test_orchestrator.py new file mode 100644 index 000000000..7d30bf9b2 --- /dev/null +++ b/aiac/test/agent/uc/onboarding/test_orchestrator.py @@ -0,0 +1,91 @@ +"""Unit tests for the Service Onboarding Orchestrator (UC1, issue 4.5). + +The Orchestrator is a plain function that sequences exactly two stages: +Service Provision (a compiled StateGraph) -> Service Policy Builder. Both are mocked +here via the module-level `build_provision_graph` / `ServicePolicyBuilder` seams — no +live graph, IdP, Kubernetes, or LLM. The Orchestrator applies nothing (no PCE call); it +returns `(list[PolicyRule], override=False)` to the Controller, which makes the single +`compute_and_apply` call afterwards. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from aiac.agent.uc.onboarding import orchestrator +from aiac.idp.configuration.models import ServiceType + +SERVICE_ID = "svc-1" + + +class TestBothStagesSucceed: + def test_provision_result_fed_to_builder_and_rules_returned_with_override_false(self): + # The Orchestrator treats the rule list as opaque — the builder (mocked) owns + # PolicyRule construction, so a sentinel list is enough to prove pass-through. + rules = [object()] + graph = MagicMock() + graph.invoke.return_value = {"service_type": ServiceType.AGENT} + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + ): + spb.build.return_value = rules + result = orchestrator.onboard_service(SERVICE_ID) + + # service_type produced by Provision is fed into the Service Policy Builder + spb.build.assert_called_once_with(SERVICE_ID, ServiceType.AGENT) + # Orchestrator returns the builder's rules paired with the append flag + assert result == (rules, False) + + def test_provision_graph_invoked_with_service_id_in_trigger(self): + # The service_id must reach Provision as the trigger's entity_id (Keycloak + # client_id) — otherwise Provision classifies the wrong service. The other + # tests never inspect the graph's argument, so this guards that wiring. + graph = MagicMock() + graph.invoke.return_value = {"service_type": ServiceType.AGENT} + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + ): + spb.build.return_value = [object()] + orchestrator.onboard_service(SERVICE_ID) + + (state,), _ = graph.invoke.call_args + assert state.trigger.entity_id == SERVICE_ID + + +class TestProvisionFails: + def test_builder_not_called_and_provision_error_propagates(self): + graph = MagicMock() + graph.invoke.side_effect = HTTPException(502, "IdP config unavailable") + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + ): + with pytest.raises(HTTPException) as exc: + orchestrator.onboard_service(SERVICE_ID) + + assert exc.value.status_code == 502 + spb.build.assert_not_called() + + +class TestBuilderFails: + def test_builder_error_propagates_after_provision_ran(self): + graph = MagicMock() + graph.invoke.return_value = {"service_type": ServiceType.TOOL} + + with ( + patch.object(orchestrator, "build_provision_graph", return_value=graph), + patch.object(orchestrator, "ServicePolicyBuilder") as spb, + ): + spb.build.side_effect = HTTPException(502, "IdP Configuration Service unavailable") + with pytest.raises(HTTPException) as exc: + orchestrator.onboard_service(SERVICE_ID) + + assert exc.value.status_code == 502 + # Provision ran before the builder was reached + graph.invoke.assert_called_once() diff --git a/aiac/test/idp/__init__.py b/aiac/test/idp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/idp/configuration/__init__.py b/aiac/test/idp/configuration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/idp/configuration/show_keycloak_data.py b/aiac/test/idp/configuration/show_keycloak_data.py new file mode 100644 index 000000000..ab0b4b7ed --- /dev/null +++ b/aiac/test/idp/configuration/show_keycloak_data.py @@ -0,0 +1,129 @@ +"""Print live Keycloak data via the IdP configuration service, exercising all Configuration methods. + +Usage: + .venv/bin/python test/idp/configuration/show_keycloak_data.py + +Requires the service to be reachable at AIAC_PDP_CONFIG_URL (default: http://127.0.0.1:7071). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import Role, Scope, Service, Subject + +REALM = "kagenti" + + +def _fmt_roles(roles: list[Role], indent: int = 4) -> str: + if not roles: + return " " * indent + "—" + pad = " " * indent + lines = [] + for r in roles: + composite = "composite" if r.composite else "simple" + lines.append(f"{pad}{r.name} (id={r.id}) [{composite}] desc={r.description or '—'}") + if r.childRoles: + for cr in r.childRoles: + lines.append(f"{pad} child: {cr.name} (id={cr.id})") + return "\n".join(lines) + + +def _fmt_scopes(scopes: list[Scope], indent: int = 4) -> str: + if not scopes: + return " " * indent + "—" + pad = " " * indent + return "\n".join(f"{pad}{sc.name} (id={sc.id}) desc={sc.description or '—'}" for sc in scopes) + + +def main() -> None: + cfg = Configuration.for_realm(REALM) + + # --- Subjects (users) --- + print("=== Subjects ===") + subjects: list[Subject] = cfg.get_subjects() + for s in subjects: + full_name = " ".join(filter(None, [s.firstName, s.lastName])) or "—" + status = "enabled" if s.enabled else "disabled" + print(f" id={s.id}") + print(f" username : {s.username}") + print(f" name : {full_name}") + print(f" email : {s.email or '—'}") + print(f" status : {status}") + if s.roles: + print(" roles:") + print(_fmt_roles(s.roles, indent=6)) + else: + print(" roles : —") + print(f"Total: {len(subjects)} subject(s)\n") + + # --- Roles --- + print("=== Roles ===") + roles: list[Role] = cfg.get_roles() + for r in roles: + composite = "composite" if r.composite else "simple" + print(f" id={r.id}") + print(f" name : {r.name}") + print(f" description : {r.description or '—'}") + print(f" type : {composite}") + if r.childRoles: + print(" childRoles:") + for cr in r.childRoles: + print(f" {cr.name} (id={cr.id})") + else: + print(" childRoles : —") + print(f"Total: {len(roles)} role(s)\n") + + # --- Services (clients) --- + print("=== Services ===") + services: list[Service] = cfg.get_services() + for svc in services: + status = "enabled" if svc.enabled else "disabled" + print(f" id={svc.id}") + print(f" serviceId : {svc.serviceId or '—'}") + print(f" name : {svc.name or '—'}") + print(f" description : {svc.description or '—'}") + print(f" status : {status}") + print(f" type : {svc.type or '—'}") + if svc.roles: + print(" roles:") + print(_fmt_roles(svc.roles, indent=6)) + else: + print(" roles : —") + if svc.scopes: + print(" scopes:") + print(_fmt_scopes(svc.scopes, indent=6)) + else: + print(" scopes : —") + print(f"Total: {len(services)} service(s)\n") + + # --- Scopes --- + print("=== Scopes ===") + scopes: list[Scope] = cfg.get_scopes() + for sc in scopes: + print(f" id={sc.id}") + print(f" name : {sc.name}") + print(f" description : {sc.description or '—'}") + print(f"Total: {len(scopes)} scope(s)\n") + + # --- Services by Role (PCE queries) --- + print("=== Services by Role ===") + for r in roles: + svcs_for_role: list[Service] = cfg.get_services_by_role(r) + names = ", ".join(s.serviceId or s.id or "?" for s in svcs_for_role) if svcs_for_role else "—" + print(f" {r.name}: {names}") + print() + + # --- Services by Scope (PCE queries) --- + print("=== Services by Scope ===") + for sc in scopes: + svcs_for_scope: list[Service] = cfg.get_services_by_scope(sc) + names = ", ".join(s.serviceId or s.id or "?" for s in svcs_for_scope) if svcs_for_scope else "—" + print(f" {sc.name}: {names}") + print() + + +if __name__ == "__main__": + main() diff --git a/aiac/test/idp/configuration/test_configuration.py b/aiac/test/idp/configuration/test_configuration.py new file mode 100644 index 000000000..e2b0bb5b9 --- /dev/null +++ b/aiac/test/idp/configuration/test_configuration.py @@ -0,0 +1,1172 @@ +"""Unit tests for aiac.idp.configuration.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import Role, RoleKind, Scope, Service, ServiceType, Subject + +REALM = "kagenti" +BASE = "http://127.0.0.1:7071" + + +@pytest.fixture(autouse=True) +def _single_attempt(monkeypatch): + """Configuration now retries transient failures internally (``_request`` → ``run_upstream``). + Pin the budget to a single attempt so error-path unit tests stay fast and single-call.""" + monkeypatch.setenv("UPSTREAM_MAX_RETRIES", "1") + + +def _ok(json_data, status=200): + resp = MagicMock() + resp.ok = True + resp.status_code = status + resp.json.return_value = json_data + return resp + + +def _err(status=500): + resp = MagicMock() + resp.ok = False + resp.status_code = status + resp.text = "internal error" + return resp + + +# --------------------------------------------------------------------------- +# aiac.managed marker surfaced on Role / Scope (naming convention) +# --------------------------------------------------------------------------- + + +class TestAiacManagedMarker: + def test_role_with_marker_is_managed(self): + role = Role.model_validate( + {"id": "r1", "name": "source-helper", "composite": False, + "attributes": {"aiac.managed": ["true"]}} + ) + assert role.aiac_managed is True + + def test_role_without_marker_is_not_managed(self): + role = Role.model_validate( + {"id": "r1", "name": "default-roles-realm", "composite": False} + ) + assert role.aiac_managed is False + + def test_scope_with_marker_is_managed(self): + scope = Scope.model_validate( + {"id": "s1", "name": "source-access", "attributes": {"aiac.managed": "true"}} + ) + assert scope.aiac_managed is True + + def test_scope_without_marker_is_not_managed(self): + scope = Scope.model_validate({"id": "s1", "name": "profile"}) + assert scope.aiac_managed is False + + +# --------------------------------------------------------------------------- +# Factory method +# --------------------------------------------------------------------------- + + +class TestForRealm: + def test_returns_configuration_bound_to_realm(self): + cfg = Configuration.for_realm(REALM) + assert isinstance(cfg, Configuration) + assert cfg.realm == REALM + + def test_direct_init_sets_realm(self): + cfg = Configuration(REALM) + assert cfg.realm == REALM + + +# --------------------------------------------------------------------------- +# get_subjects +# --------------------------------------------------------------------------- + + +class TestGetSubjects: + # Call order: GET /subjects, GET /roles (+ per-composite /composites), GET /subjects/{id}/assignments — per subject. + + def test_returns_list_of_subject(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "u1", "username": "alice", "enabled": True}] + assignments = {"realmMappings": [], "serviceMappings": {}} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _ok(assignments)], + ) as m: + result = Configuration.for_realm(REALM).get_subjects() + assert isinstance(result[0], Subject) + assert result[0].username == "alice" + assert m.call_args_list[0] == ((f"{BASE}/subjects",), {"params": {"realm": REALM}}) + + def test_roles_populated_from_keycloak_assignments(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "u1", "username": "alice", "enabled": True}] + all_roles = [{"id": "r1", "name": "viewer", "composite": False}] + assignments = {"realmMappings": [{"id": "r1", "name": "viewer"}], "serviceMappings": {}} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok(all_roles), _ok(assignments)], + ): + result = Configuration.for_realm(REALM).get_subjects() + assert len(result[0].roles) == 1 + assert result[0].roles[0].name == "viewer" + + def test_unassigned_roles_not_included(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "u1", "username": "alice", "enabled": True}] + all_roles = [ + {"id": "r1", "name": "viewer", "composite": False}, + {"id": "r2", "name": "admin", "composite": False}, + ] + assignments = {"realmMappings": [{"id": "r1"}], "serviceMappings": {}} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok(all_roles), _ok(assignments)], + ): + result = Configuration.for_realm(REALM).get_subjects() + assert len(result[0].roles) == 1 + assert result[0].roles[0].id == "r1" + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_subjects() + + def test_raises_when_assignments_call_fails(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "u1", "username": "alice", "enabled": True}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _err(502)], + ): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_subjects() + + def test_realm_forwarded_on_all_calls(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "u1", "username": "alice", "enabled": True}] + assignments = {"realmMappings": [], "serviceMappings": {}} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _ok(assignments)], + ) as m: + Configuration.for_realm(REALM).get_subjects() + for c in m.call_args_list: + assert c[1].get("params") == {"realm": REALM} + + +# --------------------------------------------------------------------------- +# get_roles +# --------------------------------------------------------------------------- + + +class TestGetRoles: + def test_returns_list_of_role(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "r1", "name": "admin", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", + return_value=_ok(payload)) as m: + result = Configuration.for_realm(REALM).get_roles() + assert isinstance(result[0], Role) + assert result[0].name == "admin" + assert m.call_args_list[0][0][0] == f"{BASE}/roles" + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_roles() + + def test_non_composite_role_has_no_mappedScopes(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [{"id": "r1", "name": "viewer", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(roles)): + result = Configuration.for_realm(REALM).get_roles() + assert not hasattr(result[0], "mappedScopes") + assert result[0].childRoles == [] + + def test_non_composite_role_skips_composites_and_scopes_calls(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [{"id": "r1", "name": "viewer", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", + return_value=_ok(roles)) as m: + Configuration.for_realm(REALM).get_roles() + urls = [c[0][0] for c in m.call_args_list] + assert all("/composites" not in u for u in urls) + assert all("/scopes" not in u for u in urls) + + def test_get_roles_never_calls_scopes_endpoint(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [ + {"id": "r1", "name": "admin", "composite": True}, + {"id": "r2", "name": "viewer", "composite": False}, + ] + child_roles = [{"id": "r2", "name": "viewer", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", + side_effect=[_ok(roles), _ok(child_roles)]) as m: + Configuration.for_realm(REALM).get_roles() + urls = [c[0][0] for c in m.call_args_list] + assert all("/scopes" not in u for u in urls) + + def test_composite_role_populates_child_roles(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [{"id": "r1", "name": "admin", "composite": True}] + child_roles = [{"id": "r2", "name": "viewer", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", + side_effect=[_ok(roles), _ok(child_roles)]): + result = Configuration.for_realm(REALM).get_roles() + assert result[0].childRoles[0].name == "viewer" + + def test_composite_role_has_no_mappedScopes(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [{"id": "r1", "name": "admin", "composite": True}] + child_roles = [{"id": "r2", "name": "viewer", "composite": False}] + with patch("aiac.idp.configuration.api.requests.get", + side_effect=[_ok(roles), _ok(child_roles)]): + result = Configuration.for_realm(REALM).get_roles() + assert not hasattr(result[0], "mappedScopes") + + def test_raises_if_composites_call_fails(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + roles = [{"id": "r1", "name": "admin", "composite": True}] + with patch("aiac.idp.configuration.api.requests.get", + side_effect=[_ok(roles), _err(502)]): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_roles() + + +# --------------------------------------------------------------------------- +# get_services +# --------------------------------------------------------------------------- + + +class TestGetServices: + # Call order: GET /services, GET /roles (get_roles, + per-role secondary calls), + # GET /scopes (get_scopes), GET /services/{id}/roles, GET /services/{id}/scopes — per service. + + def test_returns_list_of_service(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "c1", "clientId": "my-app", "name": "my-app", "enabled": True}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _ok([]), _ok([]), _ok([])], + ) as m: + result = Configuration.for_realm(REALM).get_services() + assert isinstance(result[0], Service) + assert result[0].id == "c1" + assert m.call_args_list[0] == ( + (f"{BASE}/services",), + {"params": {"realm": REALM}}, + ) + + def test_serviceId_populated_from_clientId(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "c1", "clientId": "mlflow", "enabled": True}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _ok([]), _ok([]), _ok([])], + ): + result = Configuration.for_realm(REALM).get_services() + assert result[0].serviceId == "mlflow" + + def test_scope_descriptions_populated_from_get_scopes(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "c1", "clientId": "my-app", "name": "my-app", "enabled": True}] + all_scopes = [{"id": "s1", "name": "read:data", "description": "Read access"}] + service_scopes = [{"id": "s1", "name": "read:data"}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok([]), _ok(all_scopes), _ok([]), _ok(service_scopes)], + ): + result = Configuration.for_realm(REALM).get_services() + assert result[0].scopes[0].description == "Read access" + + def test_role_details_populated_from_get_roles(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "c1", "clientId": "my-app", "name": "my-app", "enabled": True}] + all_roles = [{"id": "r1", "name": "viewer", "composite": False}] + service_roles = [{"id": "r1", "name": "viewer"}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[_ok(payload), _ok(all_roles), _ok([]), _ok(service_roles), _ok([])], + ): + result = Configuration.for_realm(REALM).get_services() + assert result[0].roles[0].name == "viewer" + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_services() + + +# --------------------------------------------------------------------------- +# get_service +# --------------------------------------------------------------------------- +# Call order: GET /services/{id}, GET /roles (+ per-role /scopes calls), +# GET /scopes, GET /services/{id}/roles, GET /services/{id}/scopes + + +class TestGetService: + SERVICE_ID = "svc-001" + + def test_returns_single_enriched_service(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = {"id": self.SERVICE_ID, "clientId": self.SERVICE_ID, "name": "my-svc", "enabled": True} + all_roles = [{"id": "r1", "name": "viewer", "composite": False}] + all_scopes = [{"id": "s1", "name": "read:data", "description": "Read access"}] + service_roles = [{"id": "r1"}] + service_scopes = [{"id": "s1"}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), # GET /services/svc-001 + _ok(all_roles), # GET /roles (no /scopes per role) + _ok(all_scopes), # GET /scopes + _ok(service_roles), # GET /services/svc-001/roles + _ok(service_scopes), # GET /services/svc-001/scopes + ], + ): + result = Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + assert isinstance(result, Service) + assert result.id == self.SERVICE_ID + assert result.roles[0].name == "viewer" + assert result.scopes[0].name == "read:data" + + def test_type_resolved_from_client_type_attribute(self, monkeypatch): + # Typing comes from the client.type attribute (via Service._resolve_keycloak_fields), + # never from the description — the description-keyword fallback has been removed. + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = { + "id": self.SERVICE_ID, + "clientId": self.SERVICE_ID, + "name": "my-agent", + "description": "An Agent service", # keyword present but ignored + "enabled": True, + "attributes": {"client.type": "Tool"}, + } + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), # GET /services/svc-001 + _ok([]), # GET /roles + _ok([]), # GET /scopes + _ok([]), # GET /services/svc-001/roles + _ok([]), # GET /services/svc-001/scopes + ], + ): + result = Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + assert result.type == "Tool" + + def test_type_not_inferred_from_description(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = { + "id": self.SERVICE_ID, + "clientId": self.SERVICE_ID, + "name": "my-agent", + "description": "An Agent service", # no attribute → type stays None + "enabled": True, + } + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), # GET /services/svc-001 + _ok([]), # GET /roles + _ok([]), # GET /scopes + _ok([]), # GET /services/svc-001/roles + _ok([]), # GET /services/svc-001/scopes + ], + ): + result = Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + assert result.type is None + + def test_raises_when_primary_fetch_returns_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err(404)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + + def test_raises_when_service_roles_call_fails(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = {"id": self.SERVICE_ID, "name": "my-svc", "enabled": True} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), # GET /services/svc-001 + _ok([]), # GET /roles + _ok([]), # GET /scopes + _err(500), # GET /services/svc-001/roles → error + ], + ): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + + def test_raises_when_service_scopes_call_fails(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = {"id": self.SERVICE_ID, "name": "my-svc", "enabled": True} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), # GET /services/svc-001 + _ok([]), # GET /roles + _ok([]), # GET /scopes + _ok([]), # GET /services/svc-001/roles → ok + _err(500), # GET /services/svc-001/scopes → error + ], + ): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + + def test_realm_forwarded_on_every_request(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + raw = {"id": self.SERVICE_ID, "clientId": self.SERVICE_ID, "name": "my-svc", "enabled": True} + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(raw), _ok([]), _ok([]), _ok([]), _ok([]), + ], + ) as m: + Configuration.for_realm(REALM).get_service(self.SERVICE_ID) + for c in m.call_args_list: + assert c[1].get("params") == {"realm": REALM} + + +# --------------------------------------------------------------------------- +# mint_discovery_token — fetches a tool-audienced bearer token from the config service +# --------------------------------------------------------------------------- + + +class TestMintDiscoveryToken: + def test_returns_access_token(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = {"access_token": "tok", "client_id": "github-tool", "audience": ["github-tool"]} + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)) as m: + result = Configuration.for_realm(REALM).mint_discovery_token("svc-uuid") + assert result == "tok" + m.assert_called_once_with( + f"{BASE}/services/svc-uuid/discovery-token", params={"realm": REALM} + ) + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err(502)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).mint_discovery_token("svc-uuid") + + +# --------------------------------------------------------------------------- +# set_service_type — writes the client.type attribute +# --------------------------------------------------------------------------- + + +class TestSetServiceType: + def _make_service(self, **kwargs): + defaults = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + return Service.model_validate({**defaults, **kwargs}) + + def test_returns_updated_service_with_type(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + updated = { + "id": "svc-uuid", + "clientId": "svc-uuid", + "name": "my-svc", + "enabled": True, + "attributes": {"client.type": "Agent"}, + } + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)): + result = Configuration.for_realm(REALM).set_service_type(service, "Agent") + assert isinstance(result, Service) + assert result.type == "Agent" + + def test_posts_to_correct_url_with_type_body(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True, + "attributes": {"client.type": "Tool"}} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)) as m: + Configuration.for_realm(REALM).set_service_type(service, "Tool") + assert m.call_args[0][0] == f"{BASE}/services/svc-uuid/type" + assert m.call_args[1].get("json") == {"type": "Tool"} + + def test_forwards_realm_as_query_param(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True, + "attributes": {"client.type": "Agent"}} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)) as m: + Configuration.for_realm(REALM).set_service_type(service, "Agent") + assert m.call_args[1].get("params") == {"realm": REALM} + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + with patch("aiac.idp.configuration.api.requests.post", return_value=_err(502)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).set_service_type(service, "Agent") + + def test_accepts_service_type_enum(self, monkeypatch): + # ServiceType is a str enum; set_service_type unwraps it to the plain "Agent"/"Tool" value. + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True, + "attributes": {"client.type": "Agent"}} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(updated, 200)) as m: + Configuration.for_realm(REALM).set_service_type(service, ServiceType.AGENT) + assert m.call_args[1].get("json") == {"type": "Agent"} + + +# --------------------------------------------------------------------------- +# create_service_role / create_service_scope — idempotent create-or-get + map +# (tested by patching the Configuration methods they compose) +# --------------------------------------------------------------------------- + + +class TestCreateServiceRole: + def _svc(self): + return Service.model_validate({"id": "svc-1", "clientId": "svc-1", "enabled": True}) + + def test_creates_role_when_absent_then_maps_to_service(self): + cfg = Configuration.for_realm(REALM) + role_def = SimpleNamespace(name="app.agent", description="Agent role") + created = Role(id="r-1", name="app.agent", description="Agent role", composite=False) + svc = self._svc() + with ( + patch.object(cfg, "get_roles", return_value=[]), + patch.object(cfg, "create_role", return_value=created) as create, + patch.object(cfg, "get_service", return_value=svc), + patch.object(cfg, "map_role_to_service", return_value=svc) as mapper, + ): + result = cfg.create_service_role("svc-1", role_def) + create.assert_called_once_with("app.agent", "Agent role") + mapper.assert_called_once_with(svc, created) + assert result is created + + def test_reuses_existing_role_without_creating(self): + cfg = Configuration.for_realm(REALM) + role_def = SimpleNamespace(name="app.agent", description="Agent role") + existing = Role(id="r-1", name="app.agent", description="Agent role", composite=False) + svc = self._svc() + with ( + patch.object(cfg, "get_roles", return_value=[existing]), + patch.object(cfg, "create_role") as create, + patch.object(cfg, "get_service", return_value=svc), + patch.object(cfg, "map_role_to_service", return_value=svc) as mapper, + ): + result = cfg.create_service_role("svc-1", role_def) + create.assert_not_called() + mapper.assert_called_once_with(svc, existing) + assert result is existing + + +class TestCreateServiceScope: + def _svc(self): + return Service.model_validate({"id": "svc-1", "clientId": "svc-1", "enabled": True}) + + def test_creates_scope_when_absent_then_maps_to_service(self): + cfg = Configuration.for_realm(REALM) + scope_def = SimpleNamespace(name="app.read", description="Read tool") + created = Scope(id="s-1", name="app.read", description="Read tool") + svc = self._svc() + with ( + patch.object(cfg, "get_scopes", return_value=[]), + patch.object(cfg, "create_scope", return_value=created) as create, + patch.object(cfg, "get_service", return_value=svc), + patch.object(cfg, "map_scope_to_service", return_value=svc) as mapper, + ): + result = cfg.create_service_scope("svc-1", scope_def) + create.assert_called_once_with("app.read", "Read tool") + mapper.assert_called_once_with(svc, created) + assert result is created + + def test_reuses_existing_scope_without_creating(self): + cfg = Configuration.for_realm(REALM) + scope_def = SimpleNamespace(name="app.read", description="Read tool") + existing = Scope(id="s-1", name="app.read", description="Read tool") + svc = self._svc() + with ( + patch.object(cfg, "get_scopes", return_value=[existing]), + patch.object(cfg, "create_scope") as create, + patch.object(cfg, "get_service", return_value=svc), + patch.object(cfg, "map_scope_to_service", return_value=svc) as mapper, + ): + result = cfg.create_service_scope("svc-1", scope_def) + create.assert_not_called() + mapper.assert_called_once_with(svc, existing) + assert result is existing + + +# --------------------------------------------------------------------------- +# get_scopes +# --------------------------------------------------------------------------- + + +class TestGetScopes: + def test_returns_list_of_scope(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "s1", "name": "email"}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)) as m: + result = Configuration.for_realm(REALM).get_scopes() + assert isinstance(result[0], Scope) + assert result[0].name == "email" + m.assert_called_once_with(f"{BASE}/scopes", params={"realm": REALM}) + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_scopes() + + +# --------------------------------------------------------------------------- +# create_scope +# --------------------------------------------------------------------------- + + +class TestCreateScope: + def test_returns_scope_instance(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "sc1", "name": "read:data", "description": "Read access"} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)): + result = Configuration.for_realm(REALM).create_scope( + scope_name="read:data", scope_description="Read access" + ) + assert isinstance(result, Scope) + assert result.name == "read:data" + assert result.id == "sc1" + + def test_posts_to_correct_url(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "sc1", "name": "write"} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_scope("write", "Write access") + url = m.call_args[0][0] + assert url == f"{BASE}/scopes" + + def test_forwards_realm_as_query_param(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "sc1", "name": "read"} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_scope("read", "desc") + params = m.call_args[1].get("params", {}) + assert params == {"realm": REALM} + + def test_json_body_contains_name_and_description(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "sc1", "name": "read"} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_scope("read", "Read access") + body = m.call_args[1].get("json", {}) + assert body == {"name": "read", "description": "Read access"} + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.post", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).create_scope("read", "desc") + + def test_raises_on_409_conflict(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.post", return_value=_err(409)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).create_scope("dupe", "desc") + + +# --------------------------------------------------------------------------- +# map_scope_to_service +# --------------------------------------------------------------------------- + + +class TestMapScopeToService: + def _make_service(self, **kwargs): + defaults = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + return Service.model_validate({**defaults, **kwargs}) + + def _make_scope(self, **kwargs): + defaults = {"id": "scope-id", "name": "read:data"} + return Scope.model_validate({**defaults, **kwargs}) + + def test_returns_updated_service(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + scope = self._make_scope() + updated = { + "id": "svc-uuid", + "clientId": "svc-uuid", + "name": "my-svc", + "enabled": True, + "scopes": [{"id": "scope-id", "name": "read:data"}], + } + post_resp = _ok({}, 201) + get_resp = _ok(updated) + with patch("aiac.idp.configuration.api.requests.post", return_value=post_resp), \ + patch("aiac.idp.configuration.api.requests.get", return_value=get_resp) as get_m: + result = Configuration.for_realm(REALM).map_scope_to_service(service, scope) + assert isinstance(result, Service) + assert result.id == "svc-uuid" + get_m.assert_called_once_with(f"{BASE}/services/svc-uuid", params={"realm": REALM}) + + def test_issues_post_to_correct_url(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + scope = self._make_scope() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok({}, 201)) as post_m, \ + patch("aiac.idp.configuration.api.requests.get", return_value=_ok(updated)): + Configuration.for_realm(REALM).map_scope_to_service(service, scope) + url = post_m.call_args[0][0] + assert url == f"{BASE}/services/svc-uuid/scopes/scope-id" + + def test_raises_on_post_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + scope = self._make_scope() + with patch("aiac.idp.configuration.api.requests.post", return_value=_err(409)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).map_scope_to_service(service, scope) + + def test_realm_forwarded_on_both_calls(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + scope = self._make_scope() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok({}, 201)) as post_m, \ + patch("aiac.idp.configuration.api.requests.get", return_value=_ok(updated)) as get_m: + Configuration.for_realm(REALM).map_scope_to_service(service, scope) + assert post_m.call_args[1].get("params") == {"realm": REALM} + assert get_m.call_args[1].get("params") == {"realm": REALM} + + +# --------------------------------------------------------------------------- +# create_role +# --------------------------------------------------------------------------- + + +class TestCreateRole: + def test_returns_role_instance(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "r1", "name": "reader", "description": "Read-only", "composite": False} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)): + result = Configuration.for_realm(REALM).create_role("reader", "Read-only") + assert isinstance(result, Role) + assert result.name == "reader" + assert result.id == "r1" + + def test_posts_to_correct_url(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "r1", "name": "reader", "composite": False} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_role("reader", "desc") + url = m.call_args[0][0] + assert url == f"{BASE}/roles" + + def test_forwards_realm_as_query_param(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "r1", "name": "reader", "composite": False} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_role("reader", "desc") + assert m.call_args[1].get("params") == {"realm": REALM} + + def test_json_body_contains_name_and_description(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + created = {"id": "r1", "name": "reader", "composite": False} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok(created, 201)) as m: + Configuration.for_realm(REALM).create_role("reader", "Read-only") + assert m.call_args[1].get("json") == {"name": "reader", "description": "Read-only"} + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.post", return_value=_err()): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).create_role("reader", "desc") + + def test_raises_on_409_conflict(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.post", return_value=_err(409)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).create_role("dupe", "desc") + + +# --------------------------------------------------------------------------- +# map_role_to_service +# --------------------------------------------------------------------------- + + +class TestMapRoleToService: + def _make_service(self, **kwargs): + defaults = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + return Service.model_validate({**defaults, **kwargs}) + + def _make_role(self, **kwargs): + defaults = {"id": "role-id", "name": "reader", "composite": False} + return Role.model_validate({**defaults, **kwargs}) + + def test_returns_updated_service(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + role = self._make_role() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok({}, 201)), \ + patch("aiac.idp.configuration.api.requests.get", return_value=_ok(updated)) as get_m: + result = Configuration.for_realm(REALM).map_role_to_service(service, role) + assert isinstance(result, Service) + get_m.assert_called_once_with(f"{BASE}/services/svc-uuid", params={"realm": REALM}) + + def test_issues_post_to_correct_url(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + role = self._make_role() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok({}, 201)) as post_m, \ + patch("aiac.idp.configuration.api.requests.get", return_value=_ok(updated)): + Configuration.for_realm(REALM).map_role_to_service(service, role) + url = post_m.call_args[0][0] + assert url == f"{BASE}/services/svc-uuid/roles/role-id" + + def test_raises_on_post_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + role = self._make_role() + with patch("aiac.idp.configuration.api.requests.post", return_value=_err(409)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).map_role_to_service(service, role) + + def test_realm_forwarded_on_both_calls(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + service = self._make_service() + role = self._make_role() + updated = {"id": "svc-uuid", "clientId": "svc-uuid", "name": "my-svc", "enabled": True} + with patch("aiac.idp.configuration.api.requests.post", return_value=_ok({}, 201)) as post_m, \ + patch("aiac.idp.configuration.api.requests.get", return_value=_ok(updated)) as get_m: + Configuration.for_realm(REALM).map_role_to_service(service, role) + assert post_m.call_args[1].get("params") == {"realm": REALM} + assert get_m.call_args[1].get("params") == {"realm": REALM} + + +# --------------------------------------------------------------------------- +# realm forwarded as ?realm= on all methods +# --------------------------------------------------------------------------- + + +class TestRealmParameter: + @pytest.mark.parametrize("method,endpoint", [ + ("get_roles", "roles"), + ("get_scopes", "scopes"), + ]) + def test_realm_forwarded_as_query_param(self, method, endpoint, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + getattr(Configuration.for_realm(REALM), method)() + m.assert_called_once_with(f"{BASE}/{endpoint}", params={"realm": REALM}) + + def test_get_subjects_realm_forwarded_as_query_param(self, monkeypatch): + from unittest.mock import call + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + Configuration.for_realm(REALM).get_subjects() + assert call(f"{BASE}/subjects", params={"realm": REALM}) in m.call_args_list + + def test_get_services_realm_forwarded_as_query_param(self, monkeypatch): + from unittest.mock import call + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + Configuration.for_realm(REALM).get_services() + assert call(f"{BASE}/services", params={"realm": REALM}) in m.call_args_list + + +# --------------------------------------------------------------------------- +# Default URL fallback +# --------------------------------------------------------------------------- + + +def test_default_base_url_used_when_env_unset(monkeypatch): + monkeypatch.delenv("AIAC_PDP_CONFIG_URL", raising=False) + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + Configuration.for_realm(REALM).get_subjects() + assert m.call_args[0][0].startswith("http://127.0.0.1:7071") + + +# --------------------------------------------------------------------------- +# get_services_by_role +# --------------------------------------------------------------------------- + + +class TestGetServicesByRole: + """``get_services_by_role`` filters ``get_services()`` client-side by role ``id``.""" + + def _make_role(self, **kwargs): + defaults = {"id": "role-uuid", "name": "viewer", "composite": False} + return Role.model_validate({**defaults, **kwargs}) + + def _make_service(self, sid, role_ids): + return Service.model_validate({ + "id": sid, + "clientId": sid, + "name": sid, + "enabled": True, + "roles": [{"id": rid, "name": rid, "composite": False} for rid in role_ids], + }) + + def test_returns_only_services_whose_roles_contain_role_id(self): + role = self._make_role(id="r1") + services = [ + self._make_service("svc1", ["r1"]), + self._make_service("svc2", ["r2"]), + self._make_service("svc3", ["r1", "r2"]), + ] + with patch.object(Configuration, "get_services", return_value=services): + result = Configuration.for_realm(REALM).get_services_by_role(role) + assert [s.id for s in result] == ["svc1", "svc3"] + assert all(isinstance(s, Service) for s in result) + + def test_returns_empty_list_for_realm_level_role(self): + role = self._make_role(id="r-nobody") + services = [self._make_service("svc1", ["r1"]), self._make_service("svc2", ["r2"])] + with patch.object(Configuration, "get_services", return_value=services): + result = Configuration.for_realm(REALM).get_services_by_role(role) + assert result == [] + + def test_raises_on_non_2xx(self): + role = self._make_role() + with patch.object(Configuration, "get_services", side_effect=RuntimeError("HTTP 500")): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_services_by_role(role) + + +# --------------------------------------------------------------------------- +# get_services_by_scope +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# get_subjects_by_role (8.15) +# --------------------------------------------------------------------------- + + +class TestGetSubjectsByRole: + def _make_role(self, **kwargs): + defaults = {"id": "role-uuid", "name": "viewer", "composite": False} + return Role.model_validate({**defaults, **kwargs}) + + def test_returns_list_of_subject(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + payload = [{"id": "u1", "username": "alice", "enabled": True}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + result = Configuration.for_realm(REALM).get_subjects_by_role(role) + assert isinstance(result[0], Subject) + assert result[0].id == "u1" + + def test_issues_get_with_role_id_param(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role(id="my-role-id") + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + Configuration.for_realm(REALM).get_subjects_by_role(role) + assert m.call_args[0][0] == f"{BASE}/subjects" + assert m.call_args[1]["params"] == {"role_id": "my-role-id", "realm": REALM} + + def test_returns_empty_list_when_no_subjects(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])): + result = Configuration.for_realm(REALM).get_subjects_by_role(role) + assert result == [] + + def test_raises_on_non_2xx(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + with patch("aiac.idp.configuration.api.requests.get", return_value=_err(500)): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_subjects_by_role(role) + + def test_realm_forwarded_as_query_param(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok([])) as m: + Configuration.for_realm(REALM).get_subjects_by_role(role) + assert m.call_args[1]["params"]["realm"] == REALM + + def test_no_secondary_enrichment_calls(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + payload = [{"id": "u1", "username": "alice", "enabled": True}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)) as m: + Configuration.for_realm(REALM).get_subjects_by_role(role) + assert m.call_count == 1 + + # handoff 03 — actorIds consistency. The subject/username set this method + # reports is the same set the IdP service uses to populate a user-kind role's + # actorIds. Both come from the service, so they must agree and the library + # must not recompute actorIds client-side. + def test_subject_set_matches_user_role_actor_ids(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role(kind="User", actorIds=["alice", "bob"]) + payload = [ + {"id": "u1", "username": "alice", "enabled": True}, + {"id": "u2", "username": "bob", "enabled": True}, + ] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + result = Configuration.for_realm(REALM).get_subjects_by_role(role) + assert {s.username for s in result} == set(role.actorIds) + # The library surfaces the service's subject set as-is; it does not + # mutate/recompute the role's actorIds. + assert role.actorIds == ["alice", "bob"] + + def test_subject_fields_pass_through_unchanged(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + role = self._make_role() + payload = [ + { + "id": "u1", + "username": "alice", + "email": "alice@example.com", + "firstName": "Alice", + "enabled": True, + } + ] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + result = Configuration.for_realm(REALM).get_subjects_by_role(role) + assert result[0].email == "alice@example.com" + assert result[0].firstName == "Alice" + + +# --------------------------------------------------------------------------- +# get_services_by_scope (unchanged) +# --------------------------------------------------------------------------- + + +class TestGetServicesByScope: + """``get_services_by_scope`` filters ``get_services()`` client-side by scope ``id``.""" + + def _make_scope(self, **kwargs): + defaults = {"id": "scope-uuid", "name": "read:data"} + return Scope.model_validate({**defaults, **kwargs}) + + def _make_service(self, sid, scope_ids): + return Service.model_validate({ + "id": sid, + "clientId": sid, + "name": sid, + "enabled": True, + "scopes": [{"id": scid, "name": scid} for scid in scope_ids], + }) + + def test_returns_only_services_whose_scopes_contain_scope_id(self): + scope = self._make_scope(id="s1") + services = [ + self._make_service("svc1", ["s1"]), + self._make_service("svc2", ["s2"]), + self._make_service("svc3", ["s1", "s2"]), + ] + with patch.object(Configuration, "get_services", return_value=services): + result = Configuration.for_realm(REALM).get_services_by_scope(scope) + assert [s.id for s in result] == ["svc1", "svc3"] + assert all(isinstance(s, Service) for s in result) + + def test_returns_empty_list_when_no_service_exposes_scope(self): + scope = self._make_scope(id="s-nobody") + services = [self._make_service("svc1", ["s1"]), self._make_service("svc2", ["s2"])] + with patch.object(Configuration, "get_services", return_value=services): + result = Configuration.for_realm(REALM).get_services_by_scope(scope) + assert result == [] + + def test_raises_on_non_2xx(self): + scope = self._make_scope() + with patch.object(Configuration, "get_services", side_effect=RuntimeError("HTTP 500")): + with pytest.raises(RuntimeError): + Configuration.for_realm(REALM).get_services_by_scope(scope) + + +# --------------------------------------------------------------------------- +# handoff 03 — SPM/APM field pass-through through the library deserialization +# +# handoff 01 declares Role.kind / Role.actorIds / Scope.serviceId; handoff 02 +# makes the IdP *service* populate them. The Configuration library is a thin +# pass-through (model_validate), so these fields must survive onto the returned +# models with no hand-rolled mapping dropping them and no client-side derivation. +# These tests stub the HTTP layer with a service response carrying the new +# fields and assert they round-trip through the real read paths. +# --------------------------------------------------------------------------- + + +class TestFieldPassThrough: + def test_get_roles_surfaces_role_kind(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "r1", "name": "agent-role", "composite": False, "kind": "Agent"}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + roles = Configuration.for_realm(REALM).get_roles() + assert roles[0].kind == RoleKind.AGENT + + def test_get_roles_surfaces_actor_ids(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [ + { + "id": "r1", + "name": "viewer", + "composite": False, + "kind": "User", + "actorIds": ["alice", "bob"], + } + ] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + roles = Configuration.for_realm(REALM).get_roles() + assert roles[0].actorIds == ["alice", "bob"] + + def test_get_scopes_surfaces_scope_service_id(self, monkeypatch): + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "s1", "name": "read:data", "serviceId": "mlflow"}] + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + scopes = Configuration.for_realm(REALM).get_scopes() + assert scopes[0].serviceId == "mlflow" + + def test_get_services_surfaces_new_fields_on_nested_role_and_scope(self, monkeypatch): + # get_services() enriches each service's roles/scopes from the get_roles() + # / get_scopes() maps, so the new fields must survive that join too. + # Call order: GET /services, GET /roles, GET /scopes, GET /services/{id}/roles, + # GET /services/{id}/scopes. + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + services = [{"id": "c1", "clientId": "mlflow", "name": "mlflow", "enabled": True}] + all_roles = [ + { + "id": "r1", + "name": "mlflow-agent", + "composite": False, + "kind": "Agent", + "actorIds": ["mlflow"], + } + ] + all_scopes = [{"id": "s1", "name": "read:data", "serviceId": "mlflow"}] + service_roles = [{"id": "r1", "name": "mlflow-agent"}] + service_scopes = [{"id": "s1", "name": "read:data"}] + with patch( + "aiac.idp.configuration.api.requests.get", + side_effect=[ + _ok(services), + _ok(all_roles), + _ok(all_scopes), + _ok(service_roles), + _ok(service_scopes), + ], + ): + result = Configuration.for_realm(REALM).get_services() + assert result[0].roles[0].kind == RoleKind.AGENT + assert result[0].roles[0].actorIds == ["mlflow"] + assert result[0].scopes[0].serviceId == "mlflow" + + def test_role_kind_taken_from_response_not_rederived(self, monkeypatch): + # Role.kind is authoritative — the library must not re-derive it by + # classifying a role against the service list. Point get_services at a + # spy and assert get_roles never touches it. + monkeypatch.setenv("AIAC_PDP_CONFIG_URL", BASE) + payload = [{"id": "r1", "name": "agent-role", "composite": False, "kind": "Agent"}] + spy = MagicMock(side_effect=AssertionError("get_services must not be called to set Role.kind")) + with patch.object(Configuration, "get_services", spy): + with patch("aiac.idp.configuration.api.requests.get", return_value=_ok(payload)): + roles = Configuration.for_realm(REALM).get_roles() + assert roles[0].kind == RoleKind.AGENT + spy.assert_not_called() diff --git a/aiac/test/idp/configuration/test_models.py b/aiac/test/idp/configuration/test_models.py new file mode 100644 index 000000000..54d0bf71e --- /dev/null +++ b/aiac/test/idp/configuration/test_models.py @@ -0,0 +1,472 @@ +from aiac.idp.configuration.models import Role, Scope, Service, Subject + + +class TestSubject: + def test_full_payload(self): + s = Subject.model_validate( + { + "id": "u1", + "username": "alice", + "email": "alice@example.com", + "firstName": "Alice", + "lastName": "Smith", + "enabled": True, + } + ) + assert s.id == "u1" + assert s.username == "alice" + assert s.email == "alice@example.com" + assert s.firstName == "Alice" + assert s.lastName == "Smith" + assert s.enabled is True + + def test_optional_fields_absent(self): + s = Subject.model_validate({"id": "u2", "username": "bob", "enabled": False}) + assert s.email is None + assert s.firstName is None + assert s.lastName is None + + def test_roles_populated(self): + s = Subject.model_validate( + { + "id": "u1", + "username": "alice", + "enabled": True, + "roles": [{"id": "r1", "name": "admin", "composite": False}], + } + ) + assert len(s.roles) == 1 + assert s.roles[0].name == "admin" + + def test_roles_default_empty(self): + s = Subject.model_validate({"id": "u1", "username": "alice", "enabled": True}) + assert s.roles == [] + + def test_extra_fields_ignored(self): + s = Subject.model_validate( + {"id": "u3", "username": "carol", "enabled": True, "unknownField": "garbage"} + ) + assert not hasattr(s, "unknownField") + + +class TestRole: + def test_full_payload(self): + r = Role.model_validate( + { + "id": "r1", + "name": "admin", + "description": "Administrator role", + "composite": False, + } + ) + assert r.id == "r1" + assert r.name == "admin" + assert r.description == "Administrator role" + assert r.composite is False + + def test_optional_description_absent(self): + r = Role.model_validate({"id": "r2", "name": "viewer", "composite": True}) + assert r.description is None + + def test_child_roles_populated(self): + r = Role.model_validate( + { + "id": "r1", + "name": "admin", + "composite": True, + "childRoles": [{"id": "r2", "name": "viewer", "composite": False}], + } + ) + assert len(r.childRoles) == 1 + assert r.childRoles[0].name == "viewer" + + def test_child_roles_default_empty(self): + r = Role.model_validate({"id": "r1", "name": "admin", "composite": False}) + assert r.childRoles == [] + + def test_mappedScopes_field_does_not_exist(self): + r = Role.model_validate({"id": "r1", "name": "admin", "composite": False}) + assert not hasattr(r, "mappedScopes") + + def test_mappedScopes_payload_ignored_silently(self): + r = Role.model_validate( + { + "id": "r1", + "name": "admin", + "composite": False, + "mappedScopes": [{"id": "s1", "name": "email"}], + } + ) + assert not hasattr(r, "mappedScopes") + + def test_no_clientRole_field(self): + r = Role.model_validate( + {"id": "r1", "name": "admin", "composite": False, "clientRole": True} + ) + assert not hasattr(r, "clientRole") + + def test_extra_fields_ignored(self): + r = Role.model_validate( + { + "id": "r3", + "name": "editor", + "composite": False, + "containerId": "master", + } + ) + assert not hasattr(r, "containerId") + + +class TestService: + def test_full_payload(self): + s = Service.model_validate( + { + "id": "c1", + "clientId": "c1", + "name": "My Application", + "description": "Does things", + "enabled": True, + "type": "Agent", + } + ) + assert s.id == "c1" + assert s.name == "My Application" + assert s.description == "Does things" + assert s.enabled is True + assert s.type == "Agent" + + def test_type_tool(self): + s = Service.model_validate({"id": "c1", "clientId": "c1", "name": "tool-svc", "enabled": True, "type": "Tool"}) + assert s.type == "Tool" + + def test_type_none_when_absent(self): + s = Service.model_validate({"id": "c2", "clientId": "c2", "name": "bare", "enabled": True}) + assert s.type is None + + def test_optional_fields_absent(self): + s = Service.model_validate({"id": "c2", "serviceId": "c2", "enabled": False}) + assert s.name is None + assert s.description is None + assert s.type is None + + def test_description_present(self): + s = Service.model_validate({"id": "c1", "clientId": "c1", "enabled": True, "description": "a desc"}) + assert s.description == "a desc" + + def test_description_absent_is_none(self): + s = Service.model_validate({"id": "c1", "clientId": "c1", "enabled": True}) + assert s.description is None + + def test_roles_populated(self): + s = Service.model_validate( + { + "id": "c1", + "clientId": "c1", + "enabled": True, + "roles": [{"id": "r1", "name": "admin", "composite": False}], + } + ) + assert len(s.roles) == 1 + assert s.roles[0].name == "admin" + + def test_roles_default_empty(self): + s = Service.model_validate({"id": "c1", "clientId": "c1", "enabled": True}) + assert s.roles == [] + + def test_scopes_populated(self): + s = Service.model_validate( + { + "id": "c1", + "clientId": "c1", + "enabled": True, + "scopes": [{"id": "s1", "name": "email"}], + } + ) + assert len(s.scopes) == 1 + assert s.scopes[0].name == "email" + + def test_scopes_default_empty(self): + s = Service.model_validate({"id": "c1", "clientId": "c1", "enabled": True}) + assert s.scopes == [] + + def test_serviceId_populated_from_clientId(self): + s = Service.model_validate( + {"id": "c1", "enabled": True, "clientId": "my-app"} + ) + assert s.serviceId == "my-app" + + def test_serviceId_provided_directly_when_clientId_absent(self): + s = Service.model_validate({"id": "c1", "enabled": True, "serviceId": "explicit-svc"}) + assert s.serviceId == "explicit-svc" + + def test_no_clientId_field(self): + s = Service.model_validate( + {"id": "c1", "enabled": True, "clientId": "my-app"} + ) + assert not hasattr(s, "clientId") + assert s.serviceId == "my-app" + + def test_no_protocol_field(self): + s = Service.model_validate( + {"id": "c1", "clientId": "c1", "enabled": True, "protocol": "openid-connect"} + ) + assert not hasattr(s, "protocol") + + def test_no_publicClient_field(self): + s = Service.model_validate( + {"id": "c1", "clientId": "c1", "enabled": True, "publicClient": False} + ) + assert not hasattr(s, "publicClient") + + def test_extra_fields_ignored(self): + s = Service.model_validate( + {"id": "c3", "clientId": "c3", "enabled": True, "surplusField": "ignored"} + ) + assert not hasattr(s, "surplusField") + + +class TestServiceNameResolution: + def test_placeholder_name_replaced_by_clientId(self): + s = Service.model_validate( + { + "id": "abc123", + "clientId": "account", + "name": "${client_account}", + "enabled": True, + } + ) + assert s.name == "account" + + def test_absent_name_resolved_from_clientId(self): + s = Service.model_validate( + { + "id": "abc456", + "clientId": "mlflow", + "enabled": True, + } + ) + assert s.name == "mlflow" + + def test_valid_display_name_not_replaced_by_clientId(self): + s = Service.model_validate( + { + "id": "abc789", + "clientId": "github-tool", + "name": "GitHub Tool", + "enabled": True, + } + ) + assert s.name == "GitHub Tool" + + +class TestServiceTypeResolution: + def test_type_agent_from_client_type_attribute(self): + s = Service.model_validate( + { + "id": "c1", + "clientId": "some-agent", + "enabled": True, + "attributes": {"client.type": "Agent"}, + } + ) + assert s.type == "Agent" + + def test_type_tool_from_client_type_attribute(self): + s = Service.model_validate( + { + "id": "c2", + "clientId": "github-tool", + "enabled": True, + "attributes": {"client.type": "Tool"}, + } + ) + assert s.type == "Tool" + + def test_type_none_from_spiffe_clientId_without_attribute(self): + # A spiffe:// clientId means the workload is SPIRE-enabled, not that it is an + # agent. Without a client.type attribute the type stays None regardless of + # clientId shape; the operator's kagenti.io/type label (persisted as client.type) + # is the authoritative agent/tool signal. + s = Service.model_validate( + { + "id": "c3", + "clientId": "spiffe://cluster.local/ns/team1/sa/git-issue-agent", + "enabled": True, + } + ) + assert s.type is None + + def test_type_none_when_no_attribute_and_non_spiffe_clientId(self): + s = Service.model_validate( + { + "id": "c3b", + "clientId": "mlflow", + "enabled": True, + "attributes": {}, + } + ) + assert s.type is None + + def test_explicit_type_not_overridden_by_validator(self): + s = Service.model_validate( + { + "id": "c4", + "clientId": "spiffe://cluster.local/ns/team1/sa/some-agent", + "enabled": True, + "type": "Tool", + } + ) + assert s.type == "Tool" + + def test_unknown_client_type_attribute_value_gives_none(self): + s = Service.model_validate( + { + "id": "c5", + "clientId": "mlflow", + "enabled": True, + "attributes": {"client.type": "Unknown"}, + } + ) + assert s.type is None + + def test_list_valued_client_type_attribute_gives_none(self): + # Plain-string invariant: client attributes are plain strings. A list value + # (as realm-role attributes use) fails the ``in ("Agent","Tool")`` check → type None. + # Regression guard for the silent empty-pipeline-output failure mode. + s = Service.model_validate( + { + "id": "c6", + "clientId": "mlflow", + "enabled": True, + "attributes": {"client.type": ["Agent"]}, + } + ) + assert s.type is None + + +class TestKeycloakRealWorldPayloads: + """Each model parsed against a realistic Keycloak API payload including extra noise fields.""" + + def test_subject_keycloak_user(self): + s = Subject.model_validate( + { + "id": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90", + "username": "alice", + "email": "alice@kagenti.org", + "firstName": "Alice", + "lastName": "Kagenti", + "enabled": True, + "emailVerified": True, + "createdTimestamp": 1700000000, + "roles": [ + { + "id": "r-admin-uuid", + "name": "kagenti-admin", + "composite": False, + "clientRole": False, + } + ], + } + ) + assert s.id == "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90" + assert s.username == "alice" + assert s.email == "alice@kagenti.org" + assert s.firstName == "Alice" + assert s.lastName == "Kagenti" + assert s.enabled is True + assert len(s.roles) == 1 + assert s.roles[0].name == "kagenti-admin" + + def test_role_keycloak_composite_with_child_and_scope(self): + r = Role.model_validate( + { + "id": "r-dev-lead-uuid", + "name": "dev-lead", + "description": "Developer lead with admin and viewer permissions", + "composite": True, + "clientRole": False, + "containerId": "kagenti", + "childRoles": [ + { + "id": "r-dev-uuid", + "name": "developer", + "composite": False, + "clientRole": False, + } + ], + "mappedScopes": [ + {"id": "sc-read-uuid", "name": "read"}, + ], + } + ) + assert r.id == "r-dev-lead-uuid" + assert r.name == "dev-lead" + assert r.description == "Developer lead with admin and viewer permissions" + assert r.composite is True + assert len(r.childRoles) == 1 + assert r.childRoles[0].id == "r-dev-uuid" + assert r.childRoles[0].name == "developer" + assert r.childRoles[0].composite is False + assert not hasattr(r, "mappedScopes") + + def test_service_keycloak_system_client_account(self): + """Keycloak system 'account' client: placeholder name resolved, no kagenti type.""" + s = Service.model_validate( + { + "id": "account-client-uuid", + "clientId": "account", + "name": "${client_account}", + "description": "${client_account_description}", + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "attributes": {}, + } + ) + assert s.id == "account-client-uuid" + assert s.serviceId == "account" + assert s.name == "account" + assert s.description == "${client_account_description}" + assert s.enabled is True + assert s.type is None + assert s.roles == [] + assert s.scopes == [] + + def test_scope_keycloak_email_scope(self): + """Standard OpenID Connect 'email' client scope as Keycloak returns it.""" + s = Scope.model_validate( + { + "id": "sc-email-uuid", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true", + "include.in.token.scope": "true", + }, + } + ) + assert s.id == "sc-email-uuid" + assert s.name == "email" + assert s.description == "OpenID Connect built-in scope: email" + + +class TestScope: + def test_full_payload(self): + s = Scope.model_validate({"id": "s1", "name": "email", "description": "Email scope"}) + assert s.id == "s1" + assert s.name == "email" + assert s.description == "Email scope" + + def test_optional_description_absent(self): + s = Scope.model_validate({"id": "s2", "name": "profile"}) + assert s.description is None + + def test_no_protocol_field(self): + s = Scope.model_validate({"id": "s1", "name": "email", "protocol": "openid-connect"}) + assert not hasattr(s, "protocol") + + def test_extra_fields_ignored(self): + s = Scope.model_validate({"id": "s3", "name": "roles", "unknownAttr": "dropped"}) + assert not hasattr(s, "unknownAttr") diff --git a/aiac/test/idp/service/__init__.py b/aiac/test/idp/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/idp/service/configuration/__init__.py b/aiac/test/idp/service/configuration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/idp/service/configuration/keycloak/__init__.py b/aiac/test/idp/service/configuration/keycloak/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/idp/service/configuration/keycloak/test_main.py b/aiac/test/idp/service/configuration/keycloak/test_main.py new file mode 100644 index 000000000..e605a3412 --- /dev/null +++ b/aiac/test/idp/service/configuration/keycloak/test_main.py @@ -0,0 +1,1089 @@ +"""Unit tests for aiac/idp/service/configuration/keycloak/main.py FastAPI application.""" + +import base64 +import json +import os +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient +from keycloak.exceptions import KeycloakError + +from aiac.idp.service.configuration.keycloak.main import _cache, app, get_admin + +REALM = "kagenti" + + +def _make_client(admin_mock: MagicMock) -> TestClient: + app.dependency_overrides[get_admin] = lambda realm=None: admin_mock + return TestClient(app) + + +def _make_jwt(payload: dict) -> str: + """Encode a payload into a `header.payload.sig` JWT shape (unsigned — the endpoint only + base64-decodes the payload to read iss/aud; it never verifies the signature).""" + seg = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() + return f"h.{seg}.s" + + +# --------------------------------------------------------------------------- +# GET /subjects +# --------------------------------------------------------------------------- + + +class TestGetSubjects: + def test_returns_json_array(self): + admin = MagicMock() + admin.get_users.return_value = [{"id": "u1", "username": "alice"}] + resp = _make_client(admin).get(f"/subjects?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [{"id": "u1", "username": "alice"}] + + +# --------------------------------------------------------------------------- +# GET /roles +# --------------------------------------------------------------------------- + + +class TestGetRoles: + def test_returns_json_array(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [{"id": "r1", "name": "admin"}] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + assert resp.status_code == 200 + # Realm roles are user roles (clientRole == false) -> kind=User (handoff 02). + assert resp.json() == [{"id": "r1", "name": "admin", "kind": "User"}] + + def test_requests_full_representation_for_attributes(self): + # The aiac.managed marker lives in role attributes, which Keycloak's brief + # representation omits — the endpoint must ask for the full representation. + admin = MagicMock() + admin.get_realm_roles.return_value = [] + _make_client(admin).get(f"/roles?realm={REALM}") + admin.get_realm_roles.assert_called_once_with(brief_representation=False) + + def test_populates_user_kind_on_all_realm_roles(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "reader"}, + {"id": "r2", "name": "default-roles-kagenti"}, + ] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + # default-roles-kagenti is the Keycloak default composite for this realm -> excluded. + assert [r["kind"] for r in resp.json()] == ["User"] + + def test_excludes_default_roles_composite_for_realm(self): + # The default composite (default-roles-{realm}) is the sole path to Keycloak's + # built-ins (offline_access, uma_authorization, view-profile, account roles) -- + # it must never appear in the response, and its members must never be scanned. + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "reader"}, + {"id": "r2", "name": f"default-roles-{REALM}"}, + ] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + names = [r["name"] for r in resp.json()] + assert f"default-roles-{REALM}" not in names + assert names == ["reader"] + admin.get_realm_role_members.assert_not_called() + + def test_aiac_managed_role_gets_member_usernames_as_actor_ids(self): + # For a user (realm) role, actorIds = the member usernames — resolved via the same + # get_realm_role_members call that GET /subjects?role_id= uses (SPM/APM alignment). + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "invoicing", "attributes": {"aiac.managed": ["true"]}}, + ] + admin.get_realm_role_members.return_value = [ + {"id": "u1", "username": "alice"}, + {"id": "u2", "username": "bob"}, + ] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + role = resp.json()[0] + assert role["kind"] == "User" + assert role["actorIds"] == ["alice", "bob"] + admin.get_realm_role_members.assert_called_once_with("invoicing") + + def test_non_managed_role_skips_member_query(self): + # Built-ins / non-AIAC roles are not enriched with actorIds (no member scan). + admin = MagicMock() + admin.get_realm_roles.return_value = [{"id": "r1", "name": "admin"}] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + assert "actorIds" not in resp.json()[0] + admin.get_realm_role_members.assert_not_called() + + +# --------------------------------------------------------------------------- +# GET /services +# --------------------------------------------------------------------------- + + +class TestGetServices: + def test_returns_json_array(self): + admin = MagicMock() + admin.get_clients.return_value = [{"id": "c1", "clientId": "my-app"}] + resp = _make_client(admin).get(f"/services?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [{"id": "c1", "clientId": "my-app"}] + + +# --------------------------------------------------------------------------- +# GET /scopes +# --------------------------------------------------------------------------- + + +class TestGetScopes: + def test_returns_json_array(self): + admin = MagicMock() + admin.get_client_scopes.return_value = [{"id": "s1", "name": "email"}] + resp = _make_client(admin).get(f"/scopes?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [{"id": "s1", "name": "email"}] + + +# --------------------------------------------------------------------------- +# GET /subjects/{subject_id}/assignments +# --------------------------------------------------------------------------- + + +class TestGetSubjectAssignments: + def test_returns_object_with_realm_and_service_mappings(self): + admin = MagicMock() + admin.get_all_roles_of_user.return_value = { + "realmMappings": [{"id": "r1", "name": "admin"}], + "clientMappings": {"account": {"id": "a1", "mappings": []}}, + } + resp = _make_client(admin).get(f"/subjects/user-uuid/assignments?realm={REALM}") + assert resp.status_code == 200 + body = resp.json() + assert "realmMappings" in body + assert "serviceMappings" in body + + +# --------------------------------------------------------------------------- +# GET /services/{service_id}/roles +# --------------------------------------------------------------------------- + + +class TestListServiceRoles: + def test_sources_client_roles_and_service_account_realm_roles(self): + # The endpoint returns both client roles (kind=Agent via clientRole=true) and + # aiac-managed realm roles assigned to the service account (kind=Agent via the + # provisioning path used by the Configuration library). + admin = MagicMock() + admin.get_client_roles.return_value = [{"id": "cr1", "name": "invoke", "clientRole": True}] + admin.get_client.return_value = {"id": "svc-uuid", "clientId": "github-agent"} + sa_user = {"id": "sa-uid"} + admin.get_client_service_account_user.return_value = sa_user + admin.get_realm_roles_of_user.return_value = [] + resp = _make_client(admin).get(f"/services/svc-uuid/roles?realm={REALM}") + assert resp.status_code == 200 + admin.get_client_roles.assert_called_once_with("svc-uuid") + admin.get_client_service_account_user.assert_called_once_with("svc-uuid") + admin.get_realm_roles_of_user.assert_called_once_with(sa_user["id"]) + + def test_populates_agent_kind_and_owner_actor_ids(self): + # clientRole == true -> kind=Agent; actorIds = the owning client's serviceId, + # resolved from the role's containerId -> client. + admin = MagicMock() + admin.get_client_roles.return_value = [ + {"id": "cr1", "name": "invoke", "clientRole": True, "containerId": "svc-uuid"}, + ] + admin.get_client.return_value = {"id": "svc-uuid", "clientId": "github-agent"} + admin.get_client_service_account_user.return_value = {"id": "sa-uid"} + admin.get_realm_roles_of_user.return_value = [] + resp = _make_client(admin).get(f"/services/svc-uuid/roles?realm={REALM}") + assert resp.status_code == 200 + role = resp.json()[0] + assert role["kind"] == "Agent" + assert role["actorIds"] == ["github-agent"] + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.get_client_roles.side_effect = KeycloakError( + error_message="not found", response_code=404 + ) + resp = _make_client(admin).get(f"/services/svc-uuid/roles?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def test_returns_empty_list_when_client_has_no_client_roles(self): + admin = MagicMock() + admin.get_client_roles.side_effect = KeycloakError( + error_message="Client not found", response_code=400 + ) + resp = _make_client(admin).get(f"/services/svc-uuid/roles?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [] + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Assumption 1 (no cross-kind role) — fail loud (handoff 02) +# --------------------------------------------------------------------------- + + +class TestCrossKindEnforcement: + def test_role_held_by_users_and_service_accounts_returns_409(self): + # A role held by *both* human users and agent service accounts cannot be represented + # by a single actorIds list — fail loud rather than silently picking a side. + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "shared", "attributes": {"aiac.managed": ["true"]}}, + ] + admin.get_realm_role_members.return_value = [ + {"id": "u1", "username": "alice"}, + {"id": "sa", "username": "service-account-github-agent"}, + ] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + assert resp.status_code == 409 + assert "error" in resp.json() + + def test_role_held_only_by_users_is_ok(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "readers", "attributes": {"aiac.managed": ["true"]}}, + ] + admin.get_realm_role_members.return_value = [ + {"id": "u1", "username": "alice"}, + {"id": "u2", "username": "bob"}, + ] + resp = _make_client(admin).get(f"/roles?realm={REALM}") + assert resp.status_code == 200 + assert resp.json()[0]["actorIds"] == ["alice", "bob"] + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /services/{service_id}/scopes +# --------------------------------------------------------------------------- + + +class TestListServiceScopes: + def test_returns_json_array_with_owner_service_id(self): + # Scope.serviceId = the owning client (the service exposing the scope), resolved to the + # client's serviceId (clientId) — the single owner for a per-service scope listing. + admin = MagicMock() + admin.get_client_default_client_scopes.return_value = [ + {"id": "sc1", "name": "profile"}, + {"id": "sc2", "name": "email"}, + ] + admin.get_client.return_value = {"id": "svc-uuid", "clientId": "github-agent"} + resp = _make_client(admin).get(f"/services/svc-uuid/scopes?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [ + {"id": "sc1", "name": "profile", "serviceId": "github-agent"}, + {"id": "sc2", "name": "email", "serviceId": "github-agent"}, + ] + admin.get_client.assert_called_once_with("svc-uuid") + + def test_verifies_get_client_default_client_scopes_called(self): + admin = MagicMock() + admin.get_client_default_client_scopes.return_value = [] + _make_client(admin).get(f"/services/svc-uuid/scopes?realm={REALM}") + admin.get_client_default_client_scopes.assert_called_once_with("svc-uuid") + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.get_client_default_client_scopes.side_effect = KeycloakError( + error_message="not found", response_code=404 + ) + resp = _make_client(admin).get(f"/services/svc-uuid/scopes?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Assumption 2 (single scope owner) — fail loud (handoff 02) +# --------------------------------------------------------------------------- + + +class TestSingleScopeOwnerEnforcement: + _MANAGED = {"id": "aud-1", "name": "svc-a-aud", "attributes": {"aiac.managed": "true"}} + + def test_aiac_managed_scope_with_multiple_owners_returns_409(self): + # An AIAC-managed client scope must have exactly one owning client; if more than one + # client exposes it as a default scope, a single Scope.serviceId cannot represent it. + admin = MagicMock() + admin.get_client_default_client_scopes.return_value = [self._MANAGED] + admin.get_client.return_value = {"id": "svc-a", "clientId": "svc-a"} + admin.get_clients.return_value = [{"id": "svc-a"}, {"id": "svc-b"}] + resp = _make_client(admin).get(f"/services/svc-a/scopes?realm={REALM}") + assert resp.status_code == 409 + assert "error" in resp.json() + + def test_aiac_managed_scope_with_single_owner_is_ok(self): + admin = MagicMock() + admin.get_client.return_value = {"id": "svc-a", "clientId": "svc-a"} + admin.get_clients.return_value = [{"id": "svc-a"}, {"id": "svc-b"}] + + def _defaults(client_id): + return [self._MANAGED] if client_id == "svc-a" else [] + + admin.get_client_default_client_scopes.side_effect = _defaults + resp = _make_client(admin).get(f"/services/svc-a/scopes?realm={REALM}") + assert resp.status_code == 200 + assert resp.json()[0]["serviceId"] == "svc-a" + + def test_non_managed_scope_skips_owner_scan(self): + # Built-in / non-AIAC scopes are not subject to the single-owner invariant (Keycloak + # ships them assigned to many clients) — no cross-client scan runs for them. + admin = MagicMock() + admin.get_client_default_client_scopes.return_value = [{"id": "sc1", "name": "profile"}] + admin.get_client.return_value = {"id": "svc-a", "clientId": "svc-a"} + resp = _make_client(admin).get(f"/services/svc-a/scopes?realm={REALM}") + assert resp.status_code == 200 + admin.get_clients.assert_not_called() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /roles/{role_name}/composites +# --------------------------------------------------------------------------- + + +class TestGetRoleComposites: + def test_returns_json_array(self): + admin = MagicMock() + admin.get_composite_realm_roles_of_role.return_value = [{"id": "r2", "name": "viewer"}] + resp = _make_client(admin).get(f"/roles/admin/composites?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [{"id": "r2", "name": "viewer"}] + admin.get_composite_realm_roles_of_role.assert_called_once_with(role_name="admin") + + +# --------------------------------------------------------------------------- +# Realm query parameter: required, lazy per-realm cache +# --------------------------------------------------------------------------- + + +class TestRealmQueryParam: + def test_missing_realm_returns_422(self): + app.dependency_overrides.clear() + resp = TestClient(app).get("/subjects") + assert resp.status_code == 422 + + def test_realm_param_creates_admin_with_admin_realm(self): + _cache.clear() + app.dependency_overrides.clear() + admin_mock = MagicMock() + admin_mock.get_users.return_value = [] + env = { + "KEYCLOAK_URL": "http://keycloak:8080/", + "KEYCLOAK_ADMIN_REALM": "master", + "KEYCLOAK_ADMIN_USERNAME": "admin", + "KEYCLOAK_ADMIN_PASSWORD": "admin", + } + with patch.dict(os.environ, env), \ + patch("aiac.idp.service.configuration.keycloak.main.KeycloakAdmin", return_value=admin_mock) as mock_cls: + with TestClient(app) as client: + resp = client.get(f"/subjects?realm={REALM}") + assert resp.status_code == 200 + mock_cls.assert_called_once_with( + server_url="http://keycloak:8080/", + realm_name=REALM, + user_realm_name="master", + username="admin", + password="admin", + ) + + def test_second_request_same_realm_hits_cache(self): + _cache.clear() + app.dependency_overrides.clear() + admin_mock = MagicMock() + admin_mock.get_users.return_value = [] + env = { + "KEYCLOAK_URL": "http://keycloak:8080/", + "KEYCLOAK_ADMIN_REALM": "master", + "KEYCLOAK_ADMIN_USERNAME": "admin", + "KEYCLOAK_ADMIN_PASSWORD": "admin", + } + with patch.dict(os.environ, env), \ + patch("aiac.idp.service.configuration.keycloak.main.KeycloakAdmin", return_value=admin_mock) as mock_cls: + with TestClient(app) as client: + client.get(f"/subjects?realm={REALM}") + client.get(f"/subjects?realm={REALM}") + assert mock_cls.call_count == 1 + + def teardown_method(self): + app.dependency_overrides.clear() + _cache.clear() + + +# --------------------------------------------------------------------------- +# GET /health (readiness probe — pings Keycloak) +# --------------------------------------------------------------------------- + + +_HEALTH_ENV = {"KEYCLOAK_ADMIN_REALM": "master"} +_HEALTH_TARGET = "aiac.idp.service.configuration.keycloak.main._get_or_create_admin" + + +class TestHealth: + def test_returns_200_when_keycloak_reachable(self): + admin = MagicMock() + with patch(_HEALTH_TARGET, return_value=admin), patch.dict(os.environ, _HEALTH_ENV): + resp = TestClient(app).get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + admin.get_server_info.assert_called_once() + + def test_returns_503_when_keycloak_unreachable(self): + admin = MagicMock() + admin.get_server_info.side_effect = KeycloakError( + error_message="connection refused", response_code=503 + ) + with patch(_HEALTH_TARGET, return_value=admin), patch.dict(os.environ, _HEALTH_ENV): + resp = TestClient(app).get("/health") + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "unavailable" + assert "error" in body + + def test_uses_admin_realm_env_var(self): + admin = MagicMock() + with patch(_HEALTH_TARGET, return_value=admin) as mock_factory, \ + patch.dict(os.environ, {"KEYCLOAK_ADMIN_REALM": "master"}): + TestClient(app).get("/health") + mock_factory.assert_called_once_with("master") + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/scopes +# --------------------------------------------------------------------------- + + +class TestCreateScope: + def test_returns_201_with_scope_json(self): + admin = MagicMock() + admin.create_client_scope.return_value = "new-scope-id" + admin.get_client_scope.return_value = { + "id": "new-scope-id", + "name": "read:data", + "description": "Read access", + } + resp = _make_client(admin).post( + f"/services/svc-uuid/scopes?realm={REALM}", + json={"name": "read:data", "description": "Read access"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["id"] == "new-scope-id" + assert body["name"] == "read:data" + + def test_assigns_scope_as_default_to_service(self): + admin = MagicMock() + admin.create_client_scope.return_value = "scope-id-42" + admin.get_client_scope.return_value = {"id": "scope-id-42", "name": "write"} + _make_client(admin).post( + f"/services/svc-abc/scopes?realm={REALM}", + json={"name": "write", "description": "Write access"}, + ) + admin.add_client_default_client_scope.assert_called_once_with("svc-abc", "scope-id-42", {}) + + def test_creates_scope_with_openid_connect_protocol(self): + admin = MagicMock() + admin.create_client_scope.return_value = "sid" + admin.get_client_scope.return_value = {"id": "sid", "name": "read"} + _make_client(admin).post( + f"/services/svc/scopes?realm={REALM}", + json={"name": "read", "description": "desc"}, + ) + call_payload = admin.create_client_scope.call_args[0][0] + assert call_payload["protocol"] == "openid-connect" + assert call_payload["name"] == "read" + assert call_payload["description"] == "desc" + assert call_payload["attributes"] == {"aiac.managed": "true"} + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.create_client_scope.side_effect = KeycloakError( + error_message="backend failure", response_code=500 + ) + resp = _make_client(admin).post( + f"/services/svc/scopes?realm={REALM}", + json={"name": "read", "description": "desc"}, + ) + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /services/{service_id} +# --------------------------------------------------------------------------- + + +class TestGetService: + def test_returns_200_with_client_json(self): + admin = MagicMock() + admin.get_client.return_value = {"id": "svc-uuid", "clientId": "my-app"} + resp = _make_client(admin).get(f"/services/svc-uuid?realm={REALM}") + assert resp.status_code == 200 + assert resp.json()["id"] == "svc-uuid" + admin.get_client.assert_called_once_with("svc-uuid") + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.get_client.side_effect = KeycloakError(error_message="not found", response_code=404) + resp = _make_client(admin).get(f"/services/svc-uuid?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /services/{service_id}/discovery-token +# --------------------------------------------------------------------------- + + +class TestMintDiscoveryToken: + ISS = "http://keycloak.localtest.me:8080/realms/kagenti" + + def _wire(self, admin, monkeypatch, *, aud, iss=None, mappers=None, secret="sek"): + monkeypatch.setenv("KEYCLOAK_URL", "http://kc-internal:8080") + monkeypatch.delenv("AIAC_KEYCLOAK_ISSUER", raising=False) + admin.get_client.return_value = { + "id": "svc-uuid", "clientId": "github-tool", "secret": secret + } + admin.get_mappers_from_client.return_value = mappers if mappers is not None else [] + oid = MagicMock() + oid.token.return_value = {"access_token": _make_jwt({"aud": aud, "iss": iss or self.ISS})} + return oid + + def test_returns_200_with_token_and_resolves_client_id(self, monkeypatch): + admin = MagicMock() + oid = self._wire(admin, monkeypatch, aud=["github-tool"]) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + resp = _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + assert resp.status_code == 200 + body = resp.json() + assert body["access_token"] + assert body["client_id"] == "github-tool" + assert "github-tool" in body["audience"] + admin.get_client.assert_called_once_with("svc-uuid") + oid.token.assert_called_once_with(grant_type="client_credentials") + + def test_adds_audience_mapper_when_absent(self, monkeypatch): + admin = MagicMock() + oid = self._wire(admin, monkeypatch, aud=["github-tool"], mappers=[]) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + admin.add_mapper_to_client.assert_called_once() + + def test_idempotent_when_mapper_present(self, monkeypatch): + admin = MagicMock() + oid = self._wire( + admin, monkeypatch, aud=["github-tool"], + mappers=[{"name": "aiac-discovery-audience"}], + ) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + admin.add_mapper_to_client.assert_not_called() + + def test_does_not_regenerate_secret(self, monkeypatch): + admin = MagicMock() + oid = self._wire(admin, monkeypatch, aud=["github-tool"]) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + admin.generate_client_secrets.assert_not_called() + + def test_502_when_aud_missing_client_id(self, monkeypatch): + admin = MagicMock() + oid = self._wire(admin, monkeypatch, aud=["account"]) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + resp = _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + assert resp.status_code == 502 + assert "does not contain" in resp.json()["error"] + + def test_502_when_no_secret(self, monkeypatch): + admin = MagicMock() + oid = self._wire(admin, monkeypatch, aud=["github-tool"], secret=None) + admin.get_client_secrets.return_value = {} # no secret available via the secrets endpoint + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + resp = _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + assert resp.status_code == 502 + assert "no readable secret" in resp.json()["error"] + + def test_hard_iss_assertion_when_env_set(self, monkeypatch): + admin = MagicMock() + oid = self._wire( + admin, monkeypatch, aud=["github-tool"], iss="http://kc-internal:8080/realms/kagenti" + ) + monkeypatch.setenv("AIAC_KEYCLOAK_ISSUER", self.ISS) + with patch( + "aiac.idp.service.configuration.keycloak.main.KeycloakOpenID", return_value=oid + ): + resp = _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + assert resp.status_code == 502 + assert "iss" in resp.json()["error"] + + def test_502_on_keycloak_error(self, monkeypatch): + monkeypatch.setenv("KEYCLOAK_URL", "http://kc-internal:8080") + admin = MagicMock() + admin.get_client.side_effect = KeycloakError(error_message="not found", response_code=404) + resp = _make_client(admin).get(f"/services/svc-uuid/discovery-token?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/type +# --------------------------------------------------------------------------- + + +class TestSetServiceType: + def test_returns_200_with_updated_client(self): + admin = MagicMock() + admin.get_client.side_effect = [ + {"id": "svc-uuid", "clientId": "my-app", "attributes": {}}, + {"id": "svc-uuid", "clientId": "my-app", "attributes": {"client.type": "Agent"}}, + ] + resp = _make_client(admin).post( + f"/services/svc-uuid/type?realm={REALM}", json={"type": "Agent"} + ) + assert resp.status_code == 200 + assert resp.json()["attributes"] == {"client.type": "Agent"} + + def test_sets_client_type_attribute_via_update_client(self): + admin = MagicMock() + admin.get_client.return_value = {"id": "svc-uuid", "attributes": {"existing": "keep"}} + _make_client(admin).post(f"/services/svc-uuid/type?realm={REALM}", json={"type": "Tool"}) + # existing attributes preserved; client.type merged in (not clobbered) + admin.update_client.assert_called_once_with( + "svc-uuid", {"attributes": {"existing": "keep", "client.type": "Tool"}} + ) + + def test_stores_capitalized_plain_string_value(self): + admin = MagicMock() + admin.get_client.return_value = {"id": "svc-uuid", "attributes": {}} + _make_client(admin).post(f"/services/svc-uuid/type?realm={REALM}", json={"type": "Agent"}) + payload = admin.update_client.call_args[0][1] + assert payload["attributes"]["client.type"] == "Agent" # plain string, not a list + + def test_rejects_invalid_type_with_422(self): + admin = MagicMock() + resp = _make_client(admin).post( + f"/services/svc-uuid/type?realm={REALM}", json={"type": "agent"} + ) + assert resp.status_code == 422 + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.get_client.side_effect = KeycloakError(error_message="not found", response_code=404) + resp = _make_client(admin).post( + f"/services/svc-uuid/type?realm={REALM}", json={"type": "Agent"} + ) + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /scopes +# --------------------------------------------------------------------------- + + +class TestCreateScopeEndpoint: + def test_returns_201_with_scope_json(self): + admin = MagicMock() + admin.create_client_scope.return_value = "new-scope-id" + admin.get_client_scope.return_value = { + "id": "new-scope-id", + "name": "read:data", + "description": "Read access", + } + resp = _make_client(admin).post( + f"/scopes?realm={REALM}", + json={"name": "read:data", "description": "Read access"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["id"] == "new-scope-id" + assert body["name"] == "read:data" + + def test_creates_scope_with_openid_connect_protocol(self): + admin = MagicMock() + admin.create_client_scope.return_value = "sid" + admin.get_client_scope.return_value = {"id": "sid", "name": "read"} + _make_client(admin).post(f"/scopes?realm={REALM}", json={"name": "read", "description": "desc"}) + payload = admin.create_client_scope.call_args[0][0] + assert payload["protocol"] == "openid-connect" + assert payload["name"] == "read" + assert payload["description"] == "desc" + assert payload["attributes"] == {"aiac.managed": "true"} + + def test_returns_409_on_duplicate_name(self): + admin = MagicMock() + admin.create_client_scope.side_effect = KeycloakError( + error_message="Conflict", response_code=409 + ) + resp = _make_client(admin).post(f"/scopes?realm={REALM}", json={"name": "dupe", "description": ""}) + assert resp.status_code == 409 + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.create_client_scope.side_effect = KeycloakError( + error_message="backend failure", response_code=500 + ) + resp = _make_client(admin).post(f"/scopes?realm={REALM}", json={"name": "read", "description": "desc"}) + assert resp.status_code == 502 + assert "error" in resp.json() + + def test_realm_override_uses_per_realm_admin(self): + _cache.clear() + app.dependency_overrides.clear() + admin_mock = MagicMock() + admin_mock.create_client_scope.return_value = "s1" + admin_mock.get_client_scope.return_value = {"id": "s1", "name": "x"} + env = { + "KEYCLOAK_URL": "http://keycloak:8080/", + "KEYCLOAK_ADMIN_REALM": "master", + "KEYCLOAK_ADMIN_USERNAME": "admin", + "KEYCLOAK_ADMIN_PASSWORD": "admin", + } + with patch.dict(os.environ, env), \ + patch("aiac.idp.service.configuration.keycloak.main.KeycloakAdmin", return_value=admin_mock): + with TestClient(app) as client: + resp = client.post("/scopes?realm=other", json={"name": "x", "description": ""}) + assert resp.status_code == 201 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/scopes/{scope_id} +# --------------------------------------------------------------------------- + + +class TestAssignScopeToService: + def test_returns_201_on_success(self): + admin = MagicMock() + resp = _make_client(admin).post(f"/services/svc-uuid/scopes/scope-id?realm={REALM}") + assert resp.status_code == 201 + admin.add_client_default_client_scope.assert_called_once_with("svc-uuid", "scope-id", {}) + + def test_returns_409_when_already_assigned(self): + admin = MagicMock() + admin.add_client_default_client_scope.side_effect = KeycloakError( + error_message="Conflict", response_code=409 + ) + resp = _make_client(admin).post(f"/services/svc-uuid/scopes/scope-id?realm={REALM}") + assert resp.status_code == 409 + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.add_client_default_client_scope.side_effect = KeycloakError( + error_message="failure", response_code=500 + ) + resp = _make_client(admin).post(f"/services/svc-uuid/scopes/scope-id?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /roles +# --------------------------------------------------------------------------- + + +class TestCreateRoleEndpoint: + def test_returns_201_with_role_json(self): + admin = MagicMock() + admin.create_realm_role.return_value = "new-role-id" + admin.get_realm_role.return_value = { + "id": "new-role-id", + "name": "reader", + "description": "Read-only", + } + resp = _make_client(admin).post( + f"/roles?realm={REALM}", + json={"name": "reader", "description": "Read-only"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["id"] == "new-role-id" + assert body["name"] == "reader" + + def test_creates_role_with_correct_payload(self): + admin = MagicMock() + admin.create_realm_role.return_value = "rid" + admin.get_realm_role.return_value = {"id": "rid", "name": "reader"} + _make_client(admin).post(f"/roles?realm={REALM}", json={"name": "reader", "description": "desc"}) + payload = admin.create_realm_role.call_args[0][0] + assert payload == { + "name": "reader", + "description": "desc", + "attributes": {"aiac.managed": ["true"]}, + } + + def test_returns_409_on_duplicate_name(self): + admin = MagicMock() + admin.create_realm_role.side_effect = KeycloakError( + error_message="Conflict", response_code=409 + ) + resp = _make_client(admin).post(f"/roles?realm={REALM}", json={"name": "dupe", "description": ""}) + assert resp.status_code == 409 + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.create_realm_role.side_effect = KeycloakError( + error_message="backend failure", response_code=500 + ) + resp = _make_client(admin).post(f"/roles?realm={REALM}", json={"name": "reader", "description": "desc"}) + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/roles/{role_id} +# --------------------------------------------------------------------------- + + +class TestAssignRoleToService: + def test_returns_201_on_success(self): + admin = MagicMock() + admin.get_client_service_account_user.return_value = {"id": "sa-user-id"} + admin.get_realm_role_by_id.return_value = {"id": "role-id", "name": "src-helper"} + resp = _make_client(admin).post(f"/services/svc-uuid/roles/role-id?realm={REALM}") + assert resp.status_code == 201 + admin.get_client_service_account_user.assert_called_once_with("svc-uuid") + admin.get_realm_role_by_id.assert_called_once_with("role-id") + admin.assign_realm_roles.assert_called_once_with( + "sa-user-id", [{"id": "role-id", "name": "src-helper"}] + ) + + def test_returns_409_when_already_assigned(self): + admin = MagicMock() + admin.get_client_service_account_user.return_value = {"id": "sa-user-id"} + admin.assign_realm_roles.side_effect = KeycloakError( + error_message="Conflict", response_code=409 + ) + resp = _make_client(admin).post(f"/services/svc-uuid/roles/role-id?realm={REALM}") + assert resp.status_code == 409 + + def test_returns_502_on_keycloak_error(self): + admin = MagicMock() + admin.get_client_service_account_user.return_value = {"id": "sa-user-id"} + admin.assign_realm_roles.side_effect = KeycloakError( + error_message="failure", response_code=500 + ) + resp = _make_client(admin).post(f"/services/svc-uuid/roles/role-id?realm={REALM}") + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /subjects?role_id= (filtered variant, 2.14) +# --------------------------------------------------------------------------- + + +class TestGetSubjectsByRole: + def test_returns_enriched_subjects_for_role(self): + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "viewer"} + admin.get_realm_role_members.return_value = [ + {"id": "u1", "username": "alice"}, + {"id": "u2", "username": "bob"}, + ] + admin.get_all_roles_of_user.side_effect = [ + {"realmMappings": [{"id": "rid", "name": "viewer"}], "clientMappings": {}}, + {"realmMappings": [{"id": "rid", "name": "viewer"}], "clientMappings": {}}, + ] + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + admin.get_realm_role_by_id.assert_called_once_with("rid") + admin.get_realm_role_members.assert_called_once_with("viewer") + assert admin.get_all_roles_of_user.call_count == 2 + + def test_returns_empty_list_when_no_members(self): + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "viewer"} + admin.get_realm_role_members.return_value = [] + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 200 + assert resp.json() == [] + admin.get_all_roles_of_user.assert_not_called() + + def test_returns_502_on_keycloak_error_in_get_role(self): + admin = MagicMock() + admin.get_realm_role_by_id.side_effect = KeycloakError( + error_message="not found", response_code=404 + ) + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 502 + assert "error" in resp.json() + + def test_returns_502_on_keycloak_error_in_get_members(self): + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "viewer"} + admin.get_realm_role_members.side_effect = KeycloakError( + error_message="error", response_code=500 + ) + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 502 + assert "error" in resp.json() + + def test_returns_502_on_keycloak_error_during_enrichment(self): + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "viewer"} + admin.get_realm_role_members.return_value = [{"id": "u1", "username": "alice"}] + admin.get_all_roles_of_user.side_effect = KeycloakError( + error_message="error", response_code=500 + ) + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 502 + assert "error" in resp.json() + + def test_enrichment_shape_includes_realm_mappings(self): + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "viewer"} + admin.get_realm_role_members.return_value = [{"id": "u1", "username": "alice"}] + admin.get_all_roles_of_user.return_value = { + "realmMappings": [{"id": "rid", "name": "viewer"}], + "clientMappings": {"account": {"mappings": []}}, + } + resp = _make_client(admin).get(f"/subjects?realm={REALM}&role_id=rid") + assert resp.status_code == 200 + body = resp.json() + assert "realmMappings" in body[0] + assert body[0]["realmMappings"] == [{"id": "rid", "name": "viewer"}] + + def test_actor_ids_align_with_subjects_by_role(self): + # SPM/APM alignment (1.12 / 2.14): the member usernames GET /subjects?role_id= returns + # for a user (realm) role are exactly the Role.actorIds GET /roles populates for that + # kind=User role — both resolve via admin.get_realm_role_members. + admin = MagicMock() + admin.get_realm_role_by_id.return_value = {"id": "rid", "name": "invoicing"} + members = [{"id": "u1", "username": "alice"}, {"id": "u2", "username": "bob"}] + admin.get_realm_role_members.return_value = members + admin.get_all_roles_of_user.return_value = {"realmMappings": [], "clientMappings": {}} + admin.get_realm_roles.return_value = [ + {"id": "rid", "name": "invoicing", "attributes": {"aiac.managed": ["true"]}}, + ] + client = _make_client(admin) + + subjects = client.get(f"/subjects?realm={REALM}&role_id=rid").json() + roles = client.get(f"/roles?realm={REALM}").json() + + subject_usernames = [s["username"] for s in subjects] + actor_ids = roles[0]["actorIds"] + assert subject_usernames == actor_ids == ["alice", "bob"] + + def test_missing_realm_returns_422(self): + app.dependency_overrides.clear() + resp = TestClient(app).get("/subjects?role_id=rid") + assert resp.status_code == 422 + + def test_unfiltered_still_works(self): + admin = MagicMock() + admin.get_users.return_value = [{"id": "u1", "username": "alice"}] + resp = _make_client(admin).get(f"/subjects?realm={REALM}") + assert resp.status_code == 200 + assert resp.json() == [{"id": "u1", "username": "alice"}] + admin.get_users.assert_called_once() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# KeycloakError → 502 on all endpoints +# --------------------------------------------------------------------------- + + +def _keycloak_error(): + return KeycloakError(error_message="connection refused", response_code=503) + + +class TestKeycloakErrorProduces502: + def test_get_subjects(self): + admin = MagicMock() + admin.get_users.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/subjects?realm={REALM}").status_code == 502 + + def test_get_roles(self): + admin = MagicMock() + admin.get_realm_roles.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/roles?realm={REALM}").status_code == 502 + + def test_get_services(self): + admin = MagicMock() + admin.get_clients.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/services?realm={REALM}").status_code == 502 + + def test_get_scopes(self): + admin = MagicMock() + admin.get_client_scopes.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/scopes?realm={REALM}").status_code == 502 + + def test_get_subject_assignments(self): + admin = MagicMock() + admin.get_all_roles_of_user.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/subjects/u1/assignments?realm={REALM}").status_code == 502 + + def test_get_service_permissions(self): + admin = MagicMock() + admin.get_client_roles.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/services/s1/roles?realm={REALM}").status_code == 502 + + def test_get_service_scopes(self): + admin = MagicMock() + admin.get_client_default_client_scopes.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/services/s1/scopes?realm={REALM}").status_code == 502 + + def test_get_role_composites(self): + admin = MagicMock() + admin.get_composite_realm_roles_of_role.side_effect = _keycloak_error() + assert _make_client(admin).get(f"/roles/admin/composites?realm={REALM}").status_code == 502 + + def test_mint_discovery_token(self, monkeypatch): + monkeypatch.setenv("KEYCLOAK_URL", "http://kc-internal:8080") + admin = MagicMock() + admin.get_client.side_effect = _keycloak_error() + assert ( + _make_client(admin).get(f"/services/s1/discovery-token?realm={REALM}").status_code + == 502 + ) + + def teardown_method(self): + app.dependency_overrides.clear() diff --git a/aiac/test/integration/__init__.py b/aiac/test/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/integration/launcher.py b/aiac/test/integration/launcher.py new file mode 100644 index 000000000..dae161840 --- /dev/null +++ b/aiac/test/integration/launcher.py @@ -0,0 +1,318 @@ +"""Shared machinery for the integration-test launchers. + +The subprocess half — spawn aiac services as ``uvicorn`` subprocesses, poll each ``GET /health`` +until ready, run some work, tear them down — is used by ``test/pdp/policy/generate_rego.py`` (5.2) +and ``test/integration/test_policy_pipeline.py`` (5.3). + +The cluster half — ``kubectl`` cp, ``kubectl port-forward``, ``resolve_pod``, and the ``opa`` +oracle — is used by the UC-1 onboarding ladder (``test/integration/test_uc1_onboard_agent_only.py`` +and its rung-2/3 siblings, 5.4), which drives a real Kagenti/Kind cluster rather than in-process +subprocesses. + +It imports only the standard library and ``requests`` — never ``aiac`` — so a launcher may import +it *before* setting the environment variables the aiac libraries read at import time. ``pytest`` is +imported lazily inside ``opa_bin`` (only 5.4 uses it, and only under pytest) so the module stays +importable in the standalone launchers. +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import subprocess +import sys +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +import requests + + +def ensure_on_path(*paths: Path) -> None: + """Prepend each path to ``sys.path`` (once), so a launcher can import ``aiac`` from ``src`` + and the shared ``test.integration`` modules from the repo root.""" + for path in paths: + entry = str(path) + if entry not in sys.path: + sys.path.insert(0, entry) + + +def require_env(*names: str) -> dict[str, str]: + """Return the values of the named environment variables, or exit non-zero listing every one + that is unset or empty. Used by launchers for inputs that have no safe default (Keycloak + admin creds, LLM endpoint).""" + missing = [name for name in names if not os.environ.get(name)] + if missing: + print( + "error: required environment variable(s) not set: " + ", ".join(missing), + file=sys.stderr, + ) + raise SystemExit(2) + return {name: os.environ[name] for name in names} + + +def resolve_output_dir(default: Path) -> Path: + """Resolve ``REGO_OUTPUT_DIR`` (falling back to ``default``) to an absolute path.""" + return Path(os.environ.get("REGO_OUTPUT_DIR", default)).resolve() + + +@dataclass +class Service: + """A ``uvicorn``-hostable ASGI app to run as a subprocess.""" + + module_app: str # e.g. "aiac.pdp.service.policy.opa.main:app" + port: int + host: str = "127.0.0.1" + env: dict[str, str] = field(default_factory=dict) # per-service extra env + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + +def start_service(service: Service, *, src: Path) -> subprocess.Popen: + """Spawn ``service`` as a ``uvicorn`` subprocess with ``src`` on ``PYTHONPATH`` and the + service's extra env applied on top of the current environment.""" + env = dict(os.environ) + env["PYTHONPATH"] = str(src) + os.pathsep + env.get("PYTHONPATH", "") + env.update(service.env) + return subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + service.module_app, + "--host", + service.host, + "--port", + str(service.port), + ], + env=env, + ) + + +def wait_until_ready(base_url: str, *, timeout: float = 30.0) -> None: + """Poll ``GET {base_url}/health`` until it returns 200, or raise after ``timeout`` seconds.""" + deadline = time.time() + timeout + last_err: Exception | None = None + while time.time() < deadline: + try: + if requests.get(f"{base_url}/health", timeout=1).status_code == 200: + return + except requests.RequestException as exc: + last_err = exc + time.sleep(0.3) + raise RuntimeError(f"service not ready at {base_url} within {timeout}s ({last_err})") + + +def terminate(proc: subprocess.Popen) -> None: + """SIGTERM ``proc`` and wait briefly, escalating to SIGKILL if it does not exit.""" + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +@contextmanager +def running_services(services: list[Service], *, src: Path, timeout: float = 30.0) -> Iterator[None]: + """Spawn every service, poll each ``/health``, yield, then terminate them all in ``finally``. + + Every spawned subprocess is torn down even if a later spawn or health poll fails. + """ + procs: list[subprocess.Popen] = [] + try: + for service in services: + procs.append(start_service(service, src=src)) + for service in services: + wait_until_ready(service.base_url, timeout=timeout) + yield + finally: + for proc in procs: + terminate(proc) + + +def print_rego_dir(output_dir: Path) -> None: + """Print the output directory and the ``.rego`` files it contains (the launcher's result).""" + print(f"Rego written to: {output_dir}") + for path in sorted(output_dir.glob("*.rego")): + print(f" {path.name}") + + +# ====================================================================================== +# Cluster helpers (5.4) — kubectl apply/delete/rollout/get/cp + port-forward +# ====================================================================================== +# +# Thin wrappers around the ``kubectl`` CLI (no in-process K8s client — keeps launcher.py +# dependency-free and mirrors what an operator would run by hand). Every call honours +# ``KUBECONFIG`` from the environment. Failures raise ``subprocess.CalledProcessError`` with the +# captured stderr, so the caller's assertion message names the failing command. + + +def kubectl(*args: str, input_text: str | None = None, timeout: float = 60.0) -> str: + """Run ``kubectl `` and return stdout (raising on non-zero exit). ``input_text`` is + piped to stdin (e.g. for ``kubectl apply -f -``).""" + proc = subprocess.run( + ["kubectl", *args], + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + ) + if proc.returncode != 0: + raise subprocess.CalledProcessError( + proc.returncode, ["kubectl", *args], output=proc.stdout, stderr=proc.stderr + ) + return proc.stdout + + +def kubectl_apply(manifest_path: Path, *, namespace: str | None = None) -> None: + """``kubectl apply -f `` (optionally ``-n ``).""" + args = ["apply", "-f", str(manifest_path)] + if namespace: + args += ["-n", namespace] + kubectl(*args) + + +def kubectl_delete(manifest_path: Path, *, namespace: str | None = None, timeout: float = 120.0) -> None: + """``kubectl delete -f --ignore-not-found`` — safe to call in teardown even if + the workloads are already gone.""" + args = ["delete", "-f", str(manifest_path), "--ignore-not-found", "--wait=true"] + if namespace: + args += ["-n", namespace] + kubectl(*args, timeout=timeout) + + +def kubectl_rollout_status(resource: str, *, namespace: str, timeout: float = 180.0) -> None: + """Block until ``resource`` (e.g. ``deployment/github-tool``) is rolled out, or raise.""" + kubectl( + "rollout", "status", resource, "-n", namespace, f"--timeout={int(timeout)}s", timeout=timeout + 10 + ) + + +def kubectl_get_json(resource: str, *, namespace: str | None = None) -> dict: + """``kubectl get -o json`` parsed to a dict (a single object or a ``List``).""" + args = ["get", resource, "-o", "json"] + if namespace: + args += ["-n", namespace] + return json.loads(kubectl(*args)) + + +def kubectl_cp(pod: str, remote_path: str, local_path: Path, *, namespace: str, container: str | None = None) -> None: + """``kubectl cp /: `` — copy a file/dir out of a pod.""" + args = ["cp", f"{namespace}/{pod}:{remote_path}", str(local_path)] + if container: + args += ["-c", container] + kubectl(*args, timeout=120.0) + + +def resolve_pod(selector: str, *, namespace: str) -> str: + """Return the name of the first pod matching a label ``selector`` (e.g. ``app=aiac-opa``).""" + out = kubectl( + "get", "pods", "-n", namespace, "-l", selector, + "-o", "jsonpath={.items[0].metadata.name}", + ) + if not out.strip(): + raise RuntimeError(f"no pod matches selector {selector!r} in namespace {namespace!r}") + return out.strip() + + +@contextmanager +def port_forward(target: str, *, namespace: str, local_port: int, remote_port: int, + ready_url: str | None = None, timeout: float = 30.0) -> Iterator[str]: + """Run ``kubectl port-forward :`` for the duration of the block, + yielding the local ``http://127.0.0.1:`` base URL. + + ``target`` is a kubectl port-forward target (``svc/aiac-controller``, ``deploy/...``, ``pod/...``). + The forward is not yielded until it is actually up: if ``ready_url`` is given it is polled until + it answers (any HTTP status); otherwise the tunnel's own ``Forwarding from ...`` line is awaited + (the Controller exposes no ``/health``). A background thread drains the merged stdout/stderr the + whole time — both to detect that line and so the OS pipe buffer can never fill and deadlock + kubectl — and its captured output is surfaced if the forward exits early or never comes up. + """ + proc = subprocess.Popen( + ["kubectl", "port-forward", "-n", namespace, target, f"{local_port}:{remote_port}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + base_url = f"http://127.0.0.1:{local_port}" + output: list[str] = [] + forwarding = threading.Event() + + def _drain() -> None: + assert proc.stdout is not None + for line in proc.stdout: # blocks in the thread, never on the main path + output.append(line) + if "Forwarding from" in line: + forwarding.set() + + reader = threading.Thread(target=_drain, daemon=True) + reader.start() + try: + deadline = time.time() + timeout + ready = False + while time.time() < deadline: + if proc.poll() is not None: + reader.join(timeout=1) + raise RuntimeError( + f"port-forward to {target} exited early: {''.join(output).strip()}" + ) + if ready_url is None: + if forwarding.wait(timeout=0.3): # tunnel announced it is up + ready = True + break + else: + try: + requests.get(ready_url, timeout=1) + ready = True + break + except requests.RequestException: + time.sleep(0.3) + if not ready: + raise RuntimeError( + f"port-forward to {target} not ready within {timeout}s: {''.join(output).strip()}" + ) + yield base_url + finally: + terminate(proc) + reader.join(timeout=1) + + +# ====================================================================================== +# OPA oracle (5.4) — standalone ``opa eval`` as the verification binary +# ====================================================================================== + + +def opa_bin() -> str: + """Path to the ``opa`` binary (``$OPA_BIN`` -> ``PATH``), or ``pytest.skip`` the calling test. + Absence SKIPS, never fails (spec/issue acceptance).""" + found = os.environ.get("OPA_BIN") or shutil.which("opa") + if not found: + import pytest + + pytest.skip("opa binary not found (set OPA_BIN or add opa to PATH)") + return found + + +def opa_eval(rego_paths: list[Path], query: str, input_doc: dict) -> object: + """Evaluate ``query`` against the given Rego file(s) with ``input_doc`` on stdin, returning the + result value. Raises (via ``check=True``) if OPA rejects the Rego or the query errors.""" + cmd = [ + opa_bin(), + "eval", + "-f", + "json", + *sum((["-d", str(p)] for p in rego_paths), []), + "--stdin-input", + query, + ] + out = subprocess.run( + cmd, input=json.dumps(input_doc), capture_output=True, text=True, check=True + ).stdout + return json.loads(out)["result"][0]["expressions"][0]["value"] diff --git a/aiac/test/integration/policy.abstract.md b/aiac/test/integration/policy.abstract.md new file mode 100644 index 000000000..4fc56a667 --- /dev/null +++ b/aiac/test/integration/policy.abstract.md @@ -0,0 +1,2 @@ +- Developers work primarily in source — writing and maintaining code — and consult the issue tracker only to follow defect reports; grant them full read and write access to source contents, and read-only access to issues. +- Testers work exclusively in the issue tracker — filing, triaging, and updating defect reports — and do not work in source; grant them full read and write access to issues, and no access to source. diff --git a/aiac/test/integration/policy.explicit.md b/aiac/test/integration/policy.explicit.md new file mode 100644 index 000000000..42526133d --- /dev/null +++ b/aiac/test/integration/policy.explicit.md @@ -0,0 +1,16 @@ +# Access Control Policy — github-agent / github-tool + +Grant access on a least-privilege basis. Only grant a (role, scope) pair when this +policy supports it; deny by default. + +## Users → agent capabilities (inbound; user may call the agent) +- developer may use source-access and issues-access. +- tester may use issues-access. + +## Users → tool operations (outbound subject; user may reach the tool) +- developer may perform source-read, source-write, and issues-read. +- tester may perform issues-read and issues-write. + +## Agent roles → tool operations (outbound target; agent may reach the tool) +- source_operations may perform source-read and source-write. +- issue_operations may perform issues-read and issues-write. diff --git a/aiac/test/integration/probe.rego b/aiac/test/integration/probe.rego new file mode 100644 index 000000000..172cb6f90 --- /dev/null +++ b/aiac/test/integration/probe.rego @@ -0,0 +1,34 @@ +package probe.outbound +import future.keywords + +# Outbound decision probe for the fixed `github_agent` scenario. The generated +# `github_agent.outbound.rego` exposes only data + an `allow` keyed on a concrete +# tool scope; this probe binds an inbound `input.function_name` to a tool scope by +# soft-matching their token sets, so the test can drive the outbound gate the way a +# caller would (by function name) rather than by pre-resolved scope. +gen := data.authz.github_agent.outbound + +# Case/separator-insensitive token set: "Source.Read" and "source-read" both -> {"source","read"}. +tokens(s) := {lower(t) | some t in regex.split(`[._-]+`, s)} + +# Tool scopes the user (subject) is entitled to on the target. +subject_scopes contains scope if { + some role in gen.subject_roles[input.subject] + some scope in gen.subject_role_scopes[role] + scope in gen.target_scopes[input.target] +} + +# Tool scopes the agent is entitled to on the target. +agent_allowed contains scope if { + some role in gen.agent_roles + some scope in gen.agent_role_scopes[role] + scope in gen.target_scopes[input.target] +} + +default allow := false +allow if { + some s in subject_scopes + tokens(s) == tokens(input.function_name) + some a in agent_allowed + tokens(a) == tokens(input.function_name) +} diff --git a/aiac/test/integration/probe_uc1.rego b/aiac/test/integration/probe_uc1.rego new file mode 100644 index 000000000..a85e30c5f --- /dev/null +++ b/aiac/test/integration/probe_uc1.rego @@ -0,0 +1,38 @@ +package probe.outbound +import future.keywords + +# Outbound decision probe for the discovery-driven UC-1 `github_agent` scenario. Adapted from +# 5.3's `probe.rego` for two UC-1-specific facts: +# +# 1. PER-SCOPE AND (both gates). Outbound access is a per-scope two-gate AND: a requested tool +# scope is allowed iff BOTH the user (subject) is granted it AND the agent's own operator roles +# reach it. This probe binds both gates against the generated data maps. UC-1 has a single tool, +# so the capability gate uses the agent's own capability map (`agent_role_scopes`) directly — +# equivalent to the writer's `target_scopes[input.target]` for that one target — and the probe +# input stays `{subject, function_name}` (no `target` key needed). +# +# 2. EXACT-NAME MATCH. `scenario_uc1.py` stores the FULL discovered scope names +# (`github-tool.source-read`, ...) — the same strings the generated data maps contain — so +# `input.function_name` is matched to a subject/agent scope by plain string equality. No 5.3-style +# prefix-stripping token-set soft match (that was 5.3's device for bare names; here both sides +# are already prefixed). +gen := data.authz.team1_github_agent.outbound + +# Tool scopes the user (subject) is entitled to, via the generated user->tool data maps (subject gate). +subject_scopes contains scope if { + some role in gen.subject_roles[input.subject] + some scope in gen.subject_role_scopes[role] +} + +# Tool scopes the agent's own operator roles reach (capability gate; single-target UC-1). Iterate +# the map's VALUES (each a list of scope names) and flatten — ``some scopes in obj`` binds each value. +agent_scopes contains scope if { + some scopes in gen.agent_role_scopes + some scope in scopes +} + +default allow := false +allow if { + input.function_name in subject_scopes + input.function_name in agent_scopes +} diff --git a/aiac/test/integration/scenario.py b/aiac/test/integration/scenario.py new file mode 100644 index 000000000..0fc09442d --- /dev/null +++ b/aiac/test/integration/scenario.py @@ -0,0 +1,143 @@ +"""The canonical ``github-agent`` scenario — single source of truth for both launchers. + +The 5.2 launcher (``test/pdp/policy/generate_rego.py``) hand-builds a ``PolicyModel`` from +these names + role→access pair-sets; the 5.3 launcher (``test/integration/policy_pipeline.py``) +provisions the same entities into a live Keycloak realm and feeds the descriptions to the PRB. +Keeping the scenario in one place is what the spec's *Further Notes* mandate — the role→access +facts, the entity/role/scope descriptions, and both ``policy.md`` variants must stay mutually +consistent (spec: ``docs/specs/integration-test/policy-pipeline.md``, *Scenario* + +*Scenario inputs*). + +Pure data: this module imports nothing (no aiac, no stdlib beyond the language) so a launcher can +import it before the env-before-import step. Dict insertion order is significant — it is preserved +into the generated Rego, so it matches the 5.2 launcher's original literal order. +""" + +from __future__ import annotations + +# --- Realm + entity identifiers ------------------------------------------------------------- + +# MUST be a throwaway realm: this suite's ``provision_keycloak_admin`` does ``delete_realm`` + +# ``create_realm`` on it every run, so pointing it (via ``AIAC_TEST_REALM``) at a shared realm like +# ``kagenti`` DESTROYS that realm's contents. UC-1 uses its own realm (``scenario_uc1.REALM_DEFAULT``). +REALM_DEFAULT = "aiac-pp" +AGENT_ID = "github-agent" +TOOL_ID = "github-tool" + +# username -> the realm role the user holds +USERS: dict[str, str] = { + "dev-user": "developer", + "test-user": "tester", + "devops-user": "devops", +} + +# Fixed dev password for the provisioned test users (throwaway realm). +USER_PASSWORD = "password" + +# --- Descriptions (verbatim from the spec's *Scenario inputs*) ------------------------------ +# +# These descriptions feed the PRB's LLM role→scope mapping. They do NOT drive service typing: +# the IdP types services via the canonical ``client.type`` attribute (set by the launcher through +# ``config.set_service_type``), not by inferring "Agent"/"Tool" from the description text. + +AGENT_DESCRIPTION = ( + "Autonomous Agent acting on a user's behalf against source repositories and an issue tracker. " + "It inspects and changes repository source contents and reads, creates, and updates issues and " + "their threads." +) + +TOOL_DESCRIPTION = ( + "Capability provider Tool for source repositories and an issue tracker. It performs read and " + "write operations on repository source contents and on issues and their comment threads." +) + +# name -> description. Realm roles held by users. +USER_ROLES: dict[str, str] = { + "developer": ( + "Developer — an engineering user who develops the source codebase (writing and maintaining " + "code) and fixes code defects reported in the issue tracker; works primarily in source and " + "consults issues for defect reports." + ), + "tester": ( + "Tester — a quality-assurance user who verifies software quality and tracks defects through " + "the issue tracker: filing, triaging, and updating issue reports; works in the issue " + "tracker, not in source." + ), + # Deny-by-default control: devops appears in no INBOUND/OUTBOUND pair below. Its description is + # deliberately unrelated to source and issue work, so the PRB derives no agent or tool scope for + # it and deny-by-default leaves devops-user denied everywhere. + "devops": ( + "DevOps — an operations user who manages deployment infrastructure and runtime " + "environments; does not author source code and does not manage the issue tracker." + ), +} + +# name -> description. The github-agent's client roles — the per-skill operator roles, named to +# match exactly what UC-1 provisioning emits from the AgentCard skill ids +# (``github-agent.source_operations`` / ``github-agent.issue_operations``; see scenario_uc1.py). +# +# NOTE: the ``_operations`` suffix (not the actor-noun ``operator``) also sidesteps the PRB-auditor +# actor-confusion that the former plural ``issues-operator`` was chosen to dodge: the singular +# ``issue-operator`` read to the LLM auditor as an actor competing with the ``tester`` subject and +# made it wrongly reject the valid (tester, issues-write) grant. ``issue_operations`` reads as a +# category of actions, not a person, so that collision does not apply. (Historical detail: +# issues/agent/3.20-policy-rules-builder.md, "Follow-up: auditor relationship-scoping".) +AGENT_ROLES: dict[str, str] = { + "source_operations": ( + "Covers read and write access to source repository contents — listing, reading, creating, " + "and modifying files." + ), + "issue_operations": ( + "Covers read and write access to the issue tracker — reading, filing, updating, and " + "commenting on issues and their threads." + ), +} + +# name -> description. Agent-boundary scopes exposed by the github-agent. +AGENT_SCOPES: dict[str, str] = { + "source-access": ( + "Scope granting use of a source-code capability — invoking source-code functions such as " + "reading and changing repository contents." + ), + "issues-access": ( + "Scope granting use of an issue-management capability — invoking issue functions such as " + "reading and updating issues." + ), +} + +# name -> description. Fine-grained operations exposed by the github-tool. +TOOL_SCOPES: dict[str, str] = { + "source-read": "Read source repository contents: file listings and file bodies. Read-only.", + "source-write": "Create, modify, or delete source repository contents; commit file changes.", + "issues-read": "Read issues and their comment threads. Read-only.", + "issues-write": "Create and update issues: open, edit, comment, and close.", +} + +# --- Role → access facts (name-level; the single source of truth) --------------------------- +# +# Identical to the 5.2 launcher's hand-built rule lists and to policy.explicit.md. Each set maps +# 1:1 to a PRB mapping and to a generated Rego gate: +# (a) INBOUND_PAIRS — user role -> agent scope (inbound; user may call the agent) +# (b) OUTBOUND_SUBJECT_PAIRS — user role -> tool scope (outbound subject; user may reach tool) +# (c) OUTBOUND_PAIRS — agent role -> tool scope (outbound target; agent may reach tool) + +INBOUND_PAIRS: list[tuple[str, str]] = [ + ("developer", "source-access"), + ("developer", "issues-access"), + ("tester", "issues-access"), +] + +OUTBOUND_PAIRS: list[tuple[str, str]] = [ + ("source_operations", "source-read"), + ("source_operations", "source-write"), + ("issue_operations", "issues-read"), + ("issue_operations", "issues-write"), +] + +OUTBOUND_SUBJECT_PAIRS: list[tuple[str, str]] = [ + ("developer", "source-read"), + ("developer", "source-write"), + ("developer", "issues-read"), + ("tester", "issues-read"), + ("tester", "issues-write"), +] diff --git a/aiac/test/integration/scenario_uc1.py b/aiac/test/integration/scenario_uc1.py new file mode 100644 index 000000000..1ed80dc0e --- /dev/null +++ b/aiac/test/integration/scenario_uc1.py @@ -0,0 +1,167 @@ +"""The UC-1 (discovery-driven) ``github-agent`` scenario — the oracle for the UC-1 onboarding +integration-test ladder (``test_uc1_onboard_agent_only.py`` and its rung-2/3 siblings). + +Sibling of the hand-provisioned ``scenario.py`` (kept separate so 5.2/5.3 are untouched). Same +*role -> access facts and truth tables*; the difference is **provenance and naming**. Here the +agent/tool roles and scopes are not hand-picked — they are what **real UC-1 onboarding** discovers +and provisions from the deployed workloads, so every scope is **workload-prefixed** +(``github-tool.source-read``, ``github-agent.source_operations``) and the agent contributes **one +operator role per skill** (``github-agent.source_operations`` / ``github-agent.issue_operations``), +each mirroring its skill scope's name + description. The pair-lists below are therefore expressed +over those **discovered, prefixed** names — exactly the strings the generated Rego data maps contain +— so the test's expected verdicts are *computed from* this module, never from the Rego under test. + +Outbound access is a **per-scope two-gate AND**: a requested tool scope is allowed iff **both** the +user role reaches it (subject gate, ``OUTBOUND_SUBJECT_PAIRS``) **and** the agent's own per-skill +operator role reaches it (capability gate, ``OUTBOUND_TARGET_PAIRS``). The capability gate is mapped +from the operator-role **descriptions** by the PRB (capability-match under ``generic_policy.md``); in +UC-1 the agent reaches all four tool scopes, so the AND reduces to the subject gate for the verdicts +but both gates are populated and probed. + +This module is **pure data**: it imports nothing (no ``aiac``, no stdlib beyond the language) so the +test can import it before its env-before-import step, just like ``scenario.py``. + +Fact triad (spec ``docs/specs/integration-test/uc1-onboarding-pipeline.md``): the *Scenario* table, +the single abstract ``policy.md`` (``POLICY_ABSTRACT`` below), and the pair-lists here must all +agree. The generic entity/role/scope descriptions are functional and keyword-free and must not +contradict the facts. (The prior explicit variant + cross-variant equivalence are deferred to the +two-policy rung ``testing/5.4.4``; the two-stack topology that served both variants is discarded.) +""" + +from __future__ import annotations + +# --- Realm + deployment identifiers --------------------------------------------------------- + +# The realm the deployed AIAC stack operates on (the cluster's ``aiac-pdp-config`` ConfigMap sets +# ``KEYCLOAK_REALM=kagenti`` on the Controller + IdP-config pods, so the UC-1 harness must resolve +# and provision against the same realm). Never deleted/recreated; the operator registers the demo +# namespace's clients into it. Override with ``AIAC_TEST_REALM`` if the stack runs on another realm. +REALM_DEFAULT = "kagenti" + +# Namespace the demo workloads deploy into (operator registers clients as "{ns}/{workload}"). +DEMO_NAMESPACE_DEFAULT = "team1" + +# Workload names == Service names == Keycloak client.name suffix. The trigger id is the Keycloak +# *clientId* of the client whose *name* is "{ns}/{workload}" (a SPIFFE URI under SPIRE, else the +# bare "{ns}/{workload}"); the test resolves it by name, never by assuming the string. +AGENT_WORKLOAD = "github-agent" +TOOL_WORKLOAD = "github-tool" + +# username -> the realm role the user holds +USERS: dict[str, str] = { + "dev-user": "developer", + "test-user": "tester", + "devops-user": "devops", +} + +# Fixed dev password for the provisioned test users. The realm, users, and roles are provisioned +# idempotently and left in place across runs (never deleted/recreated — see ``REALM_DEFAULT``). +USER_PASSWORD = "password" + +# --- Realm-role descriptions (provisioned by the fixture; verbatim from the spec) ----------- +# +# The PRB reads these descriptions when expanding the abstract policy. ``devops`` is deliberately +# unrelated to source/issue work: it appears in no pair-list and neither policy variant, so +# deny-by-default leaves devops-user denied inbound and on every outbound function. + +USER_ROLES: dict[str, str] = { + "developer": ( + "Developer — an engineering user who develops the source codebase (writing and maintaining " + "code) and fixes code defects reported in the issue tracker; works primarily in source and " + "consults issues for defect reports." + ), + "tester": ( + "Tester — a quality-assurance user who verifies software quality and tracks defects through " + "the issue tracker: filing, triaging, and updating issue reports; works in the issue " + "tracker, not in source." + ), + "devops": ( + "DevOps — an operations user who manages deployment infrastructure and runtime " + "environments; does not author source code and does not manage the issue tracker." + ), +} + +# --- Discovered entities (what real UC-1 onboarding provisions) ----------------------------- +# +# These are NOT provisioned by the test — UC-1 discovers them (tool scopes from the MCP +# ``tools/list`` manifest, agent role/scopes from the AgentCard skills) and writes them into +# Keycloak. They are recorded here only so the pair-lists and the grant-set equivalence check can +# reference the exact prefixed strings the generated Rego contains. + +# name -> description. Agent-boundary scopes, from the AgentCard skills (verbatim descriptions). +AGENT_SCOPES: dict[str, str] = { + "github-agent.source_operations": ( + "Browse and search code; read, create, and modify repository file contents, branches, " + "and commits." + ), + "github-agent.issue_operations": ( + "Read, search, create, and update issues, comments, sub-issues, and pull requests." + ), +} + +# The per-skill operator roles UC-1 emits — one per skill, mirroring each scope's name + +# description (``analyze_agent`` builds ``roles`` from the same skills as ``scopes``). Their +# descriptions are what the PRB capability-match reads to grant the agent's outbound access on a +# domain basis, populating the agent->tool capability gate (``OUTBOUND_TARGET_PAIRS`` below). +AGENT_ROLES: dict[str, str] = dict(AGENT_SCOPES) + +# name -> description. Fine-grained tool operations, from the simplified tool's MCP ``tools/list`` +# (verbatim descriptions — identical text to ``scenario.py``'s tool scopes, only prefixed). +TOOL_SCOPES: dict[str, str] = { + "github-tool.source-read": "Read source repository contents: file listings and file bodies. Read-only.", + "github-tool.source-write": "Create, modify, or delete source repository contents; commit file changes.", + "github-tool.issues-read": "Read issues and their comment threads. Read-only.", + "github-tool.issues-write": "Create and update issues: open, edit, comment, and close.", +} + +# --- Role -> access facts (over the DISCOVERED, prefixed names; the single source of truth) -- +# +# Identical *decisions* to ``scenario.py``; only the scope-name strings are prefixed. Each set maps +# 1:1 to a generated Rego gate: +# INBOUND_PAIRS — user role -> agent scope (inbound; user may call the agent) +# OUTBOUND_SUBJECT_PAIRS — user role -> tool scope (outbound subject gate; user reaches the tool) +# OUTBOUND_TARGET_PAIRS — operator role -> tool scope (outbound capability gate; agent reaches the tool) + +INBOUND_PAIRS: list[tuple[str, str]] = [ + ("developer", "github-agent.source_operations"), + ("developer", "github-agent.issue_operations"), + ("tester", "github-agent.issue_operations"), +] + +OUTBOUND_SUBJECT_PAIRS: list[tuple[str, str]] = [ + ("developer", "github-tool.source-read"), + ("developer", "github-tool.source-write"), + ("developer", "github-tool.issues-read"), + ("tester", "github-tool.issues-read"), + ("tester", "github-tool.issues-write"), +] + +# Agent->tool capability gate: the per-skill operator role -> tool scope pairs the PRB +# capability-match maps from the operator-role descriptions (source_operations -> the two source +# scopes; issue_operations -> the two issue scopes). The agent reaches all four tool scopes, so this +# gate is fully populated (no longer degenerate) and both gates are probed as a per-scope AND. +OUTBOUND_TARGET_PAIRS: list[tuple[str, str]] = [ + ("github-agent.source_operations", "github-tool.source-read"), + ("github-agent.source_operations", "github-tool.source-write"), + ("github-agent.issue_operations", "github-tool.issues-read"), + ("github-agent.issue_operations", "github-tool.issues-write"), +] + +# --- The single abstract policy.md (baked into the AIAC stack out of band) ------------------ +# +# The AIAC pod mounts its own ``policy.md`` (via AIAC_POLICY_FILE); the test does not feed it at +# runtime. It lives here as the fact-triad anchor — verbatim from the spec's *Scenario inputs*. It +# is USER-INTENT-ONLY: it states only what users may do; it does not name the agent's operator roles. +# The agent's own capability (the ``OUTBOUND_TARGET_PAIRS`` gate) comes from the generic rubric +# (``generic_policy.md``) applied to the operator-role descriptions, not from naming those roles in +# this policy. Intent-only prose; the PRB/LLM expands intent into the discovered scopes via the +# entity/role descriptions. +# +# (The prior explicit enumerated variant and the cross-variant equivalence check are deferred to the +# two-policy rung ``testing/5.4.4``; the two-stack topology that served both variants is discarded.) +POLICY_ABSTRACT = """\ +Grant access on a least-privilege basis: allow only what this policy states; deny by default. + +- Developers may read and modify source, and read issues. +- Testers may read and modify issues. +""" diff --git a/aiac/test/integration/test_policy_pipeline.py b/aiac/test/integration/test_policy_pipeline.py new file mode 100644 index 000000000..561fa5094 --- /dev/null +++ b/aiac/test/integration/test_policy_pipeline.py @@ -0,0 +1,456 @@ +"""End-to-end policy-pipeline integration test — the generated Rego is the artifact under test. + +Parametrized pytest suite for the fixed ``github-agent`` scenario (spec: +``docs/specs/integration-test/policy-pipeline.md``). A single session fixture drives the +whole identity->policy pipeline with nothing mocked: it provisions a live Keycloak realm, spawns the +IdP Configuration, Policy Store, and OPA Policy Writer services as ``uvicorn`` subprocesses, runs the +real Policy Rules Builder (real LLM) to map roles->scopes, then the real Policy Computation Engine to +build the ``PolicyModel`` and push ``.rego`` files to the OPA filesystem stub. It does this twice — +once for the explicit ``policy.md`` and once for the abstract one — and leaves both Rego sets on disk +under ``rego_out/policy_pipeline//`` (a sibling of the UC-1 ladder's ``rego_out/uc1/``). + +Each test then evaluates the generated Rego with the standalone ``opa`` binary and asserts the verdict +against the scenario's role->access truth table (``scenario.py``). A wrong LLM/PCE mapping fails the +exact ``variant / subject[ / function_name]`` cell. The outbound gate is driven by ``function_name`` +(reformatted to exercise the soft match) through ``probe.rego``; the inbound gate queries the real +inbound ``allow`` directly. + +This is the pytest replacement for the former write-only ``policy_pipeline.py`` launcher; its helpers +were ported here verbatim. + +Run (needs KEYCLOAK_URL + admin creds + LLM_* exported, ``opa`` on PATH; realm defaults to aiac-pp): + .venv/bin/pytest test/integration/test_policy_pipeline.py -m integration -v +Without ``-m integration`` the suite is skipped; without ``opa`` each node skips at runtime. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.parse import urlsplit + +import pytest + +pytestmark = pytest.mark.integration + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +SRC = REPO_ROOT / "src" +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves +sys.path.insert(0, str(SRC)) # so ``import aiac.*`` resolves + +from test.integration import scenario as scn # noqa: E402 +from test.integration.launcher import ( # noqa: E402 + Service, + require_env, + running_services, +) + +# --- Resolve config + set env BEFORE importing aiac (the libraries read env at import time) --- +TEST_REALM = os.environ.get("AIAC_TEST_REALM", scn.REALM_DEFAULT) +os.environ["KEYCLOAK_REALM"] = TEST_REALM # the PCE reads back the realm we provision (single source of truth) +os.environ.setdefault("AIAC_PDP_CONFIG_URL", "http://127.0.0.1:7071") +os.environ.setdefault("AIAC_POLICY_STORE_URL", "http://127.0.0.1:7074") +os.environ.setdefault("AIAC_PDP_POLICY_URL", "http://127.0.0.1:7072") +os.environ.setdefault("AIAC_POLICY_FILE", str(HERE / "policy.explicit.md")) # overridden per variant +os.environ.setdefault("KEYCLOAK_ADMIN_REALM", "master") # inherited by the IdP subprocess + +from keycloak import KeycloakAdmin # noqa: E402 +from keycloak.exceptions import KeycloakError # noqa: E402 + +from aiac.agent.policy_rules_builder.graph import build_role_rules, build_scope_rules # noqa: E402 +from aiac.idp.configuration.api import Configuration # noqa: E402 +from aiac.idp.configuration.models import Role, Scope # noqa: E402 +from aiac.policy.computation.engine import compute_and_apply # noqa: E402 +from aiac.policy.model.models import PolicyRule # noqa: E402 + +log = logging.getLogger(__name__) + +VARIANTS = ("explicit", "abstract") + + +# ====================================================================================== +# Ported helpers (verbatim from the former policy_pipeline.py launcher) +# ====================================================================================== + + +def _host_port(url: str, default_port: int) -> tuple[str, int]: + parts = urlsplit(url) + return parts.hostname or "127.0.0.1", parts.port or default_port + + +def _connect_admin() -> KeycloakAdmin: + """Connect to the admin realm so the launcher can create/delete the test realm.""" + creds = require_env("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") + admin_realm = os.environ["KEYCLOAK_ADMIN_REALM"] + return KeycloakAdmin( + server_url=creds["KEYCLOAK_URL"], + realm_name=admin_realm, + user_realm_name=admin_realm, + username=creds["KEYCLOAK_ADMIN_USERNAME"], + password=creds["KEYCLOAK_ADMIN_PASSWORD"], + ) + + +def provision_keycloak_admin(admin: KeycloakAdmin, test_realm: str) -> None: + """Provision the realm via ``python-keycloak`` (idempotent: delete-if-exists, then create). + + Driven entirely off ``scenario.py``: creates the realm, every ``scn.USER_ROLES`` realm role + (``developer`` / ``tester`` / ``devops``), every ``scn.USERS`` user (with role assignments), and + the ``github-agent`` / ``github-tool`` clients. The agent client enables a service account so + its client roles can be assigned to it later. + """ + try: + admin.delete_realm(test_realm) + except KeycloakError: + pass # realm absent — nothing to delete + admin.create_realm({"realm": test_realm, "enabled": True}) + admin.change_current_realm(test_realm) + + for name, description in scn.USER_ROLES.items(): + # aiac.managed marker required: the IdP service only populates actorIds (member usernames) + # for managed roles, and the PCE needs actorIds to build the subject_roles map in the APM. + admin.create_realm_role( + {"name": name, "description": description, "attributes": {"aiac.managed": ["true"]}}, + skip_exists=True, + ) + + for username, role_name in scn.USERS.items(): + user_id = admin.create_user({"username": username, "enabled": True}, exist_ok=True) + admin.set_user_password(user_id, scn.USER_PASSWORD, temporary=False) + admin.assign_realm_roles(user_id, [admin.get_realm_role(role_name)]) + + def _client(client_id: str, description: str) -> dict: + return { + "clientId": client_id, + "enabled": True, + "description": description, + "protocol": "openid-connect", + "publicClient": False, # confidential — required for a service account + "serviceAccountsEnabled": True, + "standardFlowEnabled": False, + } + + admin.create_client(_client(scn.AGENT_ID, scn.AGENT_DESCRIPTION), skip_exists=True) + admin.create_client(_client(scn.TOOL_ID, scn.TOOL_DESCRIPTION), skip_exists=True) + + +def provision_via_config(config: Configuration) -> None: + """Provision client roles + scopes and their service mappings through the aiac IdP library. + + This is the real product surface the PCE reads back: it creates the agent/tool scopes and the + agent client roles, then maps scopes->services and client-roles->agent so ``get_services_by_*`` + and ``get_service().roles/.scopes`` resolve. NOT idempotent — call exactly once per realm. + """ + agent_scopes = {name: config.create_scope(name, desc) for name, desc in scn.AGENT_SCOPES.items()} + tool_scopes = {name: config.create_scope(name, desc) for name, desc in scn.TOOL_SCOPES.items()} + agent_roles = {name: config.create_role(name, desc) for name, desc in scn.AGENT_ROLES.items()} + + services = {svc.serviceId: svc for svc in config.get_services()} + agent_svc, tool_svc = services[scn.AGENT_ID], services[scn.TOOL_ID] + + for scope in agent_scopes.values(): + config.map_scope_to_service(agent_svc, scope) + for scope in tool_scopes.values(): + config.map_scope_to_service(tool_svc, scope) + for role in agent_roles.values(): + config.map_role_to_service(agent_svc, role) + + # Type the services via the canonical ``client.type`` attribute using the IdP setter. The IdP no + # longer infers type from the description or clientId shape (``_build_service`` leaves it to the + # ``client.type`` attribute). The Agent tag makes the PCE build the agent model; the Tool + # tag makes it omit the tool model. Without this the PCE builds an empty model — nothing is + # stored and no rego is written. + config.set_service_type(agent_svc, "Agent") + config.set_service_type(tool_svc, "Tool") + + +def _read_back(config: Configuration) -> tuple[dict[str, Role], dict[str, Scope]]: + """Read roles + scopes back through the IdP library (carrying real ids + descriptions). + + Scopes are sourced from each service's scope list (not the standalone get_scopes()), + so that scope.serviceId is populated — a required input for the PCE's SPM routing. + """ + roles = {r.name: r for r in config.get_roles()} + scopes: dict[str, Scope] = {} + for svc in config.get_services(): + for s in svc.scopes: + scopes.setdefault(s.name, s) # first owner wins; each scope has exactly one owner + return roles, scopes + + +def orchestrate_prb(roles: dict[str, Role], scopes: dict[str, Scope]) -> list[PolicyRule]: + """Proto-UC1: run the three PRB mappings against the real LLM and concatenate the rules.""" + user_roles = [roles[name] for name in scn.USER_ROLES] + agent_scopes = [scopes[name] for name in scn.AGENT_SCOPES] + tool_scopes = [scopes[name] for name in scn.TOOL_SCOPES] + agent_roles = [roles[name] for name in scn.AGENT_ROLES] + + rules: list[PolicyRule] = [] + for agent_scope in agent_scopes: # (a) user role -> agent scope + rules += build_scope_rules(user_roles, agent_scope) + for tool_scope in tool_scopes: # (b) user role -> tool scope + rules += build_scope_rules(user_roles, tool_scope) + for agent_role in agent_roles: # (c) agent role -> tool scopes + rules += build_role_rules(agent_role, tool_scopes) + return rules + + +# ====================================================================================== +# OPA evaluation +# ====================================================================================== + + +def opa_bin() -> str: + """Path to the ``opa`` binary, or skip the calling test if it cannot be found.""" + found = os.environ.get("OPA_BIN") or shutil.which("opa") + if not found: + pytest.skip("opa binary not found (set OPA_BIN or add opa to PATH)") + return found + + +def opa_eval(rego_paths: list[Path], query: str, input_doc: dict) -> bool: + """Evaluate ``query`` against the given Rego file(s) with ``input_doc`` on stdin; return the + boolean result. Raises (via ``check=True``) if OPA rejects the Rego or the query errors.""" + cmd = [ + opa_bin(), + "eval", + "-f", + "json", + *sum((["-d", str(p)] for p in rego_paths), []), + "--stdin-input", + query, + ] + out = subprocess.run( + cmd, input=json.dumps(input_doc), capture_output=True, text=True, check=True + ).stdout + return json.loads(out)["result"][0]["expressions"][0]["value"] + + +# ====================================================================================== +# Expected-verdict oracle (pure functions over the scenario truth table) +# ====================================================================================== + +_INBOUND_SOURCES = {role for role, _ in scn.INBOUND_PAIRS} # user-roles that may reach some agent scope +_OUTBOUND_SUBJECT = set(scn.OUTBOUND_SUBJECT_PAIRS) # (user-role, tool-scope) the subject may reach +_AGENT_REACHABLE = {scope for _, scope in scn.OUTBOUND_PAIRS} # tool-scopes some agent role reaches + + +def expected_inbound(subject: str) -> bool: + """A user may call the agent iff their realm role appears as a source in ``INBOUND_PAIRS``.""" + return scn.USERS[subject] in _INBOUND_SOURCES + + +def expected_outbound(subject: str, scope: str) -> bool: + """A user's call resolves to a tool scope iff the subject is entitled to it + (``OUTBOUND_SUBJECT_PAIRS``) *and* some agent role is entitled to it (``OUTBOUND_PAIRS``).""" + return (scn.USERS[subject], scope) in _OUTBOUND_SUBJECT and scope in _AGENT_REACHABLE + + +def reformat_function_name(scope: str) -> str: + """Render a tool scope as a differently-cased/separated ``function_name`` to exercise the + probe's token soft-match: ``source-read`` -> ``Source.Read``.""" + return ".".join(part.capitalize() for part in scope.split("-")) + + +# --- Grant-set extraction (semantic-equivalence oracle) ------------------------------------- +# +# The two policy variants describe the SAME access model, so the PRB must derive the same set of +# grants from each (Rego text/ordering may differ — the grant set may not). ``orchestrate_prb`` +# returns all three mappings concatenated; the four name spaces are disjoint, so each PolicyRule +# is classified into its gate by ``(role.name, scope.name)`` membership. + +_USER_ROLE_NAMES = set(scn.USER_ROLES) +_AGENT_ROLE_NAMES = set(scn.AGENT_ROLES) +_AGENT_SCOPE_NAMES = set(scn.AGENT_SCOPES) +_TOOL_SCOPE_NAMES = set(scn.TOOL_SCOPES) + + +def grant_sets(rules: list[PolicyRule]) -> dict[str, set[tuple[str, str]]]: + """Classify a flat PRB rule list into the three gate grant sets, each a set of + ``(role_name, scope_name)`` pairs: ``inbound`` (user role -> agent scope), + ``outbound_subject`` (user role -> tool scope), ``outbound_target`` (agent role -> tool scope).""" + sets: dict[str, set[tuple[str, str]]] = {"inbound": set(), "outbound_subject": set(), "outbound_target": set()} + for r in rules: + pair = (r.role.name, r.scope.name) + if r.role.name in _USER_ROLE_NAMES and r.scope.name in _AGENT_SCOPE_NAMES: + sets["inbound"].add(pair) + elif r.role.name in _USER_ROLE_NAMES and r.scope.name in _TOOL_SCOPE_NAMES: + sets["outbound_subject"].add(pair) + elif r.role.name in _AGENT_ROLE_NAMES and r.scope.name in _TOOL_SCOPE_NAMES: + sets["outbound_target"].add(pair) + return sets + + +_TRUTH: dict[str, set[tuple[str, str]]] = { + "inbound": set(scn.INBOUND_PAIRS), + "outbound_subject": set(scn.OUTBOUND_SUBJECT_PAIRS), + "outbound_target": set(scn.OUTBOUND_PAIRS), +} + + +# ====================================================================================== +# Session fixture — the one-time pipeline run (both policy variants) +# ====================================================================================== + + +@pytest.fixture(scope="session") +def pipeline() -> dict[str, dict]: + """Provision Keycloak once, then run the real PRB+PCE pipeline for each policy variant, leaving + ``.rego`` on disk under ``rego_out/policy_pipeline//``. Returns + ``{variant: {"rego_dir": Path, "rules": list[PolicyRule]}}`` — the rules are the PRB's grant set, + captured so the equivalence/truth-table assertions can compare grants directly (not Rego text).""" + require_env( + "KEYCLOAK_URL", + "KEYCLOAK_ADMIN_USERNAME", + "KEYCLOAK_ADMIN_PASSWORD", + "LLM_BASE_URL", + "LLM_MODEL", + "LLM_API_KEY", + ) + + admin = _connect_admin() + provision_keycloak_admin(admin, TEST_REALM) + + idp_host, idp_port = _host_port(os.environ["AIAC_PDP_CONFIG_URL"], 7071) + store_host, store_port = _host_port(os.environ["AIAC_POLICY_STORE_URL"], 7074) + opa_host, opa_port = _host_port(os.environ["AIAC_PDP_POLICY_URL"], 7072) + + idp = Service("aiac.idp.service.configuration.keycloak.main:app", port=idp_port, host=idp_host) + results: dict[str, dict] = {} + + # IdP stays up across both variants; the store/opa pair is restarted per variant so each variant + # writes into a fresh store (compute_and_apply merges onto the existing model with override=False). + with running_services([idp], src=SRC): + config = Configuration.for_realm(TEST_REALM) + provision_via_config(config) # exactly once — not idempotent + roles, scopes = _read_back(config) + + agent_slug = scn.AGENT_ID.replace("-", "_") # github-agent -> github_agent + for variant in VARIANTS: + rego_dir = HERE / "rego_out" / "policy_pipeline" / variant + rego_dir.mkdir(parents=True, exist_ok=True) + # Clear any rego left by a previous run so the assertions below always verify freshly + # generated policy — never a stale artifact that would let a broken pipeline pass green. + for stale in rego_dir.glob("*.rego"): + stale.unlink() + db_path = Path(tempfile.mkdtemp(prefix=f"aiac-store-{variant}-")) / "policy_model.db" + os.environ["AIAC_POLICY_FILE"] = str(HERE / f"policy.{variant}.md") # read per PRB call + log.info("variant %s: policy=%s rego_dir=%s", variant, os.environ["AIAC_POLICY_FILE"], rego_dir) + + store = Service( + "aiac.policy.store.service.main:app", + port=store_port, + host=store_host, + env={"SERVICEPOLICY_DB_PATH": str(db_path)}, + ) + opa = Service( + "aiac.pdp.service.policy.opa.main:app", + port=opa_port, + host=opa_host, + env={"REGO_OUTPUT_DIR": str(rego_dir)}, + ) + with running_services([store, opa], src=SRC): + rules = orchestrate_prb(roles, scopes) + compute_and_apply(rules, override=False) + # compute_and_apply is fire-and-forget: it swallows every dependency error and logs it, + # so a failed IdP/store/PDP interaction (or an empty derived agent set) silently writes no + # rego. Assert the agent's rego actually landed here at setup, so such a failure surfaces + # as one clear error instead of cryptic "no such file" OPA failures across every test. + expected = [rego_dir / f"{agent_slug}.inbound.rego", rego_dir / f"{agent_slug}.outbound.rego"] + missing = [p.name for p in expected if not p.is_file()] + if missing: + raise RuntimeError( + f"variant {variant!r}: compute_and_apply produced no {missing} in {rego_dir} " + f"(PRB returned {len(rules)} rule(s)); the pipeline failed silently — " + f"check the compute_and_apply logs above for a swallowed exception." + ) + results[variant] = {"rego_dir": rego_dir, "rules": rules} + + yield results + + +# ====================================================================================== +# Tests +# ====================================================================================== + + +@pytest.mark.parametrize("variant", VARIANTS) +@pytest.mark.parametrize("subject", list(scn.USERS)) +def test_inbound(pipeline: dict[str, dict], variant: str, subject: str) -> None: + """The generated inbound gate allows a user iff their role may reach some agent scope.""" + rego = pipeline[variant]["rego_dir"] / "github_agent.inbound.rego" + allowed = opa_eval([rego], "data.authz.github_agent.inbound.allow", {"subject": subject}) + assert allowed == expected_inbound(subject) + + +@pytest.mark.parametrize("variant", VARIANTS) +@pytest.mark.parametrize("subject", list(scn.USERS)) +@pytest.mark.parametrize("scope", list(scn.TOOL_SCOPES)) +def test_outbound(pipeline: dict[str, dict], variant: str, subject: str, scope: str) -> None: + """The generated outbound gate (via the probe) allows a subject's call to a tool operation iff + both the subject and some agent role are entitled to that operation's scope.""" + rego = pipeline[variant]["rego_dir"] / "github_agent.outbound.rego" + fn = reformat_function_name(scope) # soft-match rendering, e.g. source-read -> Source.Read + allowed = opa_eval( + [rego, HERE / "probe.rego"], + "data.probe.outbound.allow", + {"subject": subject, "target": scn.TOOL_ID, "function_name": fn}, + ) + assert allowed == expected_outbound(subject, scope) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_outbound_unknown_target_denied(pipeline: dict[str, dict], variant: str) -> None: + """An otherwise-allowed call to an unknown target is denied (target not in target_scopes).""" + rego = pipeline[variant]["rego_dir"] / "github_agent.outbound.rego" + allowed = opa_eval( + [rego, HERE / "probe.rego"], + "data.probe.outbound.allow", + {"subject": "dev-user", "target": "unknown-tool", "function_name": "Source.Read"}, + ) + assert allowed is False + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_outbound_soft_match_not_overbroad(pipeline: dict[str, dict], variant: str) -> None: + """A function name whose tokens match no scope is denied — guards against soft-match over-match.""" + rego = pipeline[variant]["rego_dir"] / "github_agent.outbound.rego" + allowed = opa_eval( + [rego, HERE / "probe.rego"], + "data.probe.outbound.allow", + {"subject": "dev-user", "target": scn.TOOL_ID, "function_name": "delete_everything"}, + ) + assert allowed is False + + +# ====================================================================================== +# Semantic-equivalence tests — the two policy variants must yield the SAME grant set +# ====================================================================================== + + +@pytest.mark.parametrize("variant", VARIANTS) +@pytest.mark.parametrize("gate", list(_TRUTH)) +def test_grant_set_matches_truth_table(pipeline: dict[str, dict], variant: str, gate: str) -> None: + """Each variant's PRB grant set for each gate equals the scenario truth table. Catches both + under-grants (a missing pair) and over-grants (an unsupported pair) that the coarse allow/deny + oracle above cannot see.""" + got = grant_sets(pipeline[variant]["rules"])[gate] + assert got == _TRUTH[gate], f"{variant} {gate}: missing={_TRUTH[gate] - got} extra={got - _TRUTH[gate]}" + + +@pytest.mark.parametrize("gate", list(_TRUTH)) +def test_variants_are_semantically_equivalent(pipeline: dict[str, dict], gate: str) -> None: + """The explicit and abstract variants describe the same access model, so the PRB must derive the + same grant set from each. Compared as order-independent sets (Rego text/ordering may differ).""" + explicit = grant_sets(pipeline["explicit"]["rules"])[gate] + abstract = grant_sets(pipeline["abstract"]["rules"])[gate] + assert explicit == abstract, ( + f"{gate}: variants diverge — only-explicit={explicit - abstract} only-abstract={abstract - explicit}" + ) diff --git a/aiac/test/integration/test_uc1_onboard_agent_only.py b/aiac/test/integration/test_uc1_onboard_agent_only.py new file mode 100644 index 000000000..269bd319a --- /dev/null +++ b/aiac/test/integration/test_uc1_onboard_agent_only.py @@ -0,0 +1,206 @@ +"""Rung 1 of the UC-1 onboarding ladder — onboard the **agent only**. + +The simplest rung (issue ``testing/5.4.1-uc1-onboard-agent-only.md``; spec +``docs/specs/integration-test/uc1-onboarding-pipeline.md``): drive the **real** in-cluster UC-1 +Service Onboarding agent (``POST /apply/service/{id}``) for **only** the ``github-agent`` — the +``github-tool`` is deployed + registered but **not** onboarded — then assert the agent-side outcome. +Proves agent discovery + inbound policy generation stand alone, and that the outbound user gate is +correctly **empty** when no tool has been onboarded. + +Single AIAC stack, OPA filesystem-stub writer, single abstract ``policy.md``. The shared harness +(config, Keycloak provisioning/cleanup, onboard trigger, Rego capture, grant-set extraction, and the +per-rung fixture flow) lives in ``uc1_onboard.py`` and is reused by every rung; this module supplies +only rung 1's oracle (the expected verdicts, computed from ``scenario_uc1.py``) and its live +assertions. It also reuses ``scenario_uc1.py`` (truth tables — the oracle) and ``probe_uc1.rego`` +(user-gate probe). + +Per-rung flow (spec § Per-rung flow): **Keycloak cleanup → onboard agent → validate end state → +Keycloak cleanup**. Deployment + client registration are **preconditions**, not test steps. + +*Onboard + evaluate — no A2A traffic, no live enforcement* (phase-1 out of scope). + +Run (needs a live Kagenti/Kind cluster with the AIAC stack + OPA filesystem-stub writer, the demo +workloads deployed + registered into ``AIAC_TEST_REALM``, a real LLM in-pod, and ``opa`` on PATH or +``$OPA_BIN``): + + .venv/bin/pytest test/integration/test_uc1_onboard_agent_only.py -m integration -v + +Without ``-m integration`` the suite is not collected; without ``opa`` it skips at runtime. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import uc1_onboard as uc1 # noqa: E402 +from test.integration.launcher import opa_eval # noqa: E402 + +TEST_REALM = uc1.TEST_REALM +INBOUND_REGO = uc1.INBOUND_REGO +OUTBOUND_REGO = uc1.OUTBOUND_REGO +AGENT_SLUG = uc1.AGENT_SLUG + + +# ====================================================================================== +# Expected-verdict oracle (pure functions over the scenario_uc1 truth table) +# ====================================================================================== +# +# Rung 1 is the exception in the ladder: with no tool onboarded there are no tool scopes in the +# universe, so the outbound **user gate is empty** (all deny) and the outbound-subject grant set is +# ``∅`` — regardless of ``scenario_uc1.OUTBOUND_SUBJECT_PAIRS`` (which is the rung-2/3 table). Inbound +# is unaffected (``uc1.expected_inbound`` — the shared inbound oracle). These encode rung 1's +# contract; verdicts are computed here, never read from the Rego under test. + +# Rung 1's expected grant sets (the oracle for the semantic-equivalence check). +RUNG1_INBOUND = uc1.INBOUND_GRANT_SET +RUNG1_OUTBOUND_SUBJECT: set[tuple[str, str]] = set() # ∅ — no tool onboarded + + +def expected_outbound(subject: str, function_name: str) -> bool: + """Rung 1: the outbound user gate is entirely empty (no tool onboarded), so every + ``(subject, function_name)`` is denied.""" + return (scn.USERS[subject], function_name) in RUNG1_OUTBOUND_SUBJECT + + +# ====================================================================================== +# Oracle contract tests — fixture-independent; pin rung 1's defining decisions +# ====================================================================================== +# +# These need neither the cluster nor ``opa``: they assert the rung-1 oracle itself (the intended +# policy the live decisions below are checked against). If these are wrong, every live assertion is +# meaningless — so they are the tracer bullet. + + +@pytest.mark.parametrize( + "subject, allowed", + [("dev-user", True), ("test-user", True), ("devops-user", False)], +) +def test_inbound_oracle(subject: str, allowed: bool) -> None: + """Inbound: dev-user ✅, test-user ✅, devops-user ❌ (devops sources no agent scope).""" + assert uc1.expected_inbound(subject) is allowed + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +@pytest.mark.parametrize("function_name", list(scn.TOOL_SCOPES)) +def test_outbound_oracle_all_deny(subject: str, function_name: str) -> None: + """Rung 1's defining property: the outbound user gate is empty, so every ``(subject, function)`` + is denied — no tool was onboarded, so no tool scope is in the universe.""" + assert expected_outbound(subject, function_name) is False + + +def test_rung1_grant_set_oracle() -> None: + """Rung 1 grant-set oracle: inbound == the ``scenario_uc1`` inbound truth table; the + outbound-subject grant set is empty.""" + assert RUNG1_INBOUND == set(scn.INBOUND_PAIRS) + assert RUNG1_OUTBOUND_SUBJECT == set() + + +# ====================================================================================== +# Session fixture — cleanup → onboard agent only → capture rego → yield → cleanup +# ====================================================================================== + + +@pytest.fixture(scope="session") +def onboarded() -> dict: + """Onboard **only** the agent (the tool is deployed but not onboarded) via the shared harness, + and yield the live ``admin`` handle + captured ``rego_dir`` (+ writer ``pod``). Keycloak cleanup + runs before and after; the clients are left registered as before (spec § Per-rung flow).""" + with uc1.onboarded_stack([scn.AGENT_WORKLOAD], rego_subdir="rung1") as ctx: + yield ctx + + +# ====================================================================================== +# Live tests — Keycloak entities + opa-eval decisions (verdicts computed from scenario_uc1) +# ====================================================================================== + + +def test_agent_role_and_scopes_provisioned(onboarded: dict) -> None: + """Keycloak holds the agent's per-skill operator roles + the two AgentCard scopes, all with + their descriptions.""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + + for name, description in scn.AGENT_ROLES.items(): + role = admin.get_realm_role(name) + assert role and role.get("name") == name, f"missing realm role {name!r}" + assert (role.get("description") or "") == description, ( + f"agent role {name!r} description mismatch: {role.get('description')!r} != {description!r}" + ) + + scopes = {s["name"]: (s.get("description") or "") for s in admin.get_client_scopes()} + for name, description in scn.AGENT_SCOPES.items(): + assert name in scopes, f"missing agent scope {name!r}" + assert scopes[name] == description, ( + f"agent scope {name!r} description mismatch: {scopes[name]!r} != {description!r}" + ) + + +def test_no_tool_scopes_provisioned(onboarded: dict) -> None: + """The tool was not onboarded, so no ``github-tool.*`` scope exists (UC-1-provisioned scopes are + prefixed ``github-tool.``; the operator's ``*-aud`` audience scopes are not and don't count).""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + tool_scopes = [ + s["name"] for s in admin.get_client_scopes() + if s.get("name", "").startswith(f"{scn.TOOL_WORKLOAD}.") + ] + assert not tool_scopes, f"unexpected tool scopes provisioned: {tool_scopes}" + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +def test_inbound(onboarded: dict, subject: str) -> None: + """Inbound gate allows a user iff their role may reach some discovered agent scope + (dev-user ✅, test-user ✅, devops-user ❌).""" + rego = onboarded["rego_dir"] / INBOUND_REGO + allowed = opa_eval([rego], f"data.authz.{AGENT_SLUG}.inbound.allow", {"subject": subject}) + assert allowed == uc1.expected_inbound(subject), subject + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +@pytest.mark.parametrize("function_name", list(scn.TOOL_SCOPES)) +def test_outbound_all_deny(onboarded: dict, subject: str, function_name: str) -> None: + """Outbound user gate (via ``probe_uc1.rego``) denies every ``(subject, function)`` — the gate is + empty because no tool was onboarded (exact-name match on full discovered scope names).""" + rego = onboarded["rego_dir"] / OUTBOUND_REGO + allowed = opa_eval( + [rego, uc1.PROBE_UC1], + "data.probe.outbound.allow", + {"subject": subject, "function_name": function_name}, + ) + assert allowed == expected_outbound(subject, function_name), f"{subject} / {function_name}" + + +def test_only_agent_rego_present(onboarded: dict) -> None: + """Exactly the two agent files on disk; explicitly no ``github_tool.*.rego`` (the tool is a pure + target — no rules written for it, and it was not onboarded). Checks the writer's own ``/rego``, + not just what was copied to the host.""" + written = uc1.writer_rego_files(onboarded["writer_pod"]) + assert not [f for f in written if "github_tool" in f], ( + f"unexpected tool Rego emitted: {written}" + ) + for filename in (INBOUND_REGO, OUTBOUND_REGO): + assert filename in written, f"missing {filename} in writer /rego: {written}" + + +def test_inbound_grant_set_matches_truth_table(onboarded: dict) -> None: + """The inbound grant set re-derived from the Rego equals ``scenario_uc1``'s inbound truth table — + catching verdict-neutral over/under-grants the coarse allow/deny oracle cannot see.""" + got = uc1.inbound_grants(onboarded["rego_dir"]) + assert got == RUNG1_INBOUND, f"inbound: missing={RUNG1_INBOUND - got} extra={got - RUNG1_INBOUND}" + + +def test_outbound_subject_grant_set_empty(onboarded: dict) -> None: + """The outbound-subject grant set re-derived from the Rego is ``∅`` — no tool onboarded, so the + user gate is empty.""" + got = uc1.outbound_subject_grants(onboarded["rego_dir"]) + assert got == RUNG1_OUTBOUND_SUBJECT, f"expected empty outbound gate, got {got}" diff --git a/aiac/test/integration/test_uc1_onboard_agent_then_tool.py b/aiac/test/integration/test_uc1_onboard_agent_then_tool.py new file mode 100644 index 000000000..677d066d0 --- /dev/null +++ b/aiac/test/integration/test_uc1_onboard_agent_then_tool.py @@ -0,0 +1,242 @@ +"""Rung 2 of the UC-1 onboarding ladder — onboard the **agent, then the tool**. + +Issue ``testing/5.4.2-uc1-onboard-agent-then-tool.md``; spec +``docs/specs/integration-test/uc1-onboarding-pipeline.md``. Drive the **real** in-cluster UC-1 +Service Onboarding agent (``POST /apply/service/{id}``) for the ``github-agent`` **first** and the +``github-tool`` **second**, then assert the **full** truth table at the end. + +This rung proves the key reconciliation property: onboarding the tool **after** the agent +retroactively completes the agent's outbound policy. When the agent is onboarded alone its outbound +gate is empty (rung 1); when the tool is then onboarded, its Service Policy Builder pairs the tool's +scopes against the existing role universe (agent role + user roles) and the PCE **routes** those +``(role, tool-scope)`` rules onto the **tool's** persistent ``ServicePolicyModel`` via +``compute_and_apply(override=False)``. Because the agent's role targets a tool scope, the agent is in +the affected set, so its ``AgentPolicyModel`` is **re-derived from the SPMs** and +``github_agent.outbound.rego`` is (re)written with the full user→tool gate. + +There is **no agent re-onboard** and **no intermediate validation** — only the end state is checked. +This is order 1 of the order-independence pair; rung 3 (tool then agent) asserts its final +``APM(github-agent)`` grant sets are **identical** to this rung's. + +Reuses the shared harness (``uc1_onboard.py`` — config, Keycloak provisioning/cleanup, onboard +trigger, Rego capture, grant-set extraction, per-rung fixture flow), ``scenario_uc1.py`` (the truth +tables — the oracle), and ``probe_uc1.rego`` (user-gate probe). The **only** rung-2-specific content +here is the oracle (the full outbound gate, from ``scenario_uc1.OUTBOUND_SUBJECT_PAIRS``) and the +live assertions; the onboarding order — ``[agent, tool]`` — is passed to the shared fixture flow. + +Per-rung flow (spec § Per-rung flow): **Keycloak cleanup → onboard agent → onboard tool → validate +end state → Keycloak cleanup**. Deployment + client registration are **preconditions**, not test +steps. *Onboard + evaluate — no A2A traffic, no live enforcement* (phase-1 out of scope). + +Run (needs a live Kagenti/Kind cluster with the AIAC stack + OPA filesystem-stub writer, the demo +workloads deployed + registered into ``AIAC_TEST_REALM``, a real LLM in-pod, and ``opa`` on PATH or +``$OPA_BIN``): + + .venv/bin/pytest test/integration/test_uc1_onboard_agent_then_tool.py -m integration -v + +Without ``-m integration`` the suite is not collected; without ``opa`` it skips at runtime. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import uc1_onboard as uc1 # noqa: E402 +from test.integration.launcher import opa_eval # noqa: E402 + +TEST_REALM = uc1.TEST_REALM +INBOUND_REGO = uc1.INBOUND_REGO +OUTBOUND_REGO = uc1.OUTBOUND_REGO +AGENT_SLUG = uc1.AGENT_SLUG + + +# ====================================================================================== +# Expected-verdict oracle (pure functions over the scenario_uc1 truth table) +# ====================================================================================== +# +# Rung 2 asserts the **full** truth table. Inbound is the shared oracle (``uc1.expected_inbound`` — +# unaffected by tool onboarding). Outbound is now **non-empty**: onboarding the tool completed the +# agent's outbound user gate, so the expected user→tool grant set is exactly +# ``scenario_uc1.OUTBOUND_SUBJECT_PAIRS``. Because that gate is order-independent, it is the **shared** +# tool-onboarded oracle in ``uc1_onboard`` (``uc1.expected_outbound_with_tool`` / +# ``uc1.OUTBOUND_SUBJECT_GRANT_SET``) — the exact set rung 3 must reproduce. Verdicts are computed +# here, never read from the Rego under test. + +RUNG2_INBOUND = uc1.INBOUND_GRANT_SET +RUNG2_OUTBOUND_SUBJECT = uc1.OUTBOUND_SUBJECT_GRANT_SET +expected_outbound = uc1.expected_outbound_with_tool + + +# ====================================================================================== +# Oracle contract tests — fixture-independent; pin rung 2's defining decisions +# ====================================================================================== +# +# These need neither the cluster nor ``opa``: they assert the rung-2 oracle itself (the intended +# policy the live decisions below are checked against). If these are wrong, every live assertion is +# meaningless — so they are the tracer bullet. + + +@pytest.mark.parametrize( + "subject, allowed", + [("dev-user", True), ("test-user", True), ("devops-user", False)], +) +def test_inbound_oracle(subject: str, allowed: bool) -> None: + """Inbound: dev-user ✅, test-user ✅, devops-user ❌ (devops sources no agent scope) — unchanged + from rung 1; tool onboarding does not touch the inbound gate.""" + assert uc1.expected_inbound(subject) is allowed + + +@pytest.mark.parametrize( + "subject, function_name, allowed", + [ + # dev-user (developer): source read/write + issues read; NOT issues write. + ("dev-user", "github-tool.source-read", True), + ("dev-user", "github-tool.source-write", True), + ("dev-user", "github-tool.issues-read", True), + ("dev-user", "github-tool.issues-write", False), + # test-user (tester): issues read/write only. + ("test-user", "github-tool.source-read", False), + ("test-user", "github-tool.source-write", False), + ("test-user", "github-tool.issues-read", True), + ("test-user", "github-tool.issues-write", True), + # devops-user (devops): no access to anything. + ("devops-user", "github-tool.source-read", False), + ("devops-user", "github-tool.source-write", False), + ("devops-user", "github-tool.issues-read", False), + ("devops-user", "github-tool.issues-write", False), + ], +) +def test_outbound_oracle(subject: str, function_name: str, allowed: bool) -> None: + """Rung 2's defining property: the full user→tool outbound gate (developer: source rw + issues + read; tester: issues rw; devops: nothing) — the gate tool onboarding completed on the agent.""" + assert expected_outbound(subject, function_name) is allowed + + +def test_rung2_grant_set_oracle() -> None: + """Rung 2 grant-set oracle: inbound == the ``scenario_uc1`` inbound truth table; the + outbound-subject grant set == the (non-empty) ``OUTBOUND_SUBJECT_PAIRS`` truth table.""" + assert RUNG2_INBOUND == set(scn.INBOUND_PAIRS) + assert RUNG2_OUTBOUND_SUBJECT == set(scn.OUTBOUND_SUBJECT_PAIRS) + assert RUNG2_OUTBOUND_SUBJECT, "rung 2's outbound gate must be non-empty (the tool was onboarded)" + + +# ====================================================================================== +# Session fixture — cleanup → onboard agent → onboard tool → capture rego → yield → cleanup +# ====================================================================================== + + +@pytest.fixture(scope="session") +def onboarded() -> dict: + """Onboard the agent **then** the tool via the shared harness (order is this rung's identity — + tool onboarding retroactively completes the agent's outbound gate), and yield the live ``admin`` + handle + captured ``rego_dir`` (+ writer ``pod``). Keycloak cleanup runs before and after; the + clients are left registered as before (spec § Per-rung flow). No agent re-onboard, no + intermediate validation — only the end state is asserted below.""" + with uc1.onboarded_stack( + [scn.AGENT_WORKLOAD, scn.TOOL_WORKLOAD], rego_subdir="rung2" + ) as ctx: + yield ctx + + +# ====================================================================================== +# Live tests — Keycloak entities + opa-eval decisions (verdicts computed from scenario_uc1) +# ====================================================================================== + + +def test_agent_role_and_scopes_provisioned(onboarded: dict) -> None: + """Keycloak holds the agent's per-skill operator roles + the two AgentCard scopes, all with + their descriptions.""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + + for name, description in scn.AGENT_ROLES.items(): + role = admin.get_realm_role(name) + assert role and role.get("name") == name, f"missing realm role {name!r}" + assert (role.get("description") or "") == description, ( + f"agent role {name!r} description mismatch: {role.get('description')!r} != {description!r}" + ) + + scopes = {s["name"]: (s.get("description") or "") for s in admin.get_client_scopes()} + for name, description in scn.AGENT_SCOPES.items(): + assert name in scopes, f"missing agent scope {name!r}" + assert scopes[name] == description, ( + f"agent scope {name!r} description mismatch: {scopes[name]!r} != {description!r}" + ) + + +def test_tool_scopes_provisioned(onboarded: dict) -> None: + """The tool was onboarded, so all four ``github-tool.*`` scopes exist with their MCP + ``tools/list`` descriptions (the discovered tool boundary).""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + + scopes = {s["name"]: (s.get("description") or "") for s in admin.get_client_scopes()} + for name, description in scn.TOOL_SCOPES.items(): + assert name in scopes, f"missing tool scope {name!r}" + assert scopes[name] == description, ( + f"tool scope {name!r} description mismatch: {scopes[name]!r} != {description!r}" + ) + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +def test_inbound(onboarded: dict, subject: str) -> None: + """Inbound gate allows a user iff their role may reach some discovered agent scope + (dev-user ✅, test-user ✅, devops-user ❌) — unchanged by the tool onboarding.""" + rego = onboarded["rego_dir"] / INBOUND_REGO + allowed = opa_eval([rego], f"data.authz.{AGENT_SLUG}.inbound.allow", {"subject": subject}) + assert allowed == uc1.expected_inbound(subject), subject + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +@pytest.mark.parametrize("function_name", list(scn.TOOL_SCOPES)) +def test_outbound(onboarded: dict, subject: str, function_name: str) -> None: + """Outbound user gate (via ``probe_uc1.rego``) decides each ``(subject, function)`` per the full + ``OUTBOUND_SUBJECT_PAIRS`` table — the gate tool onboarding completed on the agent (exact-name + match on full discovered scope names).""" + rego = onboarded["rego_dir"] / OUTBOUND_REGO + allowed = opa_eval( + [rego, uc1.PROBE_UC1], + "data.probe.outbound.allow", + {"subject": subject, "function_name": function_name}, + ) + assert allowed == expected_outbound(subject, function_name), f"{subject} / {function_name}" + + +def test_only_agent_rego_present(onboarded: dict) -> None: + """Exactly the two agent files on disk; explicitly **no** ``github_tool.*.rego`` even though the + tool was onboarded (the tool is a pure target — its rules route onto the agent's model, and no + rules are written for the tool alone). Checks the writer's own ``/rego``, not just the host copy.""" + written = uc1.writer_rego_files(onboarded["writer_pod"]) + assert not [f for f in written if "github_tool" in f], ( + f"unexpected tool Rego emitted: {written}" + ) # substring, not startswith: catches the namespace-prefixed ``team1_github_tool.*.rego`` too + for filename in (INBOUND_REGO, OUTBOUND_REGO): + assert filename in written, f"missing {filename} in writer /rego: {written}" + + +def test_inbound_grant_set_matches_truth_table(onboarded: dict) -> None: + """The inbound grant set re-derived from the Rego equals ``scenario_uc1``'s inbound truth table — + catching verdict-neutral over/under-grants the coarse allow/deny oracle cannot see.""" + got = uc1.inbound_grants(onboarded["rego_dir"]) + assert got == RUNG2_INBOUND, f"inbound: missing={RUNG2_INBOUND - got} extra={got - RUNG2_INBOUND}" + + +def test_outbound_subject_grant_set_matches_truth_table(onboarded: dict) -> None: + """The reconciliation property, as a grant set: the outbound-subject grant set re-derived from + the Rego equals the **non-empty** ``OUTBOUND_SUBJECT_PAIRS`` — tool onboarding completed the + agent's outbound gate (empty in rung 1, full here). This is what rung 3 asserts identical.""" + got = uc1.outbound_subject_grants(onboarded["rego_dir"]) + assert got == RUNG2_OUTBOUND_SUBJECT, ( + f"outbound: missing={RUNG2_OUTBOUND_SUBJECT - got} extra={got - RUNG2_OUTBOUND_SUBJECT}" + ) + assert got, "outbound gate must be non-empty after onboarding the tool (the reconciliation property)" diff --git a/aiac/test/integration/test_uc1_onboard_tool_then_agent.py b/aiac/test/integration/test_uc1_onboard_tool_then_agent.py new file mode 100644 index 000000000..bc5af9e2c --- /dev/null +++ b/aiac/test/integration/test_uc1_onboard_tool_then_agent.py @@ -0,0 +1,298 @@ +"""Rung 3 of the UC-1 onboarding ladder — onboard the **tool, then the agent**. + +Issue ``testing/5.4.3-uc1-onboard-tool-then-agent.md``; spec +``docs/specs/integration-test/uc1-onboarding-pipeline.md``. Drive the **real** in-cluster UC-1 +Service Onboarding agent (``POST /apply/service/{id}``) for the ``github-tool`` **first** and the +``github-agent`` **second**, then assert the **full** truth table at the end — and, crucially, that +this end state is **identical to rung 2's** (agent→tool). + +This is the direct single-pass happy path: onboarding the tool first provisions the four +``github-tool.*`` scopes, and the ``(user role → tool scope)`` rules that pass produces are routed +**durably onto ``SPM(github-tool)``** (the tool gets an SPM, no APM; no agent APM is written yet — +no agent targets a tool scope at this point). When the agent is then onboarded, its Service Policy +Builder reads the universe (now including the tool scopes), the PCE routes the agent→tool rule to +``SPM(github-tool)``, marks the agent affected, and **derives** its APM from the SPMs — picking up +the durable user→tool rules already on ``SPM(github-tool)`` — so ``github_agent.outbound.rego`` is +emitted with the full user→tool gate in one pass. + +This rung is the **live counterpart of the PCE's order-independence unit test (8.11)** and the exact +repro of the original order-dependence bug: under the old APM-only design, tool-then-agent **lost** +the ``user role → tool scope`` rule because no agent yet targeted the tool scope at tool onboarding. +The SPM redesign stores that rule durably on ``SPM(github-tool)`` and reconstructs it when the +agent's APM is derived. So this rung asserts, on top of the full truth table, that its final +``APM(github-agent)`` grant sets **equal rung 2's** (compared as order-independent ``(role, scope)`` +sets, never a byte diff of the Rego). A divergence names the differing gate and is a **failure** — an +onboarding-order bug this rung exists to surface (spec § *Onboarding order is irrelevant*). + +Reuses the shared harness (``uc1_onboard.py`` — config, Keycloak provisioning/cleanup, onboard +trigger, Rego capture, grant-set extraction, per-rung fixture flow), the shared tool-onboarded oracle +(``uc1.expected_outbound_with_tool`` / ``uc1.OUTBOUND_SUBJECT_GRANT_SET`` — the same gate rung 2 +asserts), ``scenario_uc1.py`` (the truth tables — the oracle), and ``probe_uc1.rego`` (user-gate +probe). The **only** rung-3-specific content here is the onboarding order — ``[tool, agent]`` — and +the order-independence assertions against **rung 2's** published expectations. + +Per-rung flow (spec § Per-rung flow): **Keycloak cleanup → onboard tool → onboard agent → validate +end state → Keycloak cleanup**. Deployment + client registration are **preconditions**, not test +steps. *Onboard + evaluate — no A2A traffic, no live enforcement* (phase-1 out of scope). + +Run (needs a live Kagenti/Kind cluster with the AIAC stack + OPA filesystem-stub writer, the demo +workloads deployed + registered into ``AIAC_TEST_REALM``, a real LLM in-pod, and ``opa`` on PATH or +``$OPA_BIN``): + + .venv/bin/pytest test/integration/test_uc1_onboard_tool_then_agent.py -m integration -v + +Without ``-m integration`` the suite is not collected; without ``opa`` it skips at runtime. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration import test_uc1_onboard_agent_then_tool as rung2 # noqa: E402 +from test.integration import uc1_onboard as uc1 # noqa: E402 +from test.integration.launcher import opa_eval # noqa: E402 + +TEST_REALM = uc1.TEST_REALM +INBOUND_REGO = uc1.INBOUND_REGO +OUTBOUND_REGO = uc1.OUTBOUND_REGO +AGENT_SLUG = uc1.AGENT_SLUG + + +# ====================================================================================== +# Expected-verdict oracle (the shared tool-onboarded oracle — identical to rung 2's) +# ====================================================================================== +# +# Rung 3 asserts the **full** truth table, and it must be the **same** truth table as rung 2 (spec: +# *Onboarding order is irrelevant*). So the oracle is the shared tool-onboarded oracle in +# ``uc1_onboard`` — the same constants/helper rung 2 uses — and the order-independence check below +# compares this rung's live grant sets against **rung 2's published expectations** (``rung2.RUNG2_*``, +# which alias the same shared oracle). Verdicts are computed here, never read from the Rego under test. + +RUNG3_INBOUND = uc1.INBOUND_GRANT_SET +RUNG3_OUTBOUND_SUBJECT = uc1.OUTBOUND_SUBJECT_GRANT_SET +expected_outbound = uc1.expected_outbound_with_tool + +# Rung 2's published expectations — what this rung's end state must equal (order-independence). +RUNG2_INBOUND = rung2.RUNG2_INBOUND +RUNG2_OUTBOUND_SUBJECT = rung2.RUNG2_OUTBOUND_SUBJECT + + +# ====================================================================================== +# Oracle contract tests — fixture-independent; pin rung 3's defining decisions +# ====================================================================================== +# +# These need neither the cluster nor ``opa``: they assert the rung-3 oracle itself (the intended +# policy the live decisions below are checked against), including that it is byte-for-byte the same +# oracle as rung 2's. If these are wrong, every live assertion is meaningless — so they are the +# tracer bullet. + + +@pytest.mark.parametrize( + "subject, allowed", + [("dev-user", True), ("test-user", True), ("devops-user", False)], +) +def test_inbound_oracle(subject: str, allowed: bool) -> None: + """Inbound: dev-user ✅, test-user ✅, devops-user ❌ (devops sources no agent scope) — inbound is + unaffected by onboarding order; identical to rungs 1 and 2.""" + assert uc1.expected_inbound(subject) is allowed + + +@pytest.mark.parametrize( + "subject, function_name, allowed", + [ + # dev-user (developer): source read/write + issues read; NOT issues write. + ("dev-user", "github-tool.source-read", True), + ("dev-user", "github-tool.source-write", True), + ("dev-user", "github-tool.issues-read", True), + ("dev-user", "github-tool.issues-write", False), + # test-user (tester): issues read/write only. + ("test-user", "github-tool.source-read", False), + ("test-user", "github-tool.source-write", False), + ("test-user", "github-tool.issues-read", True), + ("test-user", "github-tool.issues-write", True), + # devops-user (devops): no access to anything. + ("devops-user", "github-tool.source-read", False), + ("devops-user", "github-tool.source-write", False), + ("devops-user", "github-tool.issues-read", False), + ("devops-user", "github-tool.issues-write", False), + ], +) +def test_outbound_oracle(subject: str, function_name: str, allowed: bool) -> None: + """Rung 3's outbound gate is the full user→tool gate (developer: source rw + issues read; tester: + issues rw; devops: nothing) — the same gate rung 2 produces, reached here in a single pass.""" + assert expected_outbound(subject, function_name) is allowed + + +def test_rung3_grant_set_oracle() -> None: + """Rung 3 grant-set oracle: inbound == the ``scenario_uc1`` inbound truth table; the + outbound-subject grant set == the (non-empty) ``OUTBOUND_SUBJECT_PAIRS`` truth table.""" + assert RUNG3_INBOUND == set(scn.INBOUND_PAIRS) + assert RUNG3_OUTBOUND_SUBJECT == set(scn.OUTBOUND_SUBJECT_PAIRS) + assert RUNG3_OUTBOUND_SUBJECT, "rung 3's outbound gate must be non-empty (the tool was onboarded)" + + +def test_order_independence_oracle() -> None: + """The order-independence property at the oracle level: rung 3's intended end state is **identical + to rung 2's** (both the inbound and the outbound-subject grant sets). The live check below then + proves the *generated* policy matches this — that onboarding order did not change the final policy.""" + assert RUNG3_INBOUND == RUNG2_INBOUND + assert RUNG3_OUTBOUND_SUBJECT == RUNG2_OUTBOUND_SUBJECT + + +# ====================================================================================== +# Session fixture — cleanup → onboard tool → onboard agent → capture rego → yield → cleanup +# ====================================================================================== + + +@pytest.fixture(scope="session") +def onboarded() -> dict: + """Onboard the tool **then** the agent via the shared harness (order is this rung's identity — + the tool's scopes already exist when the agent's Service Policy Builder reads the universe, so the + agent's APM is derived with the full user→tool gate in one pass), and yield the live ``admin`` + handle + captured ``rego_dir`` (+ writer ``pod``). Keycloak cleanup runs before and after; the + clients are left registered as before (spec § Per-rung flow).""" + with uc1.onboarded_stack( + [scn.TOOL_WORKLOAD, scn.AGENT_WORKLOAD], rego_subdir="rung3" + ) as ctx: + yield ctx + + +# ====================================================================================== +# Live tests — Keycloak entities + opa-eval decisions (verdicts computed from scenario_uc1) +# ====================================================================================== + + +def test_agent_role_and_scopes_provisioned(onboarded: dict) -> None: + """Keycloak holds the agent's per-skill operator roles + the two AgentCard scopes, all with + their descriptions.""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + + for name, description in scn.AGENT_ROLES.items(): + role = admin.get_realm_role(name) + assert role and role.get("name") == name, f"missing realm role {name!r}" + assert (role.get("description") or "") == description, ( + f"agent role {name!r} description mismatch: {role.get('description')!r} != {description!r}" + ) + + scopes = {s["name"]: (s.get("description") or "") for s in admin.get_client_scopes()} + for name, description in scn.AGENT_SCOPES.items(): + assert name in scopes, f"missing agent scope {name!r}" + assert scopes[name] == description, ( + f"agent scope {name!r} description mismatch: {scopes[name]!r} != {description!r}" + ) + + +def test_tool_scopes_provisioned(onboarded: dict) -> None: + """The tool was onboarded (first), so all four ``github-tool.*`` scopes exist with their MCP + ``tools/list`` descriptions (the discovered tool boundary).""" + admin = onboarded["admin"] + admin.change_current_realm(TEST_REALM) + + scopes = {s["name"]: (s.get("description") or "") for s in admin.get_client_scopes()} + for name, description in scn.TOOL_SCOPES.items(): + assert name in scopes, f"missing tool scope {name!r}" + assert scopes[name] == description, ( + f"tool scope {name!r} description mismatch: {scopes[name]!r} != {description!r}" + ) + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +def test_inbound(onboarded: dict, subject: str) -> None: + """Inbound gate allows a user iff their role may reach some discovered agent scope + (dev-user ✅, test-user ✅, devops-user ❌) — unaffected by onboarding order.""" + rego = onboarded["rego_dir"] / INBOUND_REGO + allowed = opa_eval([rego], f"data.authz.{AGENT_SLUG}.inbound.allow", {"subject": subject}) + assert allowed == uc1.expected_inbound(subject), subject + + +@pytest.mark.parametrize("subject", list(scn.USERS)) +@pytest.mark.parametrize("function_name", list(scn.TOOL_SCOPES)) +def test_outbound(onboarded: dict, subject: str, function_name: str) -> None: + """Outbound user gate (via ``probe_uc1.rego``) decides each ``(subject, function)`` per the full + ``OUTBOUND_SUBJECT_PAIRS`` table — reconstructed from the durable ``SPM(github-tool)`` rules when + the agent's APM was derived (exact-name match on full discovered scope names).""" + rego = onboarded["rego_dir"] / OUTBOUND_REGO + allowed = opa_eval( + [rego, uc1.PROBE_UC1], + "data.probe.outbound.allow", + {"subject": subject, "function_name": function_name}, + ) + assert allowed == expected_outbound(subject, function_name), f"{subject} / {function_name}" + + +def test_only_agent_rego_present(onboarded: dict) -> None: + """Exactly the two agent files on disk; explicitly **no** ``github_tool.*.rego`` even though the + tool was onboarded first (the tool is a pure target — its rules live durably on ``SPM(github-tool)`` + and are reconstructed onto the agent's model; no rules are written for the tool alone). Checks the + writer's own ``/rego``, not just the host copy.""" + written = uc1.writer_rego_files(onboarded["writer_pod"]) + assert not [f for f in written if "github_tool" in f], ( + f"unexpected tool Rego emitted: {written}" + ) + for filename in (INBOUND_REGO, OUTBOUND_REGO): + assert filename in written, f"missing {filename} in writer /rego: {written}" + + +def test_inbound_grant_set_matches_truth_table(onboarded: dict) -> None: + """The inbound grant set re-derived from the Rego equals ``scenario_uc1``'s inbound truth table — + catching verdict-neutral over/under-grants the coarse allow/deny oracle cannot see.""" + got = uc1.inbound_grants(onboarded["rego_dir"]) + assert got == RUNG3_INBOUND, f"inbound: missing={RUNG3_INBOUND - got} extra={got - RUNG3_INBOUND}" + + +def test_outbound_subject_grant_set_matches_truth_table(onboarded: dict) -> None: + """The single-pass property, as a grant set: the outbound-subject grant set re-derived from the + Rego equals the **non-empty** ``OUTBOUND_SUBJECT_PAIRS`` — the agent's APM was derived with the + full user→tool gate because the tool's scopes (and their durable ``SPM(github-tool)`` rules) + already existed.""" + got = uc1.outbound_subject_grants(onboarded["rego_dir"]) + assert got == RUNG3_OUTBOUND_SUBJECT, ( + f"outbound: missing={RUNG3_OUTBOUND_SUBJECT - got} extra={got - RUNG3_OUTBOUND_SUBJECT}" + ) + assert got, "outbound gate must be non-empty after onboarding the tool (single-pass derivation)" + + +# ====================================================================================== +# Order-independence — this rung's headline: tool→agent == agent→tool (vs rung 2) +# ====================================================================================== +# +# The live counterpart of the PCE's order-independence unit test (8.11) and the exact repro of the +# original order-dependence bug. Compared at the **grant-set** level (semantic ``(role, scope)`` sets), +# never a byte diff of the Rego. A divergence names the differing gate and is a **failure** — an +# onboarding-order bug, not an accepted difference (spec § *Onboarding order is irrelevant*). + + +def test_inbound_grant_set_equals_rung2(onboarded: dict) -> None: + """Onboarding-order-independence, inbound gate: rung 3's (tool→agent) inbound grant set equals + rung 2's (agent→tool). Inbound never depended on order, so this is the easy half — but asserting + it keeps the equivalence check total across both gates.""" + got = uc1.inbound_grants(onboarded["rego_dir"]) + assert got == RUNG2_INBOUND, ( + "onboarding order changed the INBOUND gate (bug): " + f"missing={RUNG2_INBOUND - got} extra={got - RUNG2_INBOUND}" + ) + + +def test_outbound_subject_grant_set_equals_rung2(onboarded: dict) -> None: + """Onboarding-order-independence, outbound user gate — this rung's raison d'être: rung 3's + (tool→agent) outbound-subject grant set equals rung 2's (agent→tool). This is the exact cell the + original order-dependence bug corrupted (tool-then-agent lost the user→tool rule); the SPM + redesign makes both orders converge. A divergence here is an onboarding-order **bug**, not an + accepted difference.""" + got = uc1.outbound_subject_grants(onboarded["rego_dir"]) + assert got == RUNG2_OUTBOUND_SUBJECT, ( + "onboarding order changed the OUTBOUND user gate (bug): " + f"missing={RUNG2_OUTBOUND_SUBJECT - got} extra={got - RUNG2_OUTBOUND_SUBJECT}" + ) + assert got, "outbound gate must be non-empty (the tool was onboarded)" diff --git a/aiac/test/integration/uc1_onboard.py b/aiac/test/integration/uc1_onboard.py new file mode 100644 index 000000000..030fb412f --- /dev/null +++ b/aiac/test/integration/uc1_onboard.py @@ -0,0 +1,502 @@ +"""Shared harness for the UC-1 onboarding integration-test ladder (rungs 1–3). + +Spec: ``docs/specs/integration-test/uc1-onboarding-pipeline.md``. Every rung follows the same +**Keycloak cleanup → onboard (in the rung's order) → validate end state → Keycloak cleanup** shape +against **one** in-cluster AIAC stack (Controller + Policy Store + OPA filesystem-stub writer). The +only thing that differs between rungs is *which workloads are onboarded and in what order* — so all +the machinery to do it lives here, and each ``test_uc1_onboard_*.py`` module supplies just its own +oracle (the expected verdicts, computed from ``scenario_uc1.py``) and live assertions. + +This module owns: + +* **Config** (env, spec § Configuration) — single stack, no variants. +* **Keycloak** — ``connect_admin`` / ``provision_realm_and_users`` (the fixture UC-1 does *not* do) / + ``resolve_service_id`` (route-safe trigger id = internal client UUID) / ``cleanup_provisioned``. +* **Onboarding + Rego capture** — ``ensure_agent_policy`` (mount the PRB's ``policy.md``), ``onboard`` + (``POST /apply/service/{id}``), and the writer-pod ``/rego`` capture helpers. +* **Grant-set extraction** — re-derive the inbound / outbound-subject grant sets from the generated + Rego via ``opa eval`` (the semantic-equivalence oracle). +* **``onboarded_stack``** — the whole per-rung fixture flow, parameterised by the ordered list of + workloads to onboard; each rung wraps it in a one-line session fixture. + +It imports only stdlib + ``requests`` + ``launcher`` + the pure-data ``scenario_uc1`` (never +``aiac``), so it is importable before the env-before-import dance, exactly like ``scenario_uc1`` and +``launcher``. It defines **no** ``test_*`` functions, so pytest does not collect it. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +import requests + +HERE = Path(__file__).resolve().parent # test/integration/ +REPO_ROOT = HERE.parents[1] # -> aiac/ +if str(REPO_ROOT) not in sys.path: # so ``import test.integration.*`` resolves + sys.path.insert(0, str(REPO_ROOT)) + +from test.integration import scenario_uc1 as scn # noqa: E402 +from test.integration.launcher import ( # noqa: E402 + kubectl, + kubectl_cp, + kubectl_rollout_status, + opa_bin, + opa_eval, + port_forward, + require_env, + resolve_pod, +) + +log = logging.getLogger(__name__) + +# --- Config (env) — spec § Configuration; single stack (no variants) ------------------------ +TEST_REALM = os.environ.get("AIAC_TEST_REALM", scn.REALM_DEFAULT) +NAMESPACE = os.environ.get("AIAC_DEMO_NAMESPACE", scn.DEMO_NAMESPACE_DEFAULT) +ADMIN_REALM = os.environ.get("KEYCLOAK_ADMIN_REALM", "master") + +# Controller (in-cluster) reached via ``kubectl port-forward``. Target/namespace/ports are +# overridable; defaults match the deployed AIAC stack (svc/aiac-agent-service:7070 in aiac-system). +CONTROLLER_NAMESPACE = os.environ.get("AIAC_CONTROLLER_NAMESPACE", "aiac-system") +CONTROLLER_TARGET = os.environ.get("AIAC_CONTROLLER_TARGET", "svc/aiac-agent-service") +CONTROLLER_LOCAL_PORT = int(os.environ.get("AIAC_CONTROLLER_LOCAL_PORT", "7070")) +CONTROLLER_REMOTE_PORT = int(os.environ.get("AIAC_CONTROLLER_REMOTE_PORT", "7070")) + +# Policy Store (in-cluster) reached via ``kubectl port-forward`` to clear stale SPMs before a run. +# The store's SQLite lives on a StatefulSet PV that survives image redeploys, so pre-fix cruft +# would otherwise accumulate across runs (onboarding appends with override=False). Defaults match +# the deployed stack (svc/aiac-policy-store-service:7074 in aiac-system). +STORE_NAMESPACE = os.environ.get("AIAC_STORE_NAMESPACE", "aiac-system") +STORE_TARGET = os.environ.get("AIAC_STORE_TARGET", "svc/aiac-policy-store-service") +STORE_LOCAL_PORT = int(os.environ.get("AIAC_STORE_LOCAL_PORT", "7074")) +STORE_REMOTE_PORT = int(os.environ.get("AIAC_STORE_REMOTE_PORT", "7074")) + +# The Controller Deployment + the abstract policy.md the PRB reads. The test mounts this policy on +# the Controller pod as a precondition-fixup (see ``ensure_agent_policy``); it is NOT written into +# any committed deployment manifest — the deployment stays free of test-specific config. +CONTROLLER_DEPLOYMENT = os.environ.get("AIAC_CONTROLLER_DEPLOYMENT", "aiac-agent") +POLICY_CONFIGMAP = os.environ.get("AIAC_POLICY_CONFIGMAP", "aiac-policy") +POLICY_MOUNT_PATH = os.environ.get("AIAC_POLICY_MOUNT_PATH", "/etc/aiac") + +# Onboarding drives the real PRB, which makes several LLM calls over the whole role/scope universe; +# against a slow reasoning model that can take minutes. Configurable so slow endpoints don't spuriously +# fail the run (``AIAC_ONBOARD_TIMEOUT``, seconds). +ONBOARD_TIMEOUT = float(os.environ.get("AIAC_ONBOARD_TIMEOUT", "900")) + +# OPA-writer pod to ``kubectl cp`` the generated .rego from (name explicit or via label selector). +# The OPA filesystem-stub writer runs as a container INSIDE the interface pod (not its own pod), +# so the .rego is captured from that container. Defaults match the deployed stack. +OPA_NAMESPACE = os.environ.get("AIAC_OPA_NAMESPACE", "aiac-system") +OPA_SELECTOR = os.environ.get("AIAC_OPA_SELECTOR", "app=aiac-interface") +OPA_CONTAINER = os.environ.get("AIAC_OPA_CONTAINER", "aiac-pdp-policy-opa") +OPA_POD = os.environ.get("AIAC_OPA_POD") +OPA_REGO_PATH = os.environ.get("AIAC_OPA_REGO_PATH", "/rego") + +# Base dir the captured ``.rego`` is copied to, one subfolder per rung (see ``fresh_rego_dir``). +# Defaults to the (gitignored) ``test/integration/rego_out/uc1/`` tree so artifacts stay in the +# project for eyeballing but never get committed; ``$REGO_OUTPUT_DIR`` overrides the base. +REGO_OUTPUT_BASE = Path(os.environ.get("REGO_OUTPUT_DIR") or (HERE / "rego_out" / "uc1")) + +AGENT_SLUG = f"{NAMESPACE}_{scn.AGENT_WORKLOAD}".replace("-", "_") # team1/github-agent -> team1_github_agent +INBOUND_REGO = f"{AGENT_SLUG}.inbound.rego" +OUTBOUND_REGO = f"{AGENT_SLUG}.outbound.rego" +AGENT_REGO_FILES = (INBOUND_REGO, OUTBOUND_REGO) + +# The outbound user-gate probe (``data.probe.outbound.allow``), shared by every rung. +PROBE_UC1 = HERE / "probe_uc1.rego" + + +# ====================================================================================== +# Shared inbound oracle (identical for every rung — inbound is unaffected by tool onboarding) +# ====================================================================================== +# +# A user may call the agent iff their realm role sources some agent scope (``INBOUND_PAIRS``). This +# holds for all rungs; only the *outbound* gate differs (empty for rung 1, the full +# ``OUTBOUND_SUBJECT_PAIRS`` once a tool is onboarded), so ``expected_outbound`` stays rung-local. + +_INBOUND_SOURCES = {role for role, _ in scn.INBOUND_PAIRS} # user-roles reaching some agent scope +INBOUND_GRANT_SET: set[tuple[str, str]] = set(scn.INBOUND_PAIRS) + + +def expected_inbound(subject: str) -> bool: + """A user may call the agent iff their realm role sources some agent scope (``INBOUND_PAIRS``).""" + return scn.USERS[subject] in _INBOUND_SOURCES + + +# ====================================================================================== +# Shared outbound oracle for the tool-onboarded rungs (rungs 2 & 3) +# ====================================================================================== +# +# Once **any** tool is onboarded, the agent's outbound user gate is the full user→tool grant set +# (``OUTBOUND_SUBJECT_PAIRS``) — and it is the same set **regardless of onboarding order** (spec: +# *Onboarding order is irrelevant*). Rung 2 (agent→tool) fills it in when the tool is onboarded after +# the agent; rung 3 (tool→agent) produces it in one pass. Both converge here, so both share this +# oracle; only rung 1 (no tool) is the exception and keeps its own empty-gate oracle. Verdicts are +# computed from this, never read from the Rego under test. + +OUTBOUND_SUBJECT_GRANT_SET: set[tuple[str, str]] = set(scn.OUTBOUND_SUBJECT_PAIRS) + +# The agent-capability gate: the tool scopes the agent's per-skill operator roles reach (the +# right-hand column of ``OUTBOUND_TARGET_PAIRS``). In UC-1 the agent reaches all four tool scopes, +# so the per-scope AND reduces to the subject gate for the verdicts — but the oracle expresses the +# AND explicitly so the two-gate decision is documented, not assumed. +OUTBOUND_TARGET_GRANT_SET: set[str] = {fn for _, fn in scn.OUTBOUND_TARGET_PAIRS} + + +def expected_outbound_with_tool(subject: str, function_name: str) -> bool: + """A user may reach a tool scope iff **both** gates pass (per-scope AND): their realm role is + granted it in the user→tool subject gate (``OUTBOUND_SUBJECT_PAIRS``) **and** the agent's own + operator roles reach it in the capability gate (``OUTBOUND_TARGET_PAIRS``). Any tool onboarding + fills both gates on the agent's model, order-independently (rungs 2 & 3).""" + user_ok = (scn.USERS[subject], function_name) in OUTBOUND_SUBJECT_GRANT_SET + agent_ok = function_name in OUTBOUND_TARGET_GRANT_SET + return user_ok and agent_ok + + +# ====================================================================================== +# Keycloak provisioning + cleanup (the fixture UC-1 does NOT do) +# ====================================================================================== + + +def connect_admin(): + """Connect to the admin realm so the fixture can provision users + clean up provisioned entities.""" + from keycloak import KeycloakAdmin + + creds = require_env("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") + return KeycloakAdmin( + server_url=creds["KEYCLOAK_URL"], + realm_name=ADMIN_REALM, + user_realm_name=ADMIN_REALM, + username=creds["KEYCLOAK_ADMIN_USERNAME"], + password=creds["KEYCLOAK_ADMIN_PASSWORD"], + ) + + +def provision_realm_and_users(admin, realm: str) -> None: + """Idempotently ensure ``realm`` holds ``scenario_uc1``'s users + realm roles with the + descriptions the PRB reads. Realm roles carry the ``aiac.managed`` marker so the IdP populates + each role's ``actorIds`` (member usernames) — the PCE needs them to build the ``subject_roles`` + map the inbound/outbound gates key on. Never deletes/recreates — reruns converge (spec: shared + realm, leave-in-place).""" + from keycloak.exceptions import KeycloakError + + try: + admin.create_realm({"realm": realm, "enabled": True}) + except KeycloakError: + pass # already exists — leave in place + admin.change_current_realm(realm) + + for name, description in scn.USER_ROLES.items(): + payload = {"name": name, "description": description, "attributes": {"aiac.managed": ["true"]}} + admin.create_realm_role(payload, skip_exists=True) + admin.update_realm_role(name, payload) # ensure the marker on a pre-existing role too + + for username, role_name in scn.USERS.items(): + user_id = admin.create_user({"username": username, "enabled": True}, exist_ok=True) + admin.set_user_password(user_id, scn.USER_PASSWORD, temporary=False) + admin.assign_realm_roles(user_id, [admin.get_realm_role(role_name)]) + + +def resolve_service_id(admin, realm: str, client_name: str) -> str: + """Return the **route-safe trigger id** — the Keycloak *internal client UUID* (``client['id']``) + of the client whose *name* is ``client_name``. + + Not the ``clientId``: under SPIRE that is a SPIFFE URI (``spiffe://.../github-agent``) whose + slashes the single-segment ``/apply/service/{id}`` route cannot carry; the Controller resolves + the trigger via ``admin.get_client(id)``, which keys on the UUID.""" + admin.change_current_realm(realm) + for client in admin.get_clients(): + if client.get("name") == client_name: + return client["id"] + raise AssertionError(f"no Keycloak client with name {client_name!r} in realm {realm!r}") + + +def cleanup_provisioned(admin, realm: str) -> None: + """Delete the entities UC-1 onboarding provisions — the realm role(s) and client scopes prefixed + ``github-agent.`` / ``github-tool.`` — so each run starts from a clean slate and reruns converge. + + Leaves the fixture's own ``developer`` / ``tester`` / ``devops`` roles, the operator's audience + client scopes (``*-aud``, no ``.`` after the workload), and everything else in place. Best-effort: + a delete of an already-absent entity is ignored.""" + from keycloak.exceptions import KeycloakError + + admin.change_current_realm(realm) + prefixes = (f"{scn.AGENT_WORKLOAD}.", f"{scn.TOOL_WORKLOAD}.") + + for role in admin.get_realm_roles(): + name = role.get("name", "") + if name.startswith(prefixes): + try: + admin.delete_realm_role(name) + except KeycloakError as exc: + log.warning("cleanup: delete realm role %r failed: %s", name, exc) + + for scope in admin.get_client_scopes(): + name = scope.get("name", "") + if name.startswith(prefixes): + try: + admin.delete_client_scope(scope["id"]) + except KeycloakError as exc: + log.warning("cleanup: delete client scope %r failed: %s", name, exc) + + +def clear_policy_store() -> None: + """Drop every persisted SPM from the in-cluster Policy Store before a run — the store-side twin + of ``cleanup_provisioned``'s Keycloak reset. + + The store's SQLite lives on a StatefulSet PV that outlives image redeploys, and onboarding + appends to each SPM with ``override=False``; without this the store accumulates pre-fix cruft + (stale role-id generations, retired ``*-aud`` edges, cross-run pollution) that the PCE replays + into every regenerated Rego — so a fixed pipeline still emits defective policy. Clearing here + guarantees each run derives its Rego from only the edges this run onboarded. + + Hits ``DELETE /policy/services`` directly through a port-forward (the harness never imports + ``aiac``). Best-effort about *reachability* — a store that is unreachable (or a port-forward that + won't come up) is tolerated and only warned about. But a store that answers with a **non-2xx** + means the clear actually failed: proceeding would run the rung on dirty state (stale SPMs + replayed into every regenerated Rego), so that case fails loudly rather than silently.""" + try: + with port_forward( + STORE_TARGET, + namespace=STORE_NAMESPACE, + local_port=STORE_LOCAL_PORT, + remote_port=STORE_REMOTE_PORT, + ready_url=f"http://127.0.0.1:{STORE_LOCAL_PORT}/health", + ) as base_url: + resp = requests.delete(f"{base_url}/policy/services", timeout=30) + except (requests.ConnectionError, requests.Timeout, RuntimeError) as exc: + # Store unreachable / port-forward failed — best-effort, must not fail the run. + log.warning("clear_policy_store: store unreachable, skipping clear (%s)", exc) + return + if not (200 <= resp.status_code < 300): + raise AssertionError( + f"clear_policy_store: DELETE /policy/services returned HTTP {resp.status_code} — the " + f"store was reached but the clear failed, so the run would proceed on dirty state " + f"(stale SPMs): {resp.text[:500]}" + ) + log.info("cleared Policy Store SPMs (HTTP %s)", resp.status_code) + + +# ====================================================================================== +# Onboarding trigger + Rego capture +# ====================================================================================== + + +def ensure_agent_policy(namespace: str) -> None: + """Ensure the PRB's ``policy.md`` is mounted in the Controller pod — the one mutable stack + precondition the ladder owns, so a fresh AIAC stack needs no manual patching. + + Phase-1's PRB reads the single abstract policy from ``AIAC_POLICY_FILE`` (default + ``/etc/aiac/policy.md``). This idempotently provisions that file as a ConfigMap and mounts it on + the Controller Deployment, rolling out **only** when the mount is absent. The policy text is + ``scenario_uc1.POLICY_ABSTRACT`` — the same abstract policy the scenario's verdicts assume. It is + never written into a committed deployment manifest (that stays free of test config) and is left + in place on teardown (benign, and keeps reruns fast).""" + cm = { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": POLICY_CONFIGMAP, "namespace": namespace}, + "data": {"policy.md": scn.POLICY_ABSTRACT}, + } + apply_out = kubectl("apply", "-f", "-", input_text=json.dumps(cm)) + # ``kubectl apply`` reports "created"/"configured" when the ConfigMap's content differs from the + # cluster and "unchanged" when it matches; use that to decide whether a rollout is needed. + cm_changed = "unchanged" not in apply_out + + mounted = kubectl( + "get", "deployment", CONTROLLER_DEPLOYMENT, "-n", namespace, + "-o", "jsonpath={.spec.template.spec.volumes[*].configMap.name}", + ) + if POLICY_CONFIGMAP in mounted.split(): + # Already mounted, so no deployment patch (and thus no rollout) is triggered. But a projected + # ConfigMap volume only re-syncs on the kubelet's own (~minute) cadence, and the PRB reads + # policy.md once at startup — so if we just changed the ConfigMap's content the running pod + # would keep serving the stale policy. Force a rollout + wait so the new policy.md is in + # place before onboarding; skip it when apply reported the ConfigMap unchanged (fast reruns). + if cm_changed: + kubectl("rollout", "restart", f"deployment/{CONTROLLER_DEPLOYMENT}", "-n", namespace) + kubectl_rollout_status(f"deployment/{CONTROLLER_DEPLOYMENT}", namespace=namespace) + return # already mounted — content is now current + + patch = { + "spec": {"template": {"spec": { + "volumes": [{"name": "aiac-policy", "configMap": {"name": POLICY_CONFIGMAP}}], + "containers": [{ + "name": CONTROLLER_DEPLOYMENT, + "volumeMounts": [ + {"name": "aiac-policy", "mountPath": POLICY_MOUNT_PATH, "readOnly": True} + ], + }], + }}} + } + kubectl( + "patch", "deployment", CONTROLLER_DEPLOYMENT, "-n", namespace, + "--type", "strategic", "-p", json.dumps(patch), + ) + kubectl_rollout_status(f"deployment/{CONTROLLER_DEPLOYMENT}", namespace=namespace) + + +def onboard(base_url: str, service_id: str) -> None: + """``POST /apply/service/{service_id}`` against the Controller; assert 200.""" + resp = requests.post(f"{base_url}/apply/service/{service_id}", timeout=ONBOARD_TIMEOUT) + assert resp.status_code == 200, ( + f"onboard {service_id!r} at {base_url}: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + +def fresh_rego_dir(subdir: str) -> Path: + """Host dir the captured ``.rego`` is copied to: a per-rung ``subdir`` under ``REGO_OUTPUT_BASE`` + (``test/integration/rego_out/uc1/`` by default — gitignored, or ``$REGO_OUTPUT_DIR`` if set). + + The artifacts live in the project tree so they can be eyeballed after a run, one folder per test + case. Every ``.rego`` in the dir is cleared at the start of each rung so a broken pipeline can't + pass green on leftovers, and each run verifies only freshly generated policy.""" + path = REGO_OUTPUT_BASE / subdir + path.mkdir(parents=True, exist_ok=True) + for stale in path.glob("*.rego"): + stale.unlink() + return path + + +def writer_pod() -> str: + """Resolve the pod hosting the OPA filesystem-stub writer (the ``aiac-pdp-policy-opa`` container + lives inside the interface pod).""" + return OPA_POD or resolve_pod(OPA_SELECTOR, namespace=OPA_NAMESPACE) + + +def clear_writer_rego(pod: str) -> None: + """Delete any stale ``.rego`` in the writer's output dir **before** onboarding, so the run + captures only freshly-generated policy — a leftover from a previous run must never let a broken + pipeline pass green. (The host capture dir is cleared too, in ``fresh_rego_dir``.)""" + kubectl( + "exec", "-n", OPA_NAMESPACE, pod, "-c", OPA_CONTAINER, "--", + "sh", "-c", f"rm -f {OPA_REGO_PATH.rstrip('/')}/*.rego", + ) + + +def writer_rego_files(pod: str) -> list[str]: + """List the ``.rego`` basenames present in the writer's output dir (the ground truth for the + file-set assertions). Lets a rung assert *what the pipeline actually wrote* — e.g. that no + ``github_tool.*.rego`` exists even after the tool is onboarded — rather than only what was + copied to the host.""" + out = kubectl( + "exec", "-n", OPA_NAMESPACE, pod, "-c", OPA_CONTAINER, "--", + "sh", "-c", f"ls -1 {OPA_REGO_PATH.rstrip('/')}/*.rego 2>/dev/null || true", + ) + return [Path(line.strip()).name for line in out.splitlines() if line.strip()] + + +def capture_rego(pod: str, rego_dir: Path) -> None: + """``kubectl cp`` the agent's inbound + outbound Rego from the writer container into ``rego_dir``. + + Only the two agent files are copied: under UC-1 the tool is a pure target and the pipeline emits + no ``github_tool.*.rego`` for any rung. (Use ``writer_rego_files`` to assert that on the pod.)""" + for filename in AGENT_REGO_FILES: + kubectl_cp( + pod, f"{OPA_REGO_PATH.rstrip('/')}/{filename}", rego_dir / filename, + namespace=OPA_NAMESPACE, container=OPA_CONTAINER, + ) + + +# ====================================================================================== +# Grant-set extraction (semantic-equivalence oracle, re-derived from the generated Rego) +# ====================================================================================== + + +def opa_dump(rego: Path, ref: str) -> object: + """Return the value of a Rego data ref (a map/list, not a boolean) via ``opa eval``. + + ``opa eval`` omits ``result`` entirely (or yields an empty expression list) when the ref is + undefined — e.g. the writer emitted a Rego with no such rule — which would otherwise surface as + a raw ``KeyError``/``IndexError`` from the result-unwrapping in ``opa_eval``. Turn that into a + clear assertion so a missing dump reads as an oracle failure, not an opaque crash.""" + try: + return opa_eval([rego], ref, {}) + except (KeyError, IndexError) as exc: + raise AssertionError( + f"opa eval produced no value for {ref!r} against {rego} — the ref is undefined " + f"(the pipeline may have emitted no such rule): {exc}" + ) from exc + + +def outbound_subject_grants(rego_dir: Path) -> set[tuple[str, str]]: + """User->tool grant set from the outbound Rego's ``subject_role_scopes`` map (``∅`` when + no tool has been onboarded, the full ``OUTBOUND_SUBJECT_PAIRS`` once one has).""" + m = opa_dump( + rego_dir / OUTBOUND_REGO, + f"data.authz.{AGENT_SLUG}.outbound.subject_role_scopes", + ) + return {(role, scope) for role, scopes in (m or {}).items() for scope in scopes} + + +def inbound_grants(rego_dir: Path) -> set[tuple[str, str]]: + """User-role->agent-scope grant set from the inbound Rego's ``role_scopes`` restricted to the + published ``agent_scopes`` (a role may list scopes the agent does not expose; those don't grant).""" + rego = rego_dir / INBOUND_REGO + role_scopes = opa_dump(rego, f"data.authz.{AGENT_SLUG}.inbound.role_scopes") or {} + agent_scopes = set(opa_dump(rego, f"data.authz.{AGENT_SLUG}.inbound.agent_scopes") or []) + return { + (role, scope) + for role, scopes in role_scopes.items() + for scope in scopes + if scope in agent_scopes + } + + +# ====================================================================================== +# Per-rung fixture flow — cleanup → onboard (in order) → capture rego → yield → cleanup +# ====================================================================================== + + +@contextmanager +def onboarded_stack(workloads: list[str], *, rego_subdir: str) -> Iterator[dict]: + """Run one rung's whole flow and yield ``{"admin", "rego_dir", "writer_pod"}``. + + Provision users/roles, onboard the given ``workloads`` **in order** through the real in-cluster + UC-1 Controller (``POST /apply/service/{id}``, one call each within a single port-forward), + capture the agent's Rego, and yield the live handles for the rung's assertions. Keycloak cleanup + runs before and after; the clients are left registered as before (spec § Per-rung flow). + + ``opa`` absence **skips** the whole suite up front (before any cluster work), so a missing oracle + binary never masquerades as a failure. The workload order is the rung's identity — e.g. rung 2 + passes ``[agent, tool]`` so tool onboarding retroactively completes the agent's outbound gate.""" + opa_bin() # skip early if opa is absent — cheaper than skipping after the onboard + require_env("KEYCLOAK_URL", "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD") + + admin = connect_admin() + cleanup_provisioned(admin, TEST_REALM) # before — clean slate (Keycloak) + clear_policy_store() # before — clean slate (Policy Store SPMs; PV survives redeploys) + provision_realm_and_users(admin, TEST_REALM) # BEFORE onboarding (PRB reads the role universe) + ensure_agent_policy(CONTROLLER_NAMESPACE) # mount the PRB's policy.md if the stack lacks it + + rego_dir = fresh_rego_dir(rego_subdir) # fresh per-rung host dir; stale .rego cleared + pod = writer_pod() + clear_writer_rego(pod) # clear stale .rego in the writer BEFORE onboarding + try: + service_ids = [ + resolve_service_id(admin, TEST_REALM, f"{NAMESPACE}/{workload}") for workload in workloads + ] + with port_forward( + CONTROLLER_TARGET, + namespace=CONTROLLER_NAMESPACE, + local_port=CONTROLLER_LOCAL_PORT, + remote_port=CONTROLLER_REMOTE_PORT, + ) as base_url: + for service_id in service_ids: # onboard in the rung's order + onboard(base_url, service_id) + + capture_rego(pod, rego_dir) + missing = [f for f in AGENT_REGO_FILES if not (rego_dir / f).is_file()] + if missing: + raise RuntimeError( + f"onboarding {workloads} produced no {missing} in {rego_dir} — the pipeline failed " + "or the OPA filesystem-stub writer is not deployed (spec § Blocked by)." + ) + yield {"admin": admin, "rego_dir": rego_dir, "writer_pod": pod} + finally: + cleanup_provisioned(admin, TEST_REALM) # after — restore the pre-run state diff --git a/aiac/test/pdp/__init__.py b/aiac/test/pdp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/library/__init__.py b/aiac/test/pdp/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/policy/__init__.py b/aiac/test/pdp/policy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/policy/generate_rego.py b/aiac/test/pdp/policy/generate_rego.py new file mode 100644 index 000000000..f82c36e86 --- /dev/null +++ b/aiac/test/pdp/policy/generate_rego.py @@ -0,0 +1,90 @@ +"""Generate github-agent Rego by driving the live PDP Policy Writer (OPA) stub. + +Standalone (NOT pytest, NOT CI). Launches the stub as a uvicorn subprocess writing to a known +local dir, applies a PolicyModel through the PDP policy library, shuts the service down, and +prints the output dir. Inspect the .rego files by hand. + +The subprocess lifecycle is shared with the 5.3 launcher via ``test.integration.launcher``, and +the fixed scenario (the same canonical github-agent worked example) lives in +``test.integration.scenario`` so the two launchers cannot drift. + +Run: + .venv/bin/python test/pdp/policy/generate_rego.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] # -> aiac/ +SRC = REPO_ROOT / "src" +sys.path.insert(0, str(REPO_ROOT)) # so ``import test.integration.*`` resolves +sys.path.insert(0, str(SRC)) # so ``import aiac.*`` resolves + +from aiac.idp.configuration.models import Role, Scope # noqa: E402 +from aiac.policy.model.models import AgentPolicyModel, PolicyModel, PolicyRule # noqa: E402 +from test.integration import scenario as scn # noqa: E402 +from test.integration.launcher import ( # noqa: E402 + Service, + print_rego_dir, + resolve_output_dir, + running_services, +) + +PORT = int(os.environ.get("PORT", "7072")) +BASE_URL = f"http://127.0.0.1:{PORT}" +OUTPUT_DIR = resolve_output_dir(Path(__file__).parent / "rego_out") + + +def _roles() -> dict[str, Role]: + """Synthesize a Role per scenario role name (ids stable as ``role-``).""" + names = list(scn.USER_ROLES) + list(scn.AGENT_ROLES) + return {name: Role(id=f"role-{name}", name=name, composite=False) for name in names} + + +def _scopes() -> dict[str, Scope]: + """Synthesize a Scope per scenario scope name (ids stable as ``scope-``).""" + names = list(scn.AGENT_SCOPES) + list(scn.TOOL_SCOPES) + return {name: Scope(id=f"scope-{name}", name=name) for name in names} + + +def build_model() -> PolicyModel: + role, scope = _roles(), _scopes() + + def rules(pairs: list[tuple[str, str]]) -> list[PolicyRule]: + return [PolicyRule(role=role[r], scope=scope[s]) for r, s in pairs] + + agent = AgentPolicyModel( + agent_id=scn.AGENT_ID, + agent_roles=[role[name] for name in scn.AGENT_ROLES], + agent_scopes=[scope[name] for name in scn.AGENT_SCOPES], + source_roles={}, + subject_roles={user: [role[role_name]] for user, role_name in scn.USERS.items()}, + target_scopes={scn.TOOL_ID: [scope[name] for name in scn.TOOL_SCOPES]}, + inbound_rules=rules(scn.INBOUND_PAIRS), + outbound_rules=rules(scn.OUTBOUND_PAIRS), + outbound_subject_rules=rules(scn.OUTBOUND_SUBJECT_PAIRS), + ) + return PolicyModel(agents=[agent]) + + +def main() -> None: + os.environ["AIAC_PDP_POLICY_URL"] = BASE_URL # consumed by the library + from aiac.pdp.policy.library.api import apply_policy # import after env is set + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + opa = Service( + "aiac.pdp.service.policy.opa.main:app", + port=PORT, + env={"REGO_OUTPUT_DIR": str(OUTPUT_DIR)}, + ) + with running_services([opa], src=SRC): + apply_policy(build_model()) + + print_rego_dir(OUTPUT_DIR) + + +if __name__ == "__main__": + main() diff --git a/aiac/test/pdp/policy/library/__init__.py b/aiac/test/pdp/policy/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/policy/library/test_api.py b/aiac/test/pdp/policy/library/test_api.py new file mode 100644 index 000000000..eb804957a --- /dev/null +++ b/aiac/test/pdp/policy/library/test_api.py @@ -0,0 +1,193 @@ +"""Unit tests for aiac.pdp.policy.library.api. + +The PDP Policy Writer HTTP boundary is mocked; no live service is required. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from aiac.policy.model.models import AgentPolicyModel, PolicyModel + +BASE = "http://127.0.0.1:7072" + +# Minimal valid model fixtures (all eight AgentPolicyModel fields present). +_AGENT_POLICY_DICT = { + "agent_id": "weather-agent", + "agent_roles": [], + "agent_scopes": [], + "subject_roles": {}, + "source_roles": {}, + "target_scopes": {}, + "inbound_rules": [], + "outbound_rules": [], +} +_POLICY_DICT = {"agents": [_AGENT_POLICY_DICT]} + + +def _ok(status: int = 200) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.ok = True + return resp + + +def _err(status: int = 500) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.ok = False + resp.text = "internal error" + return resp + + +# --------------------------------------------------------------------------- +# apply_policy +# --------------------------------------------------------------------------- + + +class TestApplyPolicy: + def test_posts_serialized_policy_model(self): + model = PolicyModel.model_validate(_POLICY_DICT) + with patch("aiac.pdp.policy.library.api.requests.post", return_value=_ok()) as m: + from aiac.pdp.policy.library.api import apply_policy + + result = apply_policy(model) + assert result is None + assert m.call_args[0][0] == f"{BASE}/policy" + assert m.call_args.kwargs["json"] == model.model_dump() + assert m.call_args.kwargs.get("params") is None + + def test_raises_on_non_2xx(self): + model = PolicyModel.model_validate(_POLICY_DICT) + with patch("aiac.pdp.policy.library.api.requests.post", return_value=_err()): + from aiac.pdp.policy.library.api import apply_policy + + with pytest.raises(RuntimeError): + apply_policy(model) + + +# --------------------------------------------------------------------------- +# apply_agent_policy +# --------------------------------------------------------------------------- + + +class TestApplyAgentPolicy: + def test_posts_serialized_agent_model_to_agent_path(self): + model = AgentPolicyModel.model_validate(_AGENT_POLICY_DICT) + with patch("aiac.pdp.policy.library.api.requests.post", return_value=_ok()) as m: + from aiac.pdp.policy.library.api import apply_agent_policy + + result = apply_agent_policy("weather-agent", model) + assert result is None + assert m.call_args[0][0] == f"{BASE}/policy/agents/weather-agent" + assert m.call_args.kwargs["json"] == model.model_dump() + assert m.call_args.kwargs.get("params") is None + + def test_raises_on_non_2xx(self): + model = AgentPolicyModel.model_validate(_AGENT_POLICY_DICT) + with patch("aiac.pdp.policy.library.api.requests.post", return_value=_err()): + from aiac.pdp.policy.library.api import apply_agent_policy + + with pytest.raises(RuntimeError): + apply_agent_policy("weather-agent", model) + + +# --------------------------------------------------------------------------- +# delete_agent_policy +# --------------------------------------------------------------------------- + + +class TestDeleteAgentPolicy: + def test_deletes_agent_path(self): + with patch( + "aiac.pdp.policy.library.api.requests.delete", return_value=_ok(204) + ) as m: + from aiac.pdp.policy.library.api import delete_agent_policy + + result = delete_agent_policy("weather-agent") + assert result is None + assert m.call_args[0][0] == f"{BASE}/policy/agents/weather-agent" + assert m.call_args.kwargs.get("params") is None + + def test_raises_on_non_2xx(self): + with patch( + "aiac.pdp.policy.library.api.requests.delete", return_value=_err(404) + ): + from aiac.pdp.policy.library.api import delete_agent_policy + + with pytest.raises(RuntimeError): + delete_agent_policy("missing-agent") + + +# --------------------------------------------------------------------------- +# delete_policy +# --------------------------------------------------------------------------- + + +class TestDeletePolicy: + def test_deletes_policy_path(self): + with patch( + "aiac.pdp.policy.library.api.requests.delete", return_value=_ok(204) + ) as m: + from aiac.pdp.policy.library.api import delete_policy + + result = delete_policy() + assert result is None + assert m.call_args[0][0] == f"{BASE}/policy" + assert m.call_args.kwargs.get("params") is None + + def test_raises_on_non_2xx(self): + with patch( + "aiac.pdp.policy.library.api.requests.delete", return_value=_err(500) + ): + from aiac.pdp.policy.library.api import delete_policy + + with pytest.raises(RuntimeError): + delete_policy() + + +# --------------------------------------------------------------------------- +# AIAC_PDP_POLICY_URL fallback +# --------------------------------------------------------------------------- + + +class TestUrlFallback: + def test_defaults_to_localhost_7072_when_env_unset(self, monkeypatch): + monkeypatch.delenv("AIAC_PDP_POLICY_URL", raising=False) + model = PolicyModel.model_validate(_POLICY_DICT) + with patch("aiac.pdp.policy.library.api.requests.post", return_value=_ok()) as m: + from aiac.pdp.policy.library.api import apply_policy + + apply_policy(model) + assert m.call_args[0][0] == "http://127.0.0.1:7072/policy" + + +# --------------------------------------------------------------------------- +# No realm query parameter on any request +# --------------------------------------------------------------------------- + + +class TestNoRealmParam: + def test_none_of_the_four_functions_append_realm(self): + policy = PolicyModel.model_validate(_POLICY_DICT) + agent = AgentPolicyModel.model_validate(_AGENT_POLICY_DICT) + with patch( + "aiac.pdp.policy.library.api.requests.post", return_value=_ok() + ) as post, patch( + "aiac.pdp.policy.library.api.requests.delete", return_value=_ok(204) + ) as delete: + from aiac.pdp.policy.library.api import ( + apply_agent_policy, + apply_policy, + delete_agent_policy, + delete_policy, + ) + + apply_policy(policy) + apply_agent_policy("weather-agent", agent) + delete_agent_policy("weather-agent") + delete_policy() + + for call in list(post.call_args_list) + list(delete.call_args_list): + assert call.kwargs.get("params") is None + assert "realm" not in call.args[0] diff --git a/aiac/test/pdp/service/__init__.py b/aiac/test/pdp/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/service/policy/__init__.py b/aiac/test/pdp/service/policy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/service/policy/keycloak/__init__.py b/aiac/test/pdp/service/policy/keycloak/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/service/policy/keycloak/test_main.py b/aiac/test/pdp/service/policy/keycloak/test_main.py new file mode 100644 index 000000000..5e22e35e6 --- /dev/null +++ b/aiac/test/pdp/service/policy/keycloak/test_main.py @@ -0,0 +1,222 @@ +"""Unit tests for aiac/pdp/service/policy/keycloak/main.py FastAPI application.""" + +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient +from keycloak.exceptions import KeycloakError + +from aiac.pdp.service.policy.keycloak.main import app, get_admin + +REALM = "kagenti" + + +def _make_client(admin_mock: MagicMock) -> TestClient: + app.dependency_overrides[get_admin] = lambda realm=None: admin_mock + return TestClient(app) + + +def _keycloak_error(): + return KeycloakError(error_message="connection refused", response_code=503) + + +# --------------------------------------------------------------------------- +# POST /roles/{role_name}/composites → 204 +# --------------------------------------------------------------------------- + + +class TestAddRoleComposites: + def test_returns_204(self): + admin = MagicMock() + resp = _make_client(admin).post( + f"/roles/admin/composites?realm={REALM}", + json=[{"id": "r1", "name": "viewer"}], + ) + assert resp.status_code == 204 + admin.add_composite_realm_roles_to_role.assert_called_once() + + def test_keycloak_error_returns_502(self): + admin = MagicMock() + admin.add_composite_realm_roles_to_role.side_effect = _keycloak_error() + resp = _make_client(admin).post( + f"/roles/admin/composites?realm={REALM}", + json=[{"id": "r1", "name": "viewer"}], + ) + assert resp.status_code == 502 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# DELETE /roles/{role_name}/composites → 204 +# --------------------------------------------------------------------------- + + +class TestRemoveRoleComposites: + def test_returns_204(self): + admin = MagicMock() + resp = _make_client(admin).request( + "DELETE", + f"/roles/admin/composites?realm={REALM}", + json=[{"id": "r1", "name": "viewer"}], + ) + assert resp.status_code == 204 + admin.remove_composite_realm_roles_to_role.assert_called_once() + + def test_keycloak_error_returns_502(self): + admin = MagicMock() + admin.remove_composite_realm_roles_to_role.side_effect = _keycloak_error() + resp = _make_client(admin).request( + "DELETE", + f"/roles/admin/composites?realm={REALM}", + json=[{"id": "r1", "name": "viewer"}], + ) + assert resp.status_code == 502 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# DELETE /composites → 204 +# --------------------------------------------------------------------------- + + +class TestClearAllComposites: + def test_returns_204(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [ + {"id": "r1", "name": "admin"}, + {"id": "r2", "name": "viewer"}, + ] + admin.get_composite_realm_roles_of_role.return_value = [{"id": "r2", "name": "viewer"}] + resp = _make_client(admin).request("DELETE", f"/composites?realm={REALM}") + assert resp.status_code == 204 + + def test_iterates_all_roles_and_removes_composites(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [{"id": "r1", "name": "admin"}] + admin.get_composite_realm_roles_of_role.return_value = [{"id": "r2", "name": "viewer"}] + _make_client(admin).request("DELETE", f"/composites?realm={REALM}") + admin.remove_composite_realm_roles_to_role.assert_called_once() + + def test_skips_role_with_no_composites(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [{"id": "r1", "name": "admin"}] + admin.get_composite_realm_roles_of_role.return_value = [] + _make_client(admin).request("DELETE", f"/composites?realm={REALM}") + admin.remove_composite_realm_roles_to_role.assert_not_called() + + def test_keycloak_error_returns_502(self): + admin = MagicMock() + admin.get_realm_roles.side_effect = _keycloak_error() + resp = _make_client(admin).request("DELETE", f"/composites?realm={REALM}") + assert resp.status_code == 502 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/permissions → 201 + JSON +# --------------------------------------------------------------------------- + + +class TestCreateServicePermission: + def test_returns_201_with_created_role(self): + admin = MagicMock() + admin.create_client_role.return_value = {"id": "cr1", "name": "view-data"} + resp = _make_client(admin).post( + f"/services/svc-uuid/permissions?realm={REALM}", + json={"name": "view-data", "description": "View data permission"}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == "view-data" + + def test_keycloak_error_returns_502(self): + admin = MagicMock() + admin.create_client_role.side_effect = _keycloak_error() + resp = _make_client(admin).post( + f"/services/svc-uuid/permissions?realm={REALM}", + json={"name": "view-data", "description": ""}, + ) + assert resp.status_code == 502 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /services/{service_id}/scopes → 201 + JSON +# --------------------------------------------------------------------------- + + +class TestCreateServiceScope: + def test_returns_201_with_created_scope(self): + admin = MagicMock() + admin.create_client_scope.return_value = {"id": "sc1", "name": "read:data"} + resp = _make_client(admin).post( + f"/services/svc-uuid/scopes?realm={REALM}", + json={"name": "read:data", "description": "Read data scope"}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == "read:data" + admin.add_client_default_client_scope.assert_called_once() + + def test_keycloak_error_returns_502(self): + admin = MagicMock() + admin.create_client_scope.side_effect = _keycloak_error() + resp = _make_client(admin).post( + f"/services/svc-uuid/scopes?realm={REALM}", + json={"name": "read:data", "description": ""}, + ) + assert resp.status_code == 502 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Realm query parameter: optional +# --------------------------------------------------------------------------- + + +class TestRealmQueryParam: + def test_no_realm_returns_204(self): + admin = MagicMock() + admin.get_realm_roles.return_value = [] + admin.get_composite_realm_roles_of_role.return_value = [] + app.dependency_overrides[get_admin] = lambda realm=None: admin + resp = TestClient(app).request("DELETE", "/composites") + assert resp.status_code == 204 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /health (readiness probe — pings Keycloak) +# --------------------------------------------------------------------------- + + +class TestHealth: + def test_returns_200_when_keycloak_reachable(self): + admin = MagicMock() + resp = _make_client(admin).get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + admin.get_server_info.assert_called_once() + + def test_returns_503_when_keycloak_unreachable(self): + admin = MagicMock() + admin.get_server_info.side_effect = KeycloakError( + error_message="connection refused", response_code=503 + ) + resp = _make_client(admin).get("/health") + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "unavailable" + assert "error" in body + + def teardown_method(self): + app.dependency_overrides.clear() diff --git a/aiac/test/pdp/service/policy/opa/__init__.py b/aiac/test/pdp/service/policy/opa/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/pdp/service/policy/opa/test_main.py b/aiac/test/pdp/service/policy/opa/test_main.py new file mode 100644 index 000000000..7d3895aca --- /dev/null +++ b/aiac/test/pdp/service/policy/opa/test_main.py @@ -0,0 +1,164 @@ +"""Unit tests for aiac/pdp/service/policy/opa/main.py FastAPI application.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from aiac.pdp.service.policy.opa.main import app, get_output_dir + + +def _make_client(out_dir: Path) -> TestClient: + app.dependency_overrides[get_output_dir] = lambda: out_dir + return TestClient(app) + + +def _agent(agent_id: str = "weather-agent") -> dict: + return { + "agent_id": agent_id, + "agent_roles": [], + "agent_scopes": [], + "subject_roles": {}, + "source_roles": {}, + "target_scopes": {}, + "inbound_rules": [], + "outbound_rules": [], + } + + +# --------------------------------------------------------------------------- +# POST /policy -> 204, writes both files for every agent +# --------------------------------------------------------------------------- + + +class TestUpsertPolicy: + def test_writes_files_for_every_agent_and_returns_204(self, tmp_path): + body = {"agents": [_agent("weather-agent"), _agent("github-tool")]} + resp = _make_client(tmp_path).post("/policy", json=body) + assert resp.status_code == 204 + assert (tmp_path / "weather_agent.inbound.rego").exists() + assert (tmp_path / "weather_agent.outbound.rego").exists() + assert (tmp_path / "github_tool.inbound.rego").exists() + assert (tmp_path / "github_tool.outbound.rego").exists() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# POST /policy/agents/{agent_id} -> 204, writes both .rego files +# --------------------------------------------------------------------------- + + +class TestUpsertAgent: + def test_writes_both_rego_files_and_returns_204(self, tmp_path): + resp = _make_client(tmp_path).post( + "/policy/agents/weather-agent", json=_agent("weather-agent") + ) + assert resp.status_code == 204 + assert (tmp_path / "weather_agent.inbound.rego").exists() + assert (tmp_path / "weather_agent.outbound.rego").exists() + + def test_written_files_contain_generated_rego(self, tmp_path): + _make_client(tmp_path).post( + "/policy/agents/weather-agent", json=_agent("weather-agent") + ) + inbound = (tmp_path / "weather_agent.inbound.rego").read_text() + outbound = (tmp_path / "weather_agent.outbound.rego").read_text() + assert "package authz.weather_agent.inbound" in inbound + assert "package authz.weather_agent.outbound" in outbound + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# DELETE /policy/agents/{agent_id} -> 204, removes both files +# --------------------------------------------------------------------------- + + +class TestDeleteAgent: + def test_removes_both_files_and_returns_204(self, tmp_path): + client = _make_client(tmp_path) + client.post("/policy/agents/weather-agent", json=_agent("weather-agent")) + resp = client.delete("/policy/agents/weather-agent") + assert resp.status_code == 204 + assert not (tmp_path / "weather_agent.inbound.rego").exists() + assert not (tmp_path / "weather_agent.outbound.rego").exists() + + def test_absent_files_still_returns_204(self, tmp_path): + resp = _make_client(tmp_path).delete("/policy/agents/never-written") + assert resp.status_code == 204 + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# DELETE /policy -> 204, removes all *.rego files +# --------------------------------------------------------------------------- + + +class TestDeleteAll: + def test_removes_all_rego_files_and_returns_204(self, tmp_path): + client = _make_client(tmp_path) + client.post("/policy/agents/weather-agent", json=_agent("weather-agent")) + client.post("/policy/agents/github-tool", json=_agent("github-tool")) + resp = client.delete("/policy") + assert resp.status_code == 204 + assert list(tmp_path.glob("*.rego")) == [] + + def test_leaves_non_rego_files_untouched(self, tmp_path): + (tmp_path / "keep.txt").write_text("data") + resp = _make_client(tmp_path).delete("/policy") + assert resp.status_code == 204 + assert (tmp_path / "keep.txt").exists() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Write endpoints -> 502 on OSError +# --------------------------------------------------------------------------- + + +class TestOSErrorHandling: + def test_upsert_agent_returns_502_on_os_error(self, tmp_path): + # out_dir points at a regular file, so writing a child path raises OSError + not_a_dir = tmp_path / "afile" + not_a_dir.write_text("x") + resp = _make_client(not_a_dir).post( + "/policy/agents/weather-agent", json=_agent("weather-agent") + ) + assert resp.status_code == 502 + assert "error" in resp.json() + + def teardown_method(self): + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# GET /health -> 200 when dir writable, 503 otherwise +# --------------------------------------------------------------------------- + + +class TestHealth: + # /health resolves the output dir from server config (REGO_OUTPUT_DIR) directly rather than + # via the injectable dependency, so drive it through the env var instead of dependency_overrides. + def test_returns_200_when_dir_writable(self, tmp_path, monkeypatch): + monkeypatch.setenv("REGO_OUTPUT_DIR", str(tmp_path)) + resp = TestClient(app).get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_returns_503_when_dir_absent(self, tmp_path, monkeypatch): + missing = tmp_path / "does-not-exist" + monkeypatch.setenv("REGO_OUTPUT_DIR", str(missing)) + resp = TestClient(app).get("/health") + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "unavailable" + assert "error" in body + + def teardown_method(self): + app.dependency_overrides.clear() diff --git a/aiac/test/pdp/service/policy/opa/test_rego.py b/aiac/test/pdp/service/policy/opa/test_rego.py new file mode 100644 index 000000000..32b256007 --- /dev/null +++ b/aiac/test/pdp/service/policy/opa/test_rego.py @@ -0,0 +1,343 @@ +"""Unit tests for aiac.pdp.service.policy.opa.rego (ID-only model).""" + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from aiac.idp.configuration.models import Role, Scope +from aiac.pdp.service.policy.opa.rego import ( + generate_inbound_rego, + generate_outbound_rego, + slugify, +) +from aiac.policy.model.models import AgentPolicyModel, PolicyRule + + +def _role(name: str = "reader") -> Role: + return Role(id=f"role-{name}", name=name, composite=False) + + +def _scope(name: str = "read") -> Scope: + return Scope(id=f"scope-{name}", name=name) + + +def _model( + agent_id: str = "weather-agent", + agent_roles: list[Role] | None = None, + agent_scopes: list[Scope] | None = None, + subject_roles: dict[str, list[Role]] | None = None, + source_roles: dict[str, list[Role]] | None = None, + target_scopes: dict[str, list[Scope]] | None = None, + inbound_rules: list[PolicyRule] | None = None, + outbound_rules: list[PolicyRule] | None = None, + outbound_subject_rules: list[PolicyRule] | None = None, +) -> AgentPolicyModel: + return AgentPolicyModel( + agent_id=agent_id, + agent_roles=agent_roles or [], + agent_scopes=agent_scopes or [], + subject_roles=subject_roles or {}, + source_roles=source_roles or {}, + target_scopes=target_scopes or {}, + inbound_rules=inbound_rules or [], + outbound_rules=outbound_rules or [], + outbound_subject_rules=outbound_subject_rules or [], + ) + + +def _github_agent() -> AgentPolicyModel: + """The worked example from the handoff.""" + developer = _role("developer") + tester = _role("tester") + source_helper = _role("source-helper") + issues_helper = _role("issues-helper") + source_access = _scope("source-access") + issues_access = _scope("issues-access") + source_read = _scope("source-read") + source_write = _scope("source-write") + issues_read = _scope("issues-read") + issues_write = _scope("issues-write") + return _model( + agent_id="github-agent", + agent_roles=[source_helper, issues_helper], + agent_scopes=[source_access, issues_access], + subject_roles={"dev-user": [developer], "test-user": [tester]}, + target_scopes={ + "github-tool": [source_read, source_write, issues_read, issues_write] + }, + inbound_rules=[ + PolicyRule(role=developer, scope=source_access), + PolicyRule(role=developer, scope=issues_access), + PolicyRule(role=tester, scope=issues_access), + ], + outbound_rules=[ + PolicyRule(role=source_helper, scope=source_read), + PolicyRule(role=source_helper, scope=source_write), + PolicyRule(role=issues_helper, scope=issues_read), + PolicyRule(role=issues_helper, scope=issues_write), + ], + outbound_subject_rules=[ + PolicyRule(role=developer, scope=source_read), + PolicyRule(role=developer, scope=source_write), + PolicyRule(role=developer, scope=issues_read), + PolicyRule(role=tester, scope=issues_read), + PolicyRule(role=tester, scope=issues_write), + ], + ) + + +# --- slugify --- + + +def test_slugify_hyphens_become_underscores(): + assert slugify("weather-agent") == "weather_agent" + + +def test_slugify_lowercases_without_hyphens(): + assert slugify("WeatherAgent") == "weatheragent" + + +def test_slugify_strips_slashes_and_colons_from_spiffe_uri(): + slug = slugify("spiffe://localtest.me/ns/team1/sa/github-agent") + assert "/" not in slug + assert ":" not in slug + assert "." not in slug + + +def test_slugify_spiffe_uri_extracts_namespace_and_name_short_id(): + """The slug must be predictable from just {namespace}/{name}, not the trust domain.""" + assert slugify("spiffe://localtest.me/ns/team1/sa/github-agent") == "team1_github_agent" + assert ( + slugify("spiffe://other-trust-domain.example/ns/team1/sa/github-agent") + == "team1_github_agent" + ) + + +def test_slugify_plain_ns_workload_clientid_matches_spiffe_slug(): + """Same {ns}/{workload} id, with or without SPIRE, must slugify identically.""" + assert slugify("team1/github-agent") == "team1_github_agent" + + +# --- generate_inbound_rego --- + + +def test_inbound_has_package_header_with_slug(): + rego = generate_inbound_rego(_model(agent_id="weather-agent")) + assert "package authz.weather_agent.inbound" in rego + + +def test_inbound_embeds_agent_scopes_list(): + model = _model(agent_scopes=[_scope("source-access"), _scope("issues-access")]) + rego = generate_inbound_rego(model) + assert 'agent_scopes := ["source-access", "issues-access"]' in rego + + +def test_inbound_embeds_subject_roles_map(): + model = _model(subject_roles={"dev-user": [_role("developer"), _role("tester")]}) + rego = generate_inbound_rego(model) + assert "subject_roles := {" in rego + assert '"dev-user": ["developer", "tester"]' in rego + + +def test_inbound_embeds_source_roles_map(): + model = _model(source_roles={"github-tool": [_role("reader")]}) + rego = generate_inbound_rego(model) + assert "source_roles := {" in rego + assert '"github-tool": ["reader"]' in rego + + +def test_inbound_role_scopes_grouped_from_inbound_rules(): + model = _github_agent() + rego = generate_inbound_rego(model) + assert "role_scopes := {" in rego + assert '"developer": ["source-access", "issues-access"]' in rego + assert '"tester": ["issues-access"]' in rego + + +def test_inbound_has_subject_and_source_gates_and_allow(): + rego = generate_inbound_rego(_github_agent()) + assert "subject_ok if {" in rego + assert "some role in subject_roles[input.subject]" in rego + assert "some scope in role_scopes[role]" in rego + assert "scope in agent_scopes" in rego + assert "source_ok if { not input.source }" in rego + assert "some role in source_roles[input.source]" in rego + assert "default allow := false" in rego + assert "allow if { subject_ok; source_ok }" in rego + + +def test_inbound_has_no_legacy_id_carrying_input(): + rego = generate_inbound_rego(_github_agent()) + assert "input.role" not in rego + assert "input.scope" not in rego + assert "source_roles[input.source]" in rego # only inside source_ok gate + assert "scope_targets" not in rego + + +def test_inbound_empty_model_renders_valid_empty_literals(): + rego = generate_inbound_rego(_model()) + assert "agent_scopes := []" in rego + assert "subject_roles := {}" in rego + assert "source_roles := {}" in rego + assert "role_scopes := {}" in rego + assert "default allow := false" in rego + assert "allow if { subject_ok; source_ok }" in rego + + +# --- generate_outbound_rego --- + + +def test_outbound_has_package_header_with_slug(): + rego = generate_outbound_rego(_model(agent_id="weather-agent")) + assert "package authz.weather_agent.outbound" in rego + + +def test_outbound_embeds_agent_roles_list(): + rego = generate_outbound_rego(_github_agent()) + assert 'agent_roles := ["source-helper", "issues-helper"]' in rego + # agent_scopes is the inbound audience gate; the outbound package must not emit it. + assert "agent_scopes :=" not in rego + + +def test_outbound_agent_role_scopes_grouped_from_outbound_rules(): + rego = generate_outbound_rego(_github_agent()) + assert "agent_role_scopes := {" in rego + assert '"source-helper": ["source-read", "source-write"]' in rego + assert '"issues-helper": ["issues-read", "issues-write"]' in rego + + +def test_outbound_target_scopes_rendered_directly(): + rego = generate_outbound_rego(_github_agent()) + assert "target_scopes := {" in rego + assert ( + '"github-tool": ["source-read", "source-write", "issues-read", "issues-write"]' + in rego + ) + assert "scope_targets" not in rego + + +def test_subject_role_scopes_grouped_from_outbound_subject_rules(): + rego = generate_outbound_rego(_github_agent()) + assert "subject_role_scopes := {" in rego + assert '"developer": ["source-read", "source-write", "issues-read"]' in rego + assert '"tester": ["issues-read", "issues-write"]' in rego + + +def test_outbound_subject_ok_matches_target_scopes_not_agent_scopes(): + rego = generate_outbound_rego(_github_agent()) + # The outbound subject gate is user->target, keyed on the requested scope: it reads + # subject_role_scopes and tests input.function_name directly (per-scope AND), + # not the agent's own scopes. + assert "subject_ok if {" in rego + assert "some role in subject_roles[input.subject]" in rego + assert "input.function_name in subject_role_scopes[role]" in rego + # The inbound-flavoured subject gate must NOT appear in the outbound package. + assert "some scope in role_scopes[role]" not in rego + assert "scope in agent_scopes" not in rego + + +def test_outbound_does_not_embed_inbound_role_scopes(): + rego = generate_outbound_rego(_github_agent()) + # The inbound ``role_scopes`` map (from inbound_rules) must not leak into the + # outbound package. Line-anchored so it does not match subject_role_scopes. + assert "\nrole_scopes :=" not in rego + assert not rego.startswith("role_scopes :=") + + +def test_outbound_has_target_gate_and_allow(): + rego = generate_outbound_rego(_github_agent()) + # The capability gate tests the requested scope against the target's scopes directly + # (target_scopes[input.target] IS the per-scope capability gate) — no agent_roles existential. + assert "target_ok if {" in rego + assert "input.function_name in target_scopes[input.target]" in rego + assert "default allow := false" in rego + assert "allow if { subject_ok; target_ok }" in rego + # allow must not reference the informational agent_roles/agent_role_scopes existential. + assert "some role in agent_roles" not in rego + + +def test_outbound_has_no_legacy_id_carrying_input(): + rego = generate_outbound_rego(_github_agent()) + assert "input.role" not in rego + assert "input.scope" not in rego + assert "scope_targets" not in rego + + +def test_outbound_empty_model_renders_valid_empty_literals(): + rego = generate_outbound_rego(_model()) + assert "agent_roles := []" in rego + assert "subject_roles := {}" in rego + assert "subject_role_scopes := {}" in rego + assert "agent_role_scopes := {}" in rego + assert "target_scopes := {}" in rego + assert "default allow := false" in rego + assert "allow if { subject_ok; target_ok }" in rego + + +# --- per-scope AND intersection semantics --- + + +def _outbound_and_model() -> AgentPolicyModel: + """A fixture that pins the per-scope-AND intersection: the target owns {A, B, C}; the user + reaches {A, C} (subject gate) and the agent reaches {B, C} on the target (capability gate). + Only C is in both gates, so only C is allowed — A (user-only) and B (agent-only) are denied.""" + user = _role("u-role") + operator = _role("op-role") + a, b, c = _scope("scope-a"), _scope("scope-b"), _scope("scope-c") + return _model( + agent_id="github-agent", + agent_roles=[operator], + subject_roles={"user1": [user]}, + # target_scopes IS the capability gate: the agent reaches {B, C} on "T". + target_scopes={"T": [b, c]}, + # user (subject gate) reaches {A, C}. + outbound_subject_rules=[PolicyRule(role=user, scope=a), PolicyRule(role=user, scope=c)], + # informational agent_role_scopes (not referenced by allow): the operator role reaches {B, C}. + outbound_rules=[PolicyRule(role=operator, scope=b), PolicyRule(role=operator, scope=c)], + ) + + +def test_outbound_per_scope_and_structural(): + """Structural: the two gates read the same request scope from disjoint maps — the subject gate + grants {A, C} to the user, the capability gate grants {B, C} on the target — so allow is their + per-scope intersection.""" + rego = generate_outbound_rego(_outbound_and_model()) + assert '"u-role": ["scope-a", "scope-c"]' in rego # subject gate: user reaches A, C + assert '"T": ["scope-b", "scope-c"]' in rego # capability gate: agent reaches B, C on T + assert "input.function_name in subject_role_scopes[role]" in rego + assert "input.function_name in target_scopes[input.target]" in rego + assert "allow if { subject_ok; target_ok }" in rego + + +@pytest.mark.skipif(not shutil.which("opa"), reason="opa binary not on PATH") +@pytest.mark.parametrize( + "function_name, allowed", + [ + ("scope-c", True), # in BOTH gates -> allowed + ("scope-a", False), # user-only (not in the agent's capability gate) -> denied + ("scope-b", False), # agent-only (not in the user's subject gate) -> denied + ], +) +def test_outbound_per_scope_and_denies_mismatch(function_name: str, allowed: bool): + """Behavioural: evaluate the generated ``allow`` with ``opa eval``. The user-only scope (A) and + the agent-only scope (B) are both denied; only the scope in both gates (C) is allowed — pinning + the per-scope intersection (an A/B mismatch denies both).""" + rego = generate_outbound_rego(_outbound_and_model()) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "outbound.rego" + path.write_text(rego) + cmd = [ + shutil.which("opa"), "eval", "-f", "json", "-d", str(path), + "--stdin-input", "data.authz.github_agent.outbound.allow", + ] + out = subprocess.run( + cmd, + input=json.dumps({"subject": "user1", "target": "T", "function_name": function_name}), + capture_output=True, text=True, check=True, + ).stdout + result = json.loads(out)["result"][0]["expressions"][0]["value"] + assert result is allowed, f"function_name={function_name!r}" diff --git a/aiac/test/policy/__init__.py b/aiac/test/policy/__init__.py new file mode 100644 index 000000000..b251d548d --- /dev/null +++ b/aiac/test/policy/__init__.py @@ -0,0 +1,3 @@ +"""Policy tests package.""" + +# Made with Bob diff --git a/aiac/test/policy/computation/__init__.py b/aiac/test/policy/computation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/policy/computation/test_engine.py b/aiac/test/policy/computation/test_engine.py new file mode 100644 index 000000000..404f2e5b8 --- /dev/null +++ b/aiac/test/policy/computation/test_engine.py @@ -0,0 +1,750 @@ +"""Unit tests for ``aiac.policy.computation.engine.compute_and_apply`` (SPM-based). + +The engine routes each pre-flattened ``PolicyRule`` to the ``ServicePolicyModel`` (SPM) of the +service that *owns* the rule's scope (``scope.serviceId``), persists the changed SPMs, computes +the affected-agent set from the batch, then **derives** each affected agent's +``AgentPolicyModel`` (APM) entirely from the SPMs (zero IdP) and partial-upserts them once. + +Tests assert external behaviour — what the engine writes to the Policy Store (SPMs) and pushes to +the PDP (derived APMs) — not internal merge logic. All downstream dependencies are mocked at the +engine's import boundary via a small in-memory ``FakeStore`` that behaves like the real Policy +Store library (fresh-empty SPM on 404, ``get_service_policies_by_role`` scanning ``inbound_rules``): + - ``Configuration.get_services`` (IdP catalog: type + own roles/scopes) + - ``engine.get_service_policy`` / ``get_service_policies_by_role`` / ``apply_service_policy`` + - ``engine.apply_policy`` (PDP Policy Writer partial upsert) +""" + +import os +from contextlib import ExitStack, contextmanager +from unittest.mock import patch + +import pytest + +from aiac.idp.configuration.api import Configuration +from aiac.idp.configuration.models import Role, RoleKind, Scope, Service, ServiceType +from aiac.policy.model.models import PolicyModel, PolicyRule, ServicePolicyModel + + +# --------------------------------------------------------------------------- # +# builders # +# --------------------------------------------------------------------------- # +def _role(id, name=None, *, kind=RoleKind.USER, actor_ids=None, composite=False, + children=None, aiac_managed=True) -> Role: + attributes = {"aiac.managed": ["true"]} if aiac_managed else {} + return Role( + id=id, name=name or id, composite=composite, childRoles=children or [], + attributes=attributes, kind=kind, actorIds=actor_ids or [], + ) + + +def _user_role(id, name=None, *, users) -> Role: + return _role(id, name, kind=RoleKind.USER, actor_ids=users) + + +def _agent_role(id, name=None, *, owner) -> Role: + return _role(id, name, kind=RoleKind.AGENT, actor_ids=[owner]) + + +def _scope(id, name=None, *, service_id="", aiac_managed=True) -> Scope: + attributes = {"aiac.managed": "true"} if aiac_managed else {} + return Scope(id=id, name=name or id, attributes=attributes, serviceId=service_id) + + +def _service(service_id, *, type=None, roles=None, scopes=None) -> Service: + return Service( + id=f"uuid-{service_id}", serviceId=service_id, enabled=True, + type=type, roles=roles or [], scopes=scopes or [], + ) + + +def _agent(service_id, *, roles=None, scopes=None) -> Service: + return _service(service_id, type=ServiceType.AGENT, roles=roles, scopes=scopes) + + +def _tool(service_id, *, roles=None, scopes=None) -> Service: + return _service(service_id, type=ServiceType.TOOL, roles=roles, scopes=scopes) + + +def _rule(role, scope) -> PolicyRule: + return PolicyRule(role=role, scope=scope) + + +def _spm(service_id, *, type=ServiceType.AGENT, owned_roles=None, owned_scopes=None, + inbound=None) -> ServicePolicyModel: + return ServicePolicyModel( + service_id=service_id, service_type=type, + owned_roles=owned_roles or [], owned_scopes=owned_scopes or [], + inbound_rules=inbound or [], + ) + + +# --------------------------------------------------------------------------- # +# harness — an in-memory Policy Store behaving like the real library # +# --------------------------------------------------------------------------- # +class FakeStore: + def __init__(self, initial=None): + self.data = {sid: m.model_copy(deep=True) for sid, m in (initial or {}).items()} + self.service_writes = [] # [(service_id, SPM)] captured from apply_service_policy + self.by_role_calls = [] # [Role] captured from get_service_policies_by_role + self.policy_pushes = [] # [PolicyModel] captured from apply_policy + self.service_deletes = [] # [service_id] captured from delete_service_policy + self.agent_deletes = [] # [agent_id] captured from delete_agent_policy + + def get_service_policy(self, service_id): + if service_id in self.data: + return self.data[service_id].model_copy(deep=True) + return _spm(service_id) # real lib returns a fresh empty SPM on 404 + + def get_service_policies_by_role(self, role): + self.by_role_calls.append(role) + return [ + m.model_copy(deep=True) + for m in self.data.values() + if any(r.role.id == role.id for r in m.inbound_rules) + ] + + def apply_service_policy(self, service_id, spm): + self.service_writes.append((service_id, spm.model_copy(deep=True))) + self.data[service_id] = spm.model_copy(deep=True) + + def apply_policy(self, model): + self.policy_pushes.append(model.model_copy(deep=True)) + + def delete_service_policy(self, service_id): + self.service_deletes.append(service_id) + self.data.pop(service_id, None) + + def delete_agent_policy(self, agent_id): + self.agent_deletes.append(agent_id) + + # ---- assertion helpers ------------------------------------------------ # + @property + def apply_policy_count(self): + return len(self.policy_pushes) + + @property + def last_push(self): + return self.policy_pushes[-1] if self.policy_pushes else None + + def pushed_agent(self, agent_id): + """The most recent derived APM for ``agent_id`` across all pushes (last wins).""" + for push in reversed(self.policy_pushes): + for apm in push.agents: + if apm.agent_id == agent_id: + return apm + return None + + @property + def pushed_agent_ids(self): + return {a.agent_id for push in self.policy_pushes for a in push.agents} + + +@contextmanager +def engine_env(catalog, store): + """Patch the engine boundary; yield ``compute_and_apply``. Multiple calls share the store.""" + with ExitStack() as stack: + stack.enter_context(patch.dict(os.environ, {"KEYCLOAK_REALM": "test-realm"})) + stack.enter_context(patch.object(Configuration, "get_services", return_value=list(catalog))) + stack.enter_context(patch("aiac.policy.computation.engine.get_service_policy", + side_effect=store.get_service_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.get_service_policies_by_role", + side_effect=store.get_service_policies_by_role)) + stack.enter_context(patch("aiac.policy.computation.engine.apply_service_policy", + side_effect=store.apply_service_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.apply_policy", + side_effect=store.apply_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.delete_service_policy", + side_effect=store.delete_service_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.delete_agent_policy", + side_effect=store.delete_agent_policy)) + from aiac.policy.computation.engine import compute_and_apply + yield compute_and_apply + + +def run_engine(rules, *, catalog=None, store_initial=None, override=False) -> FakeStore: + store = FakeStore(store_initial) + with engine_env(catalog or [], store) as compute_and_apply: + compute_and_apply(rules, override=override) + return store + + +# --------------------------------------------------------------------------- # +# comparison helpers # +# --------------------------------------------------------------------------- # +def _pairs(rules): + return sorted((r.role.id, r.scope.id) for r in rules) + + +def _norm(apm): + """Order-independent view of an APM for equality assertions.""" + return { + "agent_roles": sorted(r.id for r in apm.agent_roles), + "agent_scopes": sorted(s.id for s in apm.agent_scopes), + "inbound": _pairs(apm.inbound_rules), + "outbound": _pairs(apm.outbound_rules), + "outbound_subject": _pairs(apm.outbound_subject_rules), + "source_roles": {k: sorted(r.id for r in v) for k, v in apm.source_roles.items()}, + "subject_roles": {k: sorted(r.id for r in v) for k, v in apm.subject_roles.items()}, + "target_scopes": {k: sorted(s.id for s in v) for k, v in apm.target_scopes.items()}, + } + + +# --------------------------------------------------------------------------- # +# shared repro fixture — the order-dependence scenario # +# UR (user role) -> AS (agent A's scope) and -> TS (tool T's scope) # +# AR (agent A's client role) -> TS # +# --------------------------------------------------------------------------- # +def _repro(): + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + TS = _scope("s-tool-read", "tool-read", service_id="github-tool") + catalog = [ + _agent("github-agent", roles=[AR], scopes=[AS]), + _tool("github-tool", scopes=[TS]), + ] + return AR, UR, AS, TS, catalog + + +# --------------------------------------------------------------------------- # +# Cycle 1 — tracer: a (user role, agent scope) rule lands as an inbound edge # +# on SPM(A); A's derived APM carries it inbound; the PDP is pushed once. # +# --------------------------------------------------------------------------- # +def test_user_role_agent_scope_lands_inbound_and_pushes_once(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(UR, AS)], catalog=catalog) + + # persisted on SPM(github-agent) + assert store.service_writes[0][0] == "github-agent" + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + # derived onto the agent's APM + apm = store.pushed_agent("github-agent") + assert _pairs(apm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert apm.subject_roles == {"dev-user": [UR]} + assert store.apply_policy_count == 1 + + +# --------------------------------------------------------------------------- # +# Cycle 2 — an (agent role, tool scope) rule is stored on SPM(T); A's derived # +# APM gains outbound_rules + a target_scopes entry for the tool. # +# --------------------------------------------------------------------------- # +def test_agent_role_tool_scope_derives_outbound_and_target_scopes(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(AR, TS)], catalog=catalog) + + assert _pairs(store.data["github-tool"].inbound_rules) == [("r-agent-src", "s-tool-read")] + apm = store.pushed_agent("github-agent") + assert _pairs(apm.outbound_rules) == [("r-agent-src", "s-tool-read")] + assert {k: [s.id for s in v] for k, v in apm.target_scopes.items()} == {"github-tool": ["s-tool-read"]} + + +# --------------------------------------------------------------------------- # +# Cycle 3 — a (user role, tool scope) rule, once an agent targets that tool, # +# becomes the agent's outbound subject gate. # +# --------------------------------------------------------------------------- # +def test_user_role_tool_scope_becomes_outbound_subject_gate(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(AR, TS), _rule(UR, TS)], catalog=catalog) + + apm = store.pushed_agent("github-agent") + assert _pairs(apm.outbound_subject_rules) == [("r-user-dev", "s-tool-read")] + assert apm.subject_roles == {"dev-user": [UR]} + + +# --------------------------------------------------------------------------- # +# Cycle 4 — HEADLINE: both onboarding orders converge to an identical APM(A). # +# --------------------------------------------------------------------------- # +def test_both_orders_yield_identical_agent_policy(): + AR, UR, AS, TS, catalog = _repro() + + # order A-then-T: agent onboarded (UR->AS), then tool onboarded (AR->TS, UR->TS) + store_at = FakeStore() + with engine_env(catalog, store_at) as compute: + compute([_rule(UR, AS)]) + compute([_rule(AR, TS), _rule(UR, TS)]) + + # order T-then-A: tool onboarded first, then agent + store_ta = FakeStore() + with engine_env(catalog, store_ta) as compute: + compute([_rule(AR, TS), _rule(UR, TS)]) + compute([_rule(UR, AS)]) + + apm_at = store_at.pushed_agent("github-agent") + apm_ta = store_ta.pushed_agent("github-agent") + assert _norm(apm_at) == _norm(apm_ta) + + # and it is the expected policy: inbound {UR->AS}, outbound {AR->TS} + subject gate {UR->TS} + assert _norm(apm_at)["inbound"] == [("r-user-dev", "s-agent-inbound")] + assert _norm(apm_at)["outbound"] == [("r-agent-src", "s-tool-read")] + assert _norm(apm_at)["outbound_subject"] == [("r-user-dev", "s-tool-read")] + + +# --------------------------------------------------------------------------- # +# Cycle 5 — latent sibling bug: after A+T exist, a late (UR2 -> TS) user-role # +# rule routes to SPM(T), marks A affected, and A's re-derived subject gate # +# includes UR2. # +# --------------------------------------------------------------------------- # +def test_late_user_role_on_tool_rederives_affected_agent_subject_gate(): + AR, UR, AS, TS, catalog = _repro() + store = FakeStore() + with engine_env(catalog, store) as compute: + compute([_rule(UR, AS), _rule(AR, TS), _rule(UR, TS)]) # A + T established + UR2 = _user_role("r-user-ops", "ops", users=["ops-user"]) + compute([_rule(UR2, TS)]) # late UC3 user role on the tool + + apm = store.pushed_agent("github-agent") + subject_pairs = _pairs(apm.outbound_subject_rules) + assert ("r-user-ops", "s-tool-read") in subject_pairs + assert "ops-user" in apm.subject_roles + + +# --------------------------------------------------------------------------- # +# Cycle 6 — agent -> agent (AR -> BS): stored on SPM(B); A's APM has it outbound # +# + target_scopes[B]; B's APM has source_roles[A] += AR. # +# --------------------------------------------------------------------------- # +def test_agent_to_agent_edge_projects_into_both_policies(): + AR = _agent_role("r-a-caller", "a-caller", owner="agent-a") + BS = _scope("s-b-inbound", "b-inbound", service_id="agent-b") + catalog = [ + _agent("agent-a", roles=[AR], scopes=[_scope("s-a-inbound", service_id="agent-a")]), + _agent("agent-b", scopes=[BS]), + ] + store = run_engine([_rule(AR, BS)], catalog=catalog) + + apm_a = store.pushed_agent("agent-a") + assert _pairs(apm_a.outbound_rules) == [("r-a-caller", "s-b-inbound")] + assert {k: [s.id for s in v] for k, v in apm_a.target_scopes.items()} == {"agent-b": ["s-b-inbound"]} + + apm_b = store.pushed_agent("agent-b") + assert {k: [r.id for r in v] for k, v in apm_b.source_roles.items()} == {"agent-a": ["r-a-caller"]} + + +# --------------------------------------------------------------------------- # +# Cycle 7 — override purge across SPMs: an input role present on multiple SPMs # +# is purged from every one of them, once, before the fresh rule is appended. # +# --------------------------------------------------------------------------- # +def test_override_purges_input_role_from_every_spm(): + shared = _user_role("r-shared", "shared", users=["u"]) + s1 = _scope("s-one", service_id="svc-one") + s2 = _scope("s-two", service_id="svc-two") + catalog = [_agent("svc-one", scopes=[s1]), _agent("svc-two", scopes=[s2])] + initial = { + "svc-one": _spm("svc-one", owned_scopes=[s1], inbound=[_rule(shared, s1)]), + "svc-two": _spm("svc-two", owned_scopes=[s2], inbound=[_rule(shared, s2)]), + } + # override with the same role targeting only svc-one now + store = run_engine([_rule(shared, s1)], catalog=catalog, store_initial=initial, override=True) + + # svc-two's stale mapping for the shared role is gone; svc-one keeps the fresh one + assert _pairs(store.data["svc-two"].inbound_rules) == [] + assert _pairs(store.data["svc-one"].inbound_rules) == [("r-shared", "s-one")] + # purge scanned by role, once for the single distinct input role + assert [r.id for r in store.by_role_calls].count("r-shared") == 1 + + +# --------------------------------------------------------------------------- # +# Cycle 8 — override, two input rules sharing one role: the role is purged once # +# up-front, so the SECOND rule's freshly-appended mapping is not wiped. # +# --------------------------------------------------------------------------- # +def test_override_shared_role_purged_once_second_mapping_survives(): + shared = _user_role("r-shared", "shared", users=["u"]) + s1 = _scope("s-one", service_id="svc-one") + s2 = _scope("s-two", service_id="svc-two") + catalog = [_agent("svc-one", scopes=[s1]), _agent("svc-two", scopes=[s2])] + initial = { + "svc-one": _spm("svc-one", owned_scopes=[s1], inbound=[_rule(shared, s1)]), + "svc-two": _spm("svc-two", owned_scopes=[s2]), + } + store = run_engine( + [_rule(shared, s1), _rule(shared, s2)], + catalog=catalog, store_initial=initial, override=True, + ) + + assert _pairs(store.data["svc-one"].inbound_rules) == [("r-shared", "s-one")] + assert _pairs(store.data["svc-two"].inbound_rules) == [("r-shared", "s-two")] # not wiped + + +# --------------------------------------------------------------------------- # +# Cycle 9 — append dedup: a rule already on the target SPM (same role.id + # +# scope.id) is not appended a second time. # +# --------------------------------------------------------------------------- # +def test_duplicate_rule_not_appended_twice(): + AR, UR, AS, TS, catalog = _repro() + initial = {"github-agent": _spm("github-agent", owned_scopes=[AS], inbound=[_rule(UR, AS)])} + store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) + + assert len(store.data["github-agent"].inbound_rules) == 1 + + +# --------------------------------------------------------------------------- # +# Cycle 10 — no flattening: a composite input role never triggers per-child # +# get_service_policies_by_role calls. # +# --------------------------------------------------------------------------- # +def test_composite_role_is_not_flattened(): + child_a = _agent_role("r-child-a", "child-a", owner="github-agent") + child_b = _agent_role("r-child-b", "child-b", owner="github-agent") + composite = _agent_role("r-comp", "composite", owner="github-agent") + composite = composite.model_copy(update={"composite": True, "childRoles": [child_a, child_b]}) + TS = _scope("s-tool-read", service_id="github-tool") + catalog = [_agent("github-agent", roles=[composite]), _tool("github-tool", scopes=[TS])] + + store = run_engine([_rule(composite, TS)], catalog=catalog, override=True) + + queried = {r.id for r in store.by_role_calls} + assert "r-child-a" not in queried and "r-child-b" not in queried + + +# --------------------------------------------------------------------------- # +# Cycle 11 — P4: a Tool accrues durable inbound_rules on its SPM but is never # +# emitted as an APM; the agent's target_scopes edge to the tool still appears. # +# --------------------------------------------------------------------------- # +def test_tool_gets_spm_but_no_apm(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(AR, TS)], catalog=catalog) + + assert _pairs(store.data["github-tool"].inbound_rules) == [("r-agent-src", "s-tool-read")] + assert "github-tool" not in store.pushed_agent_ids # no tool APM + apm = store.pushed_agent("github-agent") + assert "github-tool" in apm.target_scopes + + +# --------------------------------------------------------------------------- # +# Cycle 12 — P2 identity from owned_*: the derived APM embeds the agent's own # +# aiac.managed roles/scopes; built-ins are filtered; an agent with none keeps []. # +# --------------------------------------------------------------------------- # +def test_p2_identity_embeds_aiac_managed_owned_roles_and_scopes(): + helper = _agent_role("r-helper", "helper", owner="github-agent") + builtin = _agent_role("r-default", "default-roles-aiac", owner="github-agent") + builtin = builtin.model_copy(update={"attributes": {}}) # not aiac.managed + src = _scope("s-src", "source", service_id="github-agent") + profile = _scope("s-profile", "profile", service_id="github-agent", aiac_managed=False) + agent = _agent("github-agent", roles=[helper, builtin], scopes=[src, profile]) + UR = _user_role("r-user", users=["u"]) + store = run_engine([_rule(UR, src)], catalog=[agent]) + + apm = store.pushed_agent("github-agent") + assert [r.id for r in apm.agent_roles] == ["r-helper"] # built-in role dropped + assert [s.id for s in apm.agent_scopes] == ["s-src"] # profile scope dropped + + +def test_p2_identity_empty_when_no_owned_entities(): + agent = _agent("github-agent") # no catalog roles/scopes + UR = _user_role("r-user", users=["u"]) + store = run_engine([_rule(UR, _scope("s-x", service_id="github-agent"))], catalog=[agent]) + + apm = store.pushed_agent("github-agent") + assert apm.agent_roles == [] and apm.agent_scopes == [] + + +# --------------------------------------------------------------------------- # +# Cycle 13 — directional relevance: a user role shared between an agent scope # +# and a tool scope does NOT create a false outbound edge from A to the tool. # +# --------------------------------------------------------------------------- # +def test_shared_user_role_creates_no_false_outbound_edge(): + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", service_id="github-agent") + TS = _scope("s-tool-read", service_id="github-tool") + # A owns NO agent role that maps to TS — only the shared user role touches both scopes. + catalog = [_agent("github-agent", scopes=[AS]), _tool("github-tool", scopes=[TS])] + store = run_engine([_rule(UR, AS), _rule(UR, TS)], catalog=catalog) + + apm = store.pushed_agent("github-agent") + assert apm.outbound_rules == [] + assert apm.target_scopes == {} + assert apm.outbound_subject_rules == [] # A does not target T, so no gate + + +# --------------------------------------------------------------------------- # +# Cycle 13b — UC-1-shaped multi-role capability match: an agent owning TWO # +# operator roles reaching four tool scopes, with user edges on a subset, derives # +# BOTH outbound gates — the full agent->tool outbound_rules + target_scopes, and # +# the user->tool outbound_subject gate. This is what populates the per-scope AND. # +# --------------------------------------------------------------------------- # +def test_multi_role_capability_match_populates_both_outbound_gates(): + src_op = _agent_role("r-src-op", "source_operations", owner="github-agent") + issue_op = _agent_role("r-issue-op", "issue_operations", owner="github-agent") + developer = _user_role("r-developer", "developer", users=["dev-user"]) + tester = _user_role("r-tester", "tester", users=["test-user"]) + sr = _scope("s-source-read", service_id="github-tool") + sw = _scope("s-source-write", service_id="github-tool") + ir = _scope("s-issues-read", service_id="github-tool") + iw = _scope("s-issues-write", service_id="github-tool") + catalog = [ + _agent("github-agent", roles=[src_op, issue_op], + scopes=[_scope("s-agent-inbound", service_id="github-agent")]), + _tool("github-tool", scopes=[sr, sw, ir, iw]), + ] + rules = [ + # capability gate: each operator role -> its domain's tool scopes (capability-match) + _rule(src_op, sr), _rule(src_op, sw), + _rule(issue_op, ir), _rule(issue_op, iw), + # subject gate: user roles -> a subset of the tool scopes + _rule(developer, sr), _rule(developer, sw), _rule(developer, ir), + _rule(tester, ir), _rule(tester, iw), + ] + store = run_engine(rules, catalog=catalog) + + apm = store.pushed_agent("github-agent") + # capability gate: all four agent->tool edges + target_scopes covering all four scopes + assert _pairs(apm.outbound_rules) == sorted([ + ("r-src-op", "s-source-read"), ("r-src-op", "s-source-write"), + ("r-issue-op", "s-issues-read"), ("r-issue-op", "s-issues-write"), + ]) + assert {k: sorted(s.id for s in v) for k, v in apm.target_scopes.items()} == { + "github-tool": ["s-issues-read", "s-issues-write", "s-source-read", "s-source-write"], + } + # subject gate: the user->tool grant set (developer: source rw + issues read; tester: issues rw) + assert _pairs(apm.outbound_subject_rules) == sorted([ + ("r-developer", "s-source-read"), ("r-developer", "s-source-write"), + ("r-developer", "s-issues-read"), + ("r-tester", "s-issues-read"), ("r-tester", "s-issues-write"), + ]) + + +# --------------------------------------------------------------------------- # +# Cycle 14 — affected set from the batch: an agent unrelated to the batch is # +# never derived or upserted, even though it exists in the catalog/store. # +# --------------------------------------------------------------------------- # +def test_unrelated_agent_is_not_derived(): + AR, UR, AS, TS, catalog = _repro() + catalog = catalog + [_agent("other-agent", scopes=[_scope("s-other", service_id="other-agent")])] + store = run_engine([_rule(UR, AS)], catalog=catalog) + + assert store.pushed_agent_ids == {"github-agent"} + + +# --------------------------------------------------------------------------- # +# Cycle 15 — apply_policy is called exactly once, after every SPM write. # +# --------------------------------------------------------------------------- # +def test_apply_policy_called_exactly_once_after_all_spm_writes(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(UR, AS), _rule(AR, TS), _rule(UR, TS)], catalog=catalog) + + assert store.apply_policy_count == 1 + # both SPMs (agent + tool) were persisted before the single push + assert {sid for sid, _ in store.service_writes} == {"github-agent", "github-tool"} + + +# --------------------------------------------------------------------------- # +# Cycle 16 — a service absent from the store (404) is seeded from the catalog # +# and still persisted. # +# --------------------------------------------------------------------------- # +def test_absent_service_is_seeded_and_persisted(): + AR, UR, AS, TS, catalog = _repro() + store = run_engine([_rule(UR, AS)], catalog=catalog) # empty store -> 404 for both + + spm = store.data["github-agent"] + assert spm.service_type == ServiceType.AGENT + assert [r.id for r in spm.owned_roles] == ["r-agent-src"] # seeded from catalog + assert _pairs(spm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + + +# --------------------------------------------------------------------------- # +# Cycle 17 — a dependency failure is logged and RE-RAISED (not swallowed), so the # +# caller (Controller) surfaces it as a real error instead of a silent 200 with # +# nothing applied; nothing is pushed to the PDP. # +# --------------------------------------------------------------------------- # +def test_dependency_exception_propagates(caplog): + store = FakeStore() + with ExitStack() as stack: + stack.enter_context(patch.dict(os.environ, {"KEYCLOAK_REALM": "test-realm"})) + stack.enter_context(patch.object(Configuration, "get_services", side_effect=RuntimeError("boom"))) + stack.enter_context(patch("aiac.policy.computation.engine.get_service_policy", + side_effect=store.get_service_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.get_service_policies_by_role", + side_effect=store.get_service_policies_by_role)) + stack.enter_context(patch("aiac.policy.computation.engine.apply_service_policy", + side_effect=store.apply_service_policy)) + stack.enter_context(patch("aiac.policy.computation.engine.apply_policy", + side_effect=store.apply_policy)) + from aiac.policy.computation.engine import compute_and_apply + + UR = _user_role("r-user", users=["u"]) + with pytest.raises(RuntimeError, match="boom"): + compute_and_apply([_rule(UR, _scope("s-x", service_id="svc"))]) + assert store.apply_policy_count == 0 + assert "compute_and_apply failed" in caplog.text # still logged before re-raising + + +# --------------------------------------------------------------------------- # +# Reconcile — drift GC (Handoff 10). Keycloak UUIDs churn on delete/recreate, # +# so an append-only merge would grow stale edges beside their superseded # +# generations (issue 6.3 / RC-A: SPM(github-agent) held 53 edges). On every # +# compute the engine reconciles each TOUCHED SPM against the current # +# get_services() catalog (no extra IdP read), dropping dangling edges. It # +# removes only edges whose entity is gone, so live edges / order-independence # +# are untouched. # +# --------------------------------------------------------------------------- # +def test_reconcile_drops_retired_scope_edge(): + # A pre-fix ``*-aud`` scope no longer in the catalog is pruned on re-onboarding; the current + # edge survives. Reconcile alone changes the SPM, so it is (re-)persisted. + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + aud = _scope("s-aud", "agent-team1-github-agent-aud", service_id="github-agent") + catalog = [_agent("github-agent", scopes=[AS])] # ``aud`` no longer exists + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_rule(UR, aud), _rule(UR, AS)] + ) + } + store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) + + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + + +def test_reconcile_drops_churned_scope_uuid_same_name(): + # The scope was recreated with a fresh UUID (same name); the old-UUID edge is pruned. + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + as_v1 = _scope("s-as-v1", "agent-inbound", service_id="github-agent") + as_v2 = _scope("s-as-v2", "agent-inbound", service_id="github-agent") + catalog = [_agent("github-agent", scopes=[as_v2])] # only the current generation + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[as_v1], inbound=[_rule(UR, as_v1)] + ) + } + store = run_engine([_rule(UR, as_v2)], catalog=catalog, store_initial=initial) + + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-as-v2")] + + +def test_reconcile_collapses_churned_duplicate_user_role(): + # Two same-name/different-id ``developer`` edges on one scope (Keycloak delete+recreate). The + # batch carries the current generation, so the old-generation edge is dropped; the derived APM's + # subject gate then names only the current role. + dev_old = _user_role("r-dev-v1", "developer", users=["dev-user"]) + dev_new = _user_role("r-dev-v2", "developer", users=["dev-user"]) + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + catalog = [_agent("github-agent", scopes=[AS])] + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_rule(dev_old, AS), _rule(dev_new, AS)] + ) + } + store = run_engine([_rule(dev_new, AS)], catalog=catalog, store_initial=initial) + + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-dev-v2", "s-agent-inbound")] + apm = store.pushed_agent("github-agent") + assert apm.subject_roles == {"dev-user": [dev_new]} + + +def test_reconcile_drops_retired_agent_role_self_reference(): + # An impossible focus-agent self-reference (an Agent-kind role the current builder can no longer + # emit) references a role id absent from the catalog — pruned. + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + AR = _agent_role("r-agent-src", "agent-source", owner="github-agent") # current agent role + selfref = _agent_role("r-selfref", "github-agent.agent", owner="github-agent") # retired + AS = _scope("s-agent-inbound", "agent-inbound", service_id="github-agent") + catalog = [_agent("github-agent", roles=[AR], scopes=[AS])] # r-selfref not present + initial = { + "github-agent": _spm( + "github-agent", owned_scopes=[AS], inbound=[_rule(selfref, AS), _rule(UR, AS)] + ) + } + store = run_engine([_rule(UR, AS)], catalog=catalog, store_initial=initial) + + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + + +def test_reconcile_preserves_live_edges_and_is_idempotent(): + # Re-onboarding a service whose edges are all current prunes nothing (order-independence): the + # canonical repro's full edge set survives a second identical compute. + AR, UR, AS, TS, catalog = _repro() + initial = { + "github-agent": _spm("github-agent", owned_scopes=[AS], inbound=[_rule(UR, AS)]), + "github-tool": _spm( + "github-tool", type=ServiceType.TOOL, owned_scopes=[TS], + inbound=[_rule(AR, TS), _rule(UR, TS)], + ), + } + store = run_engine( + [_rule(UR, AS), _rule(AR, TS), _rule(UR, TS)], catalog=catalog, store_initial=initial + ) + + assert _pairs(store.data["github-agent"].inbound_rules) == [("r-user-dev", "s-agent-inbound")] + assert _pairs(store.data["github-tool"].inbound_rules) == sorted( + [("r-agent-src", "s-tool-read"), ("r-user-dev", "s-tool-read")] + ) + + +def test_reconcile_skips_when_service_absent_from_catalog(): + # A transient catalog miss (owning service not returned by get_services()) must never wipe an + # SPM — reconcile is skipped and the stale edge is left intact rather than dropped. + UR = _user_role("r-user-dev", "developer", users=["dev-user"]) + orphan_scope = _scope("s-orphan", "orphan", service_id="orphan") + initial = { + "orphan": _spm("orphan", owned_scopes=[orphan_scope], inbound=[_rule(UR, orphan_scope)]) + } + store = run_engine([_rule(UR, orphan_scope)], catalog=[], store_initial=initial) + + assert _pairs(store.data["orphan"].inbound_rules) == [("r-user-dev", "s-orphan")] + + +# --------------------------------------------------------------------------- # +# decommission (service offboard) — the onboard→offboard drift case (case 3). # +# Reconcile's catalog-anchored GC skips a decommissioned service (absent from # +# get_services()); decommission() is the authoritative teardown: delete SPM(X), # +# purge X's outbound footprint from other SPMs, delete APM(X) if X is an agent, # +# and re-derive every agent whose policy changed. Two-phase: onboard the repro # +# into a shared store, then decommission against a catalog with X removed. # +# --------------------------------------------------------------------------- # +def _onboard_repro(store): + """Onboard the canonical repro into ``store`` (mutates it); return ``(AR, UR, AS, TS)``.""" + AR, UR, AS, TS, catalog = _repro() + with engine_env(catalog, store) as compute_and_apply: + compute_and_apply([_rule(UR, AS), _rule(AR, TS), _rule(UR, TS)]) + return AR, UR, AS, TS + + +def run_decommission(service_id, *, catalog, store) -> FakeStore: + with engine_env(catalog, store): + from aiac.policy.computation.engine import decommission + + decommission(service_id) + return store + + +def test_decommission_tool_strands_no_edges_and_rederives_agent(): + # Onboard agent A (targets tool T), then offboard T. SPM(T) is deleted (with its user→T and + # agent→T inbound edges); A is re-derived with its outbound to T dropped, its own inbound intact. + store = FakeStore() + _onboard_repro(store) + # sanity: onboarding gave A an outbound edge to the tool. + assert _pairs(store.pushed_agent("github-agent").outbound_rules) == [("r-agent-src", "s-tool-read")] + + # Phase 2: the tool is gone from the catalog (its Keycloak client was deleted). + run_decommission("github-tool", catalog=[_agent("github-agent", scopes=[])], store=store) + + # SPM(T) deleted; a tool has no APM, so no PDP agent-delete. + assert "github-tool" in store.service_deletes + assert "github-tool" not in store.data + assert store.agent_deletes == [] + + # A re-derived: outbound to the tool is stranded (edge lived on SPM(T)); inbound UR→AS survives. + apm = store.pushed_agent("github-agent") + assert apm.outbound_rules == [] + assert apm.target_scopes == {} + assert apm.outbound_subject_rules == [] + assert _pairs(apm.inbound_rules) == [("r-user-dev", "s-agent-inbound")] + + +def test_decommission_agent_deletes_apm_and_purges_outbound_footprint(): + # Offboard the agent A itself. SPM(A) is deleted, APM(A) is deleted from the PDP, and A's + # outbound footprint (AR→TS stored on SPM(T)) is purged while the tool keeps its user grant. + store = FakeStore() + _onboard_repro(store) + pushes_before = len(store.policy_pushes) + + # Phase 2: the agent is gone from the catalog. + run_decommission("github-agent", catalog=[_tool("github-tool", scopes=[])], store=store) + + # SPM(A) deleted and APM(A) removed from the PDP. + assert "github-agent" in store.service_deletes + assert "github-agent" not in store.data + assert store.agent_deletes == ["github-agent"] + + # A's outbound footprint purged from the tool; the tool keeps its user→TS grant. + assert _pairs(store.data["github-tool"].inbound_rules) == [("r-user-dev", "s-tool-read")] + + # No APM re-derived for the deleted agent (nothing targeted it) — no new push. + assert len(store.policy_pushes) == pushes_before diff --git a/aiac/test/policy/model/__init__.py b/aiac/test/policy/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/policy/model/test_models.py b/aiac/test/policy/model/test_models.py new file mode 100644 index 000000000..4c009c45b --- /dev/null +++ b/aiac/test/policy/model/test_models.py @@ -0,0 +1,319 @@ +import pytest +from pydantic import ValidationError + +from aiac.idp.configuration.models import ( + Role, + RoleKind, + Scope, + Service, + ServiceType, + Subject, +) +from aiac.policy.model.models import ( + AgentPolicyModel, + PolicyModel, + PolicyRule, + ServicePolicyModel, +) + + +def _role(id: str = "role-1", name: str = "admin") -> Role: + return Role(id=id, name=name, composite=False) + + +def _scope(id: str = "scope-1", name: str = "read") -> Scope: + return Scope(id=id, name=name) + + +def _service(id: str = "svc-1", service_id: str = "my-service") -> Service: + return Service(id=id, serviceId=service_id, enabled=True) + + +def _subject(id: str = "sub-1", username: str = "alice") -> Subject: + return Subject(id=id, username=username, enabled=True) + + +# --- RoleKind enum + Role.kind / Role.actorIds (SPM redesign) --- + + +def test_role_kind_values_mirror_service_type_style(): + assert RoleKind.USER == "User" + assert RoleKind.AGENT == "Agent" + + +def test_role_accepts_kind_and_actor_ids(): + role = Role( + id="r1", + name="weather-reader", + composite=False, + kind=RoleKind.AGENT, + actorIds=["weather-agent"], + ) + assert role.kind == RoleKind.AGENT + assert role.actorIds == ["weather-agent"] + + +def test_role_kind_and_actor_ids_round_trip(): + role = Role( + id="r1", + name="reader", + composite=False, + kind=RoleKind.USER, + actorIds=["alice", "bob"], + ) + restored = Role.model_validate(role.model_dump(mode="json")) + assert restored.kind == RoleKind.USER + assert restored.actorIds == ["alice", "bob"] + + +def test_role_rejects_malformed_kind(): + with pytest.raises(ValidationError): + Role(id="r1", name="reader", composite=False, kind="Bogus") + + +def test_role_rejects_non_list_actor_ids(): + with pytest.raises(ValidationError): + Role( + id="r1", + name="reader", + composite=False, + kind=RoleKind.USER, + actorIds="alice", + ) + + +# --- Scope.serviceId (SPM routing key) --- + + +def test_scope_accepts_service_id(): + scope = Scope(id="s1", name="read", serviceId="github-tool") + assert scope.serviceId == "github-tool" + + +def test_scope_service_id_round_trip(): + scope = Scope(id="s1", name="read", serviceId="github-tool") + restored = Scope.model_validate(scope.model_dump(mode="json")) + assert restored.serviceId == "github-tool" + + +# --- ServicePolicyModel (persistent source of truth) --- + + +def test_service_policy_model_constructs(): + role = _role() + scope = _scope() + spm = ServicePolicyModel( + service_id="github-tool", + service_type=ServiceType.TOOL, + owned_roles=[role], + owned_scopes=[scope], + inbound_rules=[PolicyRule(role=role, scope=scope)], + ) + assert spm.service_id == "github-tool" + assert spm.service_type == ServiceType.TOOL + assert spm.owned_roles == [role] + assert spm.owned_scopes == [scope] + assert spm.inbound_rules == [PolicyRule(role=role, scope=scope)] + + +def test_service_policy_model_round_trip_string_keys_only(): + role = _role() + scope = _scope() + spm = ServicePolicyModel( + service_id="weather-agent", + service_type=ServiceType.AGENT, + owned_roles=[role], + owned_scopes=[scope], + inbound_rules=[PolicyRule(role=role, scope=scope)], + ) + dumped = spm.model_dump(mode="json") + assert all(isinstance(k, str) for k in dumped.keys()) + restored = ServicePolicyModel.model_validate(dumped) + assert restored == spm + + +def test_service_policy_model_ignores_extra_fields(): + spm = ServicePolicyModel.model_validate( + { + "service_id": "svc", + "service_type": "Tool", + "owned_roles": [], + "owned_scopes": [], + "inbound_rules": [], + "unknown_field": "ignored", + } + ) + assert not hasattr(spm, "unknown_field") + + +# --- PolicyRule construction --- + + +def test_policy_rule_with_typed_role_and_scope(): + role = _role() + scope = _scope() + rule = PolicyRule(role=role, scope=scope) + assert rule.role == role + assert rule.scope == scope + + +def test_policy_rule_rejects_plain_str_role(): + with pytest.raises(ValidationError): + PolicyRule(role="admin", scope=_scope()) + + +def test_policy_rule_rejects_plain_str_scope(): + with pytest.raises(ValidationError): + PolicyRule(role=_role(), scope="read") + + +# --- AgentPolicyModel relationship maps keyed by string id --- + + +def test_agent_policy_model_source_roles_keyed_by_service_id(): + svc = _service() + role = _role() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[role], + agent_scopes=[], + subject_roles={}, + source_roles={svc.id: [role]}, + target_scopes={}, + inbound_rules=[], + outbound_rules=[], + ) + dumped = model.model_dump(mode="json") + assert list(dumped["source_roles"].keys()) == [svc.id] + + +def test_agent_policy_model_target_scopes_keyed_by_target_id(): + scope = _scope() + svc = _service() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[scope], + subject_roles={}, + source_roles={}, + target_scopes={svc.id: [scope]}, + inbound_rules=[], + outbound_rules=[], + ) + dumped = model.model_dump(mode="json") + assert list(dumped["target_scopes"].keys()) == [svc.id] + + +def test_agent_policy_model_subject_roles_keyed_by_subject_id(): + subject = _subject() + role = _role() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[], + subject_roles={subject.id: [role]}, + source_roles={}, + target_scopes={}, + inbound_rules=[], + outbound_rules=[], + ) + dumped = model.model_dump(mode="json") + assert list(dumped["subject_roles"].keys()) == [subject.id] + + +# --- outbound_subject_rules (user role -> tool scope) --- + + +def test_agent_policy_model_outbound_subject_rules_defaults_empty(): + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[], + subject_roles={}, + source_roles={}, + target_scopes={}, + inbound_rules=[], + outbound_rules=[], + ) + assert model.outbound_subject_rules == [] + + +def test_agent_policy_model_outbound_subject_rules_round_trip(): + role = _role() + scope = _scope() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[], + agent_scopes=[], + subject_roles={}, + source_roles={}, + target_scopes={}, + inbound_rules=[], + outbound_rules=[], + outbound_subject_rules=[PolicyRule(role=role, scope=scope)], + ) + restored = AgentPolicyModel.model_validate(model.model_dump(mode="json")) + assert restored.outbound_subject_rules == [PolicyRule(role=role, scope=scope)] + + +# --- model_validate round-trip (JSON mode) --- + + +def test_agent_policy_model_round_trip(): + subject = _subject() + role = _role() + scope = _scope() + svc = _service() + model = AgentPolicyModel( + agent_id="agent-1", + agent_roles=[role], + agent_scopes=[scope], + subject_roles={subject.id: [role]}, + source_roles={svc.id: [role]}, + target_scopes={svc.id: [scope]}, + inbound_rules=[PolicyRule(role=role, scope=scope)], + outbound_rules=[], + ) + dumped = model.model_dump(mode="json") + restored = AgentPolicyModel.model_validate(dumped) + assert restored == model + + +# --- extra='ignore' on all three model types --- + + +def test_policy_rule_ignores_extra_fields(): + role = _role() + scope = _scope() + rule = PolicyRule.model_validate({"role": role.model_dump(), "scope": scope.model_dump(), "unknown": "x"}) + assert not hasattr(rule, "unknown") + + +def test_agent_policy_model_ignores_extra_fields(): + model = AgentPolicyModel.model_validate( + { + "agent_id": "a", + "agent_roles": [], + "agent_scopes": [], + "subject_roles": {}, + "source_roles": {}, + "target_scopes": {}, + "inbound_rules": [], + "outbound_rules": [], + "unknown_field": "ignored", + } + ) + assert not hasattr(model, "unknown_field") + + +def test_policy_model_ignores_extra_fields(): + model = PolicyModel.model_validate({"agents": [], "extra_key": "ignored"}) + assert not hasattr(model, "extra_key") + + +# --- Empty PolicyModel --- + + +def test_policy_model_empty(): + model = PolicyModel(agents=[]) + assert model.agents == [] diff --git a/aiac/test/policy/store/__init__.py b/aiac/test/policy/store/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/policy/store/library/__init__.py b/aiac/test/policy/store/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/policy/store/library/test_api.py b/aiac/test/policy/store/library/test_api.py new file mode 100644 index 000000000..b2e9e20e7 --- /dev/null +++ b/aiac/test/policy/store/library/test_api.py @@ -0,0 +1,297 @@ +"""Unit tests for aiac/policy/store/library/api.py. + +SPM-centric HTTP client. Mocks the ``requests`` layer — no live Policy Store. The store +persists ``ServicePolicyModel`` only; there are no per-agent or whole-collection functions. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from aiac.idp.configuration.models import Role, Scope, ServiceType +from aiac.policy.model.models import PolicyRule, ServicePolicyModel +from aiac.policy.store.keying import encode_service_id +from aiac.policy.store.library.api import _HTTP_TIMEOUT + +BASE_URL = "http://127.0.0.1:7074" + + +def _spm_dict(service_id: str = "svc-1", role_id: str = "role-1") -> dict: + return ServicePolicyModel( + service_id=service_id, + service_type=ServiceType.AGENT, + owned_roles=[], + owned_scopes=[], + inbound_rules=[ + PolicyRule( + role=Role(id=role_id, name="admin", composite=False), + scope=Scope(id="scope-1", name="read", serviceId=service_id), + ) + ], + ).model_dump(mode="json") + + +def _mock_response(status_code: int, json_data=None) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.ok = 200 <= status_code < 300 + if json_data is not None: + resp.json.return_value = json_data + return resp + + +# --------------------------------------------------------------------------- +# get_service_policy (by-id) +# --------------------------------------------------------------------------- + + +class TestGetServicePolicy: + def test_by_id_hit_returns_spm(self): + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, _spm_dict("svc-1")) + from aiac.policy.store.library.api import get_service_policy + + result = get_service_policy("svc-1") + mock_get.assert_called_once_with( + f"{BASE_URL}/policy/services/{encode_service_id('svc-1')}", timeout=_HTTP_TIMEOUT + ) + assert isinstance(result, ServicePolicyModel) + assert result.service_id == "svc-1" + + def test_by_id_miss_returns_fresh_empty_spm_no_raise(self): + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(404) + from aiac.policy.store.library.api import get_service_policy + + result = get_service_policy("brand-new") + assert isinstance(result, ServicePolicyModel) + assert result.service_id == "brand-new" + assert result.owned_roles == [] + assert result.owned_scopes == [] + assert result.inbound_rules == [] + + def test_raises_on_other_error_response(self): + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(500) + from aiac.policy.store.library.api import get_service_policy + + with pytest.raises(RuntimeError): + get_service_policy("svc-1") + + @pytest.mark.parametrize( + "service_id", + ["team1/github-agent", "spiffe://localtest.me/ns/team1/sa/github-agent"], + ) + def test_slash_bearing_id_encoded_in_path(self, service_id): + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, _spm_dict(service_id)) + from aiac.policy.store.library.api import get_service_policy + + get_service_policy(service_id) + called_url = mock_get.call_args[0][0] + path_segment = called_url.rsplit("/", 1)[-1] + assert "/" not in path_segment + assert called_url == f"{BASE_URL}/policy/services/{encode_service_id(service_id)}" + + +# --------------------------------------------------------------------------- +# get_service_policy_by_scope +# --------------------------------------------------------------------------- + + +class TestGetServicePolicyByScope: + def test_resolves_via_scope_service_id(self): + scope = Scope(id="scope-1", name="read", serviceId="owning-svc") + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, _spm_dict("owning-svc")) + from aiac.policy.store.library.api import get_service_policy_by_scope + + result = get_service_policy_by_scope(scope) + mock_get.assert_called_once_with( + f"{BASE_URL}/policy/services/{encode_service_id('owning-svc')}", timeout=_HTTP_TIMEOUT + ) + assert result is not None + assert result.service_id == "owning-svc" + + def test_returns_none_when_scope_has_no_owner(self): + scope = Scope(id="scope-1", name="read") # serviceId defaults to "" + with patch("requests.get") as mock_get: + from aiac.policy.store.library.api import get_service_policy_by_scope + + result = get_service_policy_by_scope(scope) + assert result is None + mock_get.assert_not_called() + + def test_raises_on_non_404_error(self): + scope = Scope(id="scope-1", name="read", serviceId="owning-svc") + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(500) + from aiac.policy.store.library.api import get_service_policy_by_scope + + with pytest.raises(RuntimeError): + get_service_policy_by_scope(scope) + + +# --------------------------------------------------------------------------- +# get_service_policies_by_role +# --------------------------------------------------------------------------- + + +class TestGetServicePoliciesByRole: + def test_by_role_hit_returns_matching_spm(self): + role = Role(id="user-role", name="reader", composite=False) + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, [_spm_dict("svc-a", "user-role")]) + from aiac.policy.store.library.api import get_service_policies_by_role + + result = get_service_policies_by_role(role) + mock_get.assert_called_once_with( + f"{BASE_URL}/policy/services", params={"role": "user-role"}, timeout=_HTTP_TIMEOUT + ) + assert [s.service_id for s in result] == ["svc-a"] + + def test_by_role_multiple_returns_all(self): + role = Role(id="shared", name="reader", composite=False) + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, [_spm_dict("svc-a", "shared"), _spm_dict("svc-b", "shared")]) + from aiac.policy.store.library.api import get_service_policies_by_role + + result = get_service_policies_by_role(role) + assert sorted(s.service_id for s in result) == ["svc-a", "svc-b"] + + def test_by_role_miss_returns_empty_list(self): + role = Role(id="nobody", name="reader", composite=False) + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, []) + from aiac.policy.store.library.api import get_service_policies_by_role + + assert get_service_policies_by_role(role) == [] + + def test_raises_on_error_response(self): + role = Role(id="user-role", name="reader", composite=False) + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(500) + from aiac.policy.store.library.api import get_service_policies_by_role + + with pytest.raises(RuntimeError): + get_service_policies_by_role(role) + + +# --------------------------------------------------------------------------- +# apply_service_policy (upsert) +# --------------------------------------------------------------------------- + + +class TestApplyServicePolicy: + def test_posts_serialized_spm_upsert(self): + spm = ServicePolicyModel.model_validate(_spm_dict("svc-1")) + with patch("requests.post") as mock_post: + mock_post.return_value = _mock_response(204) + from aiac.policy.store.library.api import apply_service_policy + + result = apply_service_policy("svc-1", spm) + mock_post.assert_called_once_with( + f"{BASE_URL}/policy/services/{encode_service_id('svc-1')}", + json=spm.model_dump(), + timeout=_HTTP_TIMEOUT, + ) + assert result is None + + def test_raises_on_error_response(self): + spm = ServicePolicyModel.model_validate(_spm_dict("svc-1")) + with patch("requests.post") as mock_post: + mock_post.return_value = _mock_response(502) + from aiac.policy.store.library.api import apply_service_policy + + with pytest.raises(RuntimeError): + apply_service_policy("svc-1", spm) + + +# --------------------------------------------------------------------------- +# delete_service_policy +# --------------------------------------------------------------------------- + + +class TestDeleteServicePolicy: + def test_deletes_service_policy(self): + with patch("requests.delete") as mock_delete: + mock_delete.return_value = _mock_response(204) + from aiac.policy.store.library.api import delete_service_policy + + result = delete_service_policy("svc-1") + mock_delete.assert_called_once_with( + f"{BASE_URL}/policy/services/{encode_service_id('svc-1')}", timeout=_HTTP_TIMEOUT + ) + assert result is None + + def test_raises_on_error_response(self): + with patch("requests.delete") as mock_delete: + mock_delete.return_value = _mock_response(502) + from aiac.policy.store.library.api import delete_service_policy + + with pytest.raises(RuntimeError): + delete_service_policy("svc-1") + + +# --------------------------------------------------------------------------- +# clear_service_policies (collection-root DELETE) +# --------------------------------------------------------------------------- + + +class TestClearServicePolicies: + def test_deletes_collection_root_no_id_segment(self): + with patch("requests.delete") as mock_delete: + mock_delete.return_value = _mock_response(204) + from aiac.policy.store.library.api import clear_service_policies + + result = clear_service_policies() + mock_delete.assert_called_once_with(f"{BASE_URL}/policy/services", timeout=_HTTP_TIMEOUT) + assert result is None + + def test_raises_on_error_response(self): + with patch("requests.delete") as mock_delete: + mock_delete.return_value = _mock_response(502) + from aiac.policy.store.library.api import clear_service_policies + + with pytest.raises(RuntimeError): + clear_service_policies() + + +# --------------------------------------------------------------------------- +# Removed surface: no per-agent / whole-collection functions +# --------------------------------------------------------------------------- + + +class TestRemovedFunctions: + def test_no_agent_or_collection_functions(self): + import aiac.policy.store.library.api as api + + for removed in ( + "get_policy", + "apply_policy", + "delete_policy", + "get_agent_policy", + "apply_agent_policy", + "delete_agent_policy", + ): + assert not hasattr(api, removed), f"{removed} should be removed" + + +# --------------------------------------------------------------------------- +# URL fallback +# --------------------------------------------------------------------------- + + +class TestUrlFallback: + def test_defaults_to_localhost_7074_when_env_unset(self): + import os + + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("AIAC_POLICY_STORE_URL", None) + with patch("requests.get") as mock_get: + mock_get.return_value = _mock_response(200, _spm_dict("svc-1")) + from aiac.policy.store.library.api import get_service_policy + + get_service_policy("svc-1") + call_url = mock_get.call_args[0][0] + assert call_url == f"http://127.0.0.1:7074/policy/services/{encode_service_id('svc-1')}" diff --git a/aiac/test/policy/store/service/__init__.py b/aiac/test/policy/store/service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aiac/test/policy/store/service/test_main.py b/aiac/test/policy/store/service/test_main.py new file mode 100644 index 000000000..68de971e2 --- /dev/null +++ b/aiac/test/policy/store/service/test_main.py @@ -0,0 +1,301 @@ +"""Unit tests for aiac/policy/store/service/main.py FastAPI application. + +SPM-centric surface: the store persists ``ServicePolicyModel`` rows keyed by +``service_id``. ``AgentPolicyModel`` is derived and never persisted, so there are no +per-agent or whole-collection endpoints. Seam: an in-memory SQLite connection is injected +on startup instead of opening ``SERVICEPOLICY_DB_PATH``. +""" + +import sqlite3 +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +import aiac.policy.store.service.main as svc +from aiac.idp.configuration.models import Role, Scope, ServiceType +from aiac.policy.model.models import PolicyRule, ServicePolicyModel +from aiac.policy.store.keying import encode_service_id +from aiac.policy.store.service.main import app, get_db + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + + +def _role(id: str = "role-1", name: str = "admin") -> Role: + return Role(id=id, name=name, composite=False) + + +def _scope(id: str = "scope-1", name: str = "read", service_id: str = "my-service") -> Scope: + return Scope(id=id, name=name, serviceId=service_id) + + +def _spm( + service_id: str = "my-service", + role_id: str = "role-1", + service_type: ServiceType = ServiceType.AGENT, +) -> ServicePolicyModel: + return ServicePolicyModel( + service_id=service_id, + service_type=service_type, + owned_roles=[_role()], + owned_scopes=[_scope(service_id=service_id)], + inbound_rules=[PolicyRule(role=_role(id=role_id), scope=_scope(service_id=service_id))], + ) + + +@pytest.fixture +def client(): + """In-memory SQLite DB injected; lifespan bypassed.""" + conn = sqlite3.connect(":memory:", check_same_thread=False, isolation_level=None) + svc._init_db(conn) + svc._db_conn = conn + svc._cache = {} + app.dependency_overrides[get_db] = lambda: conn + yield TestClient(app) + app.dependency_overrides.clear() + conn.close() + svc._db_conn = None + svc._cache = {} + + +def _preload(spm: ServicePolicyModel) -> None: + """Insert an SPM directly into the current DB + cache (simulating prior state).""" + svc._db_conn.execute( + "INSERT OR REPLACE INTO service_policies (service_id, spec) VALUES (?, ?)", + (spm.service_id, spm.model_dump_json()), + ) + svc._cache[spm.service_id] = spm + + +# --------------------------------------------------------------------------- +# Startup: cache population from SQLite +# --------------------------------------------------------------------------- + + +class TestStartup: + def test_load_cache_populates_from_db_rows(self): + conn = sqlite3.connect(":memory:", check_same_thread=False, isolation_level=None) + svc._init_db(conn) + spm = _spm("weather-service") + conn.execute( + "INSERT INTO service_policies (service_id, spec) VALUES (?, ?)", + ("weather-service", spm.model_dump_json()), + ) + + svc._load_cache(conn) + + assert "weather-service" in svc._cache + assert svc._cache["weather-service"].service_id == "weather-service" + conn.close() + svc._cache = {} + + def test_load_cache_empty_when_db_empty(self): + conn = sqlite3.connect(":memory:", check_same_thread=False, isolation_level=None) + svc._init_db(conn) + + svc._load_cache(conn) + + assert svc._cache == {} + conn.close() + svc._cache = {} + + +# --------------------------------------------------------------------------- +# GET /policy/services/{service_id} (by-id) +# --------------------------------------------------------------------------- + + +class TestGetServicePolicy: + def test_returns_spm_when_in_cache(self, client): + _preload(_spm("weather-service")) + resp = client.get(f"/policy/services/{encode_service_id('weather-service')}") + assert resp.status_code == 200 + assert resp.json()["service_id"] == "weather-service" + + def test_returns_404_when_not_in_cache(self, client): + resp = client.get(f"/policy/services/{encode_service_id('missing-service')}") + assert resp.status_code == 404 + assert resp.json() == {"error": "service missing-service not found"} + + def test_get_after_post_returns_updated_value_from_cache(self, client): + client.post(f"/policy/services/{encode_service_id('svc-x')}", json=_spm("svc-x").model_dump()) + resp = client.get(f"/policy/services/{encode_service_id('svc-x')}") + assert resp.status_code == 200 + assert resp.json()["service_id"] == "svc-x" + + def test_slash_bearing_id_round_trips_via_encoded_path(self, client): + service_id = "team1/github-agent" + client.post(f"/policy/services/{encode_service_id(service_id)}", json=_spm(service_id).model_dump()) + resp = client.get(f"/policy/services/{encode_service_id(service_id)}") + assert resp.status_code == 200 + assert resp.json()["service_id"] == service_id + assert service_id in svc._cache + row = svc._db_conn.execute( + "SELECT service_id FROM service_policies WHERE service_id = ?", (service_id,) + ).fetchone() + assert row is not None + + +# --------------------------------------------------------------------------- +# GET /policy/services?role={role_id} (by-role) +# --------------------------------------------------------------------------- + + +class TestGetServicePoliciesByRole: + def test_returns_single_spm_referencing_role(self, client): + _preload(_spm("svc-a", role_id="user-role")) + _preload(_spm("svc-b", role_id="other-role")) + resp = client.get("/policy/services", params={"role": "user-role"}) + assert resp.status_code == 200 + body = resp.json() + assert [s["service_id"] for s in body] == ["svc-a"] + + def test_returns_all_spms_when_several_reference_role(self, client): + _preload(_spm("svc-a", role_id="shared-role")) + _preload(_spm("svc-b", role_id="shared-role")) + _preload(_spm("svc-c", role_id="other-role")) + resp = client.get("/policy/services", params={"role": "shared-role"}) + assert resp.status_code == 200 + ids = sorted(s["service_id"] for s in resp.json()) + assert ids == ["svc-a", "svc-b"] + + def test_returns_empty_list_when_no_spm_references_role(self, client): + _preload(_spm("svc-a", role_id="user-role")) + resp = client.get("/policy/services", params={"role": "nobody"}) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_returns_empty_list_when_cache_empty(self, client): + resp = client.get("/policy/services", params={"role": "anything"}) + assert resp.status_code == 200 + assert resp.json() == [] + + +# --------------------------------------------------------------------------- +# POST /policy/services/{service_id} (upsert) +# --------------------------------------------------------------------------- + + +class TestUpsertServicePolicy: + def test_writes_to_db_updates_cache_returns_204(self, client): + resp = client.post(f"/policy/services/{encode_service_id('svc-1')}", json=_spm("svc-1").model_dump()) + assert resp.status_code == 204 + assert "svc-1" in svc._cache + row = svc._db_conn.execute("SELECT spec FROM service_policies WHERE service_id = ?", ("svc-1",)).fetchone() + assert row is not None + + def test_repeat_post_replaces_row_upsert_round_trip(self, client): + encoded = encode_service_id("svc-1") + client.post(f"/policy/services/{encoded}", json=_spm("svc-1", role_id="role-a").model_dump()) + client.post(f"/policy/services/{encoded}", json=_spm("svc-1", role_id="role-b").model_dump()) + + rows = svc._db_conn.execute( + "SELECT service_id FROM service_policies WHERE service_id = ?", ("svc-1",) + ).fetchall() + assert len(rows) == 1 # replaced, not duplicated + + # The stored/cached SPM now carries the second write's rule. + resp = client.get(f"/policy/services/{encoded}") + rule_role_ids = [r["role"]["id"] for r in resp.json()["inbound_rules"]] + assert rule_role_ids == ["role-b"] + + def test_returns_502_on_sqlite_error(self, client): + bad_conn = MagicMock() + bad_conn.execute.side_effect = sqlite3.OperationalError("disk full") + app.dependency_overrides[get_db] = lambda: bad_conn + resp = client.post(f"/policy/services/{encode_service_id('svc-err')}", json=_spm("svc-err").model_dump()) + assert resp.status_code == 502 + assert "error" in resp.json() + + +# --------------------------------------------------------------------------- +# DELETE /policy/services/{service_id} +# --------------------------------------------------------------------------- + + +class TestDeleteServicePolicy: + def test_removes_row_from_db_and_cache_returns_204(self, client): + _preload(_spm("svc-1")) + resp = client.delete(f"/policy/services/{encode_service_id('svc-1')}") + assert resp.status_code == 204 + assert "svc-1" not in svc._cache + row = svc._db_conn.execute( + "SELECT service_id FROM service_policies WHERE service_id = ?", ("svc-1",) + ).fetchone() + assert row is None + + def test_returns_502_on_sqlite_error(self, client): + bad_conn = MagicMock() + bad_conn.execute.side_effect = sqlite3.OperationalError("disk full") + app.dependency_overrides[get_db] = lambda: bad_conn + resp = client.delete(f"/policy/services/{encode_service_id('svc-1')}") + assert resp.status_code == 502 + assert "error" in resp.json() + + +# --------------------------------------------------------------------------- +# DELETE /policy/services (clear-all) +# --------------------------------------------------------------------------- + + +class TestClearServicePolicies: + def test_clears_all_rows_from_db_and_cache_returns_204(self, client): + _preload(_spm("svc-a")) + _preload(_spm("svc-b")) + resp = client.delete("/policy/services") + assert resp.status_code == 204 + assert svc._cache == {} + rows = svc._db_conn.execute("SELECT service_id FROM service_policies").fetchall() + assert rows == [] + + def test_clear_empty_store_is_noop_204(self, client): + resp = client.delete("/policy/services") + assert resp.status_code == 204 + assert svc._cache == {} + + def test_clear_then_get_by_id_404s(self, client): + _preload(_spm("svc-a")) + client.delete("/policy/services") + resp = client.get(f"/policy/services/{encode_service_id('svc-a')}") + assert resp.status_code == 404 + + def test_returns_502_on_sqlite_error(self, client): + bad_conn = MagicMock() + bad_conn.execute.side_effect = sqlite3.OperationalError("disk full") + app.dependency_overrides[get_db] = lambda: bad_conn + resp = client.delete("/policy/services") + assert resp.status_code == 502 + assert "error" in resp.json() + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +class TestHealth: + def test_returns_200_when_sqlite_reachable(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + + def test_returns_503_when_sqlite_unavailable(self, client): + bad_conn = MagicMock() + bad_conn.execute.side_effect = sqlite3.OperationalError("disk I/O error") + app.dependency_overrides[get_db] = lambda: bad_conn + resp = client.get("/health") + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# Removed surface: no per-agent / whole-collection endpoints +# --------------------------------------------------------------------------- + + +class TestRemovedEndpoints: + def test_whole_collection_get_policy_absent(self, client): + assert client.get("/policy").status_code == 404 + + def test_per_agent_get_absent(self, client): + assert client.get("/policy/agents/agent-1").status_code == 404 diff --git a/aiac/test/policy/store/test_keying.py b/aiac/test/policy/store/test_keying.py new file mode 100644 index 000000000..acca7fa97 --- /dev/null +++ b/aiac/test/policy/store/test_keying.py @@ -0,0 +1,33 @@ +"""Unit tests for aiac/policy/store/keying.py — slash-safe service_id encoding.""" + +import pytest + +from aiac.policy.store.keying import decode_service_id, encode_service_id + + +@pytest.mark.parametrize( + "service_id", + [ + "github-agent", + "team1/github-agent", + "spiffe://localtest.me/ns/team1/sa/github-agent", + ], +) +def test_round_trips(service_id): + encoded = encode_service_id(service_id) + assert decode_service_id(encoded) == service_id + + +@pytest.mark.parametrize( + "service_id", + [ + "github-agent", + "team1/github-agent", + "spiffe://localtest.me/ns/team1/sa/github-agent", + ], +) +def test_encoding_is_path_segment_safe(service_id): + encoded = encode_service_id(service_id) + assert "/" not in encoded + assert ":" not in encoded + assert "=" not in encoded diff --git a/aiac/uv.lock b/aiac/uv.lock new file mode 100644 index 000000000..bda020730 --- /dev/null +++ b/aiac/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 000000000..767724fe4 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "extraPaths": [ + "aiac/src", + "aiac/src/aiac/agent/onboarding/policy" + ], + "pythonVersion": "3.12", + "typeCheckingMode": "basic" +}