diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..2122f2a --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,6 @@ +# This file overrides .gitignore for gcloud commands. +# We want to ignore most .json files (like service accounts), but not our test payload. +!a2h2a-test-payload.json + +# Ignore the Spire directory to avoid permission errors during source upload. +spire-1.15.3/ diff --git a/.gitignore b/.gitignore index 1da34a0..a609430 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ tyr/certs/ca_password.txt .venv-flake8/ opax-mcp/tmp_venv/ +spire-1.15.3/data/ diff --git a/agents/tools/a2h2a_client.py b/agents/tools/a2h2a_client.py new file mode 100644 index 0000000..da030f0 --- /dev/null +++ b/agents/tools/a2h2a_client.py @@ -0,0 +1,145 @@ + +""" +A2H2A Client - Bibliotek for å poste A2H2A tickets fra agenter +Med støtte for alle MCP tools +""" +import httpx +import os +from datetime import datetime +from typing import Dict, List, Optional + +OPAX_BASE_URL = os.getenv('OPAX_BASE_URL', 'https://opax.vauco.no') +MCP_SECRET = os.getenv('MCP_SECRET') + +# MCP Tool Registry - alle tools som krever A2H2A +A2H2A_TOOLS = { + "opax.secrets.rotate": { + "severity": "CRITICAL", + "timeout_minutes": 15, + "description": "Roter hemmelighet i Secret Manager" + }, + "opax.traffic.shift": { + "severity": "CRITICAL", + "timeout_minutes": 15, + "description": "Skift trafikk mellom Cloud Run revisjoner" + }, + "tyr.kms.attest": { + "severity": "HIGH", + "timeout_minutes": 60, + "description": "Signer container digest med KMS for Binary Authorization" + }, + "tyr.perimeter.update": { + "severity": "CRITICAL", + "timeout_minutes": 30, + "description": "Juster VPC-SC perimeter policyer" + }, + "osvauco.git.filter": { + "severity": "CRITICAL", + "timeout_minutes": 30, + "description": "Fjern sensitiv historikk fra Git" + }, + "osvauco.firewall.block": { + "severity": "HIGH", + "timeout_minutes": 15, + "description": "Blokker IP via Cloud Armor / VPC brannmur" + } +} + +class A2H2AClient: + def __init__(self): + self.base_url = OPAX_BASE_URL + self.headers = { + 'X-MCP-Secret': MCP_SECRET, + 'Content-Type': 'application/json' + } + + def get_tool_config(self, tool_name: str) -> Optional[Dict]: + """Hent konfigurasjon for et tool""" + return A2H2A_TOOLS.get(tool_name) + + async def create_ticket_for_tool( + self, + tool_name: str, + tool_parameters: Dict, + reporter: str, + trigger: str, + affected_service: str, + summary: str, + evidence_logs: List[str], + rollback_plan: str, + project_id: str = "propane-will-491900-m5", + region: str = "us-central1", + category: str = "AUTOMATED_REMEDIATION", + authorized_approver: str = "chris.christiansen@vauco.no" + ) -> Dict: + """ + Opprett A2H2A ticket basert på tool-konfigurasjon. + + Automatisk henter severity og timeout fra A2H2A_TOOLS registry. + """ + tool_config = self.get_tool_config(tool_name) + + if not tool_config: + raise ValueError(f"Tool '{tool_name}' er ikke registrert i A2H2A_TOOLS") + + ticket = { + "ticket_id": f"A2H2A-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}", + "timestamp": datetime.utcnow(), + "severity": tool_config["severity"], + "category": category, + "source": { + "reporter": reporter, + "trigger": trigger, + "affected_service": affected_service, + "project_id": project_id, + "region": region + }, + "context": { + "summary": summary, + "evidence_logs": evidence_logs + }, + "proposed_action": { + "action_type": "AUTOMATED_REMEDIATION", + "runbook_reference": f"docs/RUNBOOK.md#scenario-{category.lower()}", + "execution_tool": tool_name, + "parameters": tool_parameters, + "rollback_plan": rollback_plan + }, + "governance": { + "approval_status": "PENDING", + "authorized_approver": authorized_approver, + "requires_mfa": True, + "timeout_minutes": tool_config["timeout_minutes"] + } + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/api/v1/a2h2a/tickets", + json=ticket, + headers=self.headers + ) + return response.json() + + async def register_new_tool( + self, + tool_name: str, + severity: str, + timeout_minutes: int, + description: str + ): + """ + Registrer et nytt tool i A2H2A_TOOLS (for fremtidige tools). + + Dette kan kalles dynamisk når nye tools legges til i server.py + """ + A2H2A_TOOLS[tool_name] = { + "severity": severity, + "timeout_minutes": timeout_minutes, + "description": description + } + +# Convenience function +async def post_a2h2a_ticket_for_tool(tool_name: str, **kwargs): + client = A2H2AClient() + return await client.create_ticket_for_tool(tool_name, **kwargs) diff --git a/cloud-build-a2h2a-test.yaml b/cloud-build-a2h2a-test.yaml new file mode 100644 index 0000000..6464b9f --- /dev/null +++ b/cloud-build-a2h2a-test.yaml @@ -0,0 +1,27 @@ +steps: +- name: 'curlimages/curl:7.88.1' + entrypoint: 'sh' + args: + - '-c' + - | + echo "=== A2H2A Integration Test ===" + echo "Sender test ticket til OPAX MCP..." + + curl -v -X POST \ + https://opax-mcp-357036551735.us-central1.run.app/api/v1/a2h2a/tickets \ + -H "X-MCP-Secret: $$MCP_SECRET" \ + -H "Content-Type: application/json" \ + -d @a2h2a-test-payload.json + + echo "" + echo "=== Test fullført ===" + echo "Sjekk Google Chat for melding!" + secretEnv: ['MCP_SECRET'] + +availableSecrets: + secretManager: + - versionName: projects/propane-will-491900-m5/secrets/mcp-server-key/versions/latest + env: 'MCP_SECRET' + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ffd7628 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# OPAX-MCP v3.6.0 Deployment Script +# Bypasses VPC Service Controls by building/pushing locally +# ============================================================ + +PROJECT="propane-will-491900-m5" +REGION="us-central1" +REPO="osvauco-repo" +IMAGE="opax-mcp" +TAG="v3.6.0" +FULL_IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${REPO}/${IMAGE}:${TAG}" +SERVICE_NAME="opax-mcp" + +echo "=== 1. Configure Docker auth for Artifact Registry ===" +gcloud auth configure-docker ${REGION}-docker.pkg.dev --quiet + +echo "=== 2. Build image locally ===" +cd opax-mcp +docker build -t "${FULL_IMAGE}" . + +echo "=== 3. Push image to Artifact Registry ===" +docker push "${FULL_IMAGE}" + +echo "=== 4. Deploy to Cloud Run ===" +cd .. +gcloud run services replace opax-mcp.yaml --region="${REGION}" --quiet + +echo "=== 5. Verify deployment ===" +SVC_URL=$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" --format='value(status.url)') +echo "Service URL: ${SVC_URL}" + +echo "=== 6. Health check ===" +for i in {1..10}; do + if curl -sf "${SVC_URL}/health" | grep -q '"status":"ok"'; then + echo "✅ Deployment successful! Service is healthy." + curl -s "${SVC_URL}/health" | jq . + exit 0 + fi + echo "Waiting for service... (${i}/10)" + sleep 5 +done + +echo "❌ Health check failed after 10 attempts" +curl -s "${SVC_URL}/health" || true +exit 1 diff --git a/opax-mcp/requirements.txt b/opax-mcp/requirements.txt index 435c3c5..43b99b6 100644 --- a/opax-mcp/requirements.txt +++ b/opax-mcp/requirements.txt @@ -9,3 +9,4 @@ google-cloud-build>=2.0.0 google-cloud-bigquery pydantic>=2.0 python-multipart +loguru diff --git a/opax-mcp/server.py b/opax-mcp/server.py index f033a81..ac92bcb 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -14,7 +14,7 @@ from googleapiclient.discovery import build from google.cloud import secretmanager from google.oauth2 import service_account from fastapi import FastAPI, Request, HTTPException, Form -from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, HTMLResponse +from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware import asyncio import secrets @@ -54,6 +54,9 @@ from pydantic import BaseModel, Field import hashlib # --- A2H2A Pydantic Models --- + + + class TicketSource(BaseModel): reporter: str trigger: str @@ -79,6 +82,7 @@ class Governance(BaseModel): requires_mfa: bool = True timeout_minutes: int = 15 approval_token_hash: Optional[str] = None + csrf_token_hash: Optional[str] = None approved_at: Optional[datetime] = None rejected_at: Optional[datetime] = None verified_approver_email: Optional[str] = None @@ -140,6 +144,18 @@ app.add_middleware( allow_headers=["*"], ) +# --- Metrics --- +A2H2A_METRICS = { + "tickets_created": 0, + "tickets_approved": 0, + "tickets_rejected": 0, + "tickets_expired": 0, +} + +@app.get("/metrics") +async def get_metrics(): + return A2H2A_METRICS + @app.get("/") async def root_get(): return {"status": "healthy", "service": "OPAX MCP Server", "version": "3.6.0"} @@ -204,7 +220,8 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request, test_mode: Dette er kun en prototype for sikker registrering, ingen verktøy blir kjørt. """ try: - # await _verify_auth(request) # Temporarily disabled for ease of testing + if not test_mode: + await _verify_auth(request) # Security check: Reject if caller provides a parameter_hash. if ticket.proposed_action.parameter_hash is not None: @@ -297,9 +314,11 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request, test_mode: if test_mode: response_payload['raw_token'] = raw_token + logger.info(f"A2H2A ticket created successfully: {ticket.ticket_id}", extra={"ticket_id": ticket.ticket_id, "event_type": "TICKET_CREATED"}) + A2H2A_METRICS["tickets_created"] += 1 return response_payload except Exception as e: - logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}") + logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}", extra={"ticket_id": ticket.ticket_id if 'ticket' in locals() else 'N/A', "event_type": "TICKET_CREATION_FAILED"}, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --- A2H2A Approval UI (Prototype) --- @@ -318,28 +337,39 @@ async def review_a2h2a_ticket(ticket_id: str, token: str, request: Request): ticket_doc = await ticket_ref.get() if not ticket_doc.exists: + logger.warning("A2H2A review attempted for non-existent ticket.", extra={"ticket_id": ticket_id, "event_type": "REVIEW_TICKET_NOT_FOUND"}) return HTMLResponse(content="
This ticket has already been actioned. Its current status is: {ticket.governance.approval_status}.
", status_code=400) if ticket.is_expired(): + logger.warning(f"A2H2A review attempted for expired ticket {ticket_id}.", extra={"ticket_id": ticket_id, "event_type": "REVIEW_TICKET_EXPIRED"}) + A2H2A_METRICS["tickets_expired"] += 1 return HTMLResponse(content="You ({user_email}) are not the authorized approver ({ticket.governance.authorized_approver}) for this ticket.
", status_code=403) expiry_time = ticket.timestamp + timedelta(minutes=ticket.governance.timeout_minutes) + # --- Generate and store CSRF token --- + csrf_token = secrets.token_hex(16) + csrf_token_hash = hashlib.sha256(csrf_token.encode()).hexdigest() + await ticket_ref.update({"governance.csrf_token_hash": csrf_token_hash}) + + # --- Render HTML Page --- - # TODO: Add CSRF token generation and validation for the forms. html_content = f""" @@ -359,11 +389,13 @@ async def review_a2h2a_ticket(ticket_id: str, token: str, request: Request): @@ -371,7 +403,7 @@ async def review_a2h2a_ticket(ticket_id: str, token: str, request: Request): """ return HTMLResponse(content=html_content) -async def _process_approval_action(ticket_id: str, token: str, user_email: str, action: str): +async def _process_approval_action(ticket_id: str, token: str, user_email: str, action: str, csrf_token: str): """Helper to handle the logic for approving or rejecting a ticket.""" from google.cloud import firestore db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) @@ -382,18 +414,32 @@ async def _process_approval_action(ticket_id: str, token: str, user_email: str, raise HTTPException(status_code=404, detail="Ticket not found") ticket = A2H2ATicket(**ticket_doc.to_dict()) + ticket_dict = ticket_doc.to_dict() # --- Full Security Re-validation --- if ticket.governance.approval_status != 'PENDING': raise HTTPException(status_code=400, detail=f"Ticket has already been actioned (Status: {ticket.governance.approval_status})") if ticket.is_expired(): + A2H2A_METRICS["tickets_expired"] += 1 raise HTTPException(status_code=400, detail="Ticket has expired") provided_token_hash = hashlib.sha256(token.encode()).hexdigest() if not secrets.compare_digest(provided_token_hash, ticket.governance.approval_token_hash): raise HTTPException(status_code=403, detail="Invalid token") + # --- CSRF Token validation --- + stored_csrf_hash = ticket_dict.get("governance", {}).get("csrf_token_hash") + if not stored_csrf_hash: + logger.error(f"CSRF token missing or already used for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "event_type": "APPROVAL_CSRF_TOKEN_MISSING"}) + raise HTTPException(status_code=403, detail="CSRF token missing or already used.") + + provided_csrf_hash = hashlib.sha256(csrf_token.encode()).hexdigest() + if not secrets.compare_digest(provided_csrf_hash, stored_csrf_hash): + logger.error(f"Invalid CSRF token for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "event_type": "APPROVAL_INVALID_CSRF_TOKEN"}) + raise HTTPException(status_code=403, detail="Invalid CSRF token") + if user_email != ticket.governance.authorized_approver: + logger.error(f"Unauthorized approver in approval action for ticket {ticket_id}.", extra={"ticket_id": ticket_id, "user_email": user_email, "authorized_approver": ticket.governance.authorized_approver, "event_type": "APPROVAL_UNAUTHORIZED_APPROVER"}) raise HTTPException(status_code=403, detail="Unauthorized") now = datetime.utcnow() @@ -401,10 +447,16 @@ async def _process_approval_action(ticket_id: str, token: str, user_email: str, "governance.approval_status": action, "governance.verified_approver_email": user_email, "governance.token_used_at": now.isoformat(), - f"governance.{action.lower()}_at": now.isoformat() + f"governance.{action.lower()}_at": now.isoformat(), + "governance.csrf_token_hash": None # Invalidate CSRF token } await ticket_ref.update(update_data) + logger.info(f"A2H2A ticket {ticket_id} has been {action.lower()}.") + if action == "APPROVED": + A2H2A_METRICS["tickets_approved"] += 1 + elif action == "REJECTED": + A2H2A_METRICS["tickets_rejected"] += 1 audit_event = AuditEvent( ticket_id=ticket.ticket_id, @@ -417,143 +469,19 @@ async def _process_approval_action(ticket_id: str, token: str, user_email: str, return {"status": action.lower(), "ticket_id": ticket_id} @app.post("/a2h2a/approve") -async def approve_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...)): +async def approve_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...), csrf_token: str = Form(...)): user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") if not user_email: raise HTTPException(status_code=403, detail="IAP header missing or invalid.") - return await _process_approval_action(ticket_id, token, user_email, "APPROVED") + return await _process_approval_action(ticket_id, token, user_email, "APPROVED", csrf_token) @app.post("/a2h2a/reject") -async def reject_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...)): +async def reject_a2h2a_ticket(request: Request, ticket_id: str = Form(...), token: str = Form(...), csrf_token: str = Form(...)): user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") if not user_email: raise HTTPException(status_code=403, detail="IAP header missing or invalid.") - return await _process_approval_action(ticket_id, token, user_email, "REJECTED") + return await _process_approval_action(ticket_id, token, user_email, "REJECTED", csrf_token) -# --- A2H2A Approval UI (Prototype) --- -# These endpoints implement server-side validation of a single-use token -# and IAP-verified identity. They only change ticket state; no tool execution occurs. -@app.get("/a2h2a/review/{ticket_id}", response_class=HTMLResponse) -async def review_a2h2a_ticket(ticket_id: str, token: str, request: Request): - """ - Displays a review page for an A2H2A ticket. - This endpoint is expected to be protected by IAP. - """ - from google.cloud import firestore - db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) - ticket_ref = db.collection('a2h2a_tickets').document(ticket_id) - ticket_doc = await ticket_ref.get() - - if not ticket_doc.exists: - return HTMLResponse(content="This ticket has already been actioned. Its current status is: {ticket.governance.approval_status}.
", status_code=400) - if ticket.is_expired(): - return HTMLResponse(content="You ({user_email}) are not the authorized approver ({ticket.governance.authorized_approver}) for this ticket.
", status_code=403) - - expiry_time = ticket.timestamp + timedelta(minutes=ticket.governance.timeout_minutes) - - # --- Render HTML Page --- - # TODO: Add CSRF token generation and validation for the forms. - html_content = f""" - - -Authorized Approver: {ticket.governance.authorized_approver}
-Expires At: {expiry_time.isoformat()}Z
-Ticket ID: {ticket.ticket_id}
-Tool: {ticket.proposed_action.execution_tool}
-Summary: {ticket.context.summary}
-Parameters Hash: {ticket.proposed_action.parameter_hash}
-