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:
parent
3b6e4e4890
commit
310dca2db5
|
|
@ -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"<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}"
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -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="<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 ───────────────────────────────────────────────────────
|
||||
OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user