From 706b0c0d30e281de87eeda1ab7f5956df43d958f Mon Sep 17 00:00:00 2001 From: Basil Zafar Date: Thu, 22 Oct 2026 21:21:49 +0530 Subject: [PATCH] fix(api): persist server attestation timestamps --- SECURITY.md | 15 +++++++ apps/api/src/mutiny_api/ingest.py | 6 ++- apps/api/src/mutiny_api/supervisor.py | 2 + docs/PRODUCTION_READINESS.md | 13 +++--- tests/integration/test_api_hosted_auth.py | 44 +++++++++++++++++++++ tests/integration/test_api_hosted_ingest.py | 33 ++++++++++++++++ 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 50dfc69..46cb7c8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -157,6 +157,21 @@ if durable storage is needed. New Docker named volumes inherit image directory ownership; existing or externally provisioned volumes need permissions set by the operator. See [COLD_START](./docs/COLD_START.md) for commands and backup notes. +## Attestation audit metadata (P1-1) + +Attestation is an operator acknowledgment, not authorization. Single-tenant +authorization remains possession of `MUTINY_API_TOKEN` when configured; the +shared token does not identify an individual person. Loopback demos may run +without authentication under the rules above. + +Accepted campaign starts persist `attestation: true` and a server-generated UTC +`attested_at` in the `attestation.accepted` event. Campaign ingestion persists +the same fields in campaign config. For ingestion, this is the server receipt +time, not the local execution time; client timestamps cannot override it, and +idempotent retries retain the original value. Existing records are not backfilled. +This audit metadata stores neither the raw bearer token nor a token fingerprint. +No individual identity, multi-tenant authorization, or customer execution is added. + ## Reporting a vulnerability Please **do not** open a public GitHub issue for security-sensitive reports. diff --git a/apps/api/src/mutiny_api/ingest.py b/apps/api/src/mutiny_api/ingest.py index 3372d66..cc1d718 100644 --- a/apps/api/src/mutiny_api/ingest.py +++ b/apps/api/src/mutiny_api/ingest.py @@ -16,6 +16,7 @@ import json import logging import os +from datetime import datetime, timezone from typing import Any from mutiny_core.redact import REDACTED, redact_secrets @@ -113,7 +114,7 @@ def enforce_envelope(body: IngestEnvelopeBase) -> None: def _campaign_config_fingerprint(config: dict[str, Any]) -> str: """Compare durable ingest config identity (ignore volatile timestamps).""" - skip = {"started_at", "completed_at", "seq", "mutiny_version"} + skip = {"started_at", "completed_at", "seq", "mutiny_version", "attested_at"} filtered = {k: v for k, v in config.items() if k not in skip} return stable_json_hash(filtered) @@ -213,6 +214,9 @@ def _build_campaign_config( cfg["execution_mode"] = "local_cli" cfg["ingest"] = True cfg["attestation"] = True + # Server receipt time, never an uploaded timestamp. Retries retain the + # original stored config and exclude this field from identity checks. + cfg["attested_at"] = datetime.now(timezone.utc).isoformat() if body.mutiny_version: cfg["mutiny_version"] = body.mutiny_version if body.adapter: diff --git a/apps/api/src/mutiny_api/supervisor.py b/apps/api/src/mutiny_api/supervisor.py index 800f877..a4af68c 100644 --- a/apps/api/src/mutiny_api/supervisor.py +++ b/apps/api/src/mutiny_api/supervisor.py @@ -7,6 +7,7 @@ import time import uuid from collections import defaultdict +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -345,6 +346,7 @@ def start_campaign( "attestation.accepted", { "attestation": True, + "attested_at": datetime.now(timezone.utc).isoformat(), "request_id": request_id, "target": campaign["config"].get("target"), "project_path": campaign["config"].get("project_path"), diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 0f7d965..f3074b2 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -165,7 +165,7 @@ MVP limitations are labeled as such, not “bugs.” | ID | Finding | Evidence | Targets | |---|---|---|---| -| P1-1 | Attestation is **not authorization** (boolean only) | `CampaignStartRequest.attestation`; supervisor `PermissionError` if false | Hosted (and weak Local messaging) | +| P1-1 | **Audit metadata shipped (#32)**; attestation remains acknowledgment, not authorization | Server UTC `attested_at` in start events / ingest config; false still rejected; no bearer token stored | Single-tenant token possession; individual identity and multi-tenant AuthZ remain out of scope | | P1-2 | Docs/architecture claim **rate limits** and **path/URL allowlisting** | **Partial:** in-process Hosted rate limits **shipped (P1-2)**; path/URL allowlisting still only the target enum | Hosted | | P1-3 | **Secret redaction** required by PRD — **implemented (M-PR3)** | `mutiny_core.redact`; persist/display wiring | Both (resolved for common patterns) | | P1-4 | CLI **Hosted-first** when `api_url` reachable — surprises operators; pushes `project_path` to API | ~~`run_cmd.py`~~ **Resolved (M-PR2):** local default; Hosted only via `--hosted` / `--hosted-url` | Local (ops safety); Hosted blast radius | @@ -227,7 +227,7 @@ MVP limitations are labeled as such, not “bugs.” | Control | Local CLI | Hosted today | |---|---|---| | AuthN | OS user | **Bearer when configured** (`MUTINY_API_TOKEN`, M-PR7). Loopback may omit for local demo; **non-loopback fails closed without token** (P0-1/P0-4). Supported entrypoint: `python -m mutiny_api` | -| AuthZ | Operator discipline | Checkbox attestation (not identity) — **P1-1 open** | +| AuthZ | Operator discipline | Token possession; timestamped attestation acknowledgment (P1-1 audit metadata shipped), not individual identity | | Target isolation | Same process as developer agent | **M-PR8E + P0-3:** customer adapter exec + customer FS access permanently removed from Hosted; trusted `in_process_demo` only. **ADR-019 observe-only implemented** | | Network bind | N/A (CLI) | Deploy templates may bind `0.0.0.0` **only with** non-empty `MUTINY_API_TOKEN` | | Rate limit | N/A (CLI execution unlimited) | **In-process** token-bucket (P1-2; not distributed). Enabled by default when token set | @@ -557,7 +557,8 @@ Scheduled after Target A gate; do not block 0.2.0. - [x] DB backup/export procedure (P2-6 — `mutiny db backup` / `restore`); `MUTINY_DB_PATH` configurable (**done M-PR4**) - [x] Threat model in SECURITY.md matches observe-only + bind/auth + rate limits - [ ] Version **1.0.0** (or explicit 0.4+ “Hosted beta” with same technical bar, labeled beta) -- [ ] AuthZ beyond attestation (P1-1); multi-tenant / durable ops as needed for full Target B +- [x] P1-1 audit metadata: server UTC attestation timestamp persisted for starts and ingest; retries retain original ingest timestamp; no raw bearer token stored (#32). +- [ ] Multi-tenant AuthZ / durable ops as needed for full Target B; timestamped acknowledgment is not authorization. ### Future implementation protocol (ONE problem at a time) @@ -607,7 +608,7 @@ Scheduled after Target A gate; do not block 0.2.0. | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | Public Hosted RCE via adapter load | Low (path removed) | Critical | M-PR8E permanent remove; observe-only ingest; no public multi-tenant claim | -| Operators trust attestation as security | High | High | Docs + M-PR7 auth; P1-1 remains | +| Operators trust attestation as security | High | High | Docs + M-PR7 auth; P1-1 timestamps acknowledgment, not identity or authorization | | Doc drift causes wrong roadmap work | Med | Med | P1-7 reconcile; this doc as gate | | Refund hardcoding → false “engine doesn’t work” on other tools | Med | Med | M-PR6 | | Secret leakage in SQLite/SSE | Med | High | M-PR3 | @@ -675,7 +676,7 @@ ADR-017 already supersedes ADR-001 for **product priority**. **M-PR2** aligns ru ### Critical Findings (P0/P1) - **P0:** ~~Unauthenticated Hosted + in-process adapter exec + policy write + public bind~~ — **mitigated** (M-PR8E, P0-3, P0-1/P0-4). -- **P1 open:** Attestation≠authorization (P1-1); path/URL allowlisting still target enum (remainder of P1-2). *(P1-2 rate limits shipped; P1-3…P1-7 resolved.)* +- **P1 status:** P1-1 audit metadata shipped (#32); attestation still does not grant authorization. Path/URL allowlisting remains a target enum (remainder of P1-2). *(P1-2 rate limits shipped; P1-3…P1-7 resolved.)* ### Architectural Findings (ADR needed) @@ -710,7 +711,7 @@ ADR-017 already supersedes ADR-001 for **product priority**. **M-PR2** aligns ru 9. M-PR8 ADR-019 Hosted execution model 10. **P0-3 / P0-1/P0-4 / P1-2 / P1-7** — FS-inert, bind fail-closed, in-process rate limits, docs reconcile (**done**) -Then remaining P1-1 / P2-* / P3/P4; **1.0.0** only after Target B DoD. +Then remaining authorization / P2-* / P3/P4 work; **1.0.0** only after Target B DoD. ### Release Plan diff --git a/tests/integration/test_api_hosted_auth.py b/tests/integration/test_api_hosted_auth.py index 7bac447..f0f01cc 100644 --- a/tests/integration/test_api_hosted_auth.py +++ b/tests/integration/test_api_hosted_auth.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import datetime, timezone from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -19,6 +20,8 @@ tokens_match, ) from mutiny_cli import run_cmd +from mutiny_api.db import connect +from mutiny_api.repository import Repository FAKE_TOKEN = "test-mutiny-api-token-mpr7-not-real" @@ -468,3 +471,44 @@ def test_e2e_campaign_auth_gate(client: TestClient) -> None: def test_auth_disabled_allows_local_demo(client_no_auth: TestClient) -> None: r = client_no_auth.get("/api/campaigns") assert r.status_code == 200 + + +@pytest.mark.parametrize("client_fixture", ["client", "client_no_auth"]) +def test_start_persists_attestation_timestamp( + client_fixture, request, api_db, caplog +) -> None: + client = request.getfixturevalue(client_fixture) + headers = _auth_headers() if client_fixture == "client" else {} + created = client.post( + "/api/campaigns", headers=headers, + json={ + "population_size": 2, "max_generations": 1, "elite_count": 1, + "max_turns": 2, "target": "in_process_demo", + }, + ) + assert created.status_code == 201, created.text + cid = created.json()["id"] + denied = client.post( + f"/api/campaigns/{cid}/start", headers=headers, + json={"attestation": False}, + ) + assert denied.status_code == 403 + before = datetime.now(timezone.utc) + started = client.post( + f"/api/campaigns/{cid}/start", headers=headers, + json={"attestation": True}, + ) + assert started.status_code == 200, started.text + after = datetime.now(timezone.utc) + conn = connect(api_db) + try: + events = Repository(conn).list_events(cid) + accepted = [e for e in events if e["type"] == "attestation.accepted"] + assert len(accepted) == 1 + payload = accepted[0]["payload"] + assert payload["attestation"] is True + assert before <= datetime.fromisoformat(payload["attested_at"]) <= after + assert FAKE_TOKEN not in "\n".join(conn.iterdump()) + finally: + conn.close() + assert FAKE_TOKEN not in caplog.text diff --git a/tests/integration/test_api_hosted_ingest.py b/tests/integration/test_api_hosted_ingest.py index 10fde53..c34b880 100644 --- a/tests/integration/test_api_hosted_ingest.py +++ b/tests/integration/test_api_hosted_ingest.py @@ -5,6 +5,7 @@ import hashlib import json import uuid +from datetime import datetime, timezone from pathlib import Path from typing import Any from unittest.mock import patch @@ -100,6 +101,38 @@ def _open_campaign( ), cid, payload +def test_attestation_timestamp_is_server_owned_and_stable(client, api_db, caplog): + payload = _envelope( + campaign_id="attestation-audit", local_project_key="audit-project", + attestation=False, config={"attested_at": "1900-01-01T00:00:00+00:00"}, + ) + url = "/api/ingest/v1/campaigns" + denied = client.post(url, headers=_auth(), json=payload) + assert denied.status_code == 400 + assert denied.json()["error"]["code"] == "attestation_required" + payload["attestation"] = True + before = datetime.now(timezone.utc) + opened = client.post(url, headers=_auth(), json=payload) + after = datetime.now(timezone.utc) + assert opened.status_code == 201, opened.text + timestamp = opened.json()["campaign"]["config"]["attested_at"] + assert before <= datetime.fromisoformat(timestamp) <= after + payload["config"]["attested_at"] = "2099-01-01T00:00:00+00:00" + retry = client.post(url, headers=_auth(), json=payload) + assert retry.status_code == 201, retry.text + assert retry.json()["created"] is False + assert retry.json()["campaign"]["config"]["attested_at"] == timestamp + conn = connect(api_db) + try: + config = Repository(conn).get_campaign(payload["campaign_id"])["config"] + assert config["attestation"] is True + assert config["attested_at"] == timestamp + assert FAKE_TOKEN not in "\n".join(conn.iterdump()) + finally: + conn.close() + assert FAKE_TOKEN not in caplog.text + + @pytest.mark.parametrize("kind", ["candidate", "trace", "regression"]) def test_artifact_hash_mismatch_rejects_batch_before_writes( client: TestClient, api_db: Path, kind: str