From 310dca2db5674914a1d24688cabc05550855e50b Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Fri, 4 Sep 2026 12:23:59 +0000 Subject: [PATCH] feat(a2h2a): implement phase 1 approval flow Implements a secure, state-only approval workflow for A2H2A tickets. - Adds single-use token generation and validation. - Implements an IAP-gated HTML review page. - Adds state-changing approve/reject endpoints with full validation. - Introduces audit logging for ticket events. - Execution of tools remains disabled. --- opax-mcp/server.py | 193 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 181 insertions(+), 12 deletions(-) diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 0ecc447..8711e9d 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -13,8 +13,8 @@ import google.oauth2.id_token from googleapiclient.discovery import build from google.cloud import secretmanager from google.oauth2 import service_account -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi import FastAPI, Request, HTTPException, Form +from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware import asyncio import secrets @@ -78,6 +78,20 @@ class Governance(BaseModel): authorized_approver: str requires_mfa: bool = True timeout_minutes: int = 15 + approval_token_hash: Optional[str] = None + approved_at: Optional[datetime] = None + rejected_at: Optional[datetime] = None + verified_approver_email: Optional[str] = None + token_used_at: Optional[datetime] = None + +class AuditEvent(BaseModel): + event_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + ticket_id: str + timestamp: datetime = Field(default_factory=datetime.utcnow) + event_type: str # e.g., PROPOSAL_CREATED, TICKET_APPROVED, TICKET_REJECTED + actor: str + details: Dict[str, Any] + class A2H2ATicket(BaseModel): ticket_id: str @@ -177,6 +191,12 @@ A2H2A_TOOL_ALLOW_LIST = { # Add real, vetted tools here with execution_enabled: True ONLY after extensive security review. } +async def _create_audit_event(db, event: AuditEvent): + """Stores an audit event in Firestore.""" + event_ref = db.collection('a2h2a_audit_events').document(event.event_id) + await event_ref.set(event.model_dump(mode='json')) + logger.info(f"Created audit event {event.event_type} for ticket {event.ticket_id}") + @app.post("/api/v1/a2h2a/tickets") async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): """ @@ -198,19 +218,45 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): # Generate parameter hash server-side for integrity. ticket.proposed_action.parameter_hash = ticket.calculate_parameter_hash() + # Generate a cryptographically secure, single-use approval token. + # The raw token is returned once in the review URL and never persisted. + # Only its SHA-256 hash is stored server-side. + raw_token = secrets.token_hex(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + ticket.governance.approval_token_hash = token_hash + from google.cloud import firestore - db = firestore.Client(project=GOOGLE_CLOUD_PROJECT) + db = firestore.AsyncClient(project=GOOGLE_CLOUD_PROJECT) + ticket_data = ticket.model_dump(mode='json') ticket_data['created_at'] = datetime.utcnow().isoformat() - ticket_data['status'] = 'PENDING' - doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id) - doc_ref.set(ticket_data) + ticket_data['governance']['approval_status'] = 'PENDING' - # The approval URL now points to a conceptual, IAP-protected UI. - approval_ui_url = f"https://opax.vauco.no/a2h2a/review/{ticket.ticket_id}" + doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id) + await doc_ref.set(ticket_data) # Use await with AsyncClient + + # --- Create Initial Audit Event --- + audit_event = AuditEvent( + ticket_id=ticket.ticket_id, + event_type="PROPOSAL_CREATED", + actor="opax-mcp-server", # Use a fixed, trusted server identity + details={ + "severity": ticket.severity, + "execution_tool": ticket.proposed_action.execution_tool, + "parameter_hash": ticket.proposed_action.parameter_hash, + "authorized_approver": ticket.governance.authorized_approver, + "timeout_minutes": ticket.governance.timeout_minutes, + } + ) + await _create_audit_event(db, audit_event) + + + # The approval URL now contains the raw token for one-time use. + approval_ui_url = f"https://opax.vauco.no/a2h2a/review/{ticket.ticket_id}?token={raw_token}" google_chat_webhook = os.getenv('A2H2A_GOOGLE_CHAT_WEBHOOK') if google_chat_webhook: + # The raw_token is NOT included in the chat payload, only in the approval_ui_url chat_payload = { "cards": [ { @@ -224,10 +270,7 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): "widgets": [ { "textParagraph": { - "text": f"Category: {ticket.category}
" - f"Service: {ticket.source.affected_service}
" - f"Tool: {ticket.proposed_action.execution_tool}
" - f"Summary: {ticket.context.summary}" + "text": f"Category: {ticket.category}
" f"Service: {ticket.source.affected_service}
" f"Tool: {ticket.proposed_action.execution_tool}
" f"Summary: {ticket.context.summary}" } }, { @@ -255,6 +298,132 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}") raise HTTPException(status_code=500, detail=str(e)) +# --- 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="

404: Ticket not found

", status_code=404) + + ticket = A2H2ATicket(**ticket_doc.to_dict()) + + # --- Security Validation --- + if ticket.governance.approval_status != 'PENDING': + return HTMLResponse(content=f"

400: Ticket Not Pending

This ticket has already been actioned. Its current status is: {ticket.governance.approval_status}.

", status_code=400) + if ticket.is_expired(): + return HTMLResponse(content="

400: Ticket Expired

", status_code=400) + + provided_token_hash = hashlib.sha256(token.encode()).hexdigest() + if not secrets.compare_digest(provided_token_hash, ticket.governance.approval_token_hash): + return HTMLResponse(content="

403: Invalid Token

", status_code=403) + + user_email = request.headers.get("X-Goog-Authenticated-User-Email", "").replace("accounts.google.com:", "") + if not user_email or user_email != ticket.governance.authorized_approver: + return HTMLResponse(content=f"

403: Unauthorized

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""" + + + A2H2A Ticket Review + + + +

A2H2A Ticket Review: {ticket.severity}

+

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}

+
+
+ + + +
+
+ + + +
+ + + """ + return HTMLResponse(content=html_content) + +async def _process_approval_action(ticket_id: str, token: str, user_email: str, action: str): + """Helper to handle the logic for approving or rejecting a ticket.""" + 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: + raise HTTPException(status_code=404, detail="Ticket not found") + + ticket = A2H2ATicket(**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(): + 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") + + if user_email != ticket.governance.authorized_approver: + raise HTTPException(status_code=403, detail="Unauthorized") + + now = datetime.utcnow() + update_data = { + "governance.approval_status": action, + "governance.verified_approver_email": user_email, + "governance.token_used_at": now.isoformat(), + f"governance.{action.lower()}_at": now.isoformat() + } + + await ticket_ref.update(update_data) + + audit_event = AuditEvent( + ticket_id=ticket.ticket_id, + event_type=f"TICKET_{action}", + actor=user_email, + details={"message": f"Ticket was {action.lower()} by {user_email}."} + ) + await _create_audit_event(db, audit_event) + + 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(...)): + 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") + +@app.post("/a2h2a/reject") +async def reject_a2h2a_ticket(request: Request, ticket_id: str = Form(...), 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") + # ── Service discovery ─────────────────────────────────────────────────────── OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app