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.
This commit is contained in:
Chris Christiansen 2026-09-04 12:23:59 +00:00
parent 3b6e4e4890
commit 310dca2db5

View File

@ -13,8 +13,8 @@ import google.oauth2.id_token
from googleapiclient.discovery import build from googleapiclient.discovery import build
from google.cloud import secretmanager from google.cloud import secretmanager
from google.oauth2 import service_account from google.oauth2 import service_account
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException, Form
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import asyncio import asyncio
import secrets import secrets
@ -78,6 +78,20 @@ class Governance(BaseModel):
authorized_approver: str authorized_approver: str
requires_mfa: bool = True requires_mfa: bool = True
timeout_minutes: int = 15 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): class A2H2ATicket(BaseModel):
ticket_id: str 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. # 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") @app.post("/api/v1/a2h2a/tickets")
async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): 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. # Generate parameter hash server-side for integrity.
ticket.proposed_action.parameter_hash = ticket.calculate_parameter_hash() 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 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 = ticket.model_dump(mode='json')
ticket_data['created_at'] = datetime.utcnow().isoformat() ticket_data['created_at'] = datetime.utcnow().isoformat()
ticket_data['status'] = 'PENDING' ticket_data['governance']['approval_status'] = 'PENDING'
doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id)
doc_ref.set(ticket_data)
# The approval URL now points to a conceptual, IAP-protected UI. doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id)
approval_ui_url = f"https://opax.vauco.no/a2h2a/review/{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') google_chat_webhook = os.getenv('A2H2A_GOOGLE_CHAT_WEBHOOK')
if 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 = { chat_payload = {
"cards": [ "cards": [
{ {
@ -224,10 +270,7 @@ async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request):
"widgets": [ "widgets": [
{ {
"textParagraph": { "textParagraph": {
"text": f"<b>Category:</b> {ticket.category}<br>" "text": f"<b>Category:</b> {ticket.category}<br>" f"<b>Service:</b> {ticket.source.affected_service}<br>" f"<b>Tool:</b> {ticket.proposed_action.execution_tool}<br>" f"<b>Summary:</b> {ticket.context.summary}"
f"<b>Service:</b> {ticket.source.affected_service}<br>"
f"<b>Tool:</b> {ticket.proposed_action.execution_tool}<br>"
f"<b>Summary:</b> {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}") logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}")
raise HTTPException(status_code=500, detail=str(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="<h1>404: Ticket not found</h1>", status_code=404)
ticket = A2H2ATicket(**ticket_doc.to_dict())
# --- Security Validation ---
if ticket.governance.approval_status != 'PENDING':
return HTMLResponse(content=f"<h1>400: Ticket Not Pending</h1><p>This ticket has already been actioned. Its current status is: <b>{ticket.governance.approval_status}</b>.</p>", status_code=400)
if ticket.is_expired():
return HTMLResponse(content="<h1>400: Ticket Expired</h1>", 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="<h1>403: Invalid Token</h1>", 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"<h1>403: Unauthorized</h1><p>You (<b>{user_email}</b>) are not the authorized approver (<b>{ticket.governance.authorized_approver}</b>) for this ticket.</p>", 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"""
<html>
<head>
<title>A2H2A Ticket Review</title>
<style> body {{ font-family: sans-serif; }} </style>
</head>
<body>
<h1>A2H2A Ticket Review: {ticket.severity}</h1>
<p><b>Authorized Approver:</b> {ticket.governance.authorized_approver}</p>
<p><b>Expires At:</b> {expiry_time.isoformat()}Z</p>
<hr>
<p><b>Ticket ID:</b> {ticket.ticket_id}</p>
<p><b>Tool:</b> {ticket.proposed_action.execution_tool}</p>
<p><b>Summary:</b> {ticket.context.summary}</p>
<p><b>Parameters Hash:</b> {ticket.proposed_action.parameter_hash}</p>
<hr>
<form action="/a2h2a/approve" method="post" style="display: inline-block;">
<input type="hidden" name="ticket_id" value="{ticket.ticket_id}">
<input type="hidden" name="token" value="{token}">
<button type="submit" style="background-color: #28a745; color: white; padding: 10px; border: none; border-radius: 5px; cursor: pointer;">Approve</button>
</form>
<form action="/a2h2a/reject" method="post" style="display: inline-block;">
<input type="hidden" name="ticket_id" value="{ticket.ticket_id}">
<input type="hidden" name="token" value="{token}">
<button type="submit" style="background-color: #dc3545; color: white; padding: 10px; border: none; border-radius: 5px; cursor: pointer;">Reject</button>
</form>
</body>
</html>
"""
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 ─────────────────────────────────────────────────────── # ── Service discovery ───────────────────────────────────────────────────────
OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app