mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
Replace the plaintext SSM String parameter with a Secrets Manager secret referenced by ARN only. The token is fetched and cached at module level on first invocation, keeping shared secrets out of CloudFormation templates and Lambda environment variables. Refs: PR-97
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""
|
|
Web UI Lambda.
|
|
|
|
Serves a simple HTML dashboard for viewing work orders and comments.
|
|
Accessed via Lambda Function URL.
|
|
"""
|
|
|
|
import hmac
|
|
import json
|
|
import os
|
|
from html import escape as esc
|
|
|
|
import boto3
|
|
|
|
dynamodb = boto3.resource("dynamodb")
|
|
|
|
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
|
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
|
|
|
# Defense-in-depth auth gate. The public Function URL was removed (INFRA-74), but
|
|
# the handler must still refuse unauthenticated requests so any future invocation
|
|
# path does not re-expose the whole WO DB. Callers must present the shared secret
|
|
# in the X-Auth-Token header (or Authorization: Bearer <token>). The secret is
|
|
# fetched at runtime from Secrets Manager to keep it out of CloudFormation
|
|
# templates and Lambda env vars. If the ARN is unset or the secret is missing, the
|
|
# handler fails closed and denies every request.
|
|
_WEB_UI_AUTH_TOKEN_SECRET_ARN = os.environ.get("WEB_UI_AUTH_TOKEN_SECRET_ARN")
|
|
_auth_token_cache = None
|
|
|
|
|
|
def _get_auth_token() -> str | None:
|
|
"""Fetch the shared web UI auth token from Secrets Manager.
|
|
|
|
Cached at module level across warm invocations so we don't hit Secrets
|
|
Manager on every request. Returns None when not configured.
|
|
"""
|
|
global _auth_token_cache
|
|
if _auth_token_cache is not None:
|
|
return _auth_token_cache
|
|
if not _WEB_UI_AUTH_TOKEN_SECRET_ARN:
|
|
return None
|
|
secrets = boto3.client("secretsmanager")
|
|
try:
|
|
secret = secrets.get_secret_value(SecretId=_WEB_UI_AUTH_TOKEN_SECRET_ARN)
|
|
_auth_token_cache = secret["SecretString"]
|
|
return _auth_token_cache
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _header(event: dict, name: str) -> str:
|
|
"""Case-insensitive header lookup from a Lambda Function URL / APIGW event."""
|
|
headers = event.get("headers") or {}
|
|
name_lower = name.lower()
|
|
for key, value in headers.items():
|
|
if key.lower() == name_lower:
|
|
return value or ""
|
|
return ""
|
|
|
|
|
|
def is_authenticated(event: dict) -> bool:
|
|
"""Constant-time check of the request's shared secret against the configured
|
|
token. Fails closed when no token is configured."""
|
|
token = _get_auth_token()
|
|
if not token:
|
|
return False
|
|
presented = _header(event, "x-auth-token")
|
|
if not presented:
|
|
auth = _header(event, "authorization")
|
|
if auth.lower().startswith("bearer "):
|
|
presented = auth[7:].strip()
|
|
if not presented:
|
|
return False
|
|
return hmac.compare_digest(presented, token)
|
|
|
|
|
|
def get_work_orders(limit=500):
|
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
|
items = []
|
|
response = table.scan()
|
|
items.extend(response.get("Items", []))
|
|
while "LastEvaluatedKey" in response:
|
|
response = table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
items.extend(response.get("Items", []))
|
|
items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
return items[:limit]
|
|
|
|
|
|
def get_comments(work_order_id):
|
|
table = dynamodb.Table(COMMENTS_TABLE)
|
|
items = []
|
|
kwargs = {
|
|
"KeyConditionExpression": "work_order_id = :woid",
|
|
"ExpressionAttributeValues": {":woid": work_order_id},
|
|
}
|
|
response = table.query(**kwargs)
|
|
items.extend(response.get("Items", []))
|
|
while "LastEvaluatedKey" in response:
|
|
response = table.query(**kwargs, ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
items.extend(response.get("Items", []))
|
|
items.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
return items
|
|
|
|
|
|
def render_badge(value, color_map):
|
|
if not value:
|
|
value = "unknown"
|
|
color = color_map.get(value.lower(), "#9ca3af")
|
|
label = esc(value.replace("_", " ").title())
|
|
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
|
|
|
|
|
|
STATUS_COLORS = {
|
|
"new": "#3b82f6",
|
|
"assigned": "#8b5cf6",
|
|
"in_progress": "#f59e0b",
|
|
"on_hold": "#6b7280",
|
|
"completed": "#10b981",
|
|
"cancelled": "#ef4444",
|
|
"unknown": "#9ca3af",
|
|
}
|
|
|
|
RECORD_TYPE_COLORS = {
|
|
"new_work_order": "#3b82f6",
|
|
"comment": "#8b5cf6",
|
|
"update": "#f59e0b",
|
|
"cancellation": "#ef4444",
|
|
"unknown": "#9ca3af",
|
|
}
|
|
|
|
|
|
def render_work_order_detail(wo, events):
|
|
events_html = ""
|
|
if events:
|
|
for e in events:
|
|
record_type = e.get("record_type", "unknown")
|
|
commenter = esc(e.get("commenter", ""))
|
|
created = esc(e.get("created_at", ""))
|
|
text = esc(e.get("text", ""))
|
|
badge = render_badge(record_type, RECORD_TYPE_COLORS)
|
|
commenter_str = (
|
|
f"<strong>{commenter}</strong> — " if commenter else ""
|
|
)
|
|
|
|
border_colors = {
|
|
"new_work_order": "#3b82f6",
|
|
"comment": "#8b5cf6",
|
|
"update": "#f59e0b",
|
|
"cancellation": "#ef4444",
|
|
}
|
|
border = border_colors.get(record_type, "#94a3b8")
|
|
|
|
events_html += f"""
|
|
<div style="border-left:3px solid {border};padding:8px 12px;margin-bottom:10px;background:#f8fafc;border-radius:0 6px 6px 0;">
|
|
<div style="font-size:12px;color:#64748b;margin-bottom:4px;">
|
|
{badge} {commenter_str}{created}
|
|
</div>
|
|
{"<div style='font-size:14px;color:#1e293b;margin-top:6px;'>" + text + "</div>" if text else ""}
|
|
</div>"""
|
|
else:
|
|
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>'
|
|
|
|
wo_id = esc(wo.get("work_order_id", ""))
|
|
fields = [
|
|
("Description", esc(wo.get("description", "")) or None),
|
|
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
|
|
(
|
|
"Record Type",
|
|
render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS),
|
|
),
|
|
("Site Code", esc(wo.get("site_code", "")) or None),
|
|
("Building", esc(wo.get("building", "")) or None),
|
|
("Address", esc(wo.get("address", "")) or None),
|
|
("Severity", esc(wo.get("severity", "")) or None),
|
|
("Priority", esc(wo.get("priority", "")) or None),
|
|
("Due Date", esc(wo.get("due_date", "")) or None),
|
|
("Date Reported", esc(wo.get("date_reported", "")) or None),
|
|
("Scheduled Start", esc(wo.get("scheduled_start", "")) or None),
|
|
("Assigned To", esc(wo.get("assigned_to", "")) or None),
|
|
("Created", esc(wo.get("created_at", "")) or None),
|
|
("Last Updated", esc(wo.get("updated_at", "")) or None),
|
|
]
|
|
|
|
details_html = ""
|
|
for label, value in fields:
|
|
if value:
|
|
details_html += f"""
|
|
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
|
|
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
|
|
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
|
|
</div>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WO {wo_id} - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:800px;margin:0 auto;padding:20px;">
|
|
<div style="margin-bottom:20px;">
|
|
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">← All Work Orders</a>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
|
|
<h1 style="font-size:20px;margin-bottom:16px;">Work Order {wo_id}</h1>
|
|
{details_html}
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
<h2 style="font-size:16px;margin-bottom:16px;">Events ({len(events)})</h2>
|
|
{events_html}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def render_work_orders_list(work_orders):
|
|
rows = ""
|
|
for wo in work_orders:
|
|
wo_id = esc(wo.get("work_order_id", ""))
|
|
desc = esc(wo.get("description", ""))
|
|
site = esc(wo.get("site_code", ""))
|
|
status = wo.get("wo_status", "unknown")
|
|
record_type = wo.get("record_type", "unknown")
|
|
due = esc(wo.get("due_date", ""))
|
|
updated = esc((wo.get("updated_at") or "")[:16])
|
|
|
|
rows += f"""
|
|
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location={esc(json.dumps(f"/wo?id={wo.get("work_order_id", "")}"), quote=True)}">
|
|
<td style="padding:12px;font-weight:500;color:#3b82f6;">{wo_id}</td>
|
|
<td style="padding:12px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{desc}</td>
|
|
<td style="padding:12px;">{site}</td>
|
|
<td style="padding:12px;">{render_badge(record_type, RECORD_TYPE_COLORS)}</td>
|
|
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
|
|
<td style="padding:12px;">{due}</td>
|
|
<td style="padding:12px;color:#64748b;font-size:13px;">{updated}</td>
|
|
</tr>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Work Orders - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
tr:hover {{ background: #f8fafc; }}
|
|
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:1100px;margin:0 auto;padding:20px;">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
|
<h1 style="font-size:22px;">Work Orders</h1>
|
|
<span style="color:#64748b;font-size:14px;">{len(work_orders)} total</span>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>WO #</th>
|
|
<th>Description</th>
|
|
<th>Site</th>
|
|
<th>Last Action</th>
|
|
<th>Status</th>
|
|
<th>Due Date</th>
|
|
<th>Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No work orders yet.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def handler(event, context):
|
|
if not is_authenticated(event):
|
|
return {
|
|
"statusCode": 401,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": "<h1>401 Unauthorized</h1>",
|
|
}
|
|
|
|
path = event.get("rawPath", "/")
|
|
qs = event.get("queryStringParameters") or {}
|
|
|
|
if path == "/wo" and "id" in qs:
|
|
wo_id = qs["id"]
|
|
# Get work order
|
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
|
result = table.get_item(Key={"work_order_id": wo_id})
|
|
wo = result.get("Item")
|
|
if not wo:
|
|
return {
|
|
"statusCode": 404,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": "<h1>Work order not found</h1>",
|
|
}
|
|
events = get_comments(wo_id)
|
|
html = render_work_order_detail(wo, events)
|
|
else:
|
|
work_orders = get_work_orders()
|
|
html = render_work_orders_list(work_orders)
|
|
|
|
return {
|
|
"statusCode": 200,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": html,
|
|
}
|