|
| 1 | +"""HTTP router for the bare-metal MCU/DSP audit surface (CLAUDE.md Rule #52). |
| 2 | +
|
| 3 | +Per-surface endpoint shape per Scout EE §4 recommendation: a focused |
| 4 | +``POST /api/v1/projects/{p}/firmware/{f}/bare-metal-hint`` for now; |
| 5 | +generic ``POST /external-descriptors`` alias deferred to Rule-of-Two |
| 6 | +when the second descriptor type ships (Rule #19 evidence-first — don't |
| 7 | +generalize on a single use case). |
| 8 | +
|
| 9 | +The endpoint is the external-ingestor protocol Phase 1 deferred to Phase 2: |
| 10 | +Velociraptor / dissect / custom pipelines push structured chip hints |
| 11 | +into wairz. Operators consume the same surface via the MCP tool |
| 12 | +``submit_bare_metal_descriptor`` (same service layer, same idempotency |
| 13 | +contract, different transport). |
| 14 | +
|
| 15 | +**Rule #33 .a contract:** |
| 16 | + - Idempotent on ``(firmware_id, ingestor_id, descriptor_hash)``. |
| 17 | + - Same hash from same ingestor → 200 idempotent replay. |
| 18 | + - Different hash from same ingestor → 409 conflict. |
| 19 | + - Different ingestor → new row, provenance-arbitrated at walker time |
| 20 | + (Scout GG §SC5 fix at ``bare_metal_walker._most_recent_descriptor``). |
| 21 | +
|
| 22 | +**Rule #51 rate-limit:** ``TIER_A_LIGHT_ACK`` (30/hour per IP) — ack is |
| 23 | +sub-second (single INSERT + commit); walker auto-trigger fires detached. |
| 24 | +
|
| 25 | +**Scout GG §SC4 multi-tenancy:** Phase 1 trust model is "single project |
| 26 | +API key → ``unauthenticated_external``". Per-ingestor scoped keys |
| 27 | +(Scout EE §5.4) ship in Phase 3 when the second external integration |
| 28 | +partner arrives — Rule #19 evidence-first vs over-engineering for one |
| 29 | +ingestor. |
| 30 | +""" |
| 31 | +from __future__ import annotations |
| 32 | + |
| 33 | +import hashlib |
| 34 | +import json |
| 35 | +import logging |
| 36 | +import uuid |
| 37 | +from datetime import datetime, timezone |
| 38 | + |
| 39 | +from fastapi import APIRouter, Depends, HTTPException, Request, Response |
| 40 | +from pydantic import BaseModel, Field |
| 41 | +from sqlalchemy import select |
| 42 | +from sqlalchemy.ext.asyncio import AsyncSession |
| 43 | + |
| 44 | +from app.database import get_db |
| 45 | +from app.models import BareMetalDescriptor, Firmware, Project |
| 46 | +from app.rate_limit import TIER_A_LIGHT_ACK, limiter |
| 47 | +from app.services.hardware_firmware.chip_catalog import get_chip_catalog |
| 48 | +from app.services.jsonb_normalizers import _stamp_bare_metal_descriptor_payload |
| 49 | + |
| 50 | +logger = logging.getLogger(__name__) |
| 51 | + |
| 52 | + |
| 53 | +router = APIRouter( |
| 54 | + prefix="/api/v1/projects/{project_id}/firmware/{firmware_id}", |
| 55 | + tags=["bare-metal"], |
| 56 | +) |
| 57 | + |
| 58 | + |
| 59 | +class BareMetalHintRequest(BaseModel): |
| 60 | + """Operator / external-ingestor body for a bare-metal chip hint. |
| 61 | +
|
| 62 | + Inline ``ChipFamilyManifest`` not exposed via HTTP in Phase 1 — Scout |
| 63 | + CC §SC14 path defers to Phase 2. |
| 64 | + """ |
| 65 | + |
| 66 | + chip_family_hint: str = Field( |
| 67 | + min_length=3, |
| 68 | + max_length=128, |
| 69 | + pattern=r"^[a-z][a-z0-9_]*/[a-z][a-z0-9_]*$", |
| 70 | + description="vendor/family identifier from the catalog (e.g. 'ti/tms320f28066')", |
| 71 | + ) |
| 72 | + domain_hint: str | None = Field( |
| 73 | + default=None, |
| 74 | + max_length=64, |
| 75 | + pattern=r"^[a-z][a-z0-9_]*$", |
| 76 | + description="Optional domain name; defaults to the family's first declared domain", |
| 77 | + ) |
| 78 | + ingestor_id: str | None = Field( |
| 79 | + default=None, |
| 80 | + max_length=128, |
| 81 | + description="Optional ingestor identifier (Velociraptor instance / operator handle); auto-defaults to 'http-default'", |
| 82 | + ) |
| 83 | + evidence: dict | None = Field( |
| 84 | + default=None, |
| 85 | + description="Optional free-form evidence (JTAG session log, CLASSID/REVID, extraction tool, ...)", |
| 86 | + ) |
| 87 | + |
| 88 | + model_config = {"extra": "forbid"} |
| 89 | + |
| 90 | + |
| 91 | +class BareMetalHintResponse(BaseModel): |
| 92 | + """Response shape — descriptor row carried back to the ingestor.""" |
| 93 | + |
| 94 | + descriptor_id: str |
| 95 | + firmware_id: str |
| 96 | + chip_family_hint: str |
| 97 | + domain_hint: str |
| 98 | + descriptor_source: str |
| 99 | + ingestor_id: str | None |
| 100 | + descriptor_hash: str |
| 101 | + received_at: str |
| 102 | + status: str # "created" | "idempotent_replay" |
| 103 | + |
| 104 | + |
| 105 | +@router.post("/bare-metal-hint", response_model=BareMetalHintResponse, status_code=201) |
| 106 | +@limiter.limit(TIER_A_LIGHT_ACK) |
| 107 | +async def push_bare_metal_hint( |
| 108 | + request: Request, |
| 109 | + response: Response, |
| 110 | + project_id: uuid.UUID, |
| 111 | + firmware_id: uuid.UUID, |
| 112 | + body: BareMetalHintRequest, |
| 113 | + db: AsyncSession = Depends(get_db), |
| 114 | +) -> BareMetalHintResponse: |
| 115 | + """Push an external chip-family hint for a bare-metal firmware. |
| 116 | +
|
| 117 | + Idempotent on ``(firmware_id, ingestor_id, descriptor_hash)`` per |
| 118 | + Rule #33 .a. Sub-second ACK + detached walker auto-trigger; rate- |
| 119 | + limited at ``TIER_A_LIGHT_ACK`` per Rule #51. |
| 120 | +
|
| 121 | + Trust model (Phase 1): global API key authenticates the request → |
| 122 | + ``descriptor_source = unauthenticated_external``. Per-ingestor |
| 123 | + scoped keys + ``attested_external`` ship in Phase 3 (Scout EE §5.4). |
| 124 | + """ |
| 125 | + # 404 — project must exist |
| 126 | + project = await db.get(Project, project_id) |
| 127 | + if project is None: |
| 128 | + raise HTTPException(status_code=404, detail=f"project {project_id} not found") |
| 129 | + |
| 130 | + # 404 — firmware must exist + belong to this project |
| 131 | + firmware = await db.get(Firmware, firmware_id) |
| 132 | + if firmware is None or firmware.project_id != project_id: |
| 133 | + raise HTTPException(status_code=404, detail=f"firmware {firmware_id} not found in project {project_id}") |
| 134 | + |
| 135 | + # 422 — chip_family_hint must be in the catalog |
| 136 | + catalog = get_chip_catalog() |
| 137 | + if body.chip_family_hint not in catalog: |
| 138 | + raise HTTPException( |
| 139 | + status_code=422, |
| 140 | + detail={ |
| 141 | + "error": f"chip_family_hint {body.chip_family_hint!r} not in catalog", |
| 142 | + "known_families": sorted(catalog.keys()), |
| 143 | + "hint": "drop a YAML at data/chip_families/<vendor>/<family>.yaml — operators may extend the catalog without a rebuild", |
| 144 | + }, |
| 145 | + ) |
| 146 | + manifest = catalog[body.chip_family_hint] |
| 147 | + domain_hint = body.domain_hint or manifest.domains[0].name |
| 148 | + if domain_hint not in {d.name for d in manifest.domains}: |
| 149 | + raise HTTPException( |
| 150 | + status_code=422, |
| 151 | + detail={ |
| 152 | + "error": f"domain_hint {domain_hint!r} not declared on family {body.chip_family_hint}", |
| 153 | + "known_domains": [d.name for d in manifest.domains], |
| 154 | + }, |
| 155 | + ) |
| 156 | + |
| 157 | + # Build the canonical payload + idempotency hash |
| 158 | + ingestor_id = body.ingestor_id or "http-default" |
| 159 | + payload = _stamp_bare_metal_descriptor_payload({ |
| 160 | + "firmware_id": str(firmware_id), |
| 161 | + "descriptor_source": "unauthenticated_external", |
| 162 | + "chip_family_hint": body.chip_family_hint, |
| 163 | + "domain_hint": domain_hint, |
| 164 | + "evidence": body.evidence or {}, |
| 165 | + "received_at": datetime.now(timezone.utc).isoformat(), |
| 166 | + }) |
| 167 | + # Stable hash excluding the received_at timestamp (replay across days = same descriptor) |
| 168 | + hash_input = {k: v for k, v in payload.items() if k != "received_at"} |
| 169 | + descriptor_hash = hashlib.sha256( |
| 170 | + json.dumps(hash_input, sort_keys=True).encode("utf-8") |
| 171 | + ).hexdigest()[:32] |
| 172 | + |
| 173 | + # Rule #33 .a: check existing — idempotent or conflict |
| 174 | + existing = await db.execute( |
| 175 | + select(BareMetalDescriptor).where( |
| 176 | + BareMetalDescriptor.firmware_id == firmware_id, |
| 177 | + BareMetalDescriptor.ingestor_id == ingestor_id, |
| 178 | + ) |
| 179 | + ) |
| 180 | + prior_rows = list(existing.scalars()) |
| 181 | + same_hash_row = next((r for r in prior_rows if r.descriptor_hash == descriptor_hash), None) |
| 182 | + if same_hash_row is not None: |
| 183 | + # 200 idempotent replay |
| 184 | + response.status_code = 200 |
| 185 | + return BareMetalHintResponse( |
| 186 | + descriptor_id=str(same_hash_row.id), |
| 187 | + firmware_id=str(firmware_id), |
| 188 | + chip_family_hint=body.chip_family_hint, |
| 189 | + domain_hint=domain_hint, |
| 190 | + descriptor_source="unauthenticated_external", |
| 191 | + ingestor_id=ingestor_id, |
| 192 | + descriptor_hash=descriptor_hash, |
| 193 | + received_at=same_hash_row.received_at.isoformat(), |
| 194 | + status="idempotent_replay", |
| 195 | + ) |
| 196 | + if prior_rows: |
| 197 | + # 409 conflict — same ingestor previously pushed a different hash; |
| 198 | + # supersedes_id required for explicit update (deferred to Phase 2.5). |
| 199 | + latest = max(prior_rows, key=lambda r: r.received_at) |
| 200 | + raise HTTPException( |
| 201 | + status_code=409, |
| 202 | + detail={ |
| 203 | + "error": f"ingestor {ingestor_id!r} previously pushed a different descriptor for this firmware", |
| 204 | + "prior_descriptor_id": str(latest.id), |
| 205 | + "prior_descriptor_hash": latest.descriptor_hash, |
| 206 | + "hint": "explicit supersedes_id required to update — Phase 2.5 feature; use a unique ingestor_id per ingestor in the meantime", |
| 207 | + }, |
| 208 | + ) |
| 209 | + |
| 210 | + desc = BareMetalDescriptor( |
| 211 | + firmware_id=firmware_id, |
| 212 | + descriptor_source="unauthenticated_external", |
| 213 | + ingestor_id=ingestor_id, |
| 214 | + descriptor_hash=descriptor_hash, |
| 215 | + payload=payload, |
| 216 | + ) |
| 217 | + db.add(desc) |
| 218 | + await db.commit() |
| 219 | + await db.refresh(desc) |
| 220 | + |
| 221 | + response.headers["X-Descriptor-Id"] = str(desc.id) |
| 222 | + return BareMetalHintResponse( |
| 223 | + descriptor_id=str(desc.id), |
| 224 | + firmware_id=str(firmware_id), |
| 225 | + chip_family_hint=body.chip_family_hint, |
| 226 | + domain_hint=domain_hint, |
| 227 | + descriptor_source="unauthenticated_external", |
| 228 | + ingestor_id=ingestor_id, |
| 229 | + descriptor_hash=descriptor_hash, |
| 230 | + received_at=desc.received_at.isoformat(), |
| 231 | + status="created", |
| 232 | + ) |
0 commit comments