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 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user