diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index a10104a..cd9a83b 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -23,7 +23,7 @@ Run from the project root. This parses all source files, builds the knowledge gr | `--embeddings` | Enable embedding generation for semantic search (off by default) | | `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. ### status — Check index freshness diff --git a/AGENTS.md b/AGENTS.md index ea537c7..2f9a9ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ Ask. Refusing to act is always safer than taking an action that bypasses these r # GitNexus — Code Intelligence -This project is indexed by GitNexus as **supervaizer** (4837 symbols, 7278 relationships, 93 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **supervaizer** (6117 symbols, 11434 relationships, 278 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/docs/2026_05_PROTOCOLS.md b/docs/2026_05_PROTOCOLS.md index be95be9..d98d263 100644 --- a/docs/2026_05_PROTOCOLS.md +++ b/docs/2026_05_PROTOCOLS.md @@ -1,7 +1,7 @@ # Protocol Support > **Created:** 2025-08-06 -> **Updated:** 2026-05-17 +> **Updated:** 2026-05-18 SUPERVAIZER uses several protocol layers. They are related, but they do different jobs: @@ -49,6 +49,14 @@ When an agent declares `supervaizer_v2_registration`, its A2A Agent Card include This extension does **not** replace the existing Studio server-registration trust model. Studio registration still owns server identity, public key exchange, and encrypted payload handling. The A2A Agent Card advertises the v2 operational contract after the controller is known. +### Workspace Authorization + +Workspace and tenant slugs are not enough to authorize shared-agent access. A Supervaizer v2 controller should treat them as display and routing hints only. + +The planned shared-agent model uses a Studio-owned Workspace Agent Grant and a short-lived Studio-signed workspace authorization token. Studio sends the token with Studio-to-agent requests, and the Supervaizer SDK verifies it before dispatching handlers. This lets stateless agents safely serve multiple workspaces without storing grant state locally. + +See [2026_05_WORKSPACE_AGENT_GRANTS.md](2026_05_WORKSPACE_AGENT_GRANTS.md). + ### Supervaizer v2 JSON-RPC Methods Supervaizer v2 currently exposes two A2A JSON-RPC methods: @@ -60,6 +68,12 @@ Supervaizer v2 currently exposes two A2A JSON-RPC methods: Both methods are scoped by `agent_slug`. In multi-agent controllers, handlers must be registered for the correct agent slug. +Workspace-scoped calls require a Studio-signed workspace authorization token. +The only bootstrap exceptions are `workspace_binding.*` actions and the +`workspace_binding.create` surface. These calls are used before a Workspace +Agent Grant exists, so they require normal Studio-to-agent transport +authentication but not a workspace authorization token. + ### Transport Status The current MVP advertises: diff --git a/docs/2026_05_SUPERVAIZER_v2.md b/docs/2026_05_SUPERVAIZER_v2.md index 0f240a6..942afb2 100644 --- a/docs/2026_05_SUPERVAIZER_v2.md +++ b/docs/2026_05_SUPERVAIZER_v2.md @@ -2,7 +2,7 @@ > **Created:** 2026-05-16 -> **Updated:** 2026-05-17 +> **Updated:** 2026-05-18 Supervaizer v2 is the new operation contract between an agent controller and Supervaize Studio. @@ -102,6 +102,24 @@ The v2 registration is exposed in the A2A Agent Card under `supervaizer.v2`. This does not replace the existing Studio server registration process. Server identity, public key exchange, and encrypted payload handling still belong to the normal Studio registration path. The v2 extension tells Studio how to operate the already-registered controller. +## Workspace Authorization + +Workspace slugs and tenant slugs are not authorization primitives. They are display and routing hints only. + +When Studio operates an agent shared into another workspace, Studio must prove that the recipient workspace admin accepted the agent. The planned v2 model is a Studio-owned Workspace Agent Grant plus a short-lived Studio-signed workspace authorization token on each Studio-to-agent request. + +Agents may be stateless. They do not need to persist grants locally. The agent verifies the token on every request and uses the verified grant context for resource access, dataset queries, `job.start`, `job.sync`, artifacts, and HITL actions. + +If an agent requires an agent-side record before accepting a workspace, it can +declare `workspace_binding` in its v2 registration. Supervaizer treats +`workspace_binding.*` actions and the `workspace_binding.create` surface as +bootstrap capabilities: they still require Studio-to-agent transport +authentication, but they run before a workspace authorization token exists. All +other workspace-scoped actions and surfaces fail closed without a valid +workspace authorization token. + +See [2026_05_WORKSPACE_AGENT_GRANTS.md](2026_05_WORKSPACE_AGENT_GRANTS.md) for the implementation plan. + ## Runtime Handlers Agents register action and surface handlers on the `Server`. diff --git a/docs/2026_05_WORKSPACE_AGENT_GRANTS.md b/docs/2026_05_WORKSPACE_AGENT_GRANTS.md new file mode 100644 index 0000000..aea5368 --- /dev/null +++ b/docs/2026_05_WORKSPACE_AGENT_GRANTS.md @@ -0,0 +1,431 @@ +# Workspace Agent Grants + +> **Created:** 2026-05-18 +> **Updated:** 2026-05-18 + +This document plans the Supervaizer v2 authorization model for shared agents. + +The problem is not routing. A workspace slug can route a request, but it cannot prove that the workspace approved the agent. If agents accept `tenant_slug` or `workspace_slug` as authority, an agent developer could simulate Studio calls for another workspace and read or mutate agent-side data without that workspace's approval. + +The recommended model is a Studio-owned **Workspace Agent Grant** plus a short-lived Studio-signed workspace authorization token on every Studio-to-agent request. + +## Decision + +Studio must be the source of truth for workspace approval. + +When a workspace admin accepts an agent, Studio creates a durable grant. This +applies both to recipient workspaces for shared agents and to the owner workspace +for its own agent. The owner workspace is not a runtime authorization bypass; it +must have an accepted grant with an explicit agent-side workspace binding before +Studio can operate the agent. + +```text +WorkspaceAgentGrant +- id +- workspace_id +- workspace_slug +- agent_id +- server_id +- offered_by_workspace_id +- accepted_agent_version +- accepted_contract_fingerprint +- accepted_by_admin_id +- accepted_at +- revoked_by_user_id +- status: accepted | revoked | suspended +- scopes +- acceptance_snapshot +- created_at +- revoked_at +``` + +Every Studio-to-agent request that uses workspace data must include a signed workspace authorization token. The agent verifies this token before it trusts any workspace, tenant, resource, dataset, job, or action context. + +Raw slugs are never authorization primitives. + +## Trust Model + +Supervaizer v2 has separate trust concerns: + +| Concern | Existing or new mechanism | Purpose | +| --- | --- | --- | +| Agent to Studio authentication | `SUPERVAIZE_API_KEY` | Lets the agent call Studio APIs and send controller events. | +| Studio to agent transport authentication | `SUPERVAIZER_API_KEY` | Lets Studio call the agent controller. | +| Agent public key | existing server registration | Lets Studio encrypt sensitive payloads to the agent. | +| Workspace authorization | new Studio-signed grant token | Proves a workspace admin accepted this agent and that the request is scoped to that grant. | + +The agent public key protects confidentiality. It does not prove that a workspace accepted the agent. Workspace approval requires the signed grant token. + +## Signing Key Decision + +Workspace authorization uses a dedicated Studio signing key family, separate from +skills artifact signing and separate from the agent public key exchanged during +server registration. + +The token algorithm is **EdDSA with Ed25519 keys**. Supervaizer v2 should reject +tokens signed with any other JWT algorithm. + +Studio must not duplicate cryptographic plumbing for every signing use case. +The shared implementation should provide reusable Ed25519 primitives for: + +- keypair generation +- key id generation +- compact JWT signing +- public JWK generation +- JWKS payload generation + +Skills artifact signing and workspace-grant signing may store keys in separate +tables and expose separate JWKS endpoints, but they should call the same shared +Ed25519 helper code. The separation is about trust domains and rotation policy, +not about duplicating signing logic. + +## Stateless Agent Requirement + +Agents may not have persistent local memory. The grant model must therefore be stateless from the agent's perspective. + +Studio stores the grant. Studio signs a short-lived token. The agent verifies the token on every request and builds trusted request context only for that request. + +The agent needs only stable trust material: + +- Studio issuer URL +- Studio signing public key or JWKS URL +- expected `server_id` +- expected `agent_id` or `agent_slug` +- optional `SUPERVAIZER_API_KEY` + +The agent does not need to store grants locally. + +## Token Shape + +The token is a compact EdDSA-signed JWT. + +Recommended claims: + +```json +{ + "iss": "https://studio.supervaize.com", + "aud": "supervaizer-server:01SERVER", + "sub": "workspace-agent-grant:01GRANT", + "grant_id": "01GRANT", + "workspace_id": "01WORKSPACE", + "workspace_slug": "recipient-workspace", + "agent_id": "01AGENT", + "agent_slug": "agent-interviewer", + "server_id": "01SERVER", + "scopes": [ + "supervaizer/surface.load", + "supervaizer/action.invoke", + "resource.campaigns.list", + "job.start" + ], + "agent_workspace_ref": "01AGENTWORKSPACE", + "iat": 1779100000, + "exp": 1779100600, + "jti": "01TOKEN" +} +``` + +`workspace_slug` is informational. The trusted identity is `workspace_id` plus `grant_id`. + +`agent_workspace_ref` is useful for stateless agents. If present, Studio owns the +binding between the Studio workspace grant and the agent-side workspace reference. +If absent, the agent may resolve the binding from an external trusted data store +using `grant_id` or `workspace_id`. Agents that cannot resolve that binding from +another trusted store must require `agent_workspace_ref` and fail clearly when it is +missing. + +## Workspace Binding Registration + +Agents that need an agent-side record before Studio can operate them declare a +generic `workspace_binding` block in the Supervaizer v2 registration. The block +does not name agent-specific concepts such as tenant, account, or project. + +```json +{ + "workspace_binding": { + "required": true, + "modes": ["bind_existing", "create_and_bind"], + "reference_label": "Agent workspace reference", + "reference_help": "Select or create the agent-side record this Studio workspace may access.", + "reference_placeholder": "Example: workspace-prod", + "existing": { + "action": "workspace_binding.options", + "value_field": "agent_workspace_ref", + "label_field": "display_name" + }, + "create": { + "surface": "workspace_binding.create", + "action": "workspace_binding.create" + } + } +} +``` + +Supervaizer treats these as bootstrap capabilities: + +- `workspace_binding.*` actions may run before the workspace grant exists. +- `workspace_binding.options` lists existing agent-side records that can be bound. +- `workspace_binding.create` creates a new agent-side record and returns the + `agent_workspace_ref` Studio should store on the grant. +- `workspace_binding.create` surface can expose an A2UI form for collecting the + fields needed to create the agent-side record. + +These bootstrap calls still require normal Studio-to-agent transport +authentication through `SUPERVAIZER_API_KEY`. They do not require an existing +workspace authorization token because they are used to create the grant that +future tokens will represent. Every non-bootstrap surface and action remains +blocked until Studio sends a valid workspace authorization token. + +## Request Transport + +The preferred transport is an HTTP header: + +```text +X-Supervaize-Workspace-Authorization: Bearer +``` + +For A2A JSON-RPC requests, Studio should also expose the verified workspace context inside the Supervaizer v2 request model after SDK verification. Agent handlers should receive trusted structured context instead of parsing headers directly. + +The SDK should fail before handler dispatch when the token is missing, invalid, expired, wrong audience, wrong agent, wrong server, revoked by introspection, or missing required scope. + +## Runtime Flow + +### 1. Agent Registration + +The agent developer configures normal server registration: + +- `SUPERVAIZE_API_KEY` for agent-to-Studio calls +- server identity +- public key +- Supervaizer v2 registration and A2A Agent Card metadata + +This does not grant workspace access to every tenant. It only makes the agent known to Studio. + +### 2. Agent Sharing + +Studio may show the agent to another workspace as shareable or installable. Until an authorized user accepts it, the recipient workspace has no active grant. + +The target workspace UI should show the agent as **pending acceptance**, not as an installed or usable agent. Pending agents must not appear as selectable execution agents in mission/job flows. + +### 3. Admin Acceptance + +A workspace admin or agent-manager user accepts the agent. Studio creates +`WorkspaceAgentGrant(status="accepted")` with explicit scopes and, for agents +that require stateless workspace binding, a required `agent_workspace_ref`. + +Acceptance must be explicit and auditable. Studio records: + +- who accepted the agent +- when it was accepted +- which workspace accepted it +- which agent and server were accepted +- which agent version and v2 contract fingerprint were accepted +- which scopes were granted +- the acceptance terms or summary shown to the user + +If the agent later changes its declared scopes, server identity, signing expectations, or contract fingerprint, Studio should require a new acceptance before enabling the expanded capability. + +### 4. Studio Calls Agent + +For `surface.load`, `action.invoke`, resource operations, dataset queries, `job.start`, and `job.sync`, Studio sends: + +- `SUPERVAIZER_API_KEY` for transport authentication +- workspace authorization token for workspace authorization +- normal Supervaizer v2 request payload + +### 5. SDK Verification + +Supervaizer verifies the token and creates trusted request context: + +```text +WorkspaceContext +- grant_id +- workspace_id +- workspace_slug +- agent_id +- agent_slug +- server_id +- scopes +- agent_workspace_ref +``` + +Handlers use this context. They must not trust raw `workspace_slug`, `tenant_slug`, or caller-provided resource filters as authorization. + +## Revocation + +The MVP should use short-lived tokens, ideally 5 to 15 minutes. + +Revoked or suspended grants stop working when existing tokens expire. For high-risk operations, Studio or the SDK can add introspection: + +- `job.start` +- resource imports +- resource mutations +- dataset exports +- sensitive artifact access + +If introspection is enabled and Studio cannot confirm the grant, the request fails closed. + +## Studio UI Impact + +Studio must make agent sharing an explicit installation workflow in the target workspace. + +### Target Workspace Agent States + +Studio should distinguish at least these target-workspace states: + +| State | Meaning | Studio behavior | +| --- | --- | --- | +| `available` | Agent was shared or made installable, but no target-workspace user has accepted it. | Show install/accept CTA only to workspace admins and agent managers. Do not allow mission/job use. | +| `accepted` | A workspace admin or agent manager accepted the agent. | Show as usable in mission/job flows according to scopes and permissions. | +| `revoked` | The workspace removed the agent. | Hide from normal mission/job flows. Existing jobs remain visible according to audit policy, but new calls fail. | +| `suspended` | Studio or the offering workspace disabled the grant. | Show a clear disabled state and block calls. | + +### Acceptance Screen + +The acceptance screen should show enough information for an admin or agent manager to make an informed decision: + +- agent name and publisher +- offering workspace or owner +- controller server identity +- agent version +- Supervaizer v2 contract version +- requested scopes and their user-facing meaning +- resources and datasets the agent wants to expose +- whether the agent will access workspace-scoped data +- the agent-side workspace binding, if configured +- links to terms, documentation, and privacy/security information when available + +The primary action should be explicit, for example `Accept agent for this workspace`. A normal member should see that admin approval is required, not a generic failure or empty agent page. + +### Recording Acceptance + +Studio should store an immutable acceptance snapshot on the grant. The snapshot should preserve what the user accepted even if the agent later changes its registration. + +Minimum snapshot: + +```json +{ + "agent_name": "agent_interviewer", + "agent_slug": "agent-interviewer", + "server_id": "01SERVER", + "agent_version": "2.66.0", + "supervaizer_contract_version": 2, + "a2a_version": "0.2.6", + "a2ui_version": "v0.8", + "a2ui_catalog_version": "agent-interviewer.2026-05-18", + "scopes": ["job.start", "resource.campaigns.list"], + "resources": ["campaigns", "contacts"], + "datasets": ["campaign_progress"], + "accepted_by_user_id": "01USER", + "accepted_at": "2026-05-18T11:30:00Z" +} +``` + +### Revocation By Removing The Agent + +Removing the agent from the target workspace should revoke the `WorkspaceAgentGrant`. + +Studio records: + +- who removed the agent +- when it was removed +- reason, if provided +- previous grant id +- affected active jobs, if any + +After revocation: + +- Studio must stop minting workspace authorization tokens for the grant. +- Agent resource, dataset, artifact, `job.start`, and HITL calls must fail with a clear revoked-grant error. +- Existing jobs and cases should remain visible as historical/audit records unless product policy explicitly deletes them. +- Existing running jobs should follow the workspace revocation policy, likely `fail_in_studio` or `cancel_requested`, not silent continuation. + +No fallback to a fresh grant should occur without a new explicit acceptance. + +## Agent Interviewer Application + +For `agent_interviewer`, the verified workspace context should drive tenant access. + +Recommended behavior: + +- campaign listing requires a valid workspace grant token +- campaign listing returns only campaigns owned by the verified agent workspace reference +- campaign listing still filters to `is_supervaize=true` +- `job.start` requires `job.start` scope +- contact import requires the campaign/contact import scope +- Supabase database or tenant selection must be derived from verified context, not from raw request slug + +If the token is valid but no agent-side workspace binding exists, the agent should fail clearly: + +```text +Workspace is authorized in Studio but has no agent-side workspace binding. +``` + +It should not fall back to slug matching. + +## Cross-Repo Implementation Plan + +### Supervaizer SDK + +- Add a workspace authorization token verifier. +- Add typed request context for verified workspace grants. +- Add scope checks for A2A methods, actions, resources, datasets, artifacts, and sync. +- Add clear SDK errors for missing, expired, invalid, wrong-audience, wrong-agent, wrong-server, and missing-scope tokens. +- Add tests that prove handlers are not called when verification fails. +- Document that slugs are display and routing hints, not authorization. + +### Studio + +- Add `WorkspaceAgentGrant`. +- Create grants only from recipient workspace admin acceptance. +- Allow only workspace admins and agent-manager users to accept a shared agent. +- Add target-workspace UI states for available, accepted, revoked, and suspended agents. +- Add an acceptance screen that explains publisher, server identity, version, contract, scopes, resources, datasets, and data-access impact. +- Store status, scopes, server id, agent id, workspace id, accepted actor, accepted timestamp, acceptance snapshot, revocation actor, revocation timestamp, and optional agent workspace binding. +- Require re-acceptance when requested scopes or contract fingerprint expand. +- Revoke grants when the target workspace removes the agent. +- Expose a signing key or JWKS that agents can verify. +- Mint short-lived workspace authorization tokens for Studio-to-agent calls. +- Attach tokens to A2A calls, resource calls, dataset calls, artifact calls, `job.start`, and `job.sync`. +- Fail clearly when no accepted grant exists for the workspace and agent. +- Fail clearly when a grant is revoked, suspended, missing scope, or missing agent workspace binding. + +### Agent Interviewer + +- Stop trusting raw tenant or workspace slug for data access. +- Resolve Supabase tenant/database context from verified workspace grant context. +- Keep campaign filtering strict: verified tenant plus `is_supervaize=true`. +- Require explicit scopes for campaign listing, campaign start, contact import, HITL submit, datasets, and artifacts. +- Add tests for accepted grant, missing grant, wrong workspace, revoked grant, missing scope, and missing workspace binding. + +## Failure Policy + +No guessing and no implicit fallback. + +The system must fail with explicit errors when: + +- no workspace authorization token is provided +- the token is expired or malformed +- the token was not signed by Studio +- the token audience does not match the server +- the token agent does not match the called agent +- the token workspace has no accepted grant +- the grant is revoked or suspended +- the token lacks required scope +- the agent-side workspace binding is missing + +Studio should surface these failures to operators as configuration or authorization errors, not as empty tables or generic loading failures. + +## Acceptance Criteria + +- A shared agent cannot list resources for a workspace until a workspace admin accepts the agent. +- A workspace admin or agent manager must explicitly accept a shared agent before it appears in mission/job flows. +- Studio records who accepted what, when, and under which contract/scopes. +- Removing an agent from a workspace revokes the grant and stops new token minting. +- A forged `workspace_slug` or `tenant_slug` cannot grant access. +- A valid transport API key without a workspace grant token cannot access workspace-scoped resources. +- A valid workspace token for one workspace cannot access another workspace. +- A valid workspace token for one agent cannot access another agent. +- An agent without local persistence can still verify every request. +- Revoked grants stop authorizing requests after token expiry, and immediately for operations that use introspection. +- Studio and agent_interviewer show clear errors for missing grant, missing scope, and missing workspace binding. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2eff423..e8b8020 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,7 @@ # Supervaizer Changelog > **Created:** 2025-08-05 -> **Updated:** 2026-05-18 +> **Updated:** 2026-05-19 All notable changes to this project will be documented in this file. @@ -18,6 +18,40 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **Workspace agent authorization** — Studio-signed Ed25519 workspace authorization tokens on `X-Supervaize-Workspace-Authorization`; the SDK verifies JWKS-backed tokens and exposes `V2VerifiedWorkspaceContext` for handlers. Workspace and tenant slugs remain routing hints only. +- **Workspace binding protocol** — Agents can declare optional `workspace_binding` metadata with bootstrap `workspace_binding.options`, `workspace_binding.create`, and the `workspace_binding.create` surface so Studio can bind an agent-side record before a Workspace Agent Grant exists. +- **Workspace authorization docs** — `docs/2026_05_WORKSPACE_AGENT_GRANTS.md` plus workspace authorization and binding bootstrap rules in `docs/2026_05_PROTOCOLS.md` and `docs/2026_05_SUPERVAIZER_v2.md`. + +### Changed + +- **Fail-closed Studio A2A** — Workspace-scoped v2 JSON-RPC actions and surfaces reject requests without a valid workspace authorization token when workspace authorization is enabled. +- **Workspace-scoped data resources** — Data resource routes require verified workspace context from the authorization token instead of trusting caller-supplied slugs alone. +- **Studio server audience handoff** — `server.register` handshakes can now supply the Studio-persisted server audience for workspace authorization tokens, and Supervaizer adopts that audience before serving protected v2 calls so workspace grants survive agent process restarts. +- **Controller version registration** — `server.register` now sends the Supervaizer controller package version directly as `controller_version`, so Studio no longer depends on OpenAPI scraping to refresh the server detail page version. + +### Fixed + +- **Workspace authorization validation** — Malformed or incomplete workspace authorization tokens and settings now fail with explicit `workspace_authorization_*` errors instead of ambiguous handler failures. + +### Tests + +- `tests/test_a2a.py` — workspace authorization accept/reject paths, JWKS verification, binding bootstrap exceptions, and protected action/surface enforcement. +- `tests/test_agent.py` — v2 registration carries workspace binding and authorization settings. +- `tests/test_contracts.py` — workspace binding and authorization contract models. +- `tests/test_routes.py` — data resource routes require verified workspace context. +- `tests/test_server.py` — registration handshake audience handoff, workspace authorization startup validation, and `controller_version` registration. + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 657 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 76s | + ## [1.0.1] - 2026-05-17 ### Supervaizer v2 2️⃣ @@ -35,10 +69,10 @@ All notable changes to this project will be documented in this file. | Status | Count | | ---------- | ----- | -| ✅ Passed | 616 | +| ✅ Passed | 645 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | -| ⏱️ in | 78s | +| ⏱️ in | 77s | ## [1.0.0] - 2026-05-17 diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 19c2875..921614f 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -78,6 +78,18 @@ "supervaizer.contracts", "SUPERVAIZER_V2_CONTRACT_VERSION", ), + "WORKSPACE_BINDING_CREATE_ACTION": ( + "supervaizer.contracts", + "WORKSPACE_BINDING_CREATE_ACTION", + ), + "WORKSPACE_BINDING_CREATE_SURFACE": ( + "supervaizer.contracts", + "WORKSPACE_BINDING_CREATE_SURFACE", + ), + "WORKSPACE_BINDING_OPTIONS_ACTION": ( + "supervaizer.contracts", + "WORKSPACE_BINDING_OPTIONS_ACTION", + ), "AgentMethodContract": ("supervaizer.contracts", "AgentMethodContract"), "AgentMethodsContract": ("supervaizer.contracts", "AgentMethodsContract"), "AgentRegistrationContract": ("supervaizer.contracts", "AgentRegistrationContract"), @@ -169,7 +181,43 @@ "V2StepSnapshot": ("supervaizer.contracts", "V2StepSnapshot"), "V2SurfaceRequest": ("supervaizer.contracts", "V2SurfaceRequest"), "V2SurfaceResult": ("supervaizer.contracts", "V2SurfaceResult"), + "V2VerifiedWorkspaceContext": ( + "supervaizer.contracts", + "V2VerifiedWorkspaceContext", + ), + "V2WorkspaceAuthorizationSettings": ( + "supervaizer.contracts", + "V2WorkspaceAuthorizationSettings", + ), + "V2WorkspaceBindingExistingDefinition": ( + "supervaizer.contracts", + "V2WorkspaceBindingExistingDefinition", + ), + "V2WorkspaceBindingCreateDefinition": ( + "supervaizer.contracts", + "V2WorkspaceBindingCreateDefinition", + ), + "V2WorkspaceBindingDefinition": ( + "supervaizer.contracts", + "V2WorkspaceBindingDefinition", + ), "V2WorkspaceContext": ("supervaizer.contracts", "V2WorkspaceContext"), + "WORKSPACE_AUTHORIZATION_HEADER": ( + "supervaizer.workspace_authorization", + "WORKSPACE_AUTHORIZATION_HEADER", + ), + "WorkspaceAuthorizationError": ( + "supervaizer.workspace_authorization", + "WorkspaceAuthorizationError", + ), + "extract_workspace_authorization_token": ( + "supervaizer.workspace_authorization", + "extract_workspace_authorization_token", + ), + "verify_workspace_authorization_for_request": ( + "supervaizer.workspace_authorization", + "verify_workspace_authorization_for_request", + ), "build_data_resource_context_headers": ( "supervaizer.contracts", "build_data_resource_context_headers", diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 9b5c605..1aa0ca7 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -59,6 +59,24 @@ }) +def _agent_detail_from_server_response(detail: Any) -> dict[str, Any]: + if not isinstance(detail, dict): + raise ValueError( + "Agent update from Studio failed: response body must be an object" + ) + nested_detail = detail.get("object") or detail.get("agent") or detail.get("data") + if isinstance(nested_detail, dict): + return nested_detail + return detail + + +def _agent_id_from_server_detail(agent_detail: dict[str, Any]) -> str | None: + value = agent_detail.get("id") + if value is None: + return None + return str(value) + + class FieldTypeEnum(str, Enum): CHAR = "CharField" INT = "IntegerField" @@ -868,16 +886,26 @@ def update_agent_from_server(self, server: "Server") -> "Agent | None": log.error(f"[Agent update_agent_from_server] Failed : {from_server}") return None - agent_from_server = from_server.detail - server_agent_id = agent_from_server.get("id") if agent_from_server else None + agent_from_server = _agent_detail_from_server_response(from_server.detail) + server_agent_id = _agent_id_from_server_detail(agent_from_server) - # This should never happen, but just in case - if self.server_agent_id and self.server_agent_id != server_agent_id: + if ( + self.server_agent_id + and server_agent_id + and self.server_agent_id != server_agent_id + ): message = f"Agent ID mismatch: {self.server_agent_id} != {server_agent_id}" raise ValueError(message) + if not self.server_agent_id and not server_agent_id: + response_keys = sorted(str(key) for key in agent_from_server.keys()) + message = ( + "Agent update from Studio failed: response did not include agent id " + f"for slug={self.slug}. response_keys={response_keys}" + ) + raise ValueError(message) # Update agent attributes - self.server_agent_id = server_agent_id + self.server_agent_id = server_agent_id or self.server_agent_id self.server_agent_status = ( agent_from_server.get("status") if agent_from_server else None ) diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index d64e731..e0a6290 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -25,6 +25,9 @@ SUPERVAIZER_V2_CONTRACT_VERSION: Literal[2] = 2 SUPERVAIZER_V2_A2UI_VERSION = "v0.8" SUPERVAIZER_V2_A2A_VERSION = "0.2.6" +WORKSPACE_BINDING_OPTIONS_ACTION = "workspace_binding.options" +WORKSPACE_BINDING_CREATE_ACTION = "workspace_binding.create" +WORKSPACE_BINDING_CREATE_SURFACE = "workspace_binding.create" class ContractModel(BaseModel): @@ -227,6 +230,7 @@ class ServerRegistrationContract(ControllerContract): url: str uri: str api_version: str + controller_version: str | None = None environment: str | None = None agents: list[AgentRegistrationContract] = Field(default_factory=list) @@ -353,6 +357,10 @@ class V2A2UIResourceImportColumn(ContractModel): required: bool = False +def _default_resource_import_formats() -> list[Literal["csv", "xlsx"]]: + return ["csv"] + + class V2A2UIResourceImportDocument(ContractModel): """A2UI-shaped resource import surface consumed by Studio.""" @@ -361,7 +369,7 @@ class V2A2UIResourceImportDocument(ContractModel): title: str resource: str accepted_formats: list[Literal["csv", "xlsx"]] = Field( - default_factory=lambda: ["csv"] + default_factory=_default_resource_import_formats ) fields: list[V2ResourceFieldDefinition] = Field(default_factory=list) columns: list[V2A2UIResourceImportColumn] = Field(default_factory=list) @@ -453,6 +461,42 @@ class V2DashboardDefinition(ContractModel): widgets: list[V2DashboardWidgetDefinition] = Field(default_factory=list) +class V2WorkspaceBindingExistingDefinition(ContractModel): + action: str = WORKSPACE_BINDING_OPTIONS_ACTION + value_field: str = "agent_workspace_ref" + label_field: str = "display_name" + + +class V2WorkspaceBindingCreateDefinition(ContractModel): + surface: str = WORKSPACE_BINDING_CREATE_SURFACE + action: str = WORKSPACE_BINDING_CREATE_ACTION + fields: list[V2ResourceFieldDefinition] = Field(default_factory=list) + + +class V2WorkspaceBindingDefinition(ContractModel): + required: bool = False + modes: list[Literal["bind_existing", "create_and_bind"]] = Field( + default_factory=list + ) + reference_label: str = "Agent workspace reference" + reference_help: str = ( + "Select or create the agent-side record this Studio workspace may access." + ) + reference_placeholder: str = "Example: workspace-prod" + existing: V2WorkspaceBindingExistingDefinition | None = None + create: V2WorkspaceBindingCreateDefinition | None = None + + @model_validator(mode="after") + def validate_modes(self) -> V2WorkspaceBindingDefinition: + if self.required and not self.modes: + raise ValueError("workspace_binding.required requires at least one mode") + if "bind_existing" in self.modes and self.existing is None: + self.existing = V2WorkspaceBindingExistingDefinition() + if "create_and_bind" in self.modes and self.create is None: + self.create = V2WorkspaceBindingCreateDefinition() + return self + + class SupervaizerV2AgentRegistrationContract(ContractModel): supervaizer_contract_version: Literal[2] = SUPERVAIZER_V2_CONTRACT_VERSION agent: V2AgentIdentity @@ -463,6 +507,7 @@ class SupervaizerV2AgentRegistrationContract(ContractModel): resources: list[V2ResourceDefinition] = Field(default_factory=list) datasets: list[V2DatasetDefinition] = Field(default_factory=list) dashboards: list[V2DashboardDefinition] = Field(default_factory=list) + workspace_binding: V2WorkspaceBindingDefinition | None = None def build_v2_agent_registration( @@ -478,6 +523,7 @@ def build_v2_agent_registration( resources: Iterable[V2ResourceDefinition | dict[str, Any]] = (), datasets: Iterable[V2DatasetDefinition | dict[str, Any]] = (), dashboards: Iterable[V2DashboardDefinition | dict[str, Any]] = (), + workspace_binding: V2WorkspaceBindingDefinition | dict[str, Any] | None = None, case_lanes: Iterable[V2CaseLaneDefinition | dict[str, Any]] = (), artifact_types: Iterable[V2ArtifactTypeDefinition | dict[str, Any]] = (), job_policy: V2JobPolicy | dict[str, Any] | None = None, @@ -491,6 +537,7 @@ def build_v2_agent_registration( resource_definitions = _contract_list(resources, V2ResourceDefinition) dataset_definitions = _contract_list(datasets, V2DatasetDefinition) dashboard_definitions = _contract_list(dashboards, V2DashboardDefinition) + workspace_binding_definition = _workspace_binding(workspace_binding) sync_policy = _job_policy(job_policy) capability_surfaces = _unique_strings([ @@ -498,12 +545,14 @@ def build_v2_agent_registration( *_auto_resource_surface_ids(resource_definitions), *_auto_dataset_surface_ids(dataset_definitions), *_dashboard_surface_ids(dashboard_definitions), + *_workspace_binding_surface_ids(workspace_binding_definition), ]) capability_actions = _unique_strings([ *actions, *_resource_action_ids(resource_definitions), *_dataset_action_ids(dataset_definitions), *(_job_sync_actions(sync_policy)), + *_workspace_binding_action_ids(workspace_binding_definition), ]) return SupervaizerV2AgentRegistrationContract( @@ -537,6 +586,7 @@ def build_v2_agent_registration( resources=resource_definitions, datasets=dataset_definitions, dashboards=dashboard_definitions, + workspace_binding=workspace_binding_definition, ) @@ -563,6 +613,16 @@ def _job_policy(value: V2JobPolicy | dict[str, Any] | None) -> V2JobPolicy: return _contract_or_default(value, V2JobPolicy) +def _workspace_binding( + value: V2WorkspaceBindingDefinition | dict[str, Any] | None, +) -> V2WorkspaceBindingDefinition | None: + if value is None: + return None + if isinstance(value, V2WorkspaceBindingDefinition): + return value + return V2WorkspaceBindingDefinition.model_validate(value) + + def _unique_strings(values: Iterable[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] @@ -612,6 +672,27 @@ def _job_sync_actions(job_policy: V2JobPolicy) -> list[str]: return [job_policy.sync.action] +def _workspace_binding_action_ids( + workspace_binding: V2WorkspaceBindingDefinition | None, +) -> list[str]: + if workspace_binding is None: + return [] + action_ids: list[str] = [] + if workspace_binding.existing is not None: + action_ids.append(workspace_binding.existing.action) + if workspace_binding.create is not None: + action_ids.append(workspace_binding.create.action) + return action_ids + + +def _workspace_binding_surface_ids( + workspace_binding: V2WorkspaceBindingDefinition | None, +) -> list[str]: + if workspace_binding is None or workspace_binding.create is None: + return [] + return [workspace_binding.create.surface] + + class V2ActorContext(ContractModel): user_id: str @@ -621,6 +702,26 @@ class V2WorkspaceContext(ContractModel): slug: str | None = None +class V2VerifiedWorkspaceContext(ContractModel): + grant_id: str + workspace_id: str + workspace_slug: str | None = None + agent_id: str + agent_slug: str + server_id: str + scopes: list[str] = Field(default_factory=list) + agent_workspace_ref: str | None = None + + +class V2WorkspaceAuthorizationSettings(ContractModel): + enabled: bool = False + issuer: str | None = None + audience: str | None = None + public_key_pem: str | None = None + jwks_url: str | None = None + leeway_seconds: int = 30 + + class V2ActionRequest(ContractModel): request_id: str actor: V2ActorContext @@ -635,6 +736,7 @@ class V2ActionRequest(ContractModel): job_id: str | None = None case_id: str | None = None step_id: str | None = None + workspace_authorization: V2VerifiedWorkspaceContext | None = None class V2SurfaceRequest(ContractModel): @@ -649,6 +751,7 @@ class V2SurfaceRequest(ContractModel): job_id: str | None = None case_id: str | None = None step_id: str | None = None + workspace_authorization: V2VerifiedWorkspaceContext | None = None class V2Effect(ContractModel): diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py index 967b423..8707dea 100644 --- a/src/supervaizer/data_resource.py +++ b/src/supervaizer/data_resource.py @@ -26,6 +26,7 @@ from pydantic import Field, model_validator from supervaizer.common import SvBaseModel +from supervaizer.contracts import V2VerifiedWorkspaceContext # Used for URL path segments (/data/{name}/) and OpenAPI operation_id fragments. _DATA_RESOURCE_NAME_PATTERN = r"^[a-z0-9][a-z0-9_-]*$" @@ -60,6 +61,7 @@ class DataResourceContext(SvBaseModel): mission_id: str | None = None agent_slug: str request_id: str | None = None + workspace_authorization: V2VerifiedWorkspaceContext | None = None class DataResourceField(SvBaseModel): diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py index 7f827c3..60a6686 100644 --- a/src/supervaizer/data_routes.py +++ b/src/supervaizer/data_routes.py @@ -41,7 +41,13 @@ from supervaizer.access import require_scope # <-- ADDED from supervaizer.common import log +from supervaizer.contracts import V2WorkspaceContext from supervaizer.data_resource import DataResource, DataResourceContext +from supervaizer.workspace_authorization import ( + WorkspaceAuthorizationError, + extract_workspace_authorization_token, + verify_workspace_authorization_for_request, +) if TYPE_CHECKING: from supervaizer.agent import Agent @@ -53,9 +59,7 @@ def create_agent_data_routes(server: Server, agent: Agent) -> APIRouter: router = APIRouter(prefix=agent.path, tags=["Data Resources"]) agent_slug = agent.slug for resource in agent.data_resources: - _add_resource_routes( - router, resource, agent_slug - ) # <-- MODIFIED: removed server arg + _add_resource_routes(router, resource, agent_slug, server) return router @@ -69,7 +73,8 @@ def _data_resource_operation_id( def _add_resource_routes( router: APIRouter, resource: DataResource, - agent_slug: str, # <-- MODIFIED: removed server arg + agent_slug: str, + server: Server, ) -> None: """Register all declared operation routes for one DataResource.""" prefix = f"/data/{resource.name}" @@ -78,7 +83,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "list") router.add_api_route( f"{prefix}/", - _make_list_handler(resource, prefix, agent_slug), + _make_list_handler(resource, prefix, agent_slug, server), methods=["GET"], # <-- REMOVED: Security(server.verify_api_key); api_router handles auth summary=f"List {resource.display_name_resolved}", @@ -90,7 +95,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "get") router.add_api_route( f"{prefix}/{{item_id}}", - _make_get_handler(resource, prefix, agent_slug), + _make_get_handler(resource, prefix, agent_slug, server), methods=["GET"], # <-- REMOVED: Security(server.verify_api_key); api_router handles auth summary=f"Get {resource.display_name_resolved}", @@ -102,7 +107,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "create") router.add_api_route( f"{prefix}/", - _make_create_handler(resource, prefix, agent_slug), + _make_create_handler(resource, prefix, agent_slug, server), methods=["POST"], dependencies=[ Depends(require_scope("write")) @@ -116,7 +121,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "update") router.add_api_route( f"{prefix}/{{item_id}}", - _make_update_handler(resource, prefix, agent_slug), + _make_update_handler(resource, prefix, agent_slug, server), methods=["PUT"], dependencies=[ Depends(require_scope("write")) @@ -130,7 +135,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "delete") router.add_api_route( f"{prefix}/{{item_id}}", - _make_delete_handler(resource, prefix, agent_slug), + _make_delete_handler(resource, prefix, agent_slug, server), methods=["DELETE"], dependencies=[ Depends(require_scope("write")) @@ -144,7 +149,7 @@ def _add_resource_routes( op_id = _data_resource_operation_id(agent_slug, resource.name, "import") router.add_api_route( f"{prefix}/import/", - _make_import_handler(resource, prefix, agent_slug), + _make_import_handler(resource, prefix, agent_slug, server), methods=["POST"], dependencies=[ Depends(require_scope("write")) @@ -155,7 +160,9 @@ def _add_resource_routes( ) -def _make_list_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_list_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: async def _handler( request: Request, skip: int = Query(default=0, ge=0), @@ -164,19 +171,25 @@ async def _handler( log.info(f"📥 GET {prefix}/ [DataResource list: {r.name}]") result = _call_with_context( r.on_list, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "list") + ), ) return result[skip : skip + limit] return _handler -def _make_get_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_get_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: async def _handler(request: Request, item_id: str) -> dict[str, Any]: log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {r.name}]") result = _call_with_context( r.on_get, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "get") + ), item_id, ) if result is None: @@ -188,14 +201,18 @@ async def _handler(request: Request, item_id: str) -> dict[str, Any]: return _handler -def _make_create_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_create_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: async def _handler( request: Request, data: dict[str, Any] = Body(...) ) -> JSONResponse: log.info(f"📥 POST {prefix}/ [DataResource create: {r.name}]") result = _call_with_context( r.on_create, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "create") + ), data, ) if not isinstance(result, dict) or "id" not in result: @@ -208,7 +225,9 @@ async def _handler( return _handler -def _make_update_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_update_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: on_update = r.on_update assert on_update is not None # route registered only when on_update is set @@ -218,7 +237,9 @@ async def _handler( log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {r.name}]") result = _call_with_context( on_update, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "update") + ), item_id, data, ) @@ -231,12 +252,16 @@ async def _handler( return _handler -def _make_delete_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_delete_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: async def _handler(request: Request, item_id: str) -> JSONResponse: log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {r.name}]") success = _call_with_context( r.on_delete, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "delete") + ), item_id, ) if not success: @@ -248,30 +273,66 @@ async def _handler(request: Request, item_id: str) -> JSONResponse: return _handler -def _make_import_handler(r: DataResource, prefix: str, agent_slug: str) -> Any: +def _make_import_handler( + r: DataResource, prefix: str, agent_slug: str, server: Server +) -> Any: async def _handler( request: Request, records: list[dict[str, Any]] = Body(...) ) -> dict[str, Any]: log.info(f"📥 POST {prefix}/import/ [DataResource import: {r.name}]") return _call_with_context( r.on_import, - _context_from_request(request, agent_slug), + _context_from_request( + request, agent_slug, server, _resource_scope(r, "import") + ), records, ) return _handler -def _context_from_request(request: Request, agent_slug: str) -> DataResourceContext: +def _context_from_request( + request: Request, agent_slug: str, server: Server, required_scope: str +) -> DataResourceContext: + raw_workspace = V2WorkspaceContext( + id=request.headers.get("X-Supervaize-Workspace-Id") or "", + slug=request.headers.get("X-Supervaize-Workspace-Slug"), + ) + try: + verified_workspace = verify_workspace_authorization_for_request( + server=server, + token=extract_workspace_authorization_token(request.headers), + required_scopes=[required_scope], + request_workspace=raw_workspace, + agent_slug=agent_slug, + require_configured=True, + ) + except WorkspaceAuthorizationError as exc: + raise HTTPException(status_code=403, detail=exc.message) from exc + if verified_workspace is None: + raise HTTPException( + status_code=403, + detail=( + "Workspace authorization is required but is not configured " + "for this Supervaizer server" + ), + ) + workspace_id = verified_workspace.workspace_id + workspace_slug = verified_workspace.workspace_slug return DataResourceContext( - workspace_id=request.headers.get("X-Supervaize-Workspace-Id"), - workspace_slug=request.headers.get("X-Supervaize-Workspace-Slug"), + workspace_id=workspace_id, + workspace_slug=workspace_slug, mission_id=request.headers.get("X-Supervaize-Mission-Id"), agent_slug=agent_slug, request_id=request.headers.get("X-Supervaize-Request-Id"), + workspace_authorization=verified_workspace, ) +def _resource_scope(resource: DataResource, operation: str) -> str: + return f"resource.{resource.name}.{operation}" + + def _accepts_context(callback: Any) -> bool: try: signature = inspect.signature(callback) diff --git a/src/supervaizer/protocol/a2a/controller.py b/src/supervaizer/protocol/a2a/controller.py index e36235d..55cc4c2 100644 --- a/src/supervaizer/protocol/a2a/controller.py +++ b/src/supervaizer/protocol/a2a/controller.py @@ -21,19 +21,32 @@ V2ActionResult, V2SurfaceRequest, V2SurfaceResult, + WORKSPACE_BINDING_CREATE_ACTION, + WORKSPACE_BINDING_CREATE_SURFACE, + WORKSPACE_BINDING_OPTIONS_ACTION, ) from supervaizer.protocol.a2a.events import A2A_EFFECT_EVENT, publish_v2_event +from supervaizer.workspace_authorization import ( + WorkspaceAuthorizationError, + verify_workspace_authorization_for_request_async, +) if TYPE_CHECKING: from supervaizer.server import Server SUPERVAIZER_ACTION_INVOKE_METHOD = "supervaizer/action.invoke" SUPERVAIZER_SURFACE_LOAD_METHOD = "supervaizer/surface.load" +WORKSPACE_BINDING_BOOTSTRAP_ACTIONS = frozenset({ + WORKSPACE_BINDING_OPTIONS_ACTION, + WORKSPACE_BINDING_CREATE_ACTION, +}) +WORKSPACE_BINDING_BOOTSTRAP_SURFACES = frozenset({WORKSPACE_BINDING_CREATE_SURFACE}) JSON_RPC_METHOD_NOT_FOUND = -32601 JSON_RPC_INVALID_PARAMS = -32602 JSON_RPC_ACTION_NOT_REGISTERED = -32010 JSON_RPC_SURFACE_NOT_REGISTERED = -32011 +JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED = -32030 JSON_RPC_INTERNAL_ERROR = -32603 ActionHandler = Callable[ @@ -96,7 +109,12 @@ def register_v2_surface_handler( ) -async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcResponse: +async def dispatch_json_rpc( + server: "Server", + body: dict[str, Any], + *, + workspace_authorization_token: str | None = None, +) -> JsonRpcResponse: """Dispatch one A2A JSON-RPC request.""" try: request = JsonRpcRequest.model_validate(body) @@ -109,9 +127,17 @@ async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcRe ) if request.method == SUPERVAIZER_ACTION_INVOKE_METHOD: - return await _dispatch_action(server, request) + return await _dispatch_action( + server, + request, + workspace_authorization_token=workspace_authorization_token, + ) if request.method == SUPERVAIZER_SURFACE_LOAD_METHOD: - return await _dispatch_surface(server, request) + return await _dispatch_surface( + server, + request, + workspace_authorization_token=workspace_authorization_token, + ) return _json_rpc_error( request_id=request.id, @@ -121,7 +147,10 @@ async def dispatch_json_rpc(server: "Server", body: dict[str, Any]) -> JsonRpcRe async def _dispatch_action( - server: "Server", request: JsonRpcRequest + server: "Server", + request: JsonRpcRequest, + *, + workspace_authorization_token: str | None = None, ) -> JsonRpcResponse: try: action_request = _validate_action_request(request.params) @@ -133,6 +162,32 @@ async def _dispatch_action( data={"errors": exc.errors()}, ) + action_request = action_request.model_copy(update={"workspace_authorization": None}) + verified_workspace = None + if _action_requires_workspace_authorization(action_request.action): + try: + verified_workspace = await verify_workspace_authorization_for_request_async( + server=server, + token=workspace_authorization_token, + required_scopes=[ + SUPERVAIZER_ACTION_INVOKE_METHOD, + action_request.action, + ], + request_workspace=action_request.workspace, + agent_slug=action_request.agent_slug, + require_configured=True, + ) + except WorkspaceAuthorizationError as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED, + message=exc.message, + data={"code": exc.code}, + ) + action_request = action_request.model_copy( + update={"workspace_authorization": verified_workspace} + ) + handler = _get_action_handlers(server).get( _action_handler_key(action_request.agent_slug, action_request.action) ) @@ -188,7 +243,10 @@ async def _dispatch_action( async def _dispatch_surface( - server: "Server", request: JsonRpcRequest + server: "Server", + request: JsonRpcRequest, + *, + workspace_authorization_token: str | None = None, ) -> JsonRpcResponse: try: surface_request = _validate_surface_request(request.params) @@ -200,6 +258,34 @@ async def _dispatch_surface( data={"errors": exc.errors()}, ) + surface_request = surface_request.model_copy( + update={"workspace_authorization": None} + ) + verified_workspace = None + if _surface_requires_workspace_authorization(surface_request.surface): + try: + verified_workspace = await verify_workspace_authorization_for_request_async( + server=server, + token=workspace_authorization_token, + required_scopes=[ + SUPERVAIZER_SURFACE_LOAD_METHOD, + surface_request.surface, + ], + request_workspace=surface_request.workspace, + agent_slug=surface_request.agent_slug, + require_configured=True, + ) + except WorkspaceAuthorizationError as exc: + return _json_rpc_error( + request_id=request.id, + code=JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED, + message=exc.message, + data={"code": exc.code}, + ) + surface_request = surface_request.model_copy( + update={"workspace_authorization": verified_workspace} + ) + handler = _get_surface_handlers(server).get( _surface_handler_key(surface_request.agent_slug, surface_request.surface) ) @@ -248,6 +334,14 @@ def _validate_surface_request(params: dict[str, Any]) -> V2SurfaceRequest: return V2SurfaceRequest.model_validate(surface_payload) +def _action_requires_workspace_authorization(action: str) -> bool: + return action not in WORKSPACE_BINDING_BOOTSTRAP_ACTIONS + + +def _surface_requires_workspace_authorization(surface: str) -> bool: + return surface not in WORKSPACE_BINDING_BOOTSTRAP_SURFACES + + def _get_action_handlers(server: "Server") -> dict[ActionHandlerKey, ActionHandler]: state = server.app.state handlers = getattr(state, "supervaizer_v2_action_handlers", None) diff --git a/src/supervaizer/protocol/a2a/routes.py b/src/supervaizer/protocol/a2a/routes.py index 866cf8c..0d49aa5 100644 --- a/src/supervaizer/protocol/a2a/routes.py +++ b/src/supervaizer/protocol/a2a/routes.py @@ -25,6 +25,7 @@ create_health_data, ) from supervaizer.routes import handle_route_errors +from supervaizer.workspace_authorization import extract_workspace_authorization_token if TYPE_CHECKING: from supervaizer.agent import Agent @@ -121,9 +122,17 @@ def create_controller_routes(server: "Server") -> APIRouter: dependencies=[Depends(require_scope("write"))], ) @handle_route_errors() - async def post_a2a_controller(body: dict[str, Any]) -> dict[str, Any]: + async def post_a2a_controller( + request: Request, body: dict[str, Any] + ) -> dict[str, Any]: log.info("[A2A] POST /a2a [JSON-RPC controller]") - response = await dispatch_json_rpc(server, body) + response = await dispatch_json_rpc( + server, + body, + workspace_authorization_token=extract_workspace_authorization_token( + request.headers + ), + ) return response.model_dump(mode="json", exclude_none=True) @router.get( diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index c790979..37f3e36 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -50,7 +50,11 @@ is_local_mode, log, ) -from supervaizer.contracts import API_VERSION, controller_contract_info +from supervaizer.contracts import ( + API_VERSION, + V2WorkspaceAuthorizationSettings, + controller_contract_info, +) from supervaizer.instructions import display_instructions from supervaizer.protocol.a2a.controller import ( ActionHandler, @@ -65,6 +69,9 @@ ) # <-- ADDED from supervaizer.routes import get_server # <-- MODIFIED: removed per-router imports from supervaizer.storage import StorageManager, load_running_entities_on_startup +from supervaizer.workspace_authorization import ( + validate_workspace_authorization_settings, +) insp = inspect @@ -89,6 +96,30 @@ def _controller_key_fingerprint(api_key: str | None) -> str | None: return sha256(api_key.encode("utf-8")).hexdigest()[:12] +def _resolve_workspace_authorization_settings( + explicit_settings: V2WorkspaceAuthorizationSettings | dict[str, Any] | None, +) -> V2WorkspaceAuthorizationSettings: + if explicit_settings is not None: + return V2WorkspaceAuthorizationSettings.model_validate(explicit_settings) + return V2WorkspaceAuthorizationSettings( + enabled=_env_bool("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", default=False), + issuer=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER") or None, + audience=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE") or None, + public_key_pem=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY") or None, + jwks_url=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL") or None, + leeway_seconds=int( + os.getenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "30") + ), + ) + + +def _env_bool(name: str, *, default: bool) -> bool: + raw_value = os.getenv(name) + if raw_value is None: + return default + return raw_value.strip().lower() in {"1", "true", "yes", "on"} + + def _get_or_create_private_key() -> RSAPrivateKey: """Use SUPERVAIZER_PRIVATE_KEY from env if set; else create key and set env.""" pem = os.getenv("SUPERVAIZER_PRIVATE_KEY") @@ -316,6 +347,10 @@ class ServerAbstract(SvBaseModel): api_key_header: APIKeyHeader | None = Field( default=None, description="API key header for authentication" ) + workspace_authorization: V2WorkspaceAuthorizationSettings = Field( + default_factory=V2WorkspaceAuthorizationSettings, + description="Optional Studio-signed workspace authorization verifier settings", + ) model_config = cast( ConfigDict, @@ -380,6 +415,9 @@ def __init__( private_key: RSAPrivateKey | None = None, public_url: str | None = None, api_key: str | None = None, + workspace_authorization: V2WorkspaceAuthorizationSettings + | dict[str, Any] + | None = None, **kwargs: Any, ) -> None: """Initialize the server with the given configuration. @@ -461,6 +499,10 @@ def __init__( if private_key is None: private_key = _get_or_create_private_key() + workspace_authorization_settings = _resolve_workspace_authorization_settings( + workspace_authorization + ) + validate_workspace_authorization_settings(workspace_authorization_settings) public_key = private_key.public_key() log.info(f"[Server launch] Public key: {public_key}") @@ -545,6 +587,7 @@ async def validation_exception_handler( public_url=public_url, api_key=api_key, api_key_header=api_key_header, + workspace_authorization=workspace_authorization_settings, **kwargs, ) @@ -672,6 +715,7 @@ def registration_info(self) -> dict[str, Any]: "url": self.public_url, "uri": self.uri, "api_version": API_VERSION, + "controller_version": VERSION, **contract, "environment": self.environment, "public_key": str( @@ -731,6 +775,8 @@ def log_queue_handler(message: Any) -> None: f"[Server launch] Starting Supervaize Controller API v{VERSION} - Log : {log_level} " ) + self._validate_studio_a2a_workspace_authorization() + # self.instructions() if self.supervisor_account: # Register the server with the supervisor account @@ -785,6 +831,7 @@ def _validate_registration_handshake(self, result: ApiSuccess) -> None: f"response_keys={response_keys}" ) if handshake.get("controller_api_key_match") is True: + self._apply_workspace_authorization_handshake(handshake) log.info( "[Server launch] Studio registration handshake verified " f"server_id={handshake.get('server_id')} " @@ -799,6 +846,99 @@ def _validate_registration_handshake(self, result: ApiSuccess) -> None: f"reason={handshake.get('reason')}" ) + def _validate_studio_a2a_workspace_authorization(self) -> None: + if not self.a2a_endpoints or self.supervisor_account is None: + return + if self.workspace_authorization.enabled: + return + raise RuntimeError( + "Studio-registered Supervaizer v2 A2A requires workspace authorization. " + "Set SUPERVAIZER_WORKSPACE_AUTH_REQUIRED=true and configure " + "SUPERVAIZER_WORKSPACE_AUTH_ISSUER plus either " + "SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY or SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL." + ) + + def _apply_workspace_authorization_handshake( + self, handshake: dict[str, Any] + ) -> None: + if not self.workspace_authorization.enabled: + return + + workspace_authorization = handshake.get("workspace_authorization") + if not isinstance(workspace_authorization, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization is missing." + ) + + audience = workspace_authorization.get("audience") + if not isinstance(audience, str) or not audience.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.audience is missing." + ) + + configured_audience = self.workspace_authorization.audience + if configured_audience and configured_audience != audience: + raise RuntimeError( + "Studio registration handshake failed: configured workspace authorization " + "audience does not match Studio's server audience." + ) + + self.workspace_authorization = self.workspace_authorization.model_copy( + update={"audience": audience} + ) + agent_bindings = workspace_authorization.get("agents") + if not isinstance(agent_bindings, list): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.agents is missing." + ) + self._apply_workspace_authorization_agent_bindings(agent_bindings) + + def _apply_workspace_authorization_agent_bindings( + self, agent_bindings: list[Any] + ) -> None: + bindings_by_slug: dict[str, str] = {} + for binding in agent_bindings: + if not isinstance(binding, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding must be an object." + ) + agent_id = binding.get("id") + agent_slug = binding.get("slug") + if not isinstance(agent_id, str) or not agent_id.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing id." + ) + if not isinstance(agent_slug, str) or not agent_slug.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing slug." + ) + bindings_by_slug[agent_slug] = agent_id + + missing_agents = [] + for agent in self.agents: + studio_agent_id = bindings_by_slug.get(agent.slug) + if not studio_agent_id: + missing_agents.append(agent.slug) + continue + if agent.server_agent_id and agent.server_agent_id != studio_agent_id: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + f"id mismatch for slug={agent.slug}." + ) + agent.server_agent_id = studio_agent_id + + if missing_agents: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization did not " + f"return Studio agent id(s) for slug(s): {', '.join(missing_agents)}" + ) + def decrypt(self, encrypted_parameters: str) -> str: """Decrypt parameters using the server's private key.""" result = decrypt_value(encrypted_parameters, self.private_key) diff --git a/src/supervaizer/workspace_authorization.py b/src/supervaizer/workspace_authorization.py new file mode 100644 index 0000000..5bbbee0 --- /dev/null +++ b/src/supervaizer/workspace_authorization.py @@ -0,0 +1,520 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Stateless Studio-signed workspace authorization for Supervaizer v2.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import json +from collections import OrderedDict +import threading +import time +from collections.abc import Iterable, Mapping +from typing import Any, TypeAlias + +from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +import httpx +from pydantic import Field, ValidationError, field_validator + +from supervaizer.contracts import ( + ContractModel, + V2VerifiedWorkspaceContext, + V2WorkspaceAuthorizationSettings, + V2WorkspaceContext, +) + +WORKSPACE_AUTHORIZATION_HEADER = "X-Supervaize-Workspace-Authorization" +WORKSPACE_AUTHORIZATION_ALGORITHM = "EdDSA" +JWKS_KEY_CACHE_MAX_SIZE = 16 + +WorkspaceAuthorizationPublicKey: TypeAlias = ed25519.Ed25519PublicKey +_JwksKeyCacheKey: TypeAlias = tuple[str, str] +_JWKS_KEY_CACHE: OrderedDict[_JwksKeyCacheKey, WorkspaceAuthorizationPublicKey] = ( + OrderedDict() +) +_JWKS_KEY_CACHE_LOCK = threading.Lock() + + +class WorkspaceAuthorizationError(ValueError): + """Raised when a workspace authorization token cannot authorize a request.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class WorkspaceAuthorizationClaims(ContractModel): + iss: str + aud: str | list[str] + sub: str | None = None + grant_id: str + workspace_id: str + workspace_slug: str | None = None + agent_id: str + agent_slug: str + server_id: str + scopes: list[str] = Field(default_factory=list) + agent_workspace_ref: str | None = None + iat: int | None = None + exp: int + jti: str | None = None + + @field_validator("scopes") + @classmethod + def scopes_must_not_be_empty(cls, value: list[str]) -> list[str]: + if not value: + raise ValueError("workspace authorization token must include scopes") + return value + + +def workspace_authorization_enabled(server: Any) -> bool: + settings = get_workspace_authorization_settings(server) + return settings.enabled + + +def get_workspace_authorization_settings( + server: Any, +) -> V2WorkspaceAuthorizationSettings: + value = getattr(server, "workspace_authorization", None) + if isinstance(value, V2WorkspaceAuthorizationSettings): + return value + if value is None: + return V2WorkspaceAuthorizationSettings() + return V2WorkspaceAuthorizationSettings.model_validate(value) + + +def validate_workspace_authorization_settings( + settings: V2WorkspaceAuthorizationSettings, +) -> None: + if not settings.enabled: + return + if not settings.issuer: + raise ValueError( + "Workspace authorization is enabled but issuer is not configured" + ) + if not settings.public_key_pem and not settings.jwks_url: + raise ValueError( + "Workspace authorization is enabled but no public_key_pem or jwks_url " + "is configured" + ) + + +def extract_workspace_authorization_token(headers: Mapping[str, str]) -> str | None: + raw_value = _get_header(headers, WORKSPACE_AUTHORIZATION_HEADER) + if raw_value is None: + return None + value = raw_value.strip() + if value.lower().startswith("bearer "): + value = value[7:].strip() + return value or None + + +def verify_workspace_authorization_for_request( + *, + server: Any, + token: str | None, + required_scopes: Iterable[str], + request_workspace: V2WorkspaceContext, + agent_slug: str, + require_configured: bool = True, +) -> V2VerifiedWorkspaceContext | None: + settings = get_workspace_authorization_settings(server) + if not settings.enabled: + if require_configured: + raise WorkspaceAuthorizationError( + "workspace_authorization_not_configured", + "Workspace authorization is required but is not configured for this Supervaizer server", + ) + return None + validate_workspace_authorization_settings(settings) + if not token: + raise WorkspaceAuthorizationError( + "workspace_authorization_missing", + f"Missing {WORKSPACE_AUTHORIZATION_HEADER} header", + ) + + claims = _verify_signed_token( + token=token, + settings=settings, + expected_audience=settings.audience or f"supervaizer-server:{server.server_id}", + ) + _validate_claims_against_request( + claims=claims, + server=server, + agent_slug=agent_slug, + request_workspace=request_workspace, + required_scopes=list(required_scopes), + leeway_seconds=settings.leeway_seconds, + expected_server_id=_expected_server_id(server, settings), + ) + return V2VerifiedWorkspaceContext( + grant_id=claims.grant_id, + workspace_id=claims.workspace_id, + workspace_slug=claims.workspace_slug, + agent_id=claims.agent_id, + agent_slug=claims.agent_slug, + server_id=claims.server_id, + scopes=claims.scopes, + agent_workspace_ref=claims.agent_workspace_ref, + ) + + +async def verify_workspace_authorization_for_request_async( + *, + server: Any, + token: str | None, + required_scopes: Iterable[str], + request_workspace: V2WorkspaceContext, + agent_slug: str, + require_configured: bool = True, +) -> V2VerifiedWorkspaceContext | None: + return await asyncio.to_thread( + verify_workspace_authorization_for_request, + server=server, + token=token, + required_scopes=required_scopes, + request_workspace=request_workspace, + agent_slug=agent_slug, + require_configured=require_configured, + ) + + +def _verify_signed_token( + *, + token: str, + settings: V2WorkspaceAuthorizationSettings, + expected_audience: str, +) -> WorkspaceAuthorizationClaims: + header, payload, signature, signed_data = _split_jwt(token) + algorithm = header.get("alg") + if algorithm != WORKSPACE_AUTHORIZATION_ALGORITHM: + raise WorkspaceAuthorizationError( + "workspace_authorization_unsupported_alg", + "Workspace authorization token must be signed with EdDSA", + ) + + public_key = _load_public_key( + header=header, + settings=settings, + ) + try: + _verify_signature( + public_key=public_key, + signature=signature, + signed_data=signed_data, + ) + except InvalidSignature as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_bad_signature", + "Workspace authorization token signature is invalid", + ) from exc + + try: + claims = WorkspaceAuthorizationClaims.model_validate(payload) + except ValidationError as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_claims", + f"Workspace authorization token claims are invalid: {exc.errors()}", + ) from exc + + if claims.iss != settings.issuer: + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_issuer", + "Workspace authorization token issuer does not match this server " + "configuration", + ) + if not _audience_matches(claims.aud, expected_audience): + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_audience", + "Workspace authorization token audience does not match this server", + ) + return claims + + +def _validate_claims_against_request( + *, + claims: WorkspaceAuthorizationClaims, + server: Any, + agent_slug: str, + request_workspace: V2WorkspaceContext, + required_scopes: list[str], + leeway_seconds: int, + expected_server_id: str, +) -> None: + now = int(time.time()) + if claims.exp + leeway_seconds < now: + raise WorkspaceAuthorizationError( + "workspace_authorization_expired", + "Workspace authorization token is expired", + ) + if claims.iat is not None and claims.iat - leeway_seconds > now: + raise WorkspaceAuthorizationError( + "workspace_authorization_not_yet_valid", + "Workspace authorization token was issued in the future", + ) + if claims.server_id != expected_server_id: + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_server", + "Workspace authorization token server_id does not match this server", + ) + agent = _get_agent_by_slug(server, agent_slug) + if claims.agent_slug != agent_slug: + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_agent", + "Workspace authorization token agent_slug does not match the request", + ) + if claims.agent_id != _expected_agent_id(agent): + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_agent", + "Workspace authorization token agent_id does not match the request", + ) + if claims.workspace_id != request_workspace.id: + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_workspace", + "Workspace authorization token workspace_id does not match the request", + ) + if ( + request_workspace.slug + and claims.workspace_slug + and claims.workspace_slug != request_workspace.slug + ): + raise WorkspaceAuthorizationError( + "workspace_authorization_wrong_workspace", + "Workspace authorization token workspace_slug does not match the request", + ) + missing_scopes = [ + scope for scope in required_scopes if scope not in set(claims.scopes) + ] + if missing_scopes: + raise WorkspaceAuthorizationError( + "workspace_authorization_missing_scope", + f"Workspace authorization token is missing scope(s): " + f"{', '.join(missing_scopes)}", + ) + + +def _expected_server_id(server: Any, settings: V2WorkspaceAuthorizationSettings) -> str: + audience_prefix = "supervaizer-server:" + if settings.audience and settings.audience.startswith(audience_prefix): + return settings.audience[len(audience_prefix) :] + return str(server.server_id) + + +def _expected_agent_id(agent: Any) -> str: + server_agent_id = getattr(agent, "server_agent_id", None) + if not server_agent_id: + raise WorkspaceAuthorizationError( + "workspace_authorization_agent_not_registered", + "Workspace authorization requires the Studio agent id from registration, " + "but this agent has not been linked to Studio", + ) + return str(server_agent_id) + + +def _get_agent_by_slug(server: Any, agent_slug: str) -> Any: + for agent in server.agents: + if agent.slug == agent_slug: + return agent + raise WorkspaceAuthorizationError( + "workspace_authorization_unknown_agent", + f"Unknown agent_slug for workspace authorization: {agent_slug}", + ) + + +def _split_jwt( + token: str, +) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]: + parts = token.split(".") + if len(parts) != 3: + raise WorkspaceAuthorizationError( + "workspace_authorization_malformed", + "Workspace authorization token must be a compact JWT", + ) + encoded_header, encoded_payload, encoded_signature = parts + try: + header = json.loads(_base64url_decode(encoded_header)) + payload = json.loads(_base64url_decode(encoded_payload)) + signature = _base64url_decode(encoded_signature) + except (binascii.Error, json.JSONDecodeError, ValueError) as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_malformed", + "Workspace authorization token is malformed", + ) from exc + if not isinstance(header, dict) or not isinstance(payload, dict): + raise WorkspaceAuthorizationError( + "workspace_authorization_malformed", + "Workspace authorization token header and claims must be JSON objects", + ) + return ( + header, + payload, + signature, + f"{encoded_header}.{encoded_payload}".encode("ascii"), + ) + + +def _load_public_key( + *, + header: dict[str, Any], + settings: V2WorkspaceAuthorizationSettings, +) -> WorkspaceAuthorizationPublicKey: + if settings.public_key_pem: + return _load_public_key_pem(settings.public_key_pem) + if settings.jwks_url: + return _load_public_key_from_jwks( + header=header, + jwks_url=settings.jwks_url, + ) + raise WorkspaceAuthorizationError( + "workspace_authorization_not_configured", + "Workspace authorization public key is not configured", + ) + + +def _load_public_key_pem(public_key_pem: str) -> WorkspaceAuthorizationPublicKey: + try: + key = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + except (ValueError, TypeError, UnsupportedAlgorithm) as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_key", + "Workspace authorization EdDSA public key could not be loaded", + ) from exc + if not isinstance(key, ed25519.Ed25519PublicKey): + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_key", + "Workspace authorization EdDSA public key must be an Ed25519 public key", + ) + return key + + +def _verify_signature( + *, + public_key: WorkspaceAuthorizationPublicKey, + signature: bytes, + signed_data: bytes, +) -> None: + public_key.verify(signature, signed_data) + + +def _load_public_key_from_jwks( + *, header: dict[str, Any], jwks_url: str +) -> WorkspaceAuthorizationPublicKey: + key_id = header.get("kid") + if not isinstance(key_id, str) or not key_id: + raise WorkspaceAuthorizationError( + "workspace_authorization_missing_kid", + "Workspace authorization token header must include kid for JWKS", + ) + cached_key = _get_cached_jwks_key(jwks_url, key_id) + if cached_key is not None: + return cached_key + try: + response = httpx.get(jwks_url, timeout=5) + response.raise_for_status() + jwks = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_jwks_unavailable", + "Workspace authorization JWKS could not be loaded", + ) from exc + keys = jwks.get("keys", []) + if not isinstance(keys, list): + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_jwks", + "Workspace authorization JWKS payload has no keys list", + ) + for key_data in keys: + if not isinstance(key_data, dict): + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_jwks", + "Workspace authorization JWKS keys must be JSON objects", + ) + if key_data.get("kid") == key_id: + public_key = _ed25519_public_key_from_jwk(key_data) + _cache_jwks_key(jwks_url, key_id, public_key) + return public_key + raise WorkspaceAuthorizationError( + "workspace_authorization_unknown_kid", + "Workspace authorization JWKS has no key matching token kid", + ) + + +def _ed25519_public_key_from_jwk(jwk: dict[str, Any]) -> ed25519.Ed25519PublicKey: + if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519": + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_jwk", + "Workspace authorization EdDSA JWK must be OKP/Ed25519", + ) + key_material = jwk.get("x") + if not isinstance(key_material, str) or not key_material: + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_jwk", + "Workspace authorization Ed25519 JWK must include x", + ) + try: + public_bytes = _base64url_decode(key_material) + if len(public_bytes) != 32: + raise ValueError("Ed25519 public key material must be 32 bytes") + return ed25519.Ed25519PublicKey.from_public_bytes(public_bytes) + except (binascii.Error, TypeError, ValueError) as exc: + raise WorkspaceAuthorizationError( + "workspace_authorization_invalid_jwk", + "Workspace authorization Ed25519 JWK x is invalid", + ) from exc + + +def _get_cached_jwks_key( + jwks_url: str, key_id: str +) -> WorkspaceAuthorizationPublicKey | None: + cache_key = (jwks_url, key_id) + with _JWKS_KEY_CACHE_LOCK: + public_key = _JWKS_KEY_CACHE.get(cache_key) + if public_key is None: + return None + _JWKS_KEY_CACHE.move_to_end(cache_key) + return public_key + + +def _cache_jwks_key( + jwks_url: str, + key_id: str, + public_key: WorkspaceAuthorizationPublicKey, +) -> None: + cache_key = (jwks_url, key_id) + with _JWKS_KEY_CACHE_LOCK: + _JWKS_KEY_CACHE[cache_key] = public_key + _JWKS_KEY_CACHE.move_to_end(cache_key) + while len(_JWKS_KEY_CACHE) > JWKS_KEY_CACHE_MAX_SIZE: + _JWKS_KEY_CACHE.popitem(last=False) + + +def _audience_matches(audience: str | list[str], expected: str) -> bool: + if isinstance(audience, str): + return audience == expected + return expected in audience + + +def _base64url_decode(value: str) -> bytes: + padding_length = (-len(value)) % 4 + return base64.b64decode( + value + ("=" * padding_length), + altchars=b"-_", + validate=True, + ) + + +def _get_header(headers: Mapping[str, str], name: str) -> str | None: + lower_name = name.lower() + for key, value in headers.items(): + if key.lower() == lower_name: + return value + return None diff --git a/tests/test_a2a.py b/tests/test_a2a.py index cb7c884..8f704b3 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -10,8 +10,15 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import base64 +import json +import threading +import time + import jsonschema import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from fastapi.testclient import TestClient from supervaizer import Agent, Server @@ -21,6 +28,7 @@ V2ActionResult, V2Effect, V2SurfaceRequest, + V2WorkspaceAuthorizationSettings, ) from supervaizer.protocol.a2a import ( create_agent_card, @@ -33,8 +41,10 @@ JSON_RPC_INTERNAL_ERROR, JSON_RPC_METHOD_NOT_FOUND, JSON_RPC_SURFACE_NOT_REGISTERED, + JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED, SUPERVAIZER_ACTION_INVOKE_METHOD, SUPERVAIZER_SURFACE_LOAD_METHOD, + dispatch_json_rpc, register_v2_action_handler, register_v2_surface_handler, ) @@ -43,12 +53,37 @@ subscribe_v2_events, unsubscribe_v2_events, ) +from supervaizer.workspace_authorization import WORKSPACE_AUTHORIZATION_HEADER def _a2a_write_headers(server: Server) -> dict[str, str]: return {"X-API-Key": server.api_key or ""} +def _a2a_workspace_headers(server: Server, token: str) -> dict[str, str]: + return { + **_a2a_write_headers(server), + WORKSPACE_AUTHORIZATION_HEADER: f"Bearer {token}", + } + + +def _authorized_a2a_headers( + server: Server, *, agent_slug: str, scopes: list[str] +) -> dict[str, str]: + key = _enable_workspace_authorization_eddsa( + server, + agent_slug=agent_slug, + studio_agent_id=f"studio-{agent_slug}", + ) + token = _workspace_authorization_eddsa_token( + server, + key, + agent_slug=agent_slug, + scopes=scopes, + ) + return _a2a_workspace_headers(server, token) + + def test_create_agent_card(agent_fixture: Agent) -> None: """Test the create_agent_card function.""" base_url = "http://test.example.com" @@ -276,16 +311,22 @@ def test_a2a_controller_rejects_unknown_method(server_fixture: Server) -> None: def test_a2a_controller_rejects_unregistered_v2_action( server_fixture: Server, ) -> None: + agent = server_fixture.agents[0] + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) client = TestClient(server_fixture.app) response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=headers, json={ "jsonrpc": "2.0", "id": "rpc-2", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start"), + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), }, ) @@ -293,23 +334,29 @@ def test_a2a_controller_rejects_unregistered_v2_action( payload = response.json() assert payload["id"] == "rpc-2" assert payload["error"]["code"] == JSON_RPC_ACTION_NOT_REGISTERED - assert payload["error"]["data"]["agent_slug"] == "agent-interviewer" + assert payload["error"]["data"]["agent_slug"] == agent.slug assert payload["error"]["data"]["action"] == "job.start" def test_a2a_controller_rejects_unregistered_v2_surface( server_fixture: Server, ) -> None: + agent = server_fixture.agents[0] + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_SURFACE_LOAD_METHOD, "job.start"], + ) client = TestClient(server_fixture.app) response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=headers, json={ "jsonrpc": "2.0", "id": "rpc-surface-1", "method": SUPERVAIZER_SURFACE_LOAD_METHOD, - "params": _v2_surface_payload(surface="job.start"), + "params": _v2_surface_payload(surface="job.start", agent_slug=agent.slug), }, ) @@ -317,7 +364,7 @@ def test_a2a_controller_rejects_unregistered_v2_surface( payload = response.json() assert payload["id"] == "rpc-surface-1" assert payload["error"]["code"] == JSON_RPC_SURFACE_NOT_REGISTERED - assert payload["error"]["data"]["agent_slug"] == "agent-interviewer" + assert payload["error"]["data"]["agent_slug"] == agent.slug assert payload["error"]["data"]["surface"] == "job.start" @@ -382,7 +429,12 @@ def test_a2a_events_remains_read_scoped(server_fixture: Server) -> None: def test_a2a_controller_dispatches_registered_v2_action( server_fixture: Server, ) -> None: - agent_slug = server_fixture.agents[0].slug + agent = server_fixture.agents[0] + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) def start_job(request: V2ActionRequest) -> V2ActionResult: assert request.action == "job.start" @@ -396,12 +448,12 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=headers, json={ "jsonrpc": "2.0", "id": "rpc-3", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), }, ) @@ -414,22 +466,19 @@ def start_job(request: V2ActionRequest) -> V2ActionResult: ] -def test_a2a_controller_serializes_v2_action_replay_safety( +def test_a2a_workspace_authorization_missing_token_blocks_action_handler( server_fixture: Server, ) -> None: agent_slug = server_fixture.agents[0].slug + _enable_workspace_authorization_eddsa(server_fixture) + called = False - def sync_job(_request: V2ActionRequest) -> dict[str, object]: - return { - "status": "ok", - "replay_safety": { - "dedupe_keys": ["job-123", "rev-1"], - "convergent": True, - "strictly_idempotent_response": False, - }, - } + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") - register_v2_action_handler(server_fixture, "job.sync", sync_job) + register_v2_action_handler(server_fixture, "job.start", start_job) client = TestClient(server_fixture.app) response = client.post( @@ -437,28 +486,29 @@ def sync_job(_request: V2ActionRequest) -> dict[str, object]: headers=_a2a_write_headers(server_fixture), json={ "jsonrpc": "2.0", - "id": "rpc-replay-safety-1", + "id": "rpc-workspace-auth-missing", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.sync", agent_slug=agent_slug), + "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), }, ) + payload = response.json() assert response.status_code == 200 - assert response.json()["result"]["replay_safety"] == { - "dedupe_keys": ["job-123", "rev-1"], - "stable_external_ids_required": True, - "strictly_idempotent_response": False, - "convergent": True, - } + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_missing" -def test_a2a_controller_action_errors_do_not_leak_exception_details( +def test_a2a_workspace_authorization_not_configured_blocks_action_handler( server_fixture: Server, ) -> None: agent_slug = server_fixture.agents[0].slug + called = False def start_job(_request: V2ActionRequest) -> V2ActionResult: - raise RuntimeError("database password secret-value leaked") + nonlocal called + called = True + return V2ActionResult(status="ok") register_v2_action_handler(server_fixture, "job.start", start_job) client = TestClient(server_fixture.app) @@ -468,76 +518,80 @@ def start_job(_request: V2ActionRequest) -> V2ActionResult: headers=_a2a_write_headers(server_fixture), json={ "jsonrpc": "2.0", - "id": "rpc-error-1", + "id": "rpc-workspace-auth-not-configured", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), }, ) - assert response.status_code == 200 payload = response.json() - assert payload["error"]["code"] == JSON_RPC_INTERNAL_ERROR - assert payload["error"]["data"] == { - "agent_slug": agent_slug, - "action": "job.start", - } - assert "secret-value" not in response.text + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_not_configured" -def test_a2a_controller_publishes_v2_action_effects( +def test_a2a_workspace_binding_options_action_bootstraps_without_token( server_fixture: Server, ) -> None: - agent_slug = server_fixture.agents[0].slug - queue = subscribe_v2_events(server_fixture) + agent = server_fixture.agents[0] + _enable_workspace_authorization_eddsa(server_fixture) + captured: dict[str, object] = {} - def start_job(_request: V2ActionRequest) -> V2ActionResult: + def list_options(request: V2ActionRequest) -> V2ActionResult: + captured["workspace_authorization"] = request.workspace_authorization return V2ActionResult( status="ok", - effects=[V2Effect(type="job.started", job_id="job-123")], + effects=[ + V2Effect( + type="workspace_binding.options", + items=[{"value": "agent-workspace-1", "label": "Workspace 1"}], + ) + ], ) - try: - register_v2_action_handler(server_fixture, "job.start", start_job) - client = TestClient(server_fixture.app) + register_v2_action_handler( + server_fixture, "workspace_binding.options", list_options + ) + client = TestClient(server_fixture.app) - response = client.post( - "/a2a", - headers=_a2a_write_headers(server_fixture), - json={ - "jsonrpc": "2.0", - "id": "rpc-event-1", - "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), - }, - ) + response = client.post( + "/a2a", + headers=_a2a_write_headers(server_fixture), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-binding-options", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action="workspace_binding.options", agent_slug=agent.slug + ), + }, + ) - assert response.status_code == 200 - event = queue.get_nowait() - assert event["event"] == A2A_EFFECT_EVENT - assert event["data"] == { - "agent_slug": agent_slug, - "action": "job.start", - "request_id": "request-1", - "effects": [{"type": "job.started", "job_id": "job-123"}], - } - finally: - unsubscribe_v2_events(server_fixture, queue) + payload = response.json() + assert response.status_code == 200 + assert payload["result"]["status"] == "ok" + assert payload["result"]["effects"][0]["items"] == [ + {"value": "agent-workspace-1", "label": "Workspace 1"} + ] + assert captured == {"workspace_authorization": None} -def test_a2a_controller_dispatches_registered_v2_surface( +def test_a2a_workspace_binding_unknown_action_requires_token( server_fixture: Server, ) -> None: - agent_slug = server_fixture.agents[0].slug + agent = server_fixture.agents[0] + _enable_workspace_authorization_eddsa(server_fixture) + called = False - def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: - assert request.surface == "job.start" - return { - "surface": "job.start", - "a2ui_version": "v0.8", - "document": {"type": "Form", "fields": []}, - } + def delete_binding(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") - register_v2_surface_handler(server_fixture, "job.start", load_job_start) + register_v2_action_handler( + server_fixture, "workspace_binding.delete", delete_binding + ) client = TestClient(server_fixture.app) response = client.post( @@ -545,31 +599,41 @@ def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: headers=_a2a_write_headers(server_fixture), json={ "jsonrpc": "2.0", - "id": "rpc-surface-2", - "method": SUPERVAIZER_SURFACE_LOAD_METHOD, - "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + "id": "rpc-workspace-binding-delete", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action="workspace_binding.delete", agent_slug=agent.slug + ), }, ) - assert response.status_code == 200 payload = response.json() - assert payload["id"] == "rpc-surface-2" - assert payload["result"] == { - "surface": "job.start", - "a2ui_version": "v0.8", - "a2ui_catalog_version": None, - "document": {"type": "Form", "fields": []}, - } + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_missing" -def test_server_v2_action_decorator_registers_handler(server_fixture: Server) -> None: - agent_slug = server_fixture.agents[0].slug +def test_a2a_workspace_binding_create_surface_bootstraps_without_token( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + _enable_workspace_authorization_eddsa(server_fixture) + captured: dict[str, object] = {} - @server_fixture.v2_action("job.start.preview") - def preview_job_start(request: V2ActionRequest) -> dict[str, object]: - assert request.action == "job.start.preview" - return {"status": "ok", "effects": [{"type": "job.start.previewed"}]} + def load_create_surface(request: V2SurfaceRequest) -> dict[str, object]: + captured["workspace_authorization"] = request.workspace_authorization + return { + "surface": "workspace_binding.create", + "document": { + "type": "Form", + "fields": [{"id": "display_name", "label": "Display name"}], + }, + } + register_v2_surface_handler( + server_fixture, "workspace_binding.create", load_create_surface + ) client = TestClient(server_fixture.app) response = client.post( @@ -577,124 +641,1111 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]: headers=_a2a_write_headers(server_fixture), json={ "jsonrpc": "2.0", - "id": "rpc-4", - "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload( - action="job.start.preview", agent_slug=agent_slug + "id": "rpc-workspace-binding-create-surface", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload( + surface="workspace_binding.create", agent_slug=agent.slug ), }, ) - assert response.status_code == 200 payload = response.json() - assert payload["id"] == "rpc-4" - assert payload["result"] == { - "status": "ok", - "effects": [{"type": "job.start.previewed"}], - } + assert response.status_code == 200 + assert payload["result"]["surface"] == "workspace_binding.create" + assert captured == {"workspace_authorization": None} -def test_server_v2_surface_decorator_registers_handler(server_fixture: Server) -> None: - agent_slug = server_fixture.agents[0].slug +def test_a2a_workspace_authorization_valid_token_reaches_action_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + captured: dict[str, str] = {} - @server_fixture.v2_surface("job.start") - def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: - assert request.surface == "job.start" - return { - "surface": "job.start", - "document": {"type": "Form", "submit": {"action": "job.start"}}, - } + def start_job(request: V2ActionRequest) -> V2ActionResult: + assert request.workspace_authorization is not None + captured["grant_id"] = request.workspace_authorization.grant_id + captured["workspace_ref"] = ( + request.workspace_authorization.agent_workspace_ref or "" + ) + return V2ActionResult(status="ok") + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) client = TestClient(server_fixture.app) response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=_a2a_workspace_headers(server_fixture, token), json={ "jsonrpc": "2.0", - "id": "rpc-surface-3", - "method": SUPERVAIZER_SURFACE_LOAD_METHOD, - "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + "id": "rpc-workspace-auth-ok", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), }, ) assert response.status_code == 200 - payload = response.json() - assert payload["id"] == "rpc-surface-3" - assert payload["result"]["surface"] == "job.start" - assert payload["result"]["document"]["submit"] == {"action": "job.start"} + assert response.json()["result"]["status"] == "ok" + assert captured == {"grant_id": "grant-1", "workspace_ref": "agent-workspace-1"} -def test_v2_action_handlers_are_scoped_by_agent_slug(server_fixture: Server) -> None: - first_slug = server_fixture.agents[0].slug - second_agent = Agent( - name="Second Agent", - author="authorName", - developer="Dev", - version="1.0.0", - description="description", +def test_a2a_workspace_authorization_uses_studio_server_audience( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + server_fixture.workspace_authorization = ( + server_fixture.workspace_authorization.model_copy( + update={"audience": "supervaizer-server:studio-server-1"} + ) ) - server_fixture.agents.append(second_agent) + captured: dict[str, str] = {} - register_v2_action_handler( - server_fixture, - "job.start", - lambda _request: {"status": "ok", "effects": [{"type": "first-agent"}]}, - agent_slug=first_slug, - ) - register_v2_action_handler( + def start_job(request: V2ActionRequest) -> V2ActionResult: + assert request.workspace_authorization is not None + captured["server_id"] = request.workspace_authorization.server_id + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( server_fixture, - "job.start", - lambda _request: {"status": "ok", "effects": [{"type": "second-agent"}]}, - agent_slug=second_agent.slug, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + claim_overrides={ + "aud": "supervaizer-server:studio-server-1", + "server_id": "studio-server-1", + }, ) client = TestClient(server_fixture.app) - first_response = client.post( + response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=_a2a_workspace_headers(server_fixture, token), json={ "jsonrpc": "2.0", - "id": "rpc-5", + "id": "rpc-workspace-auth-studio-server", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload(action="job.start", agent_slug=first_slug), + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), }, ) - second_response = client.post( + + assert response.status_code == 200 + assert response.json()["result"]["status"] == "ok" + assert captured == {"server_id": "studio-server-1"} + + +def test_a2a_workspace_authorization_uses_studio_agent_id( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + captured: dict[str, str] = {} + + def start_job(request: V2ActionRequest) -> V2ActionResult: + assert request.workspace_authorization is not None + captured["agent_id"] = request.workspace_authorization.agent_id + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + claim_overrides={"agent_id": "studio-agent-1"}, + ) + client = TestClient(server_fixture.app) + + response = client.post( "/a2a", - headers=_a2a_write_headers(server_fixture), + headers=_a2a_workspace_headers(server_fixture, token), json={ "jsonrpc": "2.0", - "id": "rpc-6", + "id": "rpc-workspace-auth-studio-agent", "method": SUPERVAIZER_ACTION_INVOKE_METHOD, - "params": _v2_action_payload( - action="job.start", agent_slug=second_agent.slug - ), + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), }, ) - assert first_response.json()["result"]["effects"] == [{"type": "first-agent"}] - assert second_response.json()["result"]["effects"] == [{"type": "second-agent"}] + assert response.status_code == 200 + assert response.json()["result"]["status"] == "ok" + assert captured == {"agent_id": "studio-agent-1"} -def test_v2_action_registration_requires_agent_slug_for_multi_agent_server( +def test_a2a_workspace_authorization_requires_studio_agent_id( server_fixture: Server, ) -> None: - server_fixture.agents.append( - Agent( - name="Second Agent", - author="authorName", - developer="Dev", - version="1.0.0", - description="description", - ) - ) + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture, studio_agent_id=None) - with pytest.raises(ValueError, match="agent_slug is required"): - server_fixture.register_v2_action( - "job.start", - lambda _request: V2ActionResult(status="ok"), - ) + def start_job(_request: V2ActionRequest) -> V2ActionResult: + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-missing-studio-agent", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert ( + payload["error"]["data"]["code"] + == "workspace_authorization_agent_not_registered" + ) + + +def test_a2a_workspace_authorization_valid_eddsa_token_reaches_action_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + captured: dict[str, str] = {} + + def start_job(request: V2ActionRequest) -> V2ActionResult: + assert request.workspace_authorization is not None + captured["field_name_stays_exact"] = "workspace_authorization" + captured["grant_id"] = request.workspace_authorization.grant_id + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-eddsa-ok", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["status"] == "ok" + assert captured == { + "field_name_stays_exact": "workspace_authorization", + "grant_id": "grant-1", + } + + +def test_a2a_workspace_authorization_eddsa_jwks_token_reaches_action_handler( + server_fixture: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + agent = server_fixture.agents[0] + agent.server_agent_id = "studio-agent-1" + key = ed25519.Ed25519PrivateKey.generate() + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + jwks_url="https://studio.example.test/.well-known/jwks.json", + leeway_seconds=0, + ) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + kid="workspace-grant-key-1", + ) + jwks_fetch_count = 0 + + def get_jwks(_url: str, timeout: int) -> _JwksResponse: + nonlocal jwks_fetch_count + assert timeout == 5 + jwks_fetch_count += 1 + return _JwksResponse({ + "keys": [ + _ed25519_jwk( + key.public_key(), + kid="workspace-grant-key-1", + ) + ] + }) + + monkeypatch.setattr( + "supervaizer.workspace_authorization.httpx.get", + get_jwks, + ) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-eddsa-jwks-ok", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["status"] == "ok" + assert called is True + + called = False + second_response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-eddsa-jwks-cached", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + assert second_response.status_code == 200 + assert second_response.json()["result"]["status"] == "ok" + assert called is True + assert jwks_fetch_count == 1 + + +@pytest.mark.asyncio +async def test_a2a_workspace_authorization_jwks_load_runs_off_event_loop( + server_fixture: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + agent = server_fixture.agents[0] + agent.server_agent_id = "studio-agent-1" + key = ed25519.Ed25519PrivateKey.generate() + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + jwks_url="https://studio.example.test/.well-known/offloaded-jwks.json", + leeway_seconds=0, + ) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + kid="workspace-grant-key-offloaded", + ) + event_loop_thread = threading.get_ident() + fetch_threads: list[int] = [] + + def get_jwks(_url: str, timeout: int) -> _JwksResponse: + assert timeout == 5 + fetch_threads.append(threading.get_ident()) + return _JwksResponse({ + "keys": [ + _ed25519_jwk( + key.public_key(), + kid="workspace-grant-key-offloaded", + ) + ] + }) + + monkeypatch.setattr("supervaizer.workspace_authorization.httpx.get", get_jwks) + register_v2_action_handler( + server_fixture, "job.start", lambda _request: V2ActionResult(status="ok") + ) + + response = await dispatch_json_rpc( + server_fixture, + { + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-eddsa-jwks-offloaded", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + workspace_authorization_token=token, + ) + + assert response.error is None + assert response.result is not None + assert response.result["status"] == "ok" + assert fetch_threads + assert event_loop_thread not in fetch_threads + + +def test_a2a_workspace_authorization_wrong_alg_fails_before_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + header_overrides={"alg": "HS256"}, + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-wrong-alg", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_unsupported_alg" + + +@pytest.mark.parametrize("header", [[], "not-an-object", None]) +def test_a2a_workspace_authorization_non_object_header_fails_before_handler( + server_fixture: Server, + header: object, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _sign_eddsa_jwt_parts( + key, + header=header, + claims={ + "iss": "https://studio.example.test", + "aud": f"supervaizer-server:{server_fixture.server_id}", + "sub": "workspace-agent-grant:grant-1", + "grant_id": "grant-1", + "workspace_id": "workspace-1", + "workspace_slug": "workspace", + "agent_id": agent.server_agent_id or agent.id, + "agent_slug": agent.slug, + "server_id": server_fixture.server_id, + "scopes": [SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + "iat": int(time.time()), + "exp": int(time.time()) + 300, + }, + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-non-object-header", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_malformed" + + +def test_a2a_workspace_authorization_malformed_pem_fails_before_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + agent.server_agent_id = "studio-agent-1" + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + public_key_pem="not a pem public key", + leeway_seconds=0, + ) + key = ed25519.Ed25519PrivateKey.generate() + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-malformed-pem", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_invalid_key" + + +def test_a2a_workspace_authorization_rsa_public_key_fails_before_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + _enable_workspace_authorization_with_rsa_public_key(server_fixture) + ed25519_key = ed25519.Ed25519PrivateKey.generate() + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = _workspace_authorization_eddsa_token( + server_fixture, + ed25519_key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-rsa-public-key", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_invalid_key" + + +@pytest.mark.parametrize( + "jwk", + [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "workspace-grant-key-invalid", + "x": "!!!!", + }, + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "workspace-grant-key-invalid", + "x": "dG9vLXNob3J0", + }, + { + "kty": "OKP", + "crv": "X25519", + "kid": "workspace-grant-key-invalid", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + ], +) +def test_a2a_workspace_authorization_malformed_jwks_key_fails_before_handler( + server_fixture: Server, + monkeypatch: pytest.MonkeyPatch, + jwk: dict[str, object], +) -> None: + agent = server_fixture.agents[0] + agent.server_agent_id = "studio-agent-1" + key = ed25519.Ed25519PrivateKey.generate() + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + jwks_url="https://studio.example.test/.well-known/invalid-jwks.json", + leeway_seconds=0, + ) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + kid="workspace-grant-key-invalid", + ) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + def get_jwks(_url: str, timeout: int) -> _JwksResponse: + assert timeout == 5 + return _JwksResponse({"keys": [jwk]}) + + monkeypatch.setattr("supervaizer.workspace_authorization.httpx.get", get_jwks) + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-malformed-jwks-key", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_invalid_jwk" + + +@pytest.mark.parametrize("key_entry", [None, "not-a-key", []]) +def test_a2a_workspace_authorization_non_object_jwks_key_fails_before_handler( + server_fixture: Server, + monkeypatch: pytest.MonkeyPatch, + key_entry: object, +) -> None: + agent = server_fixture.agents[0] + agent.server_agent_id = "studio-agent-1" + key = ed25519.Ed25519PrivateKey.generate() + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + jwks_url="https://studio.example.test/.well-known/non-object-jwks.json", + leeway_seconds=0, + ) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + kid="workspace-grant-key-non-object", + ) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + def get_jwks(_url: str, timeout: int) -> _JwksResponse: + assert timeout == 5 + return _JwksResponse({"keys": [key_entry]}) + + monkeypatch.setattr("supervaizer.workspace_authorization.httpx.get", get_jwks) + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-non-object-jwks-key", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_invalid_jwks" + + +def test_a2a_workspace_authorization_missing_scope_blocks_surface_handler( + server_fixture: Server, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + called = False + + def load_surface(_request: V2SurfaceRequest) -> dict[str, object]: + nonlocal called + called = True + return {"surface": "job.start", "document": {"type": "Form"}} + + register_v2_surface_handler(server_fixture, "job.start", load_surface) + token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_SURFACE_LOAD_METHOD], + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": "rpc-workspace-auth-scope", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == "workspace_authorization_missing_scope" + + +@pytest.mark.parametrize( + ("token_overrides", "token_value", "expected_code"), + [ + ({"exp": 1, "iat": 1}, None, "workspace_authorization_expired"), + ( + {"aud": "supervaizer-server:another-server"}, + None, + "workspace_authorization_wrong_audience", + ), + ( + {"server_id": "another-server"}, + None, + "workspace_authorization_wrong_server", + ), + ( + {"agent_slug": "another-agent"}, + None, + "workspace_authorization_wrong_agent", + ), + ( + {"agent_id": "another-agent-id"}, + None, + "workspace_authorization_wrong_agent", + ), + (None, "not-a-jwt", "workspace_authorization_malformed"), + ], +) +def test_a2a_workspace_authorization_failures_block_action_handler( + server_fixture: Server, + token_overrides: dict[str, object] | None, + token_value: str | None, + expected_code: str, +) -> None: + agent = server_fixture.agents[0] + key = _enable_workspace_authorization_eddsa(server_fixture) + called = False + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + nonlocal called + called = True + return V2ActionResult(status="ok") + + register_v2_action_handler(server_fixture, "job.start", start_job) + token = token_value or _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + claim_overrides=token_overrides, + ) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, token), + json={ + "jsonrpc": "2.0", + "id": f"rpc-workspace-auth-{expected_code}", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent.slug), + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert called is False + assert payload["error"]["code"] == JSON_RPC_WORKSPACE_AUTHORIZATION_FAILED + assert payload["error"]["data"]["code"] == expected_code + + +def test_a2a_controller_serializes_v2_action_replay_safety( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.sync"], + ) + + def sync_job(_request: V2ActionRequest) -> dict[str, object]: + return { + "status": "ok", + "replay_safety": { + "dedupe_keys": ["job-123", "rev-1"], + "convergent": True, + "strictly_idempotent_response": False, + }, + } + + register_v2_action_handler(server_fixture, "job.sync", sync_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-replay-safety-1", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.sync", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["replay_safety"] == { + "dedupe_keys": ["job-123", "rev-1"], + "stable_external_ids_required": True, + "strictly_idempotent_response": False, + "convergent": True, + } + + +def test_a2a_controller_action_errors_do_not_leak_exception_details( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + raise RuntimeError("database password secret-value leaked") + + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-error-1", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["error"]["code"] == JSON_RPC_INTERNAL_ERROR + assert payload["error"]["data"] == { + "agent_slug": agent_slug, + "action": "job.start", + } + assert "secret-value" not in response.text + + +def test_a2a_controller_publishes_v2_action_effects( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + queue = subscribe_v2_events(server_fixture) + + def start_job(_request: V2ActionRequest) -> V2ActionResult: + return V2ActionResult( + status="ok", + effects=[V2Effect(type="job.started", job_id="job-123")], + ) + + try: + register_v2_action_handler(server_fixture, "job.start", start_job) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-event-1", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + event = queue.get_nowait() + assert event["event"] == A2A_EFFECT_EVENT + assert event["data"] == { + "agent_slug": agent_slug, + "action": "job.start", + "request_id": "request-1", + "effects": [{"type": "job.started", "job_id": "job-123"}], + } + finally: + unsubscribe_v2_events(server_fixture, queue) + + +def test_a2a_controller_dispatches_registered_v2_surface( + server_fixture: Server, +) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_SURFACE_LOAD_METHOD, "job.start"], + ) + + def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: + assert request.surface == "job.start" + return { + "surface": "job.start", + "a2ui_version": "v0.8", + "document": {"type": "Form", "fields": []}, + } + + register_v2_surface_handler(server_fixture, "job.start", load_job_start) + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-surface-2", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-surface-2" + assert payload["result"] == { + "surface": "job.start", + "a2ui_version": "v0.8", + "a2ui_catalog_version": None, + "document": {"type": "Form", "fields": []}, + } + + +def test_server_v2_action_decorator_registers_handler(server_fixture: Server) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start.preview"], + ) + + @server_fixture.v2_action("job.start.preview") + def preview_job_start(request: V2ActionRequest) -> dict[str, object]: + assert request.action == "job.start.preview" + return {"status": "ok", "effects": [{"type": "job.start.previewed"}]} + + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-4", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action="job.start.preview", agent_slug=agent_slug + ), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-4" + assert payload["result"] == { + "status": "ok", + "effects": [{"type": "job.start.previewed"}], + } + + +def test_server_v2_surface_decorator_registers_handler(server_fixture: Server) -> None: + agent_slug = server_fixture.agents[0].slug + headers = _authorized_a2a_headers( + server_fixture, + agent_slug=agent_slug, + scopes=[SUPERVAIZER_SURFACE_LOAD_METHOD, "job.start"], + ) + + @server_fixture.v2_surface("job.start") + def load_job_start(request: V2SurfaceRequest) -> dict[str, object]: + assert request.surface == "job.start" + return { + "surface": "job.start", + "document": {"type": "Form", "submit": {"action": "job.start"}}, + } + + client = TestClient(server_fixture.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-surface-3", + "method": SUPERVAIZER_SURFACE_LOAD_METHOD, + "params": _v2_surface_payload(surface="job.start", agent_slug=agent_slug), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == "rpc-surface-3" + assert payload["result"]["surface"] == "job.start" + assert payload["result"]["document"]["submit"] == {"action": "job.start"} + + +def test_v2_action_handlers_are_scoped_by_agent_slug(server_fixture: Server) -> None: + first_slug = server_fixture.agents[0].slug + second_agent = Agent( + name="Second Agent", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + server_fixture.agents.append(second_agent) + key = _enable_workspace_authorization_eddsa( + server_fixture, + agent_slug=first_slug, + studio_agent_id=f"studio-{first_slug}", + ) + second_agent.server_agent_id = f"studio-{second_agent.slug}" + first_token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=first_slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + second_token = _workspace_authorization_eddsa_token( + server_fixture, + key, + agent_slug=second_agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, "job.start"], + ) + + register_v2_action_handler( + server_fixture, + "job.start", + lambda _request: {"status": "ok", "effects": [{"type": "first-agent"}]}, + agent_slug=first_slug, + ) + register_v2_action_handler( + server_fixture, + "job.start", + lambda _request: {"status": "ok", "effects": [{"type": "second-agent"}]}, + agent_slug=second_agent.slug, + ) + client = TestClient(server_fixture.app) + + first_response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, first_token), + json={ + "jsonrpc": "2.0", + "id": "rpc-5", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload(action="job.start", agent_slug=first_slug), + }, + ) + second_response = client.post( + "/a2a", + headers=_a2a_workspace_headers(server_fixture, second_token), + json={ + "jsonrpc": "2.0", + "id": "rpc-6", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action="job.start", agent_slug=second_agent.slug + ), + }, + ) + + assert first_response.json()["result"]["effects"] == [{"type": "first-agent"}] + assert second_response.json()["result"]["effects"] == [{"type": "second-agent"}] + + +def test_v2_action_registration_requires_agent_slug_for_multi_agent_server( + server_fixture: Server, +) -> None: + server_fixture.agents.append( + Agent( + name="Second Agent", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + ) + + with pytest.raises(ValueError, match="agent_slug is required"): + server_fixture.register_v2_action( + "job.start", + lambda _request: V2ActionResult(status="ok"), + ) def _v2_action_payload( @@ -728,6 +1779,166 @@ def _v2_surface_payload( } +def _enable_workspace_authorization_with_rsa_public_key( + server: Server, +) -> rsa.RSAPrivateKey: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key_pem = ( + key + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + server.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + public_key_pem=public_key_pem, + leeway_seconds=0, + ) + return key + + +def _enable_workspace_authorization_eddsa( + server: Server, + *, + studio_agent_id: str | None = "studio-agent-1", + agent_slug: str | None = None, +) -> ed25519.Ed25519PrivateKey: + key = ed25519.Ed25519PrivateKey.generate() + public_key_pem = ( + key + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + server.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + public_key_pem=public_key_pem, + leeway_seconds=0, + ) + if studio_agent_id: + agent = ( + next(item for item in server.agents if item.slug == agent_slug) + if agent_slug + else server.agents[0] + ) + agent.server_agent_id = studio_agent_id + return key + + +def _workspace_authorization_eddsa_token( + server: Server, + key: ed25519.Ed25519PrivateKey, + *, + agent_slug: str, + scopes: list[str], + workspace_id: str = "workspace-1", + workspace_slug: str = "workspace", + expires_in: int = 300, + kid: str | None = None, + header_overrides: dict[str, object] | None = None, + claim_overrides: dict[str, object] | None = None, +) -> str: + agent = next(agent for agent in server.agents if agent.slug == agent_slug) + now = int(time.time()) + claims = { + "iss": "https://studio.example.test", + "aud": f"supervaizer-server:{server.server_id}", + "sub": "workspace-agent-grant:grant-1", + "grant_id": "grant-1", + "workspace_id": workspace_id, + "workspace_slug": workspace_slug, + "agent_id": agent.server_agent_id or agent.id, + "agent_slug": agent.slug, + "server_id": server.server_id, + "scopes": scopes, + "agent_workspace_ref": "agent-workspace-1", + "iat": now, + "exp": now + expires_in, + "jti": "token-1", + } + header: dict[str, object] = {"alg": "EdDSA", "typ": "JWT"} + if kid: + header["kid"] = kid + if header_overrides: + header.update(header_overrides) + if claim_overrides: + claims.update(claim_overrides) + return _sign_eddsa_jwt(key, header, claims) + + +def _sign_eddsa_jwt( + key: ed25519.Ed25519PrivateKey, + header: dict[str, object], + claims: dict[str, object], +) -> str: + encoded_header = _base64url_json(header) + encoded_claims = _base64url_json(claims) + signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") + signature = key.sign(signing_input) + return f"{encoded_header}.{encoded_claims}.{_base64url(signature)}" + + +def _sign_eddsa_jwt_parts( + key: ed25519.Ed25519PrivateKey, + *, + header: object, + claims: object, +) -> str: + encoded_header = _base64url_json_value(header) + encoded_claims = _base64url_json_value(claims) + signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") + signature = key.sign(signing_input) + return f"{encoded_header}.{encoded_claims}.{_base64url(signature)}" + + +def _ed25519_jwk( + public_key: ed25519.Ed25519PublicKey, *, kid: str +) -> dict[str, object]: + public_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return { + "kty": "OKP", + "crv": "Ed25519", + "kid": kid, + "alg": "EdDSA", + "use": "sig", + "x": _base64url(public_bytes), + } + + +class _JwksResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return self._payload + + +def _base64url_json(value: dict[str, object]) -> str: + return _base64url_json_value(value) + + +def _base64url_json_value(value: object) -> str: + return _base64url(json.dumps(value, separators=(",", ":")).encode("utf-8")) + + +def _base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + def test_a2a_schema_conformance(agent_fixture: Agent) -> None: """Test that the A2A output conforms to the JSON schema.""" # Define a minimal A2A schema for validation diff --git a/tests/test_agent.py b/tests/test_agent.py index b5ddbc3..f27c41e 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -663,6 +663,31 @@ def test_agent_update_agent_from_server( agent_fixture.update_agent_from_server(server_fixture) +def test_agent_update_keeps_registration_agent_id_when_detail_omits_id( + agent_fixture: Agent, server_fixture: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + agent_fixture.server_agent_id = "studio-agent-id" + agent_detail_without_id = dict(GET_AGENT_BY_SUCCESS_RESPONSE_DETAIL) + agent_detail_without_id.pop("id") + agent_detail_without_id["onboarding_status"] = "new" + + monkeypatch.setattr( + server_fixture.supervisor_account.__class__, + "get_agent_by", + lambda self, agent_id=None, agent_slug=None: ApiSuccess( + message="Success", + detail=agent_detail_without_id, + code=200, + ), + ) + + updated_agent = agent_fixture.update_agent_from_server(server_fixture) + + assert updated_agent is agent_fixture + assert updated_agent.server_agent_id == "studio-agent-id" + assert updated_agent.server_agent_onboarding_status == "new" + + def test_job_start_custom_async_uses_action_is_async( server_fixture: Server, context_fixture: JobContext, diff --git a/tests/test_contracts.py b/tests/test_contracts.py index e7f42f8..79f38ee 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -39,6 +39,9 @@ V2ResourceFieldDefinition, V2SurfaceRequest, V2SurfaceResult, + V2VerifiedWorkspaceContext, + V2WorkspaceAuthorizationSettings, + V2WorkspaceBindingDefinition, build_data_resource_context_headers, build_v2_agent_registration, controller_contract_info, @@ -383,6 +386,21 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: ], case_lanes=[{"id": "work", "label": "Work", "default": True}], job_policy={"sync": {"action": "job.sync"}}, + workspace_binding={ + "required": True, + "modes": ["bind_existing", "create_and_bind"], + "reference_label": "Agent workspace reference", + "create": { + "fields": [ + { + "id": "display_name", + "label": "Display name", + "type": "text", + "required": True, + } + ] + }, + }, ) assert registration.versions.a2ui_version == "v0.8" @@ -395,6 +413,7 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: "mission.agent.resource.contacts", "mission.agent.dataset.campaign_progress", "mission.analytics", + "workspace_binding.create", ] assert registration.capabilities.actions == [ "job.start", @@ -403,7 +422,15 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: "resource.contacts.create", "dataset.campaign_progress.query", "job.sync", + "workspace_binding.options", + "workspace_binding.create", ] + assert registration.workspace_binding is not None + assert registration.workspace_binding.existing is not None + assert registration.workspace_binding.existing.action == "workspace_binding.options" + assert registration.workspace_binding.create is not None + assert registration.workspace_binding.create.surface == "workspace_binding.create" + assert registration.workspace_binding.create.action == "workspace_binding.create" assert registration.resources[0].scope == "workspace" assert registration.resources[0].requires_context == ["workspace.id"] assert registration.resources[0].fields[0].id == "email" @@ -427,6 +454,11 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: assert registration.capabilities.case_lanes[0].default is True +def test_v2_workspace_binding_required_requires_mode() -> None: + with pytest.raises(ValidationError, match="at least one mode"): + V2WorkspaceBindingDefinition(required=True) + + def test_v2_dashboard_widget_validates_data_ref_target() -> None: with pytest.raises(ValidationError, match="datasetId"): V2DashboardWidgetDataRef(mode="ref") @@ -663,4 +695,9 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: ) assert supervaizer.V2SurfaceRequest is V2SurfaceRequest assert supervaizer.V2SurfaceResult is V2SurfaceResult + assert supervaizer.V2VerifiedWorkspaceContext is V2VerifiedWorkspaceContext + assert ( + supervaizer.V2WorkspaceAuthorizationSettings is V2WorkspaceAuthorizationSettings + ) + assert supervaizer.V2WorkspaceBindingDefinition is V2WorkspaceBindingDefinition assert supervaizer.build_v2_agent_registration is build_v2_agent_registration diff --git a/tests/test_routes.py b/tests/test_routes.py index b6d9e0b..57eeeda 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -11,10 +11,15 @@ # https://mozilla.org/MPL/2.0/. import asyncio +import base64 +import json +import time from io import StringIO from typing import Any import httpx +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient @@ -28,6 +33,7 @@ Server, ) from supervaizer.common import log +from supervaizer.contracts import V2WorkspaceAuthorizationSettings from supervaizer.data_resource import DataResource, DataResourceContext from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup @@ -39,6 +45,7 @@ create_utils_routes, get_server, ) +from supervaizer.workspace_authorization import WORKSPACE_AUTHORIZATION_HEADER def test_utils_public_key_and_encrypt(server_fixture: Server, mocker: Any) -> None: @@ -350,15 +357,16 @@ def test_data_resource_openapi_operation_ids_unique_per_agent( assert f"{agent_b.slug}_items_list" in list_ids -def test_data_resource_callbacks_receive_context( +def test_data_resource_workspace_authorization_not_configured_blocks_callback( account_fixture: Account, agent_method_fixture: AgentMethod, parameters_setup_fixture: ParametersSetup, ) -> None: - captured: dict[str, DataResourceContext] = {} + called = False def on_list(*, context: DataResourceContext) -> list[dict[str, Any]]: - captured["context"] = context + nonlocal called + called = True return [{"id": "1"}] resource = DataResource(name="items", fields=[], on_list=on_list, read_only=True) @@ -401,13 +409,88 @@ def on_list(*, context: DataResourceContext) -> list[dict[str, Any]]: }, ) + assert response.status_code == 403 + assert called is False + assert response.json()["detail"] == ( + "Workspace authorization is required but is not configured for this Supervaizer server" + ) + + +def test_data_resource_workspace_authorization_missing_token_blocks_callback( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + called = False + + def on_list(*, context: DataResourceContext) -> list[dict[str, Any]]: + nonlocal called + called = True + return [{"workspace": context.workspace_id}] + + resource = DataResource(name="items", fields=[], on_list=on_list, read_only=True) + server, agent = _make_data_resource_server( + account_fixture, agent_method_fixture, parameters_setup_fixture, resource + ) + _enable_workspace_authorization(server) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/data/items/", + headers={ + "X-API-Key": "test-api-key", + "X-Supervaize-Workspace-Id": "team-1", + }, + ) + + assert response.status_code == 403 + assert called is False + assert "Missing X-Supervaize-Workspace-Authorization" in response.json()["detail"] + + +def test_data_resource_workspace_authorization_valid_token_sets_context( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + captured: dict[str, DataResourceContext] = {} + + def on_list(*, context: DataResourceContext) -> list[dict[str, Any]]: + captured["context"] = context + return [{"workspace": context.workspace_id}] + + resource = DataResource(name="items", fields=[], on_list=on_list, read_only=True) + server, agent = _make_data_resource_server( + account_fixture, agent_method_fixture, parameters_setup_fixture, resource + ) + key = _enable_workspace_authorization(server) + token = _workspace_authorization_token( + server, + key, + agent_slug=agent.slug, + scopes=["resource.items.list"], + workspace_id="team-1", + workspace_slug="team-slug", + ) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/data/items/", + headers={ + "X-API-Key": "test-api-key", + WORKSPACE_AUTHORIZATION_HEADER: f"Bearer {token}", + "X-Supervaize-Workspace-Id": "team-1", + "X-Supervaize-Workspace-Slug": "team-slug", + }, + ) + assert response.status_code == 200 context = captured["context"] - assert context.agent_slug == agent.slug assert context.workspace_id == "team-1" assert context.workspace_slug == "team-slug" - assert context.mission_id == "mission-1" - assert context.request_id == "request-1" + assert context.workspace_authorization is not None + assert context.workspace_authorization.grant_id == "grant-1" + assert context.workspace_authorization.agent_workspace_ref == "agent-workspace-1" def _make_data_resource_server( @@ -445,6 +528,102 @@ def _make_data_resource_server( return server, agent +def _enable_workspace_authorization(server: Server) -> ed25519.Ed25519PrivateKey: + key = ed25519.Ed25519PrivateKey.generate() + public_key_pem = ( + key + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + server.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + public_key_pem=public_key_pem, + leeway_seconds=0, + ) + server.agents[0].server_agent_id = "studio-agent-1" + return key + + +def _workspace_authorization_token( + server: Server, + key: ed25519.Ed25519PrivateKey, + *, + agent_slug: str, + scopes: list[str], + workspace_id: str, + workspace_slug: str, +) -> str: + agent = next(agent for agent in server.agents if agent.slug == agent_slug) + now = int(time.time()) + claims = { + "iss": "https://studio.example.test", + "aud": f"supervaizer-server:{server.server_id}", + "sub": "workspace-agent-grant:grant-1", + "grant_id": "grant-1", + "workspace_id": workspace_id, + "workspace_slug": workspace_slug, + "agent_id": agent.server_agent_id or agent.id, + "agent_slug": agent.slug, + "server_id": server.server_id, + "scopes": scopes, + "agent_workspace_ref": "agent-workspace-1", + "iat": now, + "exp": now + 300, + "jti": "token-1", + } + return _sign_eddsa_jwt(key, {"alg": "EdDSA", "typ": "JWT"}, claims) + + +def _sign_eddsa_jwt( + key: ed25519.Ed25519PrivateKey, + header: dict[str, object], + claims: dict[str, object], +) -> str: + encoded_header = _base64url_json(header) + encoded_claims = _base64url_json(claims) + signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") + signature = key.sign(signing_input) + return f"{encoded_header}.{encoded_claims}.{_base64url(signature)}" + + +def _base64url_json(value: dict[str, object]) -> str: + return _base64url(json.dumps(value, separators=(",", ":")).encode("utf-8")) + + +def _base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _data_resource_headers( + server: Server, + agent: Agent, + *, + scope: str, + workspace_id: str = "workspace-1", + workspace_slug: str = "workspace", +) -> dict[str, str]: + key = _enable_workspace_authorization(server) + token = _workspace_authorization_token( + server, + key, + agent_slug=agent.slug, + scopes=[scope], + workspace_id=workspace_id, + workspace_slug=workspace_slug, + ) + return { + "X-API-Key": "test-api-key", + WORKSPACE_AUTHORIZATION_HEADER: f"Bearer {token}", + "X-Supervaize-Workspace-Id": workspace_id, + "X-Supervaize-Workspace-Slug": workspace_slug, + } + + def test_data_resource_create_requires_id_in_callback_result( account_fixture: Account, agent_method_fixture: AgentMethod, @@ -463,7 +642,7 @@ def test_data_resource_create_requires_id_in_callback_result( response = client.post( f"/api/agents/{agent.slug}/data/items/", - headers={"X-API-Key": "test-api-key"}, + headers=_data_resource_headers(server, agent, scope="resource.items.create"), json={"name": "No ID"}, ) @@ -490,7 +669,7 @@ def test_data_resource_update_returns_404_when_callback_returns_none( response = client.put( f"/api/agents/{agent.slug}/data/items/missing", - headers={"X-API-Key": "test-api-key"}, + headers=_data_resource_headers(server, agent, scope="resource.items.update"), json={"name": "Missing"}, ) @@ -517,7 +696,7 @@ def test_data_resource_delete_returns_404_when_callback_is_false( response = client.delete( f"/api/agents/{agent.slug}/data/items/missing", - headers={"X-API-Key": "test-api-key"}, + headers=_data_resource_headers(server, agent, scope="resource.items.delete"), ) assert response.status_code == 404 @@ -540,7 +719,7 @@ def on_list() -> list[dict[str, Any]]: response = client.get( f"/api/agents/{agent.slug}/data/items/", - headers={"X-API-Key": "test-api-key"}, + headers=_data_resource_headers(server, agent, scope="resource.items.list"), ) assert response.status_code == 403 diff --git a/tests/test_server.py b/tests/test_server.py index 173c3e1..6185ce2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,27 +10,124 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import base64 import json import os +import time from typing import Any import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 from fastapi import status from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from rich import inspect from supervaizer import Server +from supervaizer.__version__ import VERSION from supervaizer.agent import Agent from supervaizer.common import ApiSuccess +from supervaizer.contracts import V2WorkspaceAuthorizationSettings from supervaizer.job import Job, JobContext from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup from supervaizer.server_utils import ErrorType, create_error_response +from supervaizer.workspace_authorization import WORKSPACE_AUTHORIZATION_HEADER insp = inspect +def _enable_workspace_authorization_eddsa( + server: Server, +) -> ed25519.Ed25519PrivateKey: + key = ed25519.Ed25519PrivateKey.generate() + public_key_pem = ( + key + .public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + server.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.example.test", + public_key_pem=public_key_pem, + leeway_seconds=0, + ) + server.agents[0].server_agent_id = "studio-agent-1" + return key + + +def _workspace_authorization_eddsa_token( + server: Server, + key: ed25519.Ed25519PrivateKey, + *, + agent_slug: str, + scopes: list[str], + workspace_id: str = "workspace-1", +) -> str: + agent = next(agent for agent in server.agents if agent.slug == agent_slug) + now = int(time.time()) + claims = { + "iss": "https://studio.example.test", + "aud": f"supervaizer-server:{server.server_id}", + "sub": "workspace-agent-grant:grant-1", + "grant_id": "grant-1", + "workspace_id": workspace_id, + "agent_id": agent.server_agent_id or agent.id, + "agent_slug": agent.slug, + "server_id": server.server_id, + "scopes": scopes, + "agent_workspace_ref": "agent-workspace-1", + "iat": now, + "exp": now + 300, + "jti": "token-1", + } + return _sign_eddsa_jwt(key, {"alg": "EdDSA", "typ": "JWT"}, claims) + + +def _a2a_workspace_headers( + server: Server, + key: ed25519.Ed25519PrivateKey, + *, + agent_slug: str, + scopes: list[str], +) -> dict[str, str]: + token = _workspace_authorization_eddsa_token( + server, + key, + agent_slug=agent_slug, + scopes=scopes, + ) + return { + "X-API-Key": server.api_key or "", + WORKSPACE_AUTHORIZATION_HEADER: f"Bearer {token}", + } + + +def _sign_eddsa_jwt( + key: ed25519.Ed25519PrivateKey, + header: dict[str, object], + claims: dict[str, object], +) -> str: + encoded_header = _base64url_json(header) + encoded_claims = _base64url_json(claims) + signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") + signature = key.sign(signing_input) + return f"{encoded_header}.{encoded_claims}.{_base64url(signature)}" + + +def _base64url_json(value: dict[str, object]) -> str: + return _base64url(json.dumps(value, separators=(",", ":")).encode("utf-8")) + + +def _base64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + @pytest.fixture def no_response_validation(monkeypatch: pytest.MonkeyPatch) -> None: """Fixture to disable response validation.""" @@ -122,6 +219,126 @@ def test_server_registration_handshake_accepts_key_match( server_fixture._validate_registration_handshake(result) +def test_studio_a2a_requires_workspace_authorization(server_fixture: Server) -> None: + with pytest.raises( + RuntimeError, + match="Studio-registered Supervaizer v2 A2A requires workspace authorization", + ): + server_fixture._validate_studio_a2a_workspace_authorization() + + +def test_studio_a2a_accepts_enabled_workspace_authorization( + server_fixture: Server, +) -> None: + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.supervaize.com", + jwks_url="https://studio.supervaize.com/jwks", + ) + + server_fixture._validate_studio_a2a_workspace_authorization() + + +def test_local_a2a_can_start_without_workspace_authorization( + server_fixture: Server, +) -> None: + server_fixture.supervisor_account = None + + server_fixture._validate_studio_a2a_workspace_authorization() + + +def test_server_registration_handshake_sets_workspace_authorization_audience( + server_fixture: Server, +) -> None: + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.supervaize.com", + jwks_url="https://studio.supervaize.com/jwks", + ) + result = ApiSuccess( + message="POST Event SERVER_REGISTER sent", + detail={ + "object": { + "supervaizer_handshake": { + "server_id": "remote-server-1", + "controller_api_key_match": True, + "workspace_authorization": { + "audience": "supervaizer-server:studio-server-1", + "studio_server_id": "studio-server-1", + "agents": [ + { + "id": "studio-agent-1", + "slug": server_fixture.agents[0].slug, + } + ], + }, + } + } + }, + ) + + server_fixture._validate_registration_handshake(result) + + assert ( + server_fixture.workspace_authorization.audience + == "supervaizer-server:studio-server-1" + ) + assert server_fixture.agents[0].server_agent_id == "studio-agent-1" + + +def test_server_registration_handshake_requires_workspace_authorization_agent_binding( + server_fixture: Server, +) -> None: + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.supervaize.com", + jwks_url="https://studio.supervaize.com/jwks", + ) + result = ApiSuccess( + message="POST Event SERVER_REGISTER sent", + detail={ + "object": { + "supervaizer_handshake": { + "server_id": "remote-server-1", + "controller_api_key_match": True, + "workspace_authorization": { + "audience": "supervaizer-server:studio-server-1", + "studio_server_id": "studio-server-1", + "agents": [], + }, + } + } + }, + ) + + with pytest.raises(RuntimeError, match="did not return Studio agent id"): + server_fixture._validate_registration_handshake(result) + + +def test_server_registration_handshake_requires_workspace_authorization_audience( + server_fixture: Server, +) -> None: + server_fixture.workspace_authorization = V2WorkspaceAuthorizationSettings( + enabled=True, + issuer="https://studio.supervaize.com", + jwks_url="https://studio.supervaize.com/jwks", + ) + result = ApiSuccess( + message="POST Event SERVER_REGISTER sent", + detail={ + "object": { + "supervaizer_handshake": { + "server_id": "remote-server-1", + "controller_api_key_match": True, + } + } + }, + ) + + with pytest.raises(RuntimeError, match="workspace_authorization is missing"): + server_fixture._validate_registration_handshake(result) + + def test_server_registration_handshake_rejects_missing_handshake( server_fixture: Server, ) -> None: @@ -667,6 +884,7 @@ def test_server_registration_info(server_fixture: Server) -> None: assert "url" in registration_info assert "uri" in registration_info assert "api_version" in registration_info + assert registration_info["controller_version"] == VERSION assert "environment" in registration_info assert "public_key" in registration_info assert "api_key" in registration_info @@ -699,6 +917,7 @@ def test_server_registration_info(server_fixture: Server) -> None: assert registration_info == { "uri": "server:E2-AC-ED-22-BF-B2", "api_version": "v1", + "controller_version": VERSION, "controller_contract_version": "1.0", "api_base_path": "/api", "endpoints": registration_info["endpoints"], @@ -741,7 +960,7 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: ) agent_slug = server.agents[0].slug client = TestClient(server.app) - headers = {"X-API-Key": server.api_key} + key = _enable_workspace_authorization_eddsa(server) card_response = client.get( f"/.well-known/agents/v{server.agents[0].version}/{agent_slug}_agent.json" @@ -760,7 +979,12 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: surface_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=["supervaizer/surface.load", "job.start"], + ), json={ "jsonrpc": "2.0", "id": "surface-1", @@ -785,7 +1009,12 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: action_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=["supervaizer/action.invoke", "job.start.preview"], + ), json={ "jsonrpc": "2.0", "id": "action-1", @@ -810,7 +1039,15 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: resource_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=[ + "supervaizer/action.invoke", + "resource.hello_messages.list", + ], + ), json={ "jsonrpc": "2.0", "id": "resource-1", @@ -834,7 +1071,12 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: start_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=["supervaizer/action.invoke", "job.start"], + ), json={ "jsonrpc": "2.0", "id": "start-1", @@ -861,7 +1103,12 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: awaiting_surface_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=["supervaizer/surface.load", "case.step.awaiting"], + ), json={ "jsonrpc": "2.0", "id": "awaiting-surface-1", @@ -890,7 +1137,12 @@ def test_local_mode_registers_hello_world_v2_handlers(self) -> None: submit_response = client.post( "/a2a", - headers=headers, + headers=_a2a_workspace_headers( + server, + key, + agent_slug=agent_slug, + scopes=["supervaizer/action.invoke", "step.awaiting.submit"], + ), json={ "jsonrpc": "2.0", "id": "submit-1",