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="
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}
+