From f08bab4e5764719278baba17b6dcf8bdb8adef5a Mon Sep 17 00:00:00 2001 From: Miguel Morales <191371+therevoltingx@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:28:55 -0700 Subject: [PATCH 1/2] enforces operator key checks in admin related routes --- src/ad_seller/auth/api_key_service.py | 6 +- src/ad_seller/auth/dependencies.py | 27 ++ .../interfaces/agentcore/http_main.py | 51 +-- src/ad_seller/interfaces/api/deps.py | 19 + src/ad_seller/interfaces/api/main.py | 8 +- src/ad_seller/interfaces/api/routers/admin.py | 52 ++- src/ad_seller/interfaces/api/routers/deals.py | 15 +- .../interfaces/api/routers/media_kit.py | 25 +- .../interfaces/api/routers/registry.py | 18 +- src/ad_seller/interfaces/api/schemas.py | 3 +- src/ad_seller/interfaces/cli/main.py | 59 +++ src/ad_seller/interfaces/mcp_server.py | 133 ++++++- src/ad_seller/models/api_key.py | 22 ++ tests/unit/test_csv_catalog_coherence.py | 11 + tests/unit/test_endpoint_no_flow_kickoff.py | 11 + tests/unit/test_issue34_catalog_fixes.py | 9 + tests/unit/test_operator_auth.py | 353 ++++++++++++++++++ tests/unit/test_trust_tier_verification.py | 7 + tests/unit/test_update_package_whitelist.py | 5 + 19 files changed, 783 insertions(+), 51 deletions(-) create mode 100644 tests/unit/test_operator_auth.py diff --git a/src/ad_seller/auth/api_key_service.py b/src/ad_seller/auth/api_key_service.py index 2276e70..3a8ca5b 100644 --- a/src/ad_seller/auth/api_key_service.py +++ b/src/ad_seller/auth/api_key_service.py @@ -69,6 +69,7 @@ async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse key_hash=key_hash, key_prefix_hint=full_key[:12] + "...", identity=identity, + role=request.role, label=request.label, expires_at=expires_at, ) @@ -91,9 +92,10 @@ async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse await self._storage.set("api_key_list", all_keys) logger.info( - "API key %s created for %s (label: %s)", + "API key %s created for %s (role: %s, label: %s)", key_id, identity.identity_level.value, + request.role.value, request.label, ) @@ -101,6 +103,7 @@ async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse key_id=key_id, api_key=full_key, identity=identity, + role=request.role, label=request.label, expires_at=expires_at, ) @@ -153,6 +156,7 @@ async def get_key_info(self, key_id: str) -> Optional[ApiKeyInfo]: key_id=record.key_id, key_prefix_hint=record.key_prefix_hint, identity=record.identity, + role=record.role, label=record.label, created_at=record.created_at, expires_at=record.expires_at, diff --git a/src/ad_seller/auth/dependencies.py b/src/ad_seller/auth/dependencies.py index c620d9f..d5dd417 100644 --- a/src/ad_seller/auth/dependencies.py +++ b/src/ad_seller/auth/dependencies.py @@ -117,6 +117,33 @@ async def require_api_key_record( return record +async def require_operator_key( + authorization: Optional[str] = Header(None), + x_api_key: Optional[str] = Header(None, alias="X-Api-Key"), +) -> ApiKeyRecord: + """Validate API key and require an OPERATOR-role credential. + + Builds on :func:`require_api_key_record` (anonymous → 401, invalid/ + revoked/expired → 401) and additionally rejects buyer-role keys with + 403. Use this on every operator/admin endpoint: API key lifecycle, + event log, rate card writes, registry mutations, package mutations, + inventory sync trigger, and deal push/distribute. + + Bootstrap: the first operator key is minted out-of-band with + ``ad-seller create-operator-key`` (writes directly to storage — no + network surface). + """ + from ..models.api_key import ApiKeyRole + + record = await require_api_key_record(authorization, x_api_key) + if record.role != ApiKeyRole.OPERATOR: + raise HTTPException( + status_code=403, + detail="Operator credential required", + ) + return record + + def principal_from_api_key(record: ApiKeyRecord) -> str: """Derive a stable, verified principal identifier from an API key record. diff --git a/src/ad_seller/interfaces/agentcore/http_main.py b/src/ad_seller/interfaces/agentcore/http_main.py index 87e6c19..64fa44e 100644 --- a/src/ad_seller/interfaces/agentcore/http_main.py +++ b/src/ad_seller/interfaces/agentcore/http_main.py @@ -141,40 +141,41 @@ def _run(): def _create_internal_api_key(): - """Create an internal API key by calling the seller's /auth/api-keys endpoint. + """Mint an internal API key directly via ApiKeyService (in-process). This key is used by tools like CreateDealTool that call endpoints requiring authentication. The key is stored in the module-level _INTERNAL_API_KEY variable and in the INTERNAL_API_KEY env var so crew_tools.py can access it. + + Minted in-process rather than via POST /auth/api-keys: that endpoint + requires an operator credential, which this bootstrap does not have. """ global _INTERNAL_API_KEY - import httpx + import asyncio + + async def _mint(): + from ...auth.api_key_service import ApiKeyService + from ...models.api_key import ApiKeyCreateRequest + from ...storage.factory import get_storage + + storage = await get_storage() + service = ApiKeyService(storage) + return await service.create_key( + ApiKeyCreateRequest( + seat_id="INTERNAL-AGENTCORE", + seat_name="AgentCore Internal", + agency_id="AGY-INTERNAL", + agency_name="AgentCore Runtime", + label="AgentCore internal tool-auth key", + ) + ) try: - resp = httpx.post( - f"http://localhost:{_INTERNAL_PORT}/auth/api-keys", - json={ - "buyer_tier": "preferred_agency", - "seat_id": "INTERNAL-AGENTCORE", - "seat_name": "AgentCore Internal", - "agency_id": "AGY-INTERNAL", - "agency_name": "AgentCore Runtime", - }, - timeout=10, - ) - if resp.status_code in (200, 201): - data = resp.json() - _INTERNAL_API_KEY = data.get("api_key", data.get("key", "")) - if _INTERNAL_API_KEY: - os.environ["INTERNAL_API_KEY"] = _INTERNAL_API_KEY - logger.info("Internal API key created for tool auth") - else: - logger.warning("API key response missing key field: %s", data) - else: - logger.warning( - "Failed to create internal API key: %d %s", resp.status_code, resp.text[:200] - ) + response = asyncio.run(_mint()) + _INTERNAL_API_KEY = response.api_key + os.environ["INTERNAL_API_KEY"] = _INTERNAL_API_KEY + logger.info("Internal API key created for tool auth") except Exception as e: logger.warning("Could not create internal API key (non-fatal): %s", e) diff --git a/src/ad_seller/interfaces/api/deps.py b/src/ad_seller/interfaces/api/deps.py index 7397ca4..7a84298 100644 --- a/src/ad_seller/interfaces/api/deps.py +++ b/src/ad_seller/interfaces/api/deps.py @@ -37,6 +37,25 @@ async def _get_optional_api_key_record( return await get_api_key_record(authorization, x_api_key) +async def _require_operator_api_key_record( + authorization: Optional[str] = Header(None), + x_api_key: Optional[str] = Header(None, alias="X-Api-Key"), +): + """FastAPI dependency: require a valid OPERATOR-role API key. + + Anonymous → 401. Invalid/revoked/expired key → 401. Valid buyer-role + key → 403. Only an operator credential (minted via + ``ad-seller create-operator-key`` or by an existing operator through + ``POST /auth/api-keys`` with ``role="operator"``) passes. + + Same Header-binding rationale as ``_get_optional_api_key_record``. + Tests override this function object via ``app.dependency_overrides``. + """ + from ...auth.dependencies import require_operator_key + + return await require_operator_key(authorization, x_api_key) + + def _build_buyer_context( buyer_tier: str = "public", agency_id: Optional[str] = None, diff --git a/src/ad_seller/interfaces/api/main.py b/src/ad_seller/interfaces/api/main.py index 3d45714..6f9e622 100644 --- a/src/ad_seller/interfaces/api/main.py +++ b/src/ad_seller/interfaces/api/main.py @@ -203,7 +203,9 @@ def _serialize_product(product: Any) -> dict[str, Any]: # Router mounting # ============================================================================= -from fastapi.routing import APIRoute # noqa: E402 +# FastAPI's request_response (not starlette's) — it also wires the +# dependency AsyncExitStacks into the request scope. +from fastapi.routing import APIRoute, request_response # noqa: E402 from .routers import ALL_ROUTERS # noqa: E402 @@ -220,6 +222,10 @@ def _serialize_product(product: Any) -> dict[str, Any]: # Wire dependency overrides to the app so # `app.dependency_overrides[...]` test hooks keep working. _route.dependency_overrides_provider = app + # APIRoute.__init__ already compiled the route's ASGI handler, + # capturing the provider while it was still None — rebuild the + # handler so the override wiring above actually takes effect. + _route.app = request_response(_route.get_route_handler()) app.router.routes.append(_route) if hasattr(app.router, "_mark_routes_changed"): diff --git a/src/ad_seller/interfaces/api/routers/admin.py b/src/ad_seller/interfaces/api/routers/admin.py index 47f5736..6bf8a59 100644 --- a/src/ad_seller/interfaces/api/routers/admin.py +++ b/src/ad_seller/interfaces/api/routers/admin.py @@ -48,6 +48,7 @@ async def list_events( event_type: Optional[str] = None, session_id: Optional[str] = None, limit: int = 50, + _operator=Depends(deps._require_operator_api_key_record), ): """List events, optionally filtered by flow_id, event_type, or session_id.""" from ....events.bus import get_event_bus @@ -60,7 +61,10 @@ async def list_events( @router.get("/events/{event_id}", tags=["Events"]) -async def get_event(event_id: str): +async def get_event( + event_id: str, + _operator=Depends(deps._require_operator_api_key_record), +): """Get a specific event by ID.""" from ....events.bus import get_event_bus @@ -77,16 +81,31 @@ async def get_event(event_id: str): @router.post("/auth/api-keys", tags=["Authentication"]) -async def create_api_key(request: CreateApiKeyRequest): - """Create a new API key for a buyer. +async def create_api_key( + request: CreateApiKeyRequest, + _operator=Depends(deps._require_operator_api_key_record), +): + """Create a new API key for a buyer (or another operator). + + Requires an operator credential. Bootstrap the first operator key + with ``ad-seller create-operator-key`` (writes directly to storage). The response contains the full API key which is shown ONLY ONCE. Store it securely — it cannot be retrieved again. """ from ....auth.api_key_service import ApiKeyService - from ....models.api_key import ApiKeyCreateRequest + from ....models.api_key import ApiKeyCreateRequest, ApiKeyRole from ....storage.factory import get_storage + try: + role = ApiKeyRole(request.role) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid role: {request.role}. Valid values: " + f"{[r.value for r in ApiKeyRole]}", + ) + storage = await get_storage() service = ApiKeyService(storage) @@ -99,6 +118,7 @@ async def create_api_key(request: CreateApiKeyRequest): agency_holding_company=request.agency_holding_company, advertiser_id=request.advertiser_id, advertiser_name=request.advertiser_name, + role=role, label=request.label, expires_in_days=request.expires_in_days, ) @@ -108,7 +128,9 @@ async def create_api_key(request: CreateApiKeyRequest): @router.get("/auth/api-keys", tags=["Authentication"]) -async def list_api_keys(): +async def list_api_keys( + _operator=Depends(deps._require_operator_api_key_record), +): """List all API keys (metadata only, no secrets).""" from ....auth.api_key_service import ApiKeyService from ....storage.factory import get_storage @@ -123,7 +145,10 @@ async def list_api_keys(): @router.get("/auth/api-keys/{key_id}", tags=["Authentication"]) -async def get_api_key_details(key_id: str): +async def get_api_key_details( + key_id: str, + _operator=Depends(deps._require_operator_api_key_record), +): """Get details for a specific API key.""" from ....auth.api_key_service import ApiKeyService from ....storage.factory import get_storage @@ -137,7 +162,10 @@ async def get_api_key_details(key_id: str): @router.delete("/auth/api-keys/{key_id}", tags=["Authentication"]) -async def revoke_api_key(key_id: str): +async def revoke_api_key( + key_id: str, + _operator=Depends(deps._require_operator_api_key_record), +): """Revoke an API key. Revoked keys return 401 on use.""" from ....auth.api_key_service import ApiKeyService from ....storage.factory import get_storage @@ -271,7 +299,10 @@ async def get_rate_card(): @router.put("/api/v1/rate-card", tags=["Pricing"]) -async def update_rate_card(entries: list[RateCardEntry]): +async def update_rate_card( + entries: list[RateCardEntry], + _operator=Depends(deps._require_operator_api_key_record), +): """Update the rate card with current base CPMs from ad server. Publishers should update this when their ad server rate cards change. @@ -308,6 +339,7 @@ async def get_inventory_sync_status(): @router.post("/api/v1/inventory-sync/trigger", tags=["Core"]) async def trigger_inventory_sync( incremental: bool = False, + _operator=Depends(deps._require_operator_api_key_record), ): """Manually trigger an inventory sync. @@ -370,7 +402,7 @@ async def get_sync_watermark(): async def gam_list_orders( limit: int = 50, agent_created_only: bool = False, - _auth=Depends(deps._get_optional_api_key_record), + _operator=Depends(deps._require_operator_api_key_record), ) -> dict: """List recent GAM orders directly from the ad server. @@ -392,7 +424,7 @@ async def gam_list_orders( async def gam_delivery_report( order_ids: str, days: int = 30, - _auth=Depends(deps._get_optional_api_key_record), + _operator=Depends(deps._require_operator_api_key_record), ) -> dict: """Pull a delivery report from GAM by order ID(s). diff --git a/src/ad_seller/interfaces/api/routers/deals.py b/src/ad_seller/interfaces/api/routers/deals.py index 6a48e2e..5fa86f4 100644 --- a/src/ad_seller/interfaces/api/routers/deals.py +++ b/src/ad_seller/interfaces/api/routers/deals.py @@ -288,7 +288,10 @@ async def bulk_deal_operations( @router.post("/api/v1/deals/push", tags=["Deal Booking"]) -async def push_deal_to_buyers(request: DealPushRequest): +async def push_deal_to_buyers( + request: DealPushRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Push a deal to one or more buyer endpoints via IAB Deals API v1.0. The seller sends deal terms to buyer DSPs. Each buyer receives an @@ -311,7 +314,10 @@ async def get_deal_buyer_status(deal_id: str, buyer_url: str): @router.post("/api/v1/deals/distribute", tags=["Deal Booking"]) -async def distribute_deal_via_ssp(request: SSPDealDistributeRequest): +async def distribute_deal_via_ssp( + request: SSPDealDistributeRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Distribute a deal through configured SSP(s). Routes the deal to the appropriate SSP based on routing rules @@ -350,7 +356,10 @@ async def get_curator(curator_id: str): @router.post("/api/v1/curators", tags=["Curators"], status_code=201) -async def register_curator(request: CuratorRegistrationRequest): +async def register_curator( + request: CuratorRegistrationRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Register a new curator. Curators can then create deals against this publisher's inventory diff --git a/src/ad_seller/interfaces/api/routers/media_kit.py b/src/ad_seller/interfaces/api/routers/media_kit.py index 4288d9e..4be33ee 100644 --- a/src/ad_seller/interfaces/api/routers/media_kit.py +++ b/src/ad_seller/interfaces/api/routers/media_kit.py @@ -313,7 +313,10 @@ async def get_package( @router.post("/packages", tags=["Packages"]) -async def create_package(request: PackageCreateRequest): +async def create_package( + request: PackageCreateRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Create a curated package (Layer 2).""" import uuid as _uuid @@ -381,7 +384,11 @@ async def create_package(request: PackageCreateRequest): @router.put("/packages/{package_id}", tags=["Packages"]) -async def update_package(package_id: str, updates: dict[str, Any]): +async def update_package( + package_id: str, + updates: dict[str, Any], + _operator=Depends(deps._require_operator_api_key_record), +): """Update an existing package.""" from ....events.helpers import emit_event from ....events.models import EventType @@ -400,7 +407,10 @@ async def update_package(package_id: str, updates: dict[str, Any]): @router.delete("/packages/{package_id}", tags=["Packages"]) -async def delete_package(package_id: str): +async def delete_package( + package_id: str, + _operator=Depends(deps._require_operator_api_key_record), +): """Archive a package (soft delete).""" service = await deps._get_media_kit_service() deleted = await service.delete_package(package_id) @@ -410,7 +420,10 @@ async def delete_package(package_id: str): @router.post("/packages/assemble", tags=["Packages"]) -async def assemble_package(request: DynamicPackageRequest): +async def assemble_package( + request: DynamicPackageRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Assemble a dynamic package (Layer 3) from product IDs. Product ids resolve catalog-first with storage fallback (issue #34) — @@ -436,7 +449,9 @@ async def assemble_package(request: DynamicPackageRequest): @router.post("/packages/sync", tags=["Packages"]) -async def sync_packages(): +async def sync_packages( + _operator=Depends(deps._require_operator_api_key_record), +): """Trigger ad server inventory sync (Layer 1).""" from ....events.helpers import emit_event from ....events.models import EventType diff --git a/src/ad_seller/interfaces/api/routers/registry.py b/src/ad_seller/interfaces/api/routers/registry.py index 86999e1..9dd9a5e 100644 --- a/src/ad_seller/interfaces/api/routers/registry.py +++ b/src/ad_seller/interfaces/api/routers/registry.py @@ -5,7 +5,7 @@ from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from .. import deps from ..schemas import DiscoverAgentRequest, UpdateTrustRequest @@ -145,7 +145,10 @@ async def get_registered_agent(agent_id: str): @router.post("/registry/agents/discover", tags=["Agent Registry"]) -async def discover_agent(request: DiscoverAgentRequest): +async def discover_agent( + request: DiscoverAgentRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Discover an agent by URL. Fetches the agent's card from .well-known/agent.json, checks @@ -169,7 +172,11 @@ async def discover_agent(request: DiscoverAgentRequest): @router.put("/registry/agents/{agent_id}/trust", tags=["Agent Registry"]) -async def update_agent_trust(agent_id: str, request: UpdateTrustRequest): +async def update_agent_trust( + agent_id: str, + request: UpdateTrustRequest, + _operator=Depends(deps._require_operator_api_key_record), +): """Update an agent's trust status. Use this to approve, prefer, or block agents. Trust status determines @@ -206,7 +213,10 @@ async def update_agent_trust(agent_id: str, request: UpdateTrustRequest): @router.delete("/registry/agents/{agent_id}", tags=["Agent Registry"]) -async def remove_registered_agent(agent_id: str): +async def remove_registered_agent( + agent_id: str, + _operator=Depends(deps._require_operator_api_key_record), +): """Remove an agent from the local registry.""" service = await deps._get_registry_service() removed = await service.remove_agent(agent_id) diff --git a/src/ad_seller/interfaces/api/schemas.py b/src/ad_seller/interfaces/api/schemas.py index 1a7dabb..f1eae59 100644 --- a/src/ad_seller/interfaces/api/schemas.py +++ b/src/ad_seller/interfaces/api/schemas.py @@ -270,7 +270,7 @@ class ApprovalDecisionRequest(BaseModel): class CreateApiKeyRequest(BaseModel): - """Request to create a new API key for a buyer.""" + """Request to create a new API key for a buyer (or another operator).""" seat_id: Optional[str] = None seat_name: Optional[str] = None @@ -280,6 +280,7 @@ class CreateApiKeyRequest(BaseModel): agency_holding_company: Optional[str] = None advertiser_id: Optional[str] = None advertiser_name: Optional[str] = None + role: str = "buyer" # "buyer" | "operator" label: str = "" expires_in_days: Optional[int] = None diff --git a/src/ad_seller/interfaces/cli/main.py b/src/ad_seller/interfaces/cli/main.py index f41725f..0b6a22a 100644 --- a/src/ad_seller/interfaces/cli/main.py +++ b/src/ad_seller/interfaces/cli/main.py @@ -288,6 +288,65 @@ def freewheel_login( console.print(f"Access token expires at [cyan]{state.expires_at}[/cyan]") +@app.command("create-operator-key") +def create_operator_key( + label: str = typer.Option( + "Operator key", + "--label", + "-l", + help="Human-readable label for the key", + ), + expires_in_days: Optional[int] = typer.Option( + None, + "--expires-in-days", + "-e", + help="Days until the key expires (default: never)", + ), +): + """Mint an OPERATOR-role API key directly in storage (bootstrap). + + Operator keys are required for admin endpoints (API key management, + rate card, agent trust, packages, inventory sync, deal push) and for + admin MCP tools over HTTP. This command writes directly to storage — + no network surface — so it is the safe way to create the FIRST + operator key. Subsequent keys can be minted via POST /auth/api-keys + using an existing operator credential. + """ + from ...auth.api_key_service import ApiKeyService + from ...models.api_key import ApiKeyCreateRequest, ApiKeyRole + from ...storage.factory import get_storage + + async def _mint(): + storage = await get_storage() + service = ApiKeyService(storage) + return await service.create_key( + ApiKeyCreateRequest( + role=ApiKeyRole.OPERATOR, + label=label, + expires_in_days=expires_in_days, + ) + ) + + try: + response = asyncio.run(_mint()) + except Exception as exc: + console.print(f"[red]Failed to create operator key: {exc}[/red]") + raise typer.Exit(1) from exc + + console.print(Panel("Operator API key created", title="Bootstrap")) + console.print(f"Key ID: [cyan]{response.key_id}[/cyan]") + console.print(f"Label: {response.label}") + if response.expires_at: + console.print(f"Expires: {response.expires_at}") + console.print(f"\n[bold]API key (shown once — store securely):[/bold]\n{response.api_key}") + console.print( + "\nUse it on admin endpoints and MCP:\n" + " Authorization: Bearer or X-Api-Key: \n" + "Run this with the same storage config (.env) as the server so the\n" + "key lands in the storage backend the server reads." + ) + + @app.command() def chat(): """Start interactive chat mode for buyer interactions.""" diff --git a/src/ad_seller/interfaces/mcp_server.py b/src/ad_seller/interfaces/mcp_server.py index 78cde4f..6e69a8d 100644 --- a/src/ad_seller/interfaces/mcp_server.py +++ b/src/ad_seller/interfaces/mcp_server.py @@ -25,7 +25,7 @@ import json import logging from datetime import datetime, timezone -from typing import Any +from typing import Any, Optional from mcp.server.fastmcp import FastMCP @@ -135,6 +135,65 @@ async def _api_key_service(): return ApiKeyService(storage) +async def _deny_unless_operator() -> Optional[str]: + """Enforce operator-key auth on admin MCP tools over HTTP transports. + + Returns None when the call is authorized, otherwise an error JSON + string the tool should return as-is. + + - HTTP transports (Streamable HTTP at /mcp, legacy SSE): the client + must send an OPERATOR-role API key via ``Authorization: Bearer`` + or ``X-Api-Key`` (mcp-remote: ``--header "Authorization: Bearer …"``). + - stdio transport (``python -m ad_seller.interfaces.mcp_server`` from + a local shell): no HTTP request exists; local process access is + trusted, same model as the CLI. + """ + try: + request = mcp.get_context().request_context.request + except Exception: + request = None + if request is None or not hasattr(request, "headers"): + return None # stdio / in-process — local operator access + + headers = request.headers + raw_key = headers.get("x-api-key") + if not raw_key: + authorization = headers.get("authorization", "") + parts = authorization.split(" ", 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + raw_key = parts[1] + + if not raw_key: + return _dumps( + { + "error": "authentication_required", + "detail": "This tool requires an operator API key. Send it as " + "'Authorization: Bearer ' or 'X-Api-Key: '. Bootstrap " + "the first key with: ad-seller create-operator-key", + } + ) + + from ..models.api_key import ApiKeyRole + + service = await _api_key_service() + try: + record = await service.validate_key(raw_key) + except ValueError as exc: # revoked or expired + return _dumps({"error": "invalid_credential", "detail": str(exc)}) + + if record is None: + return _dumps({"error": "invalid_credential", "detail": "Invalid API key"}) + if record.role != ApiKeyRole.OPERATOR: + return _dumps( + { + "error": "operator_required", + "detail": "This tool requires an operator-role API key; the " + "provided key is a buyer credential.", + } + ) + return None + + # ============================================================================= # Setup & Status # ============================================================================= @@ -282,6 +341,10 @@ async def get_config() -> str: async def set_publisher_identity(name: str, domain: str = "", org_id: str = "") -> str: """Set the publisher's identity (name, domain, organization ID). This is shown in the agent card and supply chain info.""" + denied = await _deny_unless_operator() + if denied: + return denied + # Write to .env file _update_env("SELLER_ORGANIZATION_NAME", name) if domain: @@ -336,6 +399,10 @@ async def list_products(limit: int | None = 50) -> str: async def sync_inventory(incremental: bool = False) -> str: """Trigger inventory sync from the ad server (GAM or FreeWheel). Use incremental=true to only sync changes since last sync.""" + denied = await _deny_unless_operator() + if denied: + return denied + from ..services.inventory_sync_scheduler import _run_sync result = await _run_sync() @@ -408,6 +475,10 @@ async def create_package( is_featured: bool = False, ) -> str: """Create a new curated package in the media kit.""" + denied = await _deny_unless_operator() + if denied: + return denied + import uuid from ..models.media_kit import Package, PackageLayer @@ -464,6 +535,10 @@ async def get_rate_card() -> str: async def update_rate_card(entries: str) -> str: """Update the rate card. Pass entries as JSON array: [{"inventory_type": "ctv", "base_cpm": 40.0}, ...]""" + denied = await _deny_unless_operator() + if denied: + return denied + storage = await _get_storage() parsed = json.loads(entries) now = datetime.now(timezone.utc).isoformat() @@ -612,6 +687,10 @@ async def list_gam_orders(limit: int = 50, agent_created_only: bool = False) -> Returns order id, name, status, and whether the order was agent-created. Requires GAM_ENABLED=true, GAM_NETWORK_CODE, GAM_JSON_KEY_PATH in .env. """ + denied = await _deny_unless_operator() + if denied: + return denied + from ..services import gam_reporting_service return await _service_json( @@ -632,6 +711,10 @@ async def get_gam_delivery_report(order_ids: str, days: int = 30) -> str: Returns order metadata, line items, and delivery data (impressions, clicks, revenue). Requires GAM_ENABLED=true, GAM_NETWORK_CODE, GAM_JSON_KEY_PATH in .env. """ + denied = await _deny_unless_operator() + if denied: + return denied + from ..services import gam_reporting_service return await _service_json( @@ -643,6 +726,10 @@ async def get_gam_delivery_report(order_ids: str, days: int = 30) -> str: async def push_deal_to_buyers(deal_id: str, buyer_urls: str) -> str: """Push a deal to buyer endpoints via IAB Deals API v1.0. Pass buyer_urls as comma-separated list.""" + denied = await _deny_unless_operator() + if denied: + return denied + from types import SimpleNamespace from ..services import deal_service @@ -673,6 +760,10 @@ async def distribute_deal_via_ssp( ) -> str: """Distribute a deal through configured SSP(s). Routes based on ssp_name or inventory_type routing rules.""" + denied = await _deny_unless_operator() + if denied: + return denied + from types import SimpleNamespace from ..services import deal_service @@ -830,6 +921,10 @@ async def list_pending_approvals() -> str: @mcp.tool() async def approve_or_reject(approval_id: str, decision: str, reason: str = "") -> str: """Submit an approval decision. decision: 'approve', 'reject', or 'counter'.""" + denied = await _deny_unless_operator() + if denied: + return denied + from ..services import approval_service return await _service_json( @@ -847,6 +942,10 @@ async def set_approval_gates( ) -> str: """Configure approval gates. required_flows is comma-separated: 'proposal_decision,deal_registration'""" + denied = await _deny_unless_operator() + if denied: + return denied + _update_env("APPROVAL_GATE_ENABLED", str(enabled).lower()) if required_flows: _update_env("APPROVAL_REQUIRED_FLOWS", required_flows) @@ -1002,6 +1101,10 @@ async def list_buyer_agents() -> str: async def register_buyer_agent(agent_url: str) -> str: """Discover and register a buyer agent by URL. Fetches their agent card and adds them to the registry.""" + denied = await _deny_unless_operator() + if denied: + return denied + service = await _registry_service() agent, tier = await service.resolve_agent_access(agent_url) @@ -1021,6 +1124,10 @@ async def register_buyer_agent(agent_url: str) -> str: async def set_agent_trust(agent_id: str, trust_level: str) -> str: """Set trust level for a buyer agent. Levels: unknown, registered, approved, preferred, blocked.""" + denied = await _deny_unless_operator() + if denied: + return denied + from ..models.agent_registry import TRUST_TO_TIER_MAP, TrustStatus try: @@ -1057,6 +1164,10 @@ async def set_agent_trust(agent_id: str, trust_level: str) -> str: @mcp.tool() async def create_api_key(name: str = "buyer", seat_id: str = "", agency_id: str = "") -> str: """Create an API key for a buyer or agent.""" + denied = await _deny_unless_operator() + if denied: + return denied + from ..models.api_key import ApiKeyCreateRequest # The historic ``name`` argument was never a field on the REST create @@ -1074,6 +1185,10 @@ async def create_api_key(name: str = "buyer", seat_id: str = "", agency_id: str @mcp.tool() async def list_api_keys() -> str: """List active API keys.""" + denied = await _deny_unless_operator() + if denied: + return denied + service = await _api_key_service() keys = await service.list_keys() return _dumps( @@ -1087,6 +1202,10 @@ async def list_api_keys() -> str: @mcp.tool() async def revoke_api_key(key_id: str) -> str: """Revoke an API key.""" + denied = await _deny_unless_operator() + if denied: + return denied + service = await _api_key_service() revoked = await service.revoke_key(key_id) if not revoked: @@ -1102,6 +1221,10 @@ async def revoke_api_key(key_id: str) -> str: @mcp.tool() async def list_sessions() -> str: """List active buyer conversation sessions.""" + denied = await _deny_unless_operator() + if denied: + return denied + from ..services import session_service return await _service_json(session_service.list_sessions()) @@ -1336,6 +1459,10 @@ async def help_prompt() -> list[Message]: async def get_inbound_queue(limit: int | None = 50) -> str: """Get everything waiting for publisher action: pending approvals, unresolved proposals. Returns a unified list sorted by urgency (most urgent first).""" + denied = await _deny_unless_operator() + if denied: + return denied + limit = limit or 50 from datetime import timedelta @@ -1416,6 +1543,10 @@ async def get_inbound_queue(limit: int | None = 50) -> str: async def get_buyer_activity(days: int | None = 7, limit: int | None = 50) -> str: """Show buyer agent engagement: who accessed inventory, initiated deals, or negotiated recently. Grouped by buyer identity.""" + denied = await _deny_unless_operator() + if denied: + return denied + days = days or 7 limit = limit or 50 from datetime import timedelta diff --git a/src/ad_seller/models/api_key.py b/src/ad_seller/models/api_key.py index e1e7e50..20c4a27 100644 --- a/src/ad_seller/models/api_key.py +++ b/src/ad_seller/models/api_key.py @@ -15,6 +15,7 @@ import hashlib import secrets from datetime import datetime +from enum import Enum from typing import Optional from pydantic import BaseModel, Field @@ -26,6 +27,20 @@ API_KEY_INDEX_PREFIX = "api_key_index:" +class ApiKeyRole(str, Enum): + """Role attached to an API key. + + - BUYER: buyer-agent credential; grants tiered data access + (seat/agency/advertiser pricing) but no control-plane rights. + - OPERATOR: publisher operator credential; required for admin + endpoints (key management, rate card, registry trust, packages, + inventory sync, deal push/distribute). + """ + + BUYER = "buyer" + OPERATOR = "operator" + + def generate_api_key() -> str: """Generate a new API key with prefix. @@ -55,6 +70,10 @@ class ApiKeyRecord(BaseModel): # The identity this key authenticates identity: BuyerIdentity + # Role: pre-existing records deserialize as "buyer" (safe default — + # never silently promotes an old key to operator). + role: ApiKeyRole = ApiKeyRole.BUYER + # Metadata label: str = "" # Human-readable label, e.g. "Acme Agency production key" created_at: datetime = Field(default_factory=datetime.utcnow) @@ -93,6 +112,7 @@ class ApiKeyCreateRequest(BaseModel): advertiser_name: Optional[str] = None # Key metadata + role: ApiKeyRole = ApiKeyRole.BUYER label: str = "" expires_in_days: Optional[int] = None # None = never expires @@ -107,6 +127,7 @@ class ApiKeyCreateResponse(BaseModel): key_id: str api_key: str # Full key, shown once identity: BuyerIdentity + role: ApiKeyRole = ApiKeyRole.BUYER label: str expires_at: Optional[datetime] = None warning: str = "Store this key securely. It will not be shown again." @@ -118,6 +139,7 @@ class ApiKeyInfo(BaseModel): key_id: str key_prefix_hint: str identity: BuyerIdentity + role: ApiKeyRole = ApiKeyRole.BUYER label: str created_at: datetime expires_at: Optional[datetime] = None diff --git a/tests/unit/test_csv_catalog_coherence.py b/tests/unit/test_csv_catalog_coherence.py index 53fed0c..3677579 100644 --- a/tests/unit/test_csv_catalog_coherence.py +++ b/tests/unit/test_csv_catalog_coherence.py @@ -109,6 +109,17 @@ def default_mode(monkeypatch): _reset_caches() +@pytest.fixture(autouse=True) +def _bypass_operator_gate(): + """POST /packages now requires an operator key; these tests exercise + catalog coherence, not auth, so bypass the operator dependency.""" + from ad_seller.interfaces.api import deps as api_deps + + app.dependency_overrides[api_deps._require_operator_api_key_record] = lambda: None + yield + app.dependency_overrides.pop(api_deps._require_operator_api_key_record, None) + + # ============================================================================= # In-memory storage (no SQLite file) — same shape as test_issue34 # ============================================================================= diff --git a/tests/unit/test_endpoint_no_flow_kickoff.py b/tests/unit/test_endpoint_no_flow_kickoff.py index 778a653..e3b3ede 100644 --- a/tests/unit/test_endpoint_no_flow_kickoff.py +++ b/tests/unit/test_endpoint_no_flow_kickoff.py @@ -59,6 +59,17 @@ def _reset_catalog_cache(): api_main._STATIC_PRODUCT_CATALOG = None +@pytest.fixture(autouse=True) +def _bypass_operator_gate(): + """/packages/sync now requires an operator key; these tests exercise + flow-kickoff behavior, not auth, so bypass the operator dependency.""" + from ad_seller.interfaces.api import deps as api_deps + + app.dependency_overrides[api_deps._require_operator_api_key_record] = lambda: None + yield + app.dependency_overrides.pop(api_deps._require_operator_api_key_record, None) + + @pytest.fixture(autouse=True) def _fail_if_flow_kickoff_called(monkeypatch): """Hard-fail the test if any code path calls ProductSetupFlow().kickoff().""" diff --git a/tests/unit/test_issue34_catalog_fixes.py b/tests/unit/test_issue34_catalog_fixes.py index 1f90052..57f197e 100644 --- a/tests/unit/test_issue34_catalog_fixes.py +++ b/tests/unit/test_issue34_catalog_fixes.py @@ -111,6 +111,15 @@ def _reset_catalog_cache(): api_main._STATIC_PRODUCT_CATALOG = None +@pytest.fixture(autouse=True) +def _bypass_operator_gate(): + """Package mutations now require an operator key; these tests exercise + catalog resolution, not auth, so bypass the operator dependency.""" + app.dependency_overrides[deps._require_operator_api_key_record] = lambda: None + yield + app.dependency_overrides.pop(deps._require_operator_api_key_record, None) + + @pytest.fixture def client(storage): """ASGI client with storage + event emission patched hermetically.""" diff --git a/tests/unit/test_operator_auth.py b/tests/unit/test_operator_auth.py new file mode 100644 index 0000000..a8beb3a --- /dev/null +++ b/tests/unit/test_operator_auth.py @@ -0,0 +1,353 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Operator-key authentication for the admin surface. + +Covers: + +(a) API key records carry a role; pre-existing records (no role field) + deserialize as BUYER — old keys are never silently promoted. +(b) ``require_operator_key``: anonymous → 401, buyer key → 403, + operator key → allowed. +(c) The admin REST surface is gated: /auth/api-keys, /events, rate-card + writes, registry mutations, package mutations, inventory-sync + trigger, deal push/distribute, curator registration. +(d) The CLI bootstrap (``ad-seller create-operator-key``) mints an + OPERATOR-role key directly in storage. +(e) Admin MCP tools deny non-operator HTTP callers but allow local + stdio access (no HTTP request context). +""" + +import sys +from types import ModuleType +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Stub broken flow modules (pre-existing @listen() bugs with CrewAI version +# mismatch) before any import of ad_seller.flows triggers __init__.py. +_broken_flows = [ + "ad_seller.flows.discovery_inquiry_flow", + "ad_seller.flows.execution_activation_flow", +] +for _mod_name in _broken_flows: + if _mod_name not in sys.modules: + _stub = ModuleType(_mod_name) + _cls_name = _mod_name.rsplit(".", 1)[-1].replace("_", " ").title().replace(" ", "") + setattr(_stub, _cls_name, type(_cls_name, (), {})) + sys.modules[_mod_name] = _stub + +import httpx # noqa: E402 +from httpx import ASGITransport # noqa: E402 + +from ad_seller.interfaces.api.main import app # noqa: E402 +from ad_seller.models.api_key import ( # noqa: E402 + API_KEY_STORAGE_PREFIX, + ApiKeyRecord, + ApiKeyRole, + generate_api_key, + hash_api_key, +) +from ad_seller.models.buyer_identity import BuyerIdentity # noqa: E402 + +# ============================================================================= +# Helpers / fixtures +# ============================================================================= + + +@pytest.fixture +def mock_storage(): + store = {} + storage = AsyncMock() + storage.get = AsyncMock(side_effect=lambda k: store.get(k)) + storage.set = AsyncMock(side_effect=lambda k, v, ttl=None: store.__setitem__(k, v)) + storage._store = store + return storage + + +@pytest.fixture +def client(): + transport = ASGITransport(app=app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +def _seed_key(store, *, role=ApiKeyRole.BUYER, key_id="key-test"): + """Seed a valid API key record with the given role; return the raw key.""" + raw_key = generate_api_key() + key_hash = hash_api_key(raw_key) + record = ApiKeyRecord( + key_id=key_id, + key_hash=key_hash, + key_prefix_hint=raw_key[:12] + "...", + identity=BuyerIdentity(agency_id="agy-1", agency_name="Acme"), + role=role, + label=f"{role.value} test key", + ) + store[f"{API_KEY_STORAGE_PREFIX}{key_hash}"] = record.model_dump(mode="json") + return raw_key + + +def _auth(raw_key: str) -> dict: + return {"Authorization": f"Bearer {raw_key}"} + + +# ============================================================================= +# (a) Role model +# ============================================================================= + + +class TestApiKeyRole: + def test_legacy_record_without_role_deserializes_as_buyer(self): + """Stored records from before the role field must load as BUYER.""" + raw_key = generate_api_key() + legacy = ApiKeyRecord( + key_id="key-legacy", + key_hash=hash_api_key(raw_key), + key_prefix_hint=raw_key[:12] + "...", + identity=BuyerIdentity(seat_id="seat-1"), + ).model_dump(mode="json") + legacy.pop("role", None) # simulate pre-role stored JSON + + record = ApiKeyRecord(**legacy) + assert record.role == ApiKeyRole.BUYER + + def test_operator_role_round_trips_through_storage_shape(self): + raw_key = generate_api_key() + record = ApiKeyRecord( + key_id="key-op", + key_hash=hash_api_key(raw_key), + key_prefix_hint=raw_key[:12] + "...", + identity=BuyerIdentity(), + role=ApiKeyRole.OPERATOR, + ) + reloaded = ApiKeyRecord(**record.model_dump(mode="json")) + assert reloaded.role == ApiKeyRole.OPERATOR + + +# ============================================================================= +# (b) + (c) REST admin surface enforcement +# ============================================================================= + + +class TestApiKeyEndpointsRequireOperator: + async def test_anonymous_create_key_is_401(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post("/auth/api-keys", json={"label": "x"}) + assert resp.status_code == 401 + + async def test_buyer_key_create_key_is_403(self, client, mock_storage): + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post( + "/auth/api-keys", json={"label": "x"}, headers=_auth(raw_key) + ) + assert resp.status_code == 403 + assert "Operator credential required" in resp.text + + async def test_operator_key_creates_buyer_and_operator_keys(self, client, mock_storage): + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + buyer_resp = await client.post( + "/auth/api-keys", + json={"label": "buyer key", "agency_id": "agy-2"}, + headers=_auth(raw_key), + ) + op_resp = await client.post( + "/auth/api-keys", + json={"label": "second operator", "role": "operator"}, + headers=_auth(raw_key), + ) + assert buyer_resp.status_code == 200 + assert buyer_resp.json()["role"] == "buyer" + assert op_resp.status_code == 200 + assert op_resp.json()["role"] == "operator" + + async def test_invalid_role_is_400(self, client, mock_storage): + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post( + "/auth/api-keys", + json={"label": "x", "role": "superadmin"}, + headers=_auth(raw_key), + ) + assert resp.status_code == 400 + + async def test_list_and_revoke_require_operator(self, client, mock_storage): + buyer_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER, key_id="key-b") + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + anon_list = await client.get("/auth/api-keys") + buyer_list = await client.get("/auth/api-keys", headers=_auth(buyer_key)) + anon_revoke = await client.delete("/auth/api-keys/key-b") + assert anon_list.status_code == 401 + assert buyer_list.status_code == 403 + assert anon_revoke.status_code == 401 + + +class TestAdminSurfaceGated: + """Spot-checks across the rest of the operator surface.""" + + async def test_events_require_operator(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/events") + assert resp.status_code == 401 + + async def test_rate_card_write_requires_operator(self, client, mock_storage): + entries = [{"inventory_type": "ctv", "base_cpm": 40.0}] + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + anon = await client.put("/api/v1/rate-card", json=entries) + op_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) + allowed = await client.put( + "/api/v1/rate-card", json=entries, headers=_auth(op_key) + ) + assert anon.status_code == 401 + assert allowed.status_code == 200 + assert allowed.json()["entries"][0]["base_cpm"] == 40.0 + + async def test_rate_card_read_stays_public(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/api/v1/rate-card") + assert resp.status_code == 200 + + async def test_registry_trust_mutation_requires_operator(self, client, mock_storage): + buyer_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + anon = await client.put( + "/registry/agents/agent-1/trust", json={"trust_status": "preferred"} + ) + buyer = await client.put( + "/registry/agents/agent-1/trust", + json={"trust_status": "preferred"}, + headers=_auth(buyer_key), + ) + assert anon.status_code == 401 + assert buyer.status_code == 403 + + async def test_registry_reads_stay_public(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.get("/registry/agents") + assert resp.status_code == 200 + + async def test_package_mutations_require_operator(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + create = await client.post("/packages", json={"name": "P", "product_ids": []}) + update = await client.put("/packages/pkg-x", json={"name": "Q"}) + delete = await client.delete("/packages/pkg-x") + sync = await client.post("/packages/sync") + assert {create.status_code, update.status_code, delete.status_code, + sync.status_code} == {401} + + async def test_inventory_sync_trigger_requires_operator(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post("/api/v1/inventory-sync/trigger") + assert resp.status_code == 401 + + async def test_deal_push_distribute_curators_require_operator(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + push = await client.post( + "/api/v1/deals/push", json={"deal_id": "d-1", "buyer_urls": []} + ) + distribute = await client.post( + "/api/v1/deals/distribute", json={"deal_id": "d-1"} + ) + curator = await client.post( + "/api/v1/curators", json={"name": "C", "curator_id": "c-1", "fee_cpm": 1.0} + ) + assert push.status_code == 401 + assert distribute.status_code == 401 + assert curator.status_code == 401 + + +# ============================================================================= +# (d) CLI bootstrap +# ============================================================================= + + +class TestCliBootstrap: + def test_create_operator_key_mints_operator_role(self, mock_storage): + from ad_seller.interfaces.cli.main import create_operator_key + + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + create_operator_key(label="bootstrap test", expires_in_days=None) + + records = [ + v + for k, v in mock_storage._store.items() + if k.startswith(API_KEY_STORAGE_PREFIX) + ] + assert len(records) == 1 + assert records[0]["role"] == "operator" + assert records[0]["label"] == "bootstrap test" + + +# ============================================================================= +# (e) MCP tool gating +# ============================================================================= + + +def _fake_mcp_context(headers: dict | None): + """Build a fake FastMCP context carrying an HTTP request (or none).""" + ctx = MagicMock() + if headers is None: + ctx.request_context.request = None + else: + request = MagicMock() + request.headers = headers + ctx.request_context.request = request + return ctx + + +class TestMcpOperatorGate: + async def test_stdio_no_request_is_allowed(self): + from ad_seller.interfaces import mcp_server + + with patch.object(mcp_server.mcp, "get_context", side_effect=LookupError): + assert await mcp_server._deny_unless_operator() is None + + async def test_http_without_key_is_denied(self): + from ad_seller.interfaces import mcp_server + + ctx = _fake_mcp_context(headers={}) + with patch.object(mcp_server.mcp, "get_context", return_value=ctx): + denied = await mcp_server._deny_unless_operator() + assert denied is not None + assert "authentication_required" in denied + + async def test_http_with_buyer_key_is_denied(self, mock_storage): + from ad_seller.interfaces import mcp_server + + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER) + ctx = _fake_mcp_context(headers={"authorization": f"Bearer {raw_key}"}) + with ( + patch.object(mcp_server.mcp, "get_context", return_value=ctx), + patch("ad_seller.storage.factory.get_storage", return_value=mock_storage), + ): + denied = await mcp_server._deny_unless_operator() + assert denied is not None + assert "operator_required" in denied + + async def test_http_with_operator_key_is_allowed(self, mock_storage): + from ad_seller.interfaces import mcp_server + + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) + ctx = _fake_mcp_context(headers={"x-api-key": raw_key}) + with ( + patch.object(mcp_server.mcp, "get_context", return_value=ctx), + patch("ad_seller.storage.factory.get_storage", return_value=mock_storage), + ): + assert await mcp_server._deny_unless_operator() is None + + async def test_gated_tool_returns_denial_over_http(self, mock_storage): + """An admin tool short-circuits with the denial payload.""" + from ad_seller.interfaces import mcp_server + + ctx = _fake_mcp_context(headers={}) + with ( + patch.object(mcp_server.mcp, "get_context", return_value=ctx), + patch("ad_seller.storage.factory.get_storage", return_value=mock_storage), + ): + result = await mcp_server.create_api_key(seat_id="seat-x") + assert "authentication_required" in result + # Nothing was minted. + assert not [ + k for k in mock_storage._store if k.startswith(API_KEY_STORAGE_PREFIX) + ] diff --git a/tests/unit/test_trust_tier_verification.py b/tests/unit/test_trust_tier_verification.py index 71e3c9d..a54f87f 100644 --- a/tests/unit/test_trust_tier_verification.py +++ b/tests/unit/test_trust_tier_verification.py @@ -486,6 +486,9 @@ async def test_registered_agent_claiming_advertiser_capped_at_seat( async def test_api_key_identity_is_not_floored(self, client, mock_storage): """EP-4.5 verified principal: seller-issued key identity survives.""" + # This test needs REAL key validation; drop the fixture's + # anonymous override so the seeded X-Api-Key is honored. + app.dependency_overrides.pop(_get_optional_api_key_record, None) raw_key = _seed_api_key( mock_storage._store, BuyerIdentity(agency_id="agency-001") ) @@ -803,6 +806,8 @@ async def test_self_asserted_agency_is_floored_to_public(self, client): class TestDealFromTemplateCeiling: async def test_registry_ceiling_caps_api_key_identity(self, client, mock_storage): """Defense in depth: key identity (AGENCY) capped by registry (SEAT).""" + # Needs REAL key validation; drop the fixture's anonymous override. + app.dependency_overrides.pop(_get_optional_api_key_record, None) raw_key = _seed_api_key( mock_storage._store, BuyerIdentity(agency_id="agency-001") ) @@ -833,6 +838,8 @@ async def test_registry_ceiling_caps_api_key_identity(self, client, mock_storage assert len(_trust_records(mock_storage)) == 1 async def test_api_key_identity_kept_without_agent_url(self, client, mock_storage): + # Needs REAL key validation; drop the fixture's anonymous override. + app.dependency_overrides.pop(_get_optional_api_key_record, None) raw_key = _seed_api_key( mock_storage._store, BuyerIdentity(agency_id="agency-001") ) diff --git a/tests/unit/test_update_package_whitelist.py b/tests/unit/test_update_package_whitelist.py index ee8e311..c1f3bd8 100644 --- a/tests/unit/test_update_package_whitelist.py +++ b/tests/unit/test_update_package_whitelist.py @@ -213,8 +213,12 @@ def api_client(storage): import httpx from httpx import ASGITransport + from ad_seller.interfaces.api import deps as api_deps from ad_seller.interfaces.api.main import app + # PUT /packages/{id} now requires an operator key; these tests exercise + # whitelist validation, not auth, so bypass the operator dependency. + app.dependency_overrides[api_deps._require_operator_api_key_record] = lambda: None with patch("ad_seller.storage.factory.get_storage", AsyncMock(return_value=storage)): with patch( "ad_seller.events.helpers.emit_event", new_callable=AsyncMock @@ -223,6 +227,7 @@ def api_client(storage): client = httpx.AsyncClient(transport=transport, base_url="http://test") client._mock_emit = mock_emit # expose for assertions yield client + app.dependency_overrides.pop(api_deps._require_operator_api_key_record, None) class TestPutPackagesEndpoint: From 6a7a5f09236040011d7f87b180e8b1e94053189d Mon Sep 17 00:00:00 2001 From: Miguel Morales <191371+therevoltingx@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:06:07 -0700 Subject: [PATCH 2/2] updates documents. adds specific operator key request object --- docs/api/agent-discovery.md | 8 +- docs/api/authentication.md | 83 ++++++++++++++-- docs/api/mcp.md | 15 ++- docs/api/overview.md | 25 +++-- docs/architecture/overview.md | 2 +- docs/guides/agent-management.md | 94 +++++++++++++------ docs/guides/claude-desktop-setup.md | 20 +++- docs/guides/developer-setup.md | 13 +-- docs/integration/buyer-agent.md | 5 +- docs/reference/endpoints.md | 3 +- docs/reference/mcp-tools.md | 2 +- src/ad_seller/auth/api_key_service.py | 58 +++++++++--- src/ad_seller/interfaces/api/deps.py | 2 +- src/ad_seller/interfaces/api/routers/admin.py | 49 +++++++--- src/ad_seller/interfaces/api/schemas.py | 14 ++- src/ad_seller/interfaces/cli/main.py | 11 +-- src/ad_seller/models/__init__.py | 4 + src/ad_seller/models/api_key.py | 18 +++- tests/unit/test_operator_auth.py | 72 +++++++++++--- 19 files changed, 384 insertions(+), 114 deletions(-) diff --git a/docs/api/agent-discovery.md b/docs/api/agent-discovery.md index 35b3554..618de21 100644 --- a/docs/api/agent-discovery.md +++ b/docs/api/agent-discovery.md @@ -121,20 +121,20 @@ A buyer agent discovers a seller through the following steps: 1. **Fetch the agent card** --- `GET https://seller.example.com/.well-known/agent.json` 2. **Inspect capabilities** --- Check supported protocols, skills, inventory types, and deal types -3. **Obtain an API key** --- `POST /auth/api-keys` with buyer identity +3. **Obtain a buyer API key** --- Ask the seller operator to mint one via `POST /auth/api-keys` (operator-gated; see [Authentication](authentication.md)) 4. **Choose a protocol** --- Use [MCP](mcp.md) for structured operations ([A2A](a2a.md) is designed but not yet served) 5. **Start transacting** --- Browse products, request pricing, submit proposals, book deals ### Registry Endpoints (Operator-Facing) -Seller operators manage the agent registry through these endpoints: +Seller operators manage the agent registry through these endpoints. Mutations require an operator API key. | Endpoint | Method | Description | |----------|--------|-------------| | `/registry/agents` | GET | List registered agents (filterable by type and trust status) | | `/registry/agents/{agent_id}` | GET | Get details for a specific agent | -| `/registry/agents/{agent_id}/trust` | PUT | Update an agent's trust status | -| `/registry/agents/discover` | POST | Discover an agent by URL (fetches their agent card) | +| `/registry/agents/{agent_id}/trust` | PUT | Update an agent's trust status (operator auth required) | +| `/registry/agents/discover` | POST | Discover an agent by URL (operator auth required) | ## See Also diff --git a/docs/api/authentication.md b/docs/api/authentication.md index af1ae8f..3835243 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -1,6 +1,6 @@ # Authentication -The seller agent supports authenticated and anonymous access. Authentication unlocks tiered pricing, negotiation, and richer data in responses. +The seller agent supports authenticated and anonymous access. Authentication unlocks tiered pricing, negotiation, and richer data in responses. **Operator** credentials unlock the control plane (key management, rate card, registry trust, packages, inventory sync). ## Authentication Methods @@ -18,14 +18,57 @@ Authorization: Bearer X-Api-Key: ``` -When both headers are present, the system validates whichever is found first. Anonymous requests (no key) are allowed on most endpoints but receive public-tier access only. +When both headers are present, the system validates whichever is found first. Anonymous requests (no key) are allowed on most buyer-facing endpoints but receive public-tier access only. + +## Key Roles + +Every API key has a role: + +| Role | Purpose | +|------|---------| +| `buyer` | Buyer-agent credential. Grants tiered data access (seat/agency/advertiser pricing). **No** control-plane rights. | +| `operator` | Publisher operator credential. Required for admin REST endpoints and admin MCP tools over HTTP. | + +Pre-existing keys (stored before the role field existed) deserialize as `buyer` — they are never silently promoted to operator. + +## Bootstrap: First Operator Key + +Creating keys via the HTTP API itself requires an operator credential. Mint the **first** operator key out-of-band with the CLI (writes directly to storage — no network surface): + +```bash +ad-seller create-operator-key --label "Primary operator" +``` + +Run this with the same storage config (`.env`) as the server so the key lands in the backend the server reads. The full key is printed **once** — store it securely. + +Subsequent operator keys can be minted over HTTP with an existing operator credential (see below). + +## Operator Surface + +These routes require a valid **operator** key (anonymous → 401, buyer key → 403): + +- All `/auth/api-keys` routes (create buyer, create operator, list, get, revoke) +- `/events`, `/events/{id}` +- `PUT /api/v1/rate-card` +- `POST /api/v1/inventory-sync/trigger` +- `GET /gam/orders`, `GET /gam/report` +- Registry mutations: discover, trust update, delete +- Package mutations: `POST/PUT/DELETE /packages`, `/packages/assemble`, `/packages/sync` +- `POST /api/v1/curators`, `POST /api/v1/deals/push`, `POST /api/v1/deals/distribute` + +Buyer-facing reads (`GET /packages`, `GET /registry/agents`, `GET /api/v1/rate-card`, media-kit search, etc.) stay public or buyer-authenticated as before. + +Admin MCP tools over HTTP (Streamable HTTP / SSE) enforce the same operator check via `Authorization` / `X-Api-Key`. Local stdio MCP access is trusted like the CLI. ## API Key Lifecycle -### Create a Key +All key-management endpoints below require an operator credential. + +### Create a Buyer Key ```bash curl -X POST http://localhost:8000/auth/api-keys \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "seat_id": "seat-acme-001", @@ -39,33 +82,52 @@ curl -X POST http://localhost:8000/auth/api-keys \ }' ``` +This endpoint always creates a **buyer** key. There is no `role` field — operator keys use a separate endpoint. + The response contains the **full API key** which is shown **only once**. Store it securely --- it cannot be retrieved again. +### Create an Operator Key + +```bash +curl -X POST http://localhost:8000/auth/api-keys/operator \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "label": "Ops secondary key", + "expires_in_days": 365 + }' +``` + +Operator keys carry no buyer identity (no seat/agency/advertiser fields) — only `label` and optional `expires_in_days`. + ### List Keys ```bash -curl http://localhost:8000/auth/api-keys +curl http://localhost:8000/auth/api-keys \ + -H "Authorization: Bearer " ``` -Returns metadata for all keys (no secrets). Includes key ID, label, identity, creation date, and status. +Returns metadata for all keys (no secrets). Includes key ID, label, role, identity, creation date, and status. ### Get Key Details ```bash -curl http://localhost:8000/auth/api-keys/{key_id} +curl http://localhost:8000/auth/api-keys/{key_id} \ + -H "Authorization: Bearer " ``` ### Revoke a Key ```bash -curl -X DELETE http://localhost:8000/auth/api-keys/{key_id} +curl -X DELETE http://localhost:8000/auth/api-keys/{key_id} \ + -H "Authorization: Bearer " ``` Revoked keys immediately return HTTP 401 on use. ## Access Tiers -Access tiers control pricing visibility, discount eligibility, and negotiation access: +Access tiers control pricing visibility, discount eligibility, and negotiation access for **buyer** keys: | Tier | Description | Pricing Visibility | Negotiation | |------|-------------|-------------------|-------------| @@ -92,19 +154,24 @@ The effective tier is the **minimum** of the API key tier and the agent trust ti ### Managing Trust +Registry mutations require an operator credential: + ```bash # Discover and register an agent curl -X POST http://localhost:8000/registry/agents/discover \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"agent_url": "https://buyer.example.com"}' # Approve the agent curl -X PUT http://localhost:8000/registry/agents/{agent_id}/trust \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"trust_status": "approved", "notes": "Verified by ops team"}' # Block a malicious agent curl -X PUT http://localhost:8000/registry/agents/{agent_id}/trust \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"trust_status": "blocked", "notes": "Abuse detected"}' ``` diff --git a/docs/api/mcp.md b/docs/api/mcp.md index b526b14..d5f43e5 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -38,6 +38,16 @@ bearer_token_env_var = "SELLER_AGENT_API_KEY" See full setup guides: [Claude](../guides/claude-desktop-setup.md) | [ChatGPT, Codex & AI IDEs](../guides/chatgpt-setup.md) +### Operator credentials for admin tools + +Admin MCP tools (API key management, trust updates, package/rate-card writes, +inventory sync, deal push/distribute, GAM reporting, etc.) require an +**operator** API key over HTTP transports. Pass it as +`Authorization: Bearer ` or `X-Api-Key: ` (see Cursor/Codex examples +above). Bootstrap the first key with `ad-seller create-operator-key` — +[Authentication](authentication.md). Local stdio MCP access is trusted like +the CLI and does not require a header. + ## Available Tools (41) ### Setup & Status @@ -114,9 +124,12 @@ See full setup guides: [Claude](../guides/claude-desktop-setup.md) | [ChatGPT, C ### API Keys +Operator auth required over HTTP. Buyer keys only via MCP — mint additional +operator keys with `POST /auth/api-keys/operator` or the CLI. + | Tool | Description | |------|-------------| -| `create_api_key` | Create an API key for a buyer or agent | +| `create_api_key` | Create a **buyer** API key | | `list_api_keys` | List active keys | | `revoke_api_key` | Revoke a key | diff --git a/docs/api/overview.md b/docs/api/overview.md index c1f62e9..c6ae324 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -90,35 +90,42 @@ The Ad Seller System API exposes **59 endpoints** across **19 tags**. All endpoi ## Packages +Package reads are public / tier-gated; mutations require an operator credential. + | Method | Path | Summary | |--------|------|---------| | GET | `/packages` | List packages with tier-gated view | | GET | `/packages/{package_id}` | Get a single package with tier-gated view | -| POST | `/packages` | Create a curated package (Layer 2) | -| PUT | `/packages/{package_id}` | Update an existing package | -| DELETE | `/packages/{package_id}` | Archive a package (soft delete) | -| POST | `/packages/assemble` | Assemble a dynamic package (Layer 3) from product IDs | -| POST | `/packages/sync` | Trigger ad server inventory sync (Layer 1) | +| POST | `/packages` | Create a curated package (Layer 2; operator auth required) | +| PUT | `/packages/{package_id}` | Update an existing package (operator auth required) | +| DELETE | `/packages/{package_id}` | Archive a package (soft delete; operator auth required) | +| POST | `/packages/assemble` | Assemble a dynamic package (Layer 3; operator auth required) | +| POST | `/packages/sync` | Trigger ad server inventory sync (Layer 1; operator auth required) | ## Authentication +All `/auth/api-keys*` routes require an **operator** credential. Bootstrap the first operator key with `ad-seller create-operator-key` (see [Authentication](authentication.md)). + | Method | Path | Summary | |--------|------|---------| -| POST | `/auth/api-keys` | Create a new API key for a buyer | +| POST | `/auth/api-keys` | Create a new **buyer** API key (operator auth required) | +| POST | `/auth/api-keys/operator` | Create a new **operator** API key (operator auth required) | | GET | `/auth/api-keys` | List all API keys (metadata only, no secrets) | | GET | `/auth/api-keys/{key_id}` | Get details for a specific API key | | DELETE | `/auth/api-keys/{key_id}` | Revoke an API key | ## Agent Registry +Registry reads are public; mutations require an operator credential. + | Method | Path | Summary | |--------|------|---------| | GET | `/.well-known/agent.json` | Serve this seller agent's card for A2A discovery | | GET | `/registry/agents` | List agents in the local registry | | GET | `/registry/agents/{agent_id}` | Get details for a specific registered agent | -| POST | `/registry/agents/discover` | Discover an agent by URL | -| PUT | `/registry/agents/{agent_id}/trust` | Update an agent's trust status | -| DELETE | `/registry/agents/{agent_id}` | Remove an agent from the local registry | +| POST | `/registry/agents/discover` | Discover an agent by URL (operator auth required) | +| PUT | `/registry/agents/{agent_id}/trust` | Update an agent's trust status (operator auth required) | +| DELETE | `/registry/agents/{agent_id}` | Remove an agent from the local registry (operator auth required) | ## Quotes diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 65f2fc4..8a51c15 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -194,7 +194,7 @@ graph LR end BA -->|"1. Discover (GET /.well-known/agent.json)"| SA - BA -->|"2. Get API Key (POST /auth/api-keys)"| SA + BA -->|"2. Receive buyer API key (operator mints via POST /auth/api-keys)"| SA BA -->|"3. MCP: Structured tool calls (/mcp/ Streamable HTTP)"| SA BA -->|"4. A2A: Natural language (/a2a/seller/jsonrpc)"| SA BA -->|"5. REST: Browse, quote, book, negotiate"| SA diff --git a/docs/guides/agent-management.md b/docs/guides/agent-management.md index 7d4df85..bbdd690 100644 --- a/docs/guides/agent-management.md +++ b/docs/guides/agent-management.md @@ -7,14 +7,27 @@ authentication and managing **agent trust** for agent-to-agent interactions. ## API Key Management -API keys tie buyer identity (seat, agency, advertiser) to requests. When a buyer -presents an API key, the seller agent resolves their identity and applies the -appropriate pricing tier. +API keys have a **role**: -### Create an API Key +| Role | Purpose | +|------|---------| +| `buyer` | Ties seat/agency/advertiser identity to requests for tiered pricing | +| `operator` | Publisher control-plane credential (key management, trust, packages, sync) | + +All `/auth/api-keys*` routes require an existing **operator** credential. Bootstrap +the first operator key with the CLI (no HTTP auth): + +```bash +ad-seller create-operator-key --label "Primary operator" +``` + +See [Authentication](../api/authentication.md) for the full operator surface. + +### Create a Buyer API Key ```bash curl -X POST http://localhost:8000/auth/api-keys \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "seat_id": "seat-mediamath-001", @@ -35,12 +48,14 @@ Response: ```json { "key_id": "key-abc12345", - "api_key": "sk-seller-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "seat_id": "seat-mediamath-001", - "agency_id": "agency-groupm-001", - "advertiser_id": "adv-cocacola-001", + "api_key": "ask_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "identity": { + "seat_id": "seat-mediamath-001", + "agency_id": "agency-groupm-001", + "advertiser_id": "adv-cocacola-001" + }, + "role": "buyer", "label": "GroupM - Coca-Cola Q1 2026", - "created_at": "2026-03-10T12:00:00Z", "expires_at": "2026-06-08T12:00:00Z" } ``` @@ -49,7 +64,22 @@ Response: The full `api_key` value is returned **only once** at creation time. It cannot be retrieved again. If lost, revoke the key and create a new one. -### Available Fields +### Create an Operator API Key + +```bash +curl -X POST http://localhost:8000/auth/api-keys/operator \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "label": "Ops secondary key", + "expires_in_days": 365 + }' +``` + +Operator keys have no buyer identity fields — only `label` and optional +`expires_in_days`. + +### Available Fields (Buyer Keys) | Field | Required | Description | |-------|----------|-------------| @@ -74,7 +104,8 @@ The identity fields determine the buyer's access tier: ### List API Keys ```bash -curl http://localhost:8000/auth/api-keys +curl http://localhost:8000/auth/api-keys \ + -H "Authorization: Bearer " ``` Response (metadata only, no secrets): @@ -84,9 +115,7 @@ Response (metadata only, no secrets): "keys": [ { "key_id": "key-abc12345", - "seat_id": "seat-mediamath-001", - "agency_id": "agency-groupm-001", - "advertiser_id": "adv-cocacola-001", + "role": "buyer", "label": "GroupM - Coca-Cola Q1 2026", "created_at": "2026-03-10T12:00:00Z", "expires_at": "2026-06-08T12:00:00Z", @@ -100,13 +129,15 @@ Response (metadata only, no secrets): ### Get Key Details ```bash -curl http://localhost:8000/auth/api-keys/{key_id} +curl http://localhost:8000/auth/api-keys/{key_id} \ + -H "Authorization: Bearer " ``` ### Revoke an API Key ```bash -curl -X DELETE http://localhost:8000/auth/api-keys/{key_id} +curl -X DELETE http://localhost:8000/auth/api-keys/{key_id} \ + -H "Authorization: Bearer " ``` Response: @@ -152,6 +183,7 @@ To onboard a new buyer agent, discover it by URL: ```bash curl -X POST http://localhost:8000/registry/agents/discover \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_url": "https://buyer-agent.example.com" @@ -221,6 +253,7 @@ Promote an agent from `unknown` to `approved`: ```bash curl -X PUT http://localhost:8000/registry/agents/{agent_id}/trust \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "trust_status": "approved", @@ -243,6 +276,7 @@ Response: ```bash curl -X PUT http://localhost:8000/registry/agents/{agent_id}/trust \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "trust_status": "blocked", @@ -255,7 +289,8 @@ Blocked agents receive `403 Forbidden` on all requests. ### Remove an Agent ```bash -curl -X DELETE http://localhost:8000/registry/agents/{agent_id} +curl -X DELETE http://localhost:8000/registry/agents/{agent_id} \ + -H "Authorization: Bearer " ``` Response: @@ -284,7 +319,7 @@ sequenceDiagram Note right of Buyer: Discovers seller capabilities Op->>Seller: POST /registry/agents/discover - Note right of Op: {"agent_url": "https://buyer.example.com"} + Note right of Op: Operator credential required Seller->>Buyer: GET /.well-known/agent.json Seller->>Registry: Verify agent registration Registry-->>Seller: Registered / Not found @@ -293,14 +328,14 @@ sequenceDiagram Note over Op: Agent starts as "unknown" or "registered" Op->>Seller: PUT /registry/agents/{id}/trust - Note right of Op: {"trust_status": "approved"} + Note right of Op: {"trust_status": "approved"} (operator auth) Op->>Seller: POST /auth/api-keys - Note right of Op: Create key with buyer identity + Note right of Op: Create buyer key with identity (operator auth) - Op-->>Buyer: Share API key securely + Op-->>Buyer: Share buyer API key securely - Buyer->>Seller: POST /proposals (with API key) + Buyer->>Seller: POST /proposals (with buyer API key) Note right of Buyer: Full access at approved tier ``` @@ -310,8 +345,9 @@ sequenceDiagram from the seller to learn about capabilities and supported protocols. 2. **Operator discovers buyer** -- The publisher operator calls - `POST /registry/agents/discover` with the buyer agent's URL. The seller - fetches the buyer's agent card and checks external registries. + `POST /registry/agents/discover` (operator credential required) with the + buyer agent's URL. The seller fetches the buyer's agent card and checks + external registries. 3. **Agent is registered locally** -- The agent starts as `unknown` (PUBLIC access) or `registered` (SEAT access) depending on whether it was found in an @@ -321,11 +357,13 @@ sequenceDiagram calls `PUT /registry/agents/{id}/trust` to upgrade the agent to `approved` or `preferred`. -5. **Operator creates API key** -- The operator creates an API key with the - buyer's identity (seat, agency, advertiser) via `POST /auth/api-keys`. +5. **Operator creates a buyer API key** -- The operator creates a **buyer** key + with the buyer's identity (seat, agency, advertiser) via + `POST /auth/api-keys`. (Operator keys use `POST /auth/api-keys/operator` or + `ad-seller create-operator-key` for bootstrap.) -6. **Key is shared securely** -- The operator shares the API key with the buyer - through a secure channel. +6. **Key is shared securely** -- The operator shares the buyer API key with the + buyer through a secure channel. 7. **Buyer transacts** -- The buyer agent uses the API key for all subsequent requests, receiving pricing and access appropriate to their tier. diff --git a/docs/guides/claude-desktop-setup.md b/docs/guides/claude-desktop-setup.md index b042fe3..763d312 100644 --- a/docs/guides/claude-desktop-setup.md +++ b/docs/guides/claude-desktop-setup.md @@ -55,7 +55,7 @@ For seller agents running on `localhost`: > **Note**: The JSON config method is for **local stdio servers only**. Remote servers must use the Settings > Integrations UI. -Alternatively, if you are running the seller agent as an HTTP server (`uvicorn ad_seller.interfaces.api.main:app --port 8000`), use `mcp-remote` to bridge it: +Alternatively, if you are running the seller agent as an HTTP server (`uvicorn ad_seller.interfaces.api.main:app --port 8000`), use `mcp-remote` to bridge it. Pass your operator API key so admin tools work: **Using npx (Node.js required):** ```json @@ -63,7 +63,12 @@ Alternatively, if you are running the seller agent as an HTTP server (`uvicorn a "mcpServers": { "seller-agent": { "command": "npx", - "args": ["mcp-remote", "http://localhost:8000/mcp/"] + "args": [ + "mcp-remote", + "http://localhost:8000/mcp/", + "--header", + "Authorization: Bearer " + ] } } } @@ -75,13 +80,20 @@ Alternatively, if you are running the seller agent as an HTTP server (`uvicorn a "mcpServers": { "seller-agent": { "command": "uvx", - "args": ["mcp-remote", "http://localhost:8000/mcp/"] + "args": [ + "mcp-remote", + "http://localhost:8000/mcp/", + "--header", + "Authorization: Bearer " + ] } } } ``` -> The trailing slash on `/mcp/` is required. +> The trailing slash on `/mcp/` is required. Mint the operator key with +> `ad-seller create-operator-key` (see [Developer Setup](developer-setup.md) +> and [Authentication](../api/authentication.md)). ## Step 2: First-Run Setup Wizard diff --git a/docs/guides/developer-setup.md b/docs/guides/developer-setup.md index 466b5f7..48f9e2b 100644 --- a/docs/guides/developer-setup.md +++ b/docs/guides/developer-setup.md @@ -96,14 +96,14 @@ Verify: `curl http://localhost:8000/health` ## Step 6: Generate Operator Credentials +The admin REST/MCP surface requires an **operator** API key. Mint the first one with the CLI (writes directly to storage — no HTTP auth needed): + ```bash -# Create an operator API key -curl -X POST http://localhost:8000/auth/api-keys \ - -H "Content-Type: application/json" \ - -d '{"name": "operator", "seat_id": "operator"}' +# Same .env / storage config as the running server +ad-seller create-operator-key --label "Primary operator" ``` -Save the returned API key. +Save the printed API key — it is shown only once. Subsequent operator keys can be minted via `POST /auth/api-keys/operator` using this credential. Buyer keys for agents go through `POST /auth/api-keys`. See [Authentication](../api/authentication.md). ## Step 7: Generate Claude Desktop Config @@ -154,7 +154,8 @@ See [Claude Desktop Setup Guide](claude-desktop-setup.md) for their instructions ## Trigger Initial Inventory Sync ```bash -curl -X POST http://localhost:8000/api/v1/inventory-sync/trigger +curl -X POST http://localhost:8000/api/v1/inventory-sync/trigger \ + -H "Authorization: Bearer " ``` This pulls inventory from your ad server so the business team has packages to work with in the wizard. diff --git a/docs/integration/buyer-agent.md b/docs/integration/buyer-agent.md index c348664..83b2524 100644 --- a/docs/integration/buyer-agent.md +++ b/docs/integration/buyer-agent.md @@ -25,10 +25,11 @@ The buyer agent uses this to determine if the seller matches its campaign needs. ### Option A: Get an API Key -The seller operator creates an API key for the buyer: +The seller operator creates a **buyer** API key (requires an operator credential): ```bash curl -X POST https://seller.example.com/auth/api-keys \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "seat_id": "seat-buyer-001", @@ -44,6 +45,8 @@ The buyer stores the returned key and uses it in all subsequent requests: Authorization: Bearer ``` +Operators bootstrap their own first credential with `ad-seller create-operator-key` — see [Authentication](../api/authentication.md). + ### Option B: Agent URL Discovery The buyer provides its own agent URL in requests. The seller fetches the buyer's agent card, checks registries (AAMP), and assigns a trust level: diff --git a/docs/reference/endpoints.md b/docs/reference/endpoints.md index 608067d..d2a2980 100644 --- a/docs/reference/endpoints.md +++ b/docs/reference/endpoints.md @@ -59,7 +59,8 @@ Routes registered on the FastAPI application | `POST` | `/approvals/{approval_id}/decide` | `decide_approval` | | `POST` | `/approvals/{approval_id}/resume` | `resume_flow` | | `GET` | `/auth/api-keys` | `list_api_keys` | -| `POST` | `/auth/api-keys` | `create_api_key` | +| `POST` | `/auth/api-keys` | `create_api_key` (buyer key; operator auth required) | +| `POST` | `/auth/api-keys/operator` | `create_operator_api_key` (operator auth required) | | `DELETE` | `/auth/api-keys/{key_id}` | `revoke_api_key` | | `GET` | `/auth/api-keys/{key_id}` | `get_api_key_details` | | `POST` | `/deals` | `generate_deal` | diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 67495cb..09387ad 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -12,7 +12,7 @@ enumerated from the live FastMCP registry. | --- | --- | | `approve_or_reject` | Submit an approval decision. decision: 'approve', 'reject', or 'counter'. | | `bulk_deal_operations` | Process multiple deal operations in one batch. | -| `create_api_key` | Create an API key for a buyer or agent. | +| `create_api_key` | Create a buyer API key (operator auth required over HTTP). | | `create_curated_deal` | Create a deal with curator overlay. The curator's fee is added on top. | | `create_deal_from_template` | Create a deal directly from parameters (one-step, no quote needed). | | `create_package` | Create a new curated package in the media kit. | diff --git a/src/ad_seller/auth/api_key_service.py b/src/ad_seller/auth/api_key_service.py index 3a8ca5b..9efa58d 100644 --- a/src/ad_seller/auth/api_key_service.py +++ b/src/ad_seller/auth/api_key_service.py @@ -24,6 +24,8 @@ ApiKeyCreateResponse, ApiKeyInfo, ApiKeyRecord, + ApiKeyRole, + OperatorApiKeyCreateRequest, generate_api_key, hash_api_key, ) @@ -40,15 +42,11 @@ def __init__(self, storage: StorageBackend): self._storage = storage async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse: - """Issue a new API key for a buyer identity. + """Issue a new BUYER-role API key for a buyer identity. Returns the full key exactly once. The key is hashed before storage and can never be retrieved again. """ - full_key = generate_api_key() - key_hash = hash_api_key(full_key) - key_id = f"key-{uuid.uuid4().hex[:8]}" - identity = BuyerIdentity( seat_id=request.seat_id, seat_name=request.seat_name, @@ -59,18 +57,52 @@ async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse advertiser_id=request.advertiser_id, advertiser_name=request.advertiser_name, ) + return await self._mint( + identity=identity, + role=ApiKeyRole.BUYER, + label=request.label, + expires_in_days=request.expires_in_days, + ) + + async def create_operator_key( + self, request: OperatorApiKeyCreateRequest + ) -> ApiKeyCreateResponse: + """Issue a new OPERATOR-role API key (no buyer identity). + + Returns the full key exactly once. The key is hashed + before storage and can never be retrieved again. + """ + return await self._mint( + identity=BuyerIdentity(), + role=ApiKeyRole.OPERATOR, + label=request.label, + expires_in_days=request.expires_in_days, + ) + + async def _mint( + self, + *, + identity: BuyerIdentity, + role: ApiKeyRole, + label: str, + expires_in_days: Optional[int], + ) -> ApiKeyCreateResponse: + """Generate, store, and return a new key (shared by both roles).""" + full_key = generate_api_key() + key_hash = hash_api_key(full_key) + key_id = f"key-{uuid.uuid4().hex[:8]}" expires_at = None - if request.expires_in_days is not None: - expires_at = datetime.utcnow() + timedelta(days=request.expires_in_days) + if expires_in_days is not None: + expires_at = datetime.utcnow() + timedelta(days=expires_in_days) record = ApiKeyRecord( key_id=key_id, key_hash=key_hash, key_prefix_hint=full_key[:12] + "...", identity=identity, - role=request.role, - label=request.label, + role=role, + label=label, expires_at=expires_at, ) @@ -95,16 +127,16 @@ async def create_key(self, request: ApiKeyCreateRequest) -> ApiKeyCreateResponse "API key %s created for %s (role: %s, label: %s)", key_id, identity.identity_level.value, - request.role.value, - request.label, + role.value, + label, ) return ApiKeyCreateResponse( key_id=key_id, api_key=full_key, identity=identity, - role=request.role, - label=request.label, + role=role, + label=label, expires_at=expires_at, ) diff --git a/src/ad_seller/interfaces/api/deps.py b/src/ad_seller/interfaces/api/deps.py index 7a84298..400e280 100644 --- a/src/ad_seller/interfaces/api/deps.py +++ b/src/ad_seller/interfaces/api/deps.py @@ -46,7 +46,7 @@ async def _require_operator_api_key_record( Anonymous → 401. Invalid/revoked/expired key → 401. Valid buyer-role key → 403. Only an operator credential (minted via ``ad-seller create-operator-key`` or by an existing operator through - ``POST /auth/api-keys`` with ``role="operator"``) passes. + ``POST /auth/api-keys/operator``) passes. Same Header-binding rationale as ``_get_optional_api_key_record``. Tests override this function object via ``app.dependency_overrides``. diff --git a/src/ad_seller/interfaces/api/routers/admin.py b/src/ad_seller/interfaces/api/routers/admin.py index 6bf8a59..fb5b618 100644 --- a/src/ad_seller/interfaces/api/routers/admin.py +++ b/src/ad_seller/interfaces/api/routers/admin.py @@ -12,6 +12,7 @@ from .. import deps from ..schemas import ( CreateApiKeyRequest, + CreateOperatorApiKeyRequest, RateCardEntry, RateCardResponse, SupplyChainNodeModel, @@ -85,27 +86,18 @@ async def create_api_key( request: CreateApiKeyRequest, _operator=Depends(deps._require_operator_api_key_record), ): - """Create a new API key for a buyer (or another operator). + """Create a new BUYER-role API key for a buyer identity. - Requires an operator credential. Bootstrap the first operator key - with ``ad-seller create-operator-key`` (writes directly to storage). + Requires an operator credential. Operator keys are created via + ``POST /auth/api-keys/operator``. The response contains the full API key which is shown ONLY ONCE. Store it securely — it cannot be retrieved again. """ from ....auth.api_key_service import ApiKeyService - from ....models.api_key import ApiKeyCreateRequest, ApiKeyRole + from ....models.api_key import ApiKeyCreateRequest from ....storage.factory import get_storage - try: - role = ApiKeyRole(request.role) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Invalid role: {request.role}. Valid values: " - f"{[r.value for r in ApiKeyRole]}", - ) - storage = await get_storage() service = ApiKeyService(storage) @@ -118,7 +110,6 @@ async def create_api_key( agency_holding_company=request.agency_holding_company, advertiser_id=request.advertiser_id, advertiser_name=request.advertiser_name, - role=role, label=request.label, expires_in_days=request.expires_in_days, ) @@ -127,6 +118,36 @@ async def create_api_key( return response.model_dump(mode="json") +@router.post("/auth/api-keys/operator", tags=["Authentication"]) +async def create_operator_api_key( + request: CreateOperatorApiKeyRequest, + _operator=Depends(deps._require_operator_api_key_record), +): + """Create a new OPERATOR-role API key (no buyer identity). + + Requires an existing operator credential. Bootstrap the FIRST + operator key with ``ad-seller create-operator-key`` (writes directly + to storage, no network surface). + + The response contains the full API key which is shown ONLY ONCE. + Store it securely — it cannot be retrieved again. + """ + from ....auth.api_key_service import ApiKeyService + from ....models.api_key import OperatorApiKeyCreateRequest + from ....storage.factory import get_storage + + storage = await get_storage() + service = ApiKeyService(storage) + + response = await service.create_operator_key( + OperatorApiKeyCreateRequest( + label=request.label, + expires_in_days=request.expires_in_days, + ) + ) + return response.model_dump(mode="json") + + @router.get("/auth/api-keys", tags=["Authentication"]) async def list_api_keys( _operator=Depends(deps._require_operator_api_key_record), diff --git a/src/ad_seller/interfaces/api/schemas.py b/src/ad_seller/interfaces/api/schemas.py index f1eae59..8d06946 100644 --- a/src/ad_seller/interfaces/api/schemas.py +++ b/src/ad_seller/interfaces/api/schemas.py @@ -270,7 +270,11 @@ class ApprovalDecisionRequest(BaseModel): class CreateApiKeyRequest(BaseModel): - """Request to create a new API key for a buyer (or another operator).""" + """Request to create a new BUYER-role API key. + + Operator keys are created via POST /auth/api-keys/operator + (CreateOperatorApiKeyRequest) — they carry no buyer identity. + """ seat_id: Optional[str] = None seat_name: Optional[str] = None @@ -280,7 +284,13 @@ class CreateApiKeyRequest(BaseModel): agency_holding_company: Optional[str] = None advertiser_id: Optional[str] = None advertiser_name: Optional[str] = None - role: str = "buyer" # "buyer" | "operator" + label: str = "" + expires_in_days: Optional[int] = None + + +class CreateOperatorApiKeyRequest(BaseModel): + """Request to create a new OPERATOR-role API key (no buyer identity).""" + label: str = "" expires_in_days: Optional[int] = None diff --git a/src/ad_seller/interfaces/cli/main.py b/src/ad_seller/interfaces/cli/main.py index 0b6a22a..2424d62 100644 --- a/src/ad_seller/interfaces/cli/main.py +++ b/src/ad_seller/interfaces/cli/main.py @@ -309,19 +309,18 @@ def create_operator_key( rate card, agent trust, packages, inventory sync, deal push) and for admin MCP tools over HTTP. This command writes directly to storage — no network surface — so it is the safe way to create the FIRST - operator key. Subsequent keys can be minted via POST /auth/api-keys - using an existing operator credential. + operator key. Subsequent operator keys can be minted via + POST /auth/api-keys/operator using an existing operator credential. """ from ...auth.api_key_service import ApiKeyService - from ...models.api_key import ApiKeyCreateRequest, ApiKeyRole + from ...models.api_key import OperatorApiKeyCreateRequest from ...storage.factory import get_storage async def _mint(): storage = await get_storage() service = ApiKeyService(storage) - return await service.create_key( - ApiKeyCreateRequest( - role=ApiKeyRole.OPERATOR, + return await service.create_operator_key( + OperatorApiKeyCreateRequest( label=label, expires_in_days=expires_in_days, ) diff --git a/src/ad_seller/models/__init__.py b/src/ad_seller/models/__init__.py index 74040b3..51b57b4 100644 --- a/src/ad_seller/models/__init__.py +++ b/src/ad_seller/models/__init__.py @@ -20,6 +20,8 @@ ApiKeyCreateResponse, ApiKeyInfo, ApiKeyRecord, + ApiKeyRole, + OperatorApiKeyCreateRequest, ) from .audience_capabilities import ( AgenticCapabilities, @@ -351,4 +353,6 @@ "ApiKeyCreateResponse", "ApiKeyInfo", "ApiKeyRecord", + "ApiKeyRole", + "OperatorApiKeyCreateRequest", ] diff --git a/src/ad_seller/models/api_key.py b/src/ad_seller/models/api_key.py index 20c4a27..4fc94d5 100644 --- a/src/ad_seller/models/api_key.py +++ b/src/ad_seller/models/api_key.py @@ -99,7 +99,11 @@ def is_active(self) -> bool: class ApiKeyCreateRequest(BaseModel): - """Request to create a new API key (operator-facing).""" + """Request to create a new BUYER-role API key. + + Operator keys are created via :class:`OperatorApiKeyCreateRequest` + (they carry no buyer identity), so this request has no role field. + """ # Identity fields for the buyer this key authenticates seat_id: Optional[str] = None @@ -112,7 +116,17 @@ class ApiKeyCreateRequest(BaseModel): advertiser_name: Optional[str] = None # Key metadata - role: ApiKeyRole = ApiKeyRole.BUYER + label: str = "" + expires_in_days: Optional[int] = None # None = never expires + + +class OperatorApiKeyCreateRequest(BaseModel): + """Request to create an OPERATOR-role API key. + + Operator keys authenticate the publisher's own control plane — they + have no buyer identity (no seat/agency/advertiser fields). + """ + label: str = "" expires_in_days: Optional[int] = None # None = never expires diff --git a/tests/unit/test_operator_auth.py b/tests/unit/test_operator_auth.py index a8beb3a..7283fcf 100644 --- a/tests/unit/test_operator_auth.py +++ b/tests/unit/test_operator_auth.py @@ -12,8 +12,10 @@ (c) The admin REST surface is gated: /auth/api-keys, /events, rate-card writes, registry mutations, package mutations, inventory-sync trigger, deal push/distribute, curator registration. -(d) The CLI bootstrap (``ad-seller create-operator-key``) mints an - OPERATOR-role key directly in storage. +(d) Operator keys are minted via ``OperatorApiKeyCreateRequest`` / + ``POST /auth/api-keys/operator`` (no buyer identity fields). The CLI + bootstrap (``ad-seller create-operator-key``) uses the same path + against storage directly. (e) Admin MCP tools deny non-operator HTTP callers but allow local stdio access (no HTTP request context). """ @@ -144,7 +146,7 @@ async def test_buyer_key_create_key_is_403(self, client, mock_storage): assert resp.status_code == 403 assert "Operator credential required" in resp.text - async def test_operator_key_creates_buyer_and_operator_keys(self, client, mock_storage): + async def test_operator_key_creates_buyer_key(self, client, mock_storage): raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): buyer_resp = await client.post( @@ -152,25 +154,55 @@ async def test_operator_key_creates_buyer_and_operator_keys(self, client, mock_s json={"label": "buyer key", "agency_id": "agy-2"}, headers=_auth(raw_key), ) + assert buyer_resp.status_code == 200 + assert buyer_resp.json()["role"] == "buyer" + assert buyer_resp.json()["identity"]["agency_id"] == "agy-2" + + async def test_operator_endpoint_mints_operator_key(self, client, mock_storage): + """POST /auth/api-keys/operator creates an operator key (no buyer identity).""" + raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): op_resp = await client.post( - "/auth/api-keys", - json={"label": "second operator", "role": "operator"}, + "/auth/api-keys/operator", + json={"label": "second operator"}, headers=_auth(raw_key), ) - assert buyer_resp.status_code == 200 - assert buyer_resp.json()["role"] == "buyer" assert op_resp.status_code == 200 - assert op_resp.json()["role"] == "operator" - - async def test_invalid_role_is_400(self, client, mock_storage): + body = op_resp.json() + assert body["role"] == "operator" + assert body["label"] == "second operator" + # Empty BuyerIdentity — operator keys carry no seat/agency/advertiser. + assert body["identity"].get("agency_id") in (None, "") + assert body["identity"].get("seat_id") in (None, "") + + async def test_buyer_create_endpoint_ignores_role_field(self, client, mock_storage): + """role is not on CreateApiKeyRequest — extra fields are ignored; always buyer.""" raw_key = _seed_key(mock_storage._store, role=ApiKeyRole.OPERATOR) with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): resp = await client.post( "/auth/api-keys", - json={"label": "x", "role": "superadmin"}, + json={"label": "x", "role": "operator", "agency_id": "agy-sneak"}, headers=_auth(raw_key), ) - assert resp.status_code == 400 + assert resp.status_code == 200 + assert resp.json()["role"] == "buyer" + + async def test_anonymous_operator_endpoint_is_401(self, client, mock_storage): + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post( + "/auth/api-keys/operator", json={"label": "x"} + ) + assert resp.status_code == 401 + + async def test_buyer_key_cannot_mint_operator_key(self, client, mock_storage): + buyer_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER) + with patch("ad_seller.storage.factory.get_storage", return_value=mock_storage): + resp = await client.post( + "/auth/api-keys/operator", + json={"label": "x"}, + headers=_auth(buyer_key), + ) + assert resp.status_code == 403 async def test_list_and_revoke_require_operator(self, client, mock_storage): buyer_key = _seed_key(mock_storage._store, role=ApiKeyRole.BUYER, key_id="key-b") @@ -277,6 +309,22 @@ def test_create_operator_key_mints_operator_role(self, mock_storage): assert len(records) == 1 assert records[0]["role"] == "operator" assert records[0]["label"] == "bootstrap test" + # Operator keys carry an empty BuyerIdentity (no seat/agency). + assert not records[0]["identity"].get("agency_id") + assert not records[0]["identity"].get("seat_id") + + +class TestOperatorApiKeyCreateRequest: + def test_has_no_buyer_identity_fields(self): + from ad_seller.models.api_key import OperatorApiKeyCreateRequest + + fields = set(OperatorApiKeyCreateRequest.model_fields) + assert fields == {"label", "expires_in_days"} + + def test_buyer_create_request_has_no_role_field(self): + from ad_seller.models.api_key import ApiKeyCreateRequest + + assert "role" not in ApiKeyCreateRequest.model_fields # =============================================================================