From 4f90b23a5f7577098c086669a049ce840267ad07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:21:59 +0000 Subject: [PATCH 1/2] feat(router): promote stateless sandbox evaluator to production, keep /share out (#3825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /tools/inflow-lab ships in production and POSTs to /api/ingestion/api/v1/sandbox/evaluate, which always 404'd because `sandbox` was absent from PRODUCTION_ENABLED_ROUTERS. experimental-features.ts already documented the symptom: "the sandbox-evaluate feeder inside the page degrades to an inline error." Promoting the router as it stood would also have mounted two endpoints that do not belong on the production surface. POST /share is an unauthenticated, tenant-less write that stores up to 2MB of caller-supplied CSV in sandbox_shares for 30 days; its share_url points at /sandbox/results/[id], a page deleted when the public sandbox was retired, so a successful share returns a link to a 404; and its 10/hour/IP cap is a per-process dict that does not hold across replicas. So split rather than promote wholesale: - Move /share and /share/{id} into sandbox/share_router.py under a new `sandbox_share` owner, which stays experimental and unpromoted. - Promote `sandbox` (evaluate + trace) into PRODUCTION_ENABLED_ROUTERS. Both are stateless: they persist nothing, accept no tenant id, and read no tenant data, so there is no tenant boundary to cross. Bounded by a per-IP rate limit and 2MB / 500-event caps. - Classify both as PUBLIC_OPERATIONS with a none:stateless-no-persistence storage boundary; the route-complete manifest rejected them as unclassified until this was explicit. Promotion-gate evidence per PRODUCTION_SURFACE.md:112-120 lives in test_sandbox_production_promotion_3825.py (21 cases): tenant isolation, contract, failure modes, and guards that fail if a write path, a tenant field, or a third route is ever added to the promoted router — plus one asserting sandbox_share is never in the production allowlist. Verification: full ingestion suite 0 failures; router policy, router-surface doc invariant, route-security manifest, server import smoke, monolith route parity, SDK route contract, OpenAPI production surface and tests/invariants all green; manifest --check clean at 261 enabled / 178 omitted-404. DB-backed tests skipped locally (no Postgres/Docker in this environment). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PP3CSvvRruKPuYTrLoXzq --- .env.example | 2 +- PRODUCTION_SURFACE.md | 19 ++ docs/engineering/ROUTER_SURFACE.md | 30 +- docs/security/route-security-manifest.json | 57 ++-- frontend/src/lib/sandbox-cta.ts | 26 +- server/main.py | 8 + server/route_security_policy.py | 12 + server/router_policy.py | 13 +- services/ingestion/app/sandbox/router.py | 152 +--------- .../ingestion/app/sandbox/share_router.py | 168 +++++++++++ .../tests/test_rate_limit_leftovers_2711.py | 4 +- .../test_sandbox_production_promotion_3825.py | 262 ++++++++++++++++++ .../tests/test_sandbox_qa5_robustness.py | 8 +- .../tests/test_sandbox_router_1342.py | 22 +- .../tests/test_sandbox_share_router.py | 6 +- .../security/test_route_security_manifest.py | 28 +- tests/test_router_policy.py | 9 +- tests/test_server_import_smoke.py | 2 +- 18 files changed, 624 insertions(+), 204 deletions(-) create mode 100644 services/ingestion/app/sandbox/share_router.py create mode 100644 services/ingestion/tests/test_sandbox_production_promotion_3825.py diff --git a/.env.example b/.env.example index 9fa8fa171..a0adb454e 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,7 @@ MIGRATIONS_DATABASE_URL=postgresql://:@postgres:5432/reg # Consolidated router exposure. Production requires this exact set; missing or # extra names abort startup. DISABLED_ROUTERS and experimental bypasses are # local/test-only. -ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance +ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance,sandbox # Optional local/test fallback for Inflow Workbench persistence. # Production tenants use Postgres tables from Alembic v073 when DATABASE_URL is available. REGENGINE_INFLOW_WORKBENCH_PATH=/tmp/regengine_inflow_workbench.json diff --git a/PRODUCTION_SURFACE.md b/PRODUCTION_SURFACE.md index 98bad9be5..aa2448e9c 100644 --- a/PRODUCTION_SURFACE.md +++ b/PRODUCTION_SURFACE.md @@ -93,6 +93,25 @@ FSMA pipeline as the export stage's public attestation surface, and both assert they read no tenant data. **Neither has a recorded sign-off against the Promotion Rule below** — that review is outstanding (#2838). +A third joined them by release-gate promotion rather than unconditional +mounting: + +- `sandbox` — `POST /api/v1/sandbox/evaluate` and `POST /api/v1/sandbox/trace` + (`services/ingestion/app/sandbox/router.py`). Public FSMA 204 checker behind + the Inflow Lab CSV evaluator. No auth, no tenant context, and nothing + persisted; bounded by a per-IP rate limit and 2 MB / 500-event payload caps. + Promoted into `ENABLED_ROUTERS` by #3825 **with** sign-off against the + Promotion Rule below: tenant-isolation, contract, and failure-mode tests all + live in `services/ingestion/tests/test_sandbox_production_promotion_3825.py`. + It belongs to the FSMA pipeline as the `validate` stage's public front end — + the same rule engine the authenticated ingest path runs, over data the + caller supplies and the platform does not keep. + + The persisted share endpoints that once shared this router were deliberately + left out, under the separate `sandbox_share` owner: that write path is + unauthenticated and tenant-less, its `share_url` points at a deleted page, + and its rate limit is per-process. See `sandbox/share_router.py`. + Together with `remediation` (15 routes across its two routers), these three account for 17 routes that were absent from the router inventory before 2026-08-26. diff --git a/docs/engineering/ROUTER_SURFACE.md b/docs/engineering/ROUTER_SURFACE.md index 9f1959bbd..297084f40 100644 --- a/docs/engineering/ROUTER_SURFACE.md +++ b/docs/engineering/ROUTER_SURFACE.md @@ -111,7 +111,12 @@ These should be disabled in production unless explicitly promoted: - `scraping` - `discovery` - `sources` -- `sandbox` +- `sandbox_share` — persisted sandbox share links (`POST /api/v1/sandbox/share`, + `GET /api/v1/sandbox/share/{id}`). Split out of `sandbox` by #3825 and held + back: the write path is unauthenticated and tenant-less, its `share_url` + points at the deleted `/sandbox/results/[id]` page, and its 10/hour/IP cap + is a per-process dict so it does not hold across replicas. Resolve all + three before promoting. - `sensitech` - `audit` (mock audit) - `sop` @@ -128,7 +133,7 @@ Rationale: these surfaces either expand the product beyond FSMA evidence flow, a ## Required Production Configuration ```text -ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance +ENABLED_ROUTERS=inflow_workbench,fda_export,csv,edi,epcis_ingestion,score,portal,billing,onboarding,recall,proof_verify,proof_recall,canonical_records,rules,exceptions,request_workflow,auditor,compliance_metrics,readiness,chain_verification,audit_export_log,sla_tracking,export_monitoring,admin,fsma_compliance,sandbox ``` This value is intentionally exact, not a customizable production subset. A @@ -136,9 +141,24 @@ router change is a release-gate change: update `server/router_policy.py`, add the operation-level auth/tenant manifest and executable test adapters, and repeat staging isolation checks before changing the environment. -Graph, NLP, scraping, discovery, sandbox, recall simulations, legacy export, -and every other omitted optional router are release-disabled and return 404. -Graph promotion additionally requires the tenant matrix against Neo4j. +Graph, NLP, scraping, discovery, sandbox share links, recall simulations, +legacy export, and every other omitted optional router are release-disabled +and return 404. Graph promotion additionally requires the tenant matrix +against Neo4j. + +### `sandbox` (promoted, #3825) + +Owner: ingestion. Surface: `POST /api/v1/sandbox/evaluate` and +`POST /api/v1/sandbox/trace` (`services/ingestion/app/sandbox/router.py`). + +Both are unauthenticated by design — they are the public FSMA 204 checker +behind the Inflow Lab CSV evaluator — and both are stateless: they persist +nothing, accept no tenant identifier, and read no tenant data, so there is no +tenant boundary to cross. Abuse is bounded by a per-IP rate limit plus 2 MB +and 500-event payload caps. Promotion evidence lives in +`services/ingestion/tests/test_sandbox_production_promotion_3825.py`, which +asserts the statelessness the promotion rests on and fails if a write path or +tenant parameter is ever added to this router. ## Route-Complete Security Manifest diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index ca5dcd44a..334e3f447 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -25,6 +25,7 @@ "recall", "request_workflow", "rules", + "sandbox", "score", "sla_tracking" ], @@ -1588,6 +1589,28 @@ "storage_boundary": "postgres-rls:credential-bound-tenant", "test_adapter": "tenant_api_key_postgres_rls" }, + { + "operation": "sandbox:POST:/api/v1/sandbox/evaluate", + "owner": "sandbox", + "method": "POST", + "path": "/api/v1/sandbox/evaluate", + "classification": "public", + "authentication": "none", + "required_permissions": [], + "storage_boundary": "none:stateless-no-persistence", + "test_adapter": "public_no_auth" + }, + { + "operation": "sandbox:POST:/api/v1/sandbox/trace", + "owner": "sandbox", + "method": "POST", + "path": "/api/v1/sandbox/trace", + "classification": "public", + "authentication": "none", + "required_permissions": [], + "storage_boundary": "none:stateless-no-persistence", + "test_adapter": "public_no_auth" + }, { "operation": "sla_tracking:GET:/api/v1/sla/alerts/{tenant_id}", "owner": "sla_tracking", @@ -4483,17 +4506,8 @@ "probe_path": "/api/v1/remediation/supplier/session/current" }, { - "operation": "sandbox:POST:/api/v1/sandbox/evaluate", - "owner": "sandbox", - "method": "POST", - "path": "/api/v1/sandbox/evaluate", - "inventory_source": "development-route-table", - "probe_method": "POST", - "probe_path": "/api/v1/sandbox/evaluate" - }, - { - "operation": "sandbox:POST:/api/v1/sandbox/share", - "owner": "sandbox", + "operation": "sandbox_share:POST:/api/v1/sandbox/share", + "owner": "sandbox_share", "method": "POST", "path": "/api/v1/sandbox/share", "inventory_source": "development-route-table", @@ -4501,23 +4515,14 @@ "probe_path": "/api/v1/sandbox/share" }, { - "operation": "sandbox:GET:/api/v1/sandbox/share/{share_id}", - "owner": "sandbox", + "operation": "sandbox_share:GET:/api/v1/sandbox/share/{share_id}", + "owner": "sandbox_share", "method": "GET", "path": "/api/v1/sandbox/share/{share_id}", "inventory_source": "development-route-table", "probe_method": "GET", "probe_path": "/api/v1/sandbox/share/00000000-0000-4000-8000-000000000000" }, - { - "operation": "sandbox:POST:/api/v1/sandbox/trace", - "owner": "sandbox", - "method": "POST", - "path": "/api/v1/sandbox/trace", - "inventory_source": "development-route-table", - "probe_method": "POST", - "probe_path": "/api/v1/sandbox/trace" - }, { "operation": "settings:GET:/api/v1/settings/{tenant_id}", "owner": "settings", @@ -4970,12 +4975,12 @@ } ], "summary": { - "enabled_operation_count": 259, - "disabled_operation_count": 180, - "disabled_unique_path_count": 163, + "enabled_operation_count": 261, + "disabled_operation_count": 178, + "disabled_unique_path_count": 161, "enabled_by_classification": { "global-sysadmin": 16, - "public": 30, + "public": 32, "tenant-scoped": 206, "workload-only": 7 } diff --git a/frontend/src/lib/sandbox-cta.ts b/frontend/src/lib/sandbox-cta.ts index 616fc9067..9690a6b19 100644 --- a/frontend/src/lib/sandbox-cta.ts +++ b/frontend/src/lib/sandbox-cta.ts @@ -1,23 +1,25 @@ // #2757 — single source of truth for every "try it yourself" CTA on the // marketing site (compare pages, homepage). // -// Why this is not a plain constant: the free CSV sandbox is served by the -// `sandbox` router, which server/main.py lists in _EXPERIMENTAL_ROUTERS and -// auto-disables in production unless ENABLE_EXPERIMENTAL_ROUTERS=true. When -// the router is not mounted, POST /api/v1/sandbox/evaluate 404s, so a CTA -// promising "results in under 60 seconds" would be a claim the deployment -// cannot honor. +// Why this is not a plain constant: a CTA must never promise a destination +// the deployment cannot serve. // // PR #2730 hit exactly this: it repointed the dead homepage sandbox hash // anchors at a dashboard page but left the sandbox prose above the buttons, // so the copy promised a CSV sandbox while the button opened a dashboard. -// Resolving copy and destination together, from one flag, is what stops -// those two drifting apart again. The fallback destination is /walkthrough; -// the old /demo route was removed with the synthetic workspace cleanup. +// Resolving copy and destination together, from one place, is what stops +// those two drifting apart again. The old /demo route was removed with the +// synthetic workspace cleanup. // -// Enabling NEXT_PUBLIC_ENABLE_EXPERIMENTAL_FEATURES (or listing 'sandbox' in -// NEXT_PUBLIC_ENABLED_EXPERIMENTAL_TOOLS) alongside the backend's -// ENABLE_EXPERIMENTAL_ROUTERS lights up the whole funnel with no code change. +// #3825 note — the backend side of this comment is no longer what it was. +// Router exposure now lives in server/router_policy.py, not main.py, and +// production rejects ENABLE_EXPERIMENTAL_ROUTERS outright: optional routers +// are promoted by name into PRODUCTION_ENABLED_ROUTERS. The `sandbox` router +// was promoted that way and POST /api/v1/sandbox/evaluate is live in +// production, serving the Inflow Lab CSV checker. That does NOT bring back a +// public /sandbox page — that route stays retired and this CTA still points +// at /ingest. Only the persisted /share endpoints remain unpromoted, under +// the separate `sandbox_share` owner. import { isExperimentalToolEnabled } from '@/lib/experimental-features'; diff --git a/server/main.py b/server/main.py index cda7a8f41..45943b618 100644 --- a/server/main.py +++ b/server/main.py @@ -576,6 +576,14 @@ async def list_enabled_features(): include_owned_router(app, sandbox_router, owner="sandbox") +# #3825: the persisted share endpoints mount under their own owner so the +# stateless evaluator above can be production-enabled without them. This +# owner stays out of PRODUCTION_ENABLED_ROUTERS; see sandbox/share_router.py. +if _router_enabled("sandbox_share"): + from services.ingestion.app.sandbox.share_router import router as sandbox_share_router + + include_owned_router(app, sandbox_share_router, owner="sandbox_share") + if _router_enabled("inflow_workbench"): from services.ingestion.app.inflow_workbench import router as inflow_workbench_router diff --git a/server/route_security_policy.py b/server/route_security_policy.py index 632811d94..6fd445d31 100644 --- a/server/route_security_policy.py +++ b/server/route_security_policy.py @@ -35,6 +35,14 @@ ("root", "GET", "/"), ("proof_verify", "POST", "/api/v1/proof/verify"), ("proof_recall", "POST", "/api/v1/proof/recall-drill"), + # #3825: public FSMA 204 checker behind the Inflow Lab CSV evaluator. + # Both are stateless — they evaluate caller-supplied data against the + # rule engine and persist nothing, so there is no tenant boundary to + # cross. Bounded by a per-IP rate limit and 2MB/500-event caps. The + # persisted /share endpoints are deliberately NOT here: they belong to + # the unpromoted "sandbox_share" owner. + ("sandbox", "POST", "/api/v1/sandbox/evaluate"), + ("sandbox", "POST", "/api/v1/sandbox/trace"), ("csv", "GET", "/api/v1/templates"), ("csv", "GET", "/api/v1/templates/{cte_type}"), ("portal", "GET", "/api/v1/portal/{portal_id}"), @@ -369,6 +377,10 @@ def _public_storage_boundary(owner: str, path: str) -> str: return "postgres-rls:opaque-portal-capability" if owner in {"proof_verify", "proof_recall"}: return "public-proof-store:non-secret" + # #3825: the promoted sandbox operations touch no store at all — input is + # evaluated in memory and discarded with the request. + if owner == "sandbox": + return "none:stateless-no-persistence" if owner == "admin" and path.startswith(("/auth/", "/v1/auth/", "/api/v1/tools/")): return "admin-postgres:identity-bound-public-flow" return "public-static-or-health:no-tenant-secret" diff --git a/server/router_policy.py b/server/router_policy.py index fe6ce4ec3..50e51b188 100644 --- a/server/router_policy.py +++ b/server/router_policy.py @@ -22,6 +22,7 @@ "discovery", "sources", "sandbox", + "sandbox_share", "inflow_workbench", "fda_export", "csv", @@ -79,6 +80,13 @@ # decision and must update the route-complete tenant/auth gate before deploy. PRODUCTION_ENABLED_ROUTERS = frozenset( { + # #3825: the stateless sandbox evaluator (/evaluate, /trace) backs the + # Inflow Lab CSV checker, which ships in production and 404'd without + # it. Promoted on the strength of its statelessness — it persists + # nothing, accepts no tenant id, and reads no tenant data. The + # persisted share endpoints deliberately stayed behind under the + # separate "sandbox_share" owner, which must NOT be added here. + "sandbox", "inflow_workbench", "fda_export", "csv", @@ -112,7 +120,10 @@ "scraping", "discovery", "sources", - "sandbox", + # #3825: "sandbox" itself is no longer experimental — it is in + # PRODUCTION_ENABLED_ROUTERS. Only the persisted share endpoints + # remain unpromoted; see share_router.py for why. + "sandbox_share", "sensitech", "audit", "sop", diff --git a/services/ingestion/app/sandbox/router.py b/services/ingestion/app/sandbox/router.py index c1df6f9dd..49163846c 100644 --- a/services/ingestion/app/sandbox/router.py +++ b/services/ingestion/app/sandbox/router.py @@ -1,27 +1,31 @@ """ Sandbox Evaluation Router. -Provides POST /api/v1/sandbox/evaluate — a stateless endpoint that accepts -raw CTE events (JSON or CSV), runs normalization + FSMA 204 rule evaluation, -and returns results WITHOUT persisting anything. +Provides two stateless endpoints that accept raw CTE events (JSON or CSV), +run normalization + FSMA 204 rule evaluation or in-memory lot tracing, and +return results WITHOUT persisting anything: -This powers the live demo on the marketing site, letting prospects drop their -own data and see RegEngine's validation in action. + POST /api/v1/sandbox/evaluate + POST /api/v1/sandbox/trace -No auth required. No data stored. Rate-limited to prevent abuse. +This backs the Inflow Lab CSV evaluator (frontend +``app/tools/inflow-lab/components/InflowLabClient.tsx``). + +No auth required. No data stored. Rate-limited per IP to prevent abuse. + +#3825: statelessness is what makes this surface production-eligible, and it +is asserted by ``services/ingestion/tests/test_sandbox_production_promotion_3825.py``. +The persisted share endpoints that used to live here now sit in +``share_router.py`` under the separate, non-promoted ``sandbox_share`` owner — +do not reintroduce a write path into this module. """ from __future__ import annotations -import hashlib -import secrets -from datetime import datetime, timedelta, timezone from typing import Any, Dict, List import structlog from fastapi import APIRouter, HTTPException, Request -from pydantic import ValidationError -from sqlalchemy import text from .csv_parser import ( _collect_value_normalizations, @@ -38,8 +42,6 @@ RuleResultResponse, SandboxRequest, SandboxResponse, - SandboxShareRequest, - SandboxShareResponse, SandboxTraceRequest, TraceGraphResponse, ) @@ -272,127 +274,3 @@ async def sandbox_trace(payload: SandboxTraceRequest, request: Request) -> Trace """Stateless in-memory lot tracing for the sandbox.""" return await _sandbox_trace_impl(payload, request) - -# --------------------------------------------------------------------------- -# Share endpoints -# --------------------------------------------------------------------------- - -# In-memory share rate limiter (10/hour per IP) -_share_buckets: Dict[str, list] = {} -_SHARE_RATE_LIMIT = 10 -_SHARE_WINDOW = 3600 -# #2711: cap unbounded key growth — when exceeded, evict buckets whose -# newest entry has aged out of the window (an IP that never returns -# otherwise keeps its bucket forever). -_SHARE_BUCKET_CAP = 10000 - - -def _evict_stale_share_buckets(now: float) -> None: - """Drop empty/aged-out share buckets once the dict exceeds the cap (#2711).""" - if len(_share_buckets) <= _SHARE_BUCKET_CAP: - return - cutoff = now - _SHARE_WINDOW - for stale_key in [k for k, v in _share_buckets.items() if not v or v[-1] < cutoff]: - _share_buckets.pop(stale_key, None) - - -@router.post( - "/share", - response_model=SandboxShareResponse, - summary="Create a shareable link for sandbox results", -) -async def sandbox_share(payload: SandboxShareRequest, request: Request) -> SandboxShareResponse: - """Store evaluation results and return a shareable URL.""" - # Trusted-proxy-aware client IP (see resolve_client_ip); falls back to the - # socket peer when no trusted proxy is configured. - client_ip = resolve_client_ip(request) - - # Rate limit shares (10/hour per IP) - now = datetime.now(timezone.utc).timestamp() - bucket = _share_buckets.setdefault(client_ip, []) - bucket[:] = [t for t in bucket if now - t < _SHARE_WINDOW] - if len(bucket) >= _SHARE_RATE_LIMIT: - raise HTTPException(status_code=429, detail="Share rate limit exceeded. Try again later.") - bucket.append(now) - _evict_stale_share_buckets(now) - - # Same payload guard as /evaluate: shares store the CSV the user - # evaluated, which was already capped at 2MB there. - if len(payload.csv) > 2_000_000: - raise HTTPException( - status_code=413, - detail="CSV text too large for sandbox (max 2MB). Contact us for unlimited evaluation.", - ) - - share_id = secrets.token_urlsafe(12) - ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] - expires_at = datetime.now(timezone.utc) + timedelta(days=30) - - try: - from shared.database import get_db - - with get_db() as db: - db.execute( - text( - "INSERT INTO sandbox_shares (id, csv_text, result_json, ip_hash, expires_at) " - "VALUES (:id, :csv, :result, :ip_hash, :expires_at)" - ), - { - "id": share_id, - "csv": payload.csv, - "result": payload.result.model_dump_json(), - "ip_hash": ip_hash, - "expires_at": expires_at, - }, - ) - db.commit() - except Exception as e: - logger.error("sandbox_share_error", error=str(e)) - raise HTTPException(status_code=503, detail="Sharing is temporarily unavailable.") - - return SandboxShareResponse( - share_id=share_id, - share_url=f"/sandbox/results/{share_id}?utm_source=sandbox_share&utm_medium=link", - expires_at=expires_at.isoformat(), - ) - - -@router.get( - "/share/{share_id}", - response_model=SandboxResponse, - summary="Retrieve shared sandbox results", -) -async def sandbox_share_get(share_id: str) -> SandboxResponse: - """Retrieve shared evaluation results by ID.""" - if len(share_id) > 20: - raise HTTPException(status_code=400, detail="Invalid share ID") - - try: - from shared.database import get_db - - with get_db() as db: - row = db.execute( - text( - "UPDATE sandbox_shares SET view_count = view_count + 1 " - "WHERE id = :id AND expires_at > now() " - "RETURNING result_json" - ), - {"id": share_id}, - ).fetchone() - db.commit() - except Exception as e: - logger.error("sandbox_share_get_error", error=str(e)) - raise HTTPException(status_code=503, detail="Share retrieval temporarily unavailable.") - - if not row: - raise HTTPException(status_code=404, detail="Shared result not found or expired.") - - import json - - result_data = row[0] if isinstance(row[0], dict) else json.loads(row[0]) - try: - return SandboxResponse(**result_data) - except ValidationError: - # Stored result predates the current response shape. Report it as - # gone rather than turning a schema drift into a 500. - raise HTTPException(status_code=404, detail="Shared result not found or expired.") diff --git a/services/ingestion/app/sandbox/share_router.py b/services/ingestion/app/sandbox/share_router.py new file mode 100644 index 000000000..09e46858c --- /dev/null +++ b/services/ingestion/app/sandbox/share_router.py @@ -0,0 +1,168 @@ +""" +Sandbox Share Router — persisted share links for sandbox evaluations. + +Split out of ``sandbox/router.py`` by #3825 so the stateless evaluation +surface (``/evaluate``, ``/trace``) could be promoted into the production +router allowlist without carrying these two endpoints with it. + +They are mounted under their own router owner, ``sandbox_share``, which stays +in ``EXPERIMENTAL_ROUTERS`` and out of ``PRODUCTION_ENABLED_ROUTERS``. Three +reasons they are not production-eligible as written: + +1. ``POST /share`` is an UNAUTHENTICATED write: it inserts caller-supplied CSV + (up to 2MB, possibly real supplier data) into ``sandbox_shares`` with no + auth principal and no tenant, retained 30 days. +2. Its output is already dead. ``share_url`` points at ``/sandbox/results/{id}``, + a frontend route that was deleted when the public sandbox page was retired, + so a successful share returns a link to a 404. +3. The rate limit does not hold at scale. ``_share_buckets`` is a per-process + dict, so the 10/hour/IP cap is per-replica rather than per-deployment. + +Retiring or repointing these endpoints is tracked as a follow-up to #3825. +Do not add ``sandbox_share`` to the production allowlist without resolving +all three. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Dict + +import structlog +from fastapi import APIRouter, HTTPException, Request +from pydantic import ValidationError +from sqlalchemy import text + +from .client_ip import resolve_client_ip +from .models import ( + SandboxResponse, + SandboxShareRequest, + SandboxShareResponse, +) + +logger = structlog.get_logger("sandbox.share") + +router = APIRouter(prefix="/api/v1/sandbox", tags=["Sandbox"]) + + +# In-memory share rate limiter (10/hour per IP) +_share_buckets: Dict[str, list] = {} +_SHARE_RATE_LIMIT = 10 +_SHARE_WINDOW = 3600 +# #2711: cap unbounded key growth — when exceeded, evict buckets whose +# newest entry has aged out of the window (an IP that never returns +# otherwise keeps its bucket forever). +_SHARE_BUCKET_CAP = 10000 + + +def _evict_stale_share_buckets(now: float) -> None: + """Drop empty/aged-out share buckets once the dict exceeds the cap (#2711).""" + if len(_share_buckets) <= _SHARE_BUCKET_CAP: + return + cutoff = now - _SHARE_WINDOW + for stale_key in [k for k, v in _share_buckets.items() if not v or v[-1] < cutoff]: + _share_buckets.pop(stale_key, None) + + +@router.post( + "/share", + response_model=SandboxShareResponse, + summary="Create a shareable link for sandbox results", +) +async def sandbox_share(payload: SandboxShareRequest, request: Request) -> SandboxShareResponse: + """Store evaluation results and return a shareable URL.""" + # Trusted-proxy-aware client IP (see resolve_client_ip); falls back to the + # socket peer when no trusted proxy is configured. + client_ip = resolve_client_ip(request) + + # Rate limit shares (10/hour per IP) + now = datetime.now(timezone.utc).timestamp() + bucket = _share_buckets.setdefault(client_ip, []) + bucket[:] = [t for t in bucket if now - t < _SHARE_WINDOW] + if len(bucket) >= _SHARE_RATE_LIMIT: + raise HTTPException(status_code=429, detail="Share rate limit exceeded. Try again later.") + bucket.append(now) + _evict_stale_share_buckets(now) + + # Same payload guard as /evaluate: shares store the CSV the user + # evaluated, which was already capped at 2MB there. + if len(payload.csv) > 2_000_000: + raise HTTPException( + status_code=413, + detail="CSV text too large for sandbox (max 2MB). Contact us for unlimited evaluation.", + ) + + share_id = secrets.token_urlsafe(12) + ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] + expires_at = datetime.now(timezone.utc) + timedelta(days=30) + + try: + from shared.database import get_db + + with get_db() as db: + db.execute( + text( + "INSERT INTO sandbox_shares (id, csv_text, result_json, ip_hash, expires_at) " + "VALUES (:id, :csv, :result, :ip_hash, :expires_at)" + ), + { + "id": share_id, + "csv": payload.csv, + "result": payload.result.model_dump_json(), + "ip_hash": ip_hash, + "expires_at": expires_at, + }, + ) + db.commit() + except Exception as e: + logger.error("sandbox_share_error", error=str(e)) + raise HTTPException(status_code=503, detail="Sharing is temporarily unavailable.") + + return SandboxShareResponse( + share_id=share_id, + share_url=f"/sandbox/results/{share_id}?utm_source=sandbox_share&utm_medium=link", + expires_at=expires_at.isoformat(), + ) + + +@router.get( + "/share/{share_id}", + response_model=SandboxResponse, + summary="Retrieve shared sandbox results", +) +async def sandbox_share_get(share_id: str) -> SandboxResponse: + """Retrieve shared evaluation results by ID.""" + if len(share_id) > 20: + raise HTTPException(status_code=400, detail="Invalid share ID") + + try: + from shared.database import get_db + + with get_db() as db: + row = db.execute( + text( + "UPDATE sandbox_shares SET view_count = view_count + 1 " + "WHERE id = :id AND expires_at > now() " + "RETURNING result_json" + ), + {"id": share_id}, + ).fetchone() + db.commit() + except Exception as e: + logger.error("sandbox_share_get_error", error=str(e)) + raise HTTPException(status_code=503, detail="Share retrieval temporarily unavailable.") + + if not row: + raise HTTPException(status_code=404, detail="Shared result not found or expired.") + + import json + + result_data = row[0] if isinstance(row[0], dict) else json.loads(row[0]) + try: + return SandboxResponse(**result_data) + except ValidationError: + # Stored result predates the current response shape. Report it as + # gone rather than turning a schema drift into a 500. + raise HTTPException(status_code=404, detail="Shared result not found or expired.") diff --git a/services/ingestion/tests/test_rate_limit_leftovers_2711.py b/services/ingestion/tests/test_rate_limit_leftovers_2711.py index 4e94a1e4e..204a9fadf 100644 --- a/services/ingestion/tests/test_rate_limit_leftovers_2711.py +++ b/services/ingestion/tests/test_rate_limit_leftovers_2711.py @@ -24,7 +24,9 @@ # The package re-exports its APIRouter as ``router``, shadowing the module — # resolve the MODULE explicitly. -sandbox_router = importlib.import_module("app.sandbox.router") +# #3825: the share-bucket limiter moved with the /share endpoints into +# app.sandbox.share_router when the stateless evaluator was promoted. +sandbox_router = importlib.import_module("app.sandbox.share_router") # --------------------------------------------------------------------------- diff --git a/services/ingestion/tests/test_sandbox_production_promotion_3825.py b/services/ingestion/tests/test_sandbox_production_promotion_3825.py new file mode 100644 index 000000000..1442e64d0 --- /dev/null +++ b/services/ingestion/tests/test_sandbox_production_promotion_3825.py @@ -0,0 +1,262 @@ +"""Promotion-gate evidence for the `sandbox` router — #3825. + +`PRODUCTION_SURFACE.md` ("Promotion Rule") requires a route entering the +production surface to carry a tenant-isolation test, a schema/contract test, a +failure-mode test for invalid input, documented ownership, and a stated reason +it belongs to the FSMA pipeline. The first three are in this file; the last two +are in `PRODUCTION_SURFACE.md` and `docs/engineering/ROUTER_SURFACE.md`. + +The promotion rests entirely on the evaluator being STATELESS — it persists +nothing, takes no tenant identifier, and reads no tenant data, so there is no +tenant boundary for it to cross. These tests exist to fail loudly if that ever +stops being true, and to keep the persisted share endpoints out of the +production allowlist. +""" + +from __future__ import annotations + +import sys +from collections import Counter +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +service_dir = Path(__file__).parent.parent +if str(service_dir) not in sys.path: + sys.path.insert(0, str(service_dir)) + +pytest.importorskip("fastapi") + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from app.sandbox.models import ( # noqa: E402 + SandboxEvent, + SandboxRequest, + SandboxTraceRequest, +) +from app.sandbox.router import router as sandbox_router # noqa: E402 + +from server.router_policy import ( # noqa: E402 + EXPERIMENTAL_ROUTERS, + PRODUCTION_ENABLED_ROUTERS, + VALID_ROUTER_NAMES, + RouterPolicy, +) + +sandbox_router_mod = sys.modules["app.sandbox.router"] + +SAMPLE_CSV = ( + "cte_type,traceability_lot_code,product_description,quantity,unit_of_measure," + "location_name,timestamp,reference_document\n" + "shipping,TLC-3825-001,Romaine Lettuce,100,cases,Valley Farms," + "2026-11-01T14:00:00Z,BOL-3825\n" +) + + +@pytest.fixture(autouse=True) +def _no_rate_limit(monkeypatch): + """The per-IP limiter is covered by its own tests; keep it out of the way.""" + monkeypatch.setattr(sandbox_router_mod, "_check_sandbox_rate_limit", lambda ip: None) + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(sandbox_router) + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Release-gate membership +# --------------------------------------------------------------------------- + + +class TestReleaseGate: + def test_sandbox_is_release_approved(self): + assert "sandbox" in PRODUCTION_ENABLED_ROUTERS + + def test_sandbox_is_no_longer_experimental(self): + assert "sandbox" not in EXPERIMENTAL_ROUTERS + + def test_share_owner_is_a_known_router(self): + assert "sandbox_share" in VALID_ROUTER_NAMES + assert "sandbox_share" in EXPERIMENTAL_ROUTERS + + def test_share_owner_is_not_release_approved(self): + """The whole point of the #3825 split. + + `sandbox_share` writes caller-supplied CSV into `sandbox_shares` with + no auth and no tenant. If a future change promotes it, this fails. + """ + assert "sandbox_share" not in PRODUCTION_ENABLED_ROUTERS + + def test_production_policy_enables_evaluator_but_not_share(self): + policy = RouterPolicy.from_environment( + production=True, + environ={"ENABLED_ROUTERS": ",".join(sorted(PRODUCTION_ENABLED_ROUTERS))}, + ) + assert policy.is_enabled("sandbox") is True + assert policy.is_enabled("sandbox_share") is False + + +# --------------------------------------------------------------------------- +# Tenant isolation — by absence, which is the claim being made +# --------------------------------------------------------------------------- + + +class TestTenantIsolation: + def test_router_exposes_only_the_two_stateless_routes(self): + paths = {route.path for route in sandbox_router.routes} + assert paths == {"/api/v1/sandbox/evaluate", "/api/v1/sandbox/trace"}, ( + f"sandbox router exposes {sorted(paths)}. Promotion (#3825) covered the " + f"stateless evaluator only — a new route here enters production without " + f"its own promotion review." + ) + + def test_router_module_has_no_database_or_persistence_path(self): + """Statelessness is the promotion's entire basis, so assert it in source.""" + source = Path(sandbox_router_mod.__file__).read_text(encoding="utf-8") + for forbidden in ("shared.database", "get_db", "sqlalchemy", "INSERT", "UPDATE", "DELETE"): + assert forbidden not in source, ( + f"{forbidden!r} appears in the production-enabled sandbox router. " + f"#3825 promoted it because it persists nothing; a write path makes " + f"it an unauthenticated, tenant-less writer and voids that review." + ) + + def test_no_route_accepts_a_tenant_path_parameter(self): + """No caller-supplied tenant id — the repo's hard rule.""" + for route in sandbox_router.routes: + for field_name in getattr(route, "param_convertors", {}): + assert "tenant" not in field_name.lower(), ( + f"{route.path} takes a tenant-shaped path parameter {field_name!r}" + ) + + @pytest.mark.parametrize("model", [SandboxRequest, SandboxEvent, SandboxTraceRequest]) + def test_request_models_declare_no_tenant_field(self, model): + """Checked on the models themselves, not FastAPI internals, so the + assertion survives framework upgrades.""" + for field_name in model.model_fields: + assert "tenant" not in field_name.lower(), ( + f"{model.__name__} declares a tenant-shaped field {field_name!r}; the " + f"sandbox must never take a caller-supplied tenant id (#3825)." + ) + + def test_caller_supplied_tenant_id_is_ignored_not_honored(self, client): + """An attacker-supplied tenant id must not be echoed or acted on.""" + resp = client.post( + "/api/v1/sandbox/evaluate", + json={"csv": SAMPLE_CSV, "tenant_id": "00000000-0000-0000-0000-000000000001"}, + ) + assert resp.status_code == 200 + assert "tenant" not in resp.text.lower() + + def test_tenant_header_does_not_change_the_result(self, client): + """Same input, with and without X-Tenant-ID, yields the same evaluation.""" + payload = {"csv": SAMPLE_CSV} + plain = client.post("/api/v1/sandbox/evaluate", json=payload) + spoofed = client.post( + "/api/v1/sandbox/evaluate", + json=payload, + headers={"X-Tenant-ID": "00000000-0000-0000-0000-000000000001"}, + ) + assert plain.status_code == spoofed.status_code == 200 + assert plain.json() == spoofed.json() + + +# --------------------------------------------------------------------------- +# Contract +# --------------------------------------------------------------------------- + + +class TestContract: + def test_evaluate_returns_the_documented_summary_shape(self, client): + resp = client.post("/api/v1/sandbox/evaluate", json={"csv": SAMPLE_CSV}) + assert resp.status_code == 200 + body = resp.json() + + for key in ( + "total_events", + "compliant_events", + "non_compliant_events", + "total_kde_errors", + "total_rule_failures", + "submission_blocked", + "blocking_reasons", + "events", + ): + assert key in body, f"missing {key!r} from the evaluate contract" + + assert body["total_events"] == 1 + assert isinstance(body["submission_blocked"], bool) + assert isinstance(body["blocking_reasons"], list) + assert len(body["events"]) == 1 + + event = body["events"][0] + for key in ("event_index", "cte_type", "traceability_lot_code", "compliant", "all_results"): + assert key in event, f"missing {key!r} from the per-event contract" + assert event["cte_type"] == "shipping" + assert event["traceability_lot_code"] == "TLC-3825-001" + + def test_rules_actually_run(self, client): + """A contract test that passed on an empty rule set would prove nothing.""" + resp = client.post("/api/v1/sandbox/evaluate", json={"csv": SAMPLE_CSV}) + event = resp.json()["events"][0] + + assert event["rules_evaluated"] > 0 + # Tallies must account for every returned result. `skip` is a real + # bucket alongside pass/fail/warn, so the counted three need not sum + # to the total — but the result list must. + assert event["rules_evaluated"] == len(event["all_results"]) + buckets = Counter(result["result"] for result in event["all_results"]) + assert buckets["pass"] == event["rules_passed"] + assert buckets["fail"] == event["rules_failed"] + assert buckets["warn"] == event["rules_warned"] + assert set(buckets) <= {"pass", "fail", "warn", "skip"}, f"unknown verdict in {dict(buckets)}" + + # Real rule content, not placeholders. + assert any(result["rule_title"] for result in event["all_results"]) + assert any(result.get("citation") for result in event["all_results"]) + + +# --------------------------------------------------------------------------- +# Failure modes for invalid input +# --------------------------------------------------------------------------- + + +class TestFailureModes: + def test_neither_events_nor_csv_is_rejected(self, client): + resp = client.post("/api/v1/sandbox/evaluate", json={}) + assert resp.status_code == 400 + assert "events" in resp.json()["detail"].lower() + + def test_csv_without_cte_type_is_rejected(self, client): + resp = client.post( + "/api/v1/sandbox/evaluate", + json={"csv": "product_description,quantity\nRomaine,100\n"}, + ) + assert resp.status_code == 400 + assert "cte_type" in resp.json()["detail"] + + def test_oversize_csv_is_rejected_before_parsing(self, client): + resp = client.post("/api/v1/sandbox/evaluate", json={"csv": "x" * 2_000_001}) + assert resp.status_code == 413 + assert "2MB" in resp.json()["detail"] + + def test_event_count_cap_is_enforced(self, client): + header = "cte_type,traceability_lot_code\n" + rows = "".join(f"shipping,TLC-{i}\n" for i in range(501)) + resp = client.post("/api/v1/sandbox/evaluate", json={"csv": header + rows}) + assert resp.status_code == 400 + assert "500" in resp.json()["detail"] + + def test_malformed_body_is_a_422_not_a_500(self, client): + resp = client.post("/api/v1/sandbox/evaluate", json={"events": "not-a-list"}) + assert resp.status_code == 422 + + def test_garbage_csv_does_not_crash_the_endpoint(self, client): + resp = client.post("/api/v1/sandbox/evaluate", json={"csv": "\x00\x01 not,really;csv\n\n"}) + assert resp.status_code in {400, 413}, f"unexpected {resp.status_code}: {resp.text[:200]}" diff --git a/services/ingestion/tests/test_sandbox_qa5_robustness.py b/services/ingestion/tests/test_sandbox_qa5_robustness.py index a01cfa6c5..6e59fb842 100644 --- a/services/ingestion/tests/test_sandbox_qa5_robustness.py +++ b/services/ingestion/tests/test_sandbox_qa5_robustness.py @@ -47,12 +47,15 @@ ) from app.sandbox.evaluators import _evaluate_relational_in_memory # noqa: E402 from app.sandbox.router import router as sandbox_router # noqa: E402 +# #3825: persisted /share endpoints now live in their own router/module. +from app.sandbox.share_router import router as sandbox_share_router # noqa: E402 from app.sandbox.validation import ( # noqa: E402 _detect_entity_mismatches, _validate_kdes, ) sandbox_router_mod = sys.modules["app.sandbox.router"] +share_router_mod = sys.modules["app.sandbox.share_router"] # --------------------------------------------------------------------------- @@ -69,13 +72,13 @@ def _clean_limiter_state(monkeypatch): rate_limiting._redis_client = None rate_limiting._redis_failed = False rate_limiting._redis_retry_at = 0.0 - sandbox_router_mod._share_buckets.clear() + share_router_mod._share_buckets.clear() yield rate_limiting._rate_buckets.clear() rate_limiting._redis_client = None rate_limiting._redis_failed = False rate_limiting._redis_retry_at = 0.0 - sandbox_router_mod._share_buckets.clear() + share_router_mod._share_buckets.clear() @pytest.fixture() @@ -84,6 +87,7 @@ def client(monkeypatch) -> TestClient: monkeypatch.setattr(sandbox_router_mod, "_check_sandbox_rate_limit", lambda ip: None) app = FastAPI() app.include_router(sandbox_router) + app.include_router(sandbox_share_router) return TestClient(app) diff --git a/services/ingestion/tests/test_sandbox_router_1342.py b/services/ingestion/tests/test_sandbox_router_1342.py index 99f648809..0805b21a1 100644 --- a/services/ingestion/tests/test_sandbox_router_1342.py +++ b/services/ingestion/tests/test_sandbox_router_1342.py @@ -83,8 +83,13 @@ def _restore_shared_database_on_module_teardown(): # reliably — instead pull the *module* directly out of sys.modules after the # import side-effect has populated it. from app.sandbox.router import router as sandbox_router # noqa: E402 +# #3825 split the persisted /share endpoints into their own router so the +# stateless evaluator could be production-promoted without them. Both are +# mounted here so this file keeps covering the same surface as before. +from app.sandbox.share_router import router as sandbox_share_router # noqa: E402 sandbox_router_mod = sys.modules["app.sandbox.router"] +share_router_mod = sys.modules["app.sandbox.share_router"] # --------------------------------------------------------------------------- @@ -98,15 +103,16 @@ def _reset_rate_limiters(monkeypatch): # Silence the imported rate-limit guard used by evaluate/trace. monkeypatch.setattr(sandbox_router_mod, "_check_sandbox_rate_limit", lambda ip: None) # Reset share-buckets state for every test. - sandbox_router_mod._share_buckets.clear() + share_router_mod._share_buckets.clear() yield - sandbox_router_mod._share_buckets.clear() + share_router_mod._share_buckets.clear() @pytest.fixture def client() -> TestClient: app = FastAPI() app.include_router(sandbox_router) + app.include_router(sandbox_share_router) return TestClient(app) @@ -640,7 +646,7 @@ def test_share_rate_limit_enforced_per_ip(self, client, monkeypatch): db = _FakeDb() _install_get_db(monkeypatch, db) # Fill up the bucket to the limit. - for _ in range(sandbox_router_mod._SHARE_RATE_LIMIT): + for _ in range(share_router_mod._SHARE_RATE_LIMIT): resp = client.post("/api/v1/sandbox/share", json=_sample_share_payload()) assert resp.status_code == 200 # Next call trips the 429. @@ -653,11 +659,11 @@ def test_share_rate_bucket_expires_old_entries(self, client, monkeypatch): db = _FakeDb() _install_get_db(monkeypatch, db) # Seed bucket with entries ancient enough to be pruned. - sandbox_router_mod._share_buckets["testclient"] = [0.0] + share_router_mod._share_buckets["testclient"] = [0.0] resp = client.post("/api/v1/sandbox/share", json=_sample_share_payload()) assert resp.status_code == 200 # Bucket should now only contain the fresh entry (ancient one pruned). - assert len(sandbox_router_mod._share_buckets["testclient"]) == 1 + assert len(share_router_mod._share_buckets["testclient"]) == 1 # =========================================================================== @@ -743,8 +749,8 @@ def test_router_has_expected_prefix_and_tag(self): assert "Sandbox" in sandbox_router.tags def test_rate_limit_constants(self): - assert sandbox_router_mod._SHARE_RATE_LIMIT == 10 - assert sandbox_router_mod._SHARE_WINDOW == 3600 + assert share_router_mod._SHARE_RATE_LIMIT == 10 + assert share_router_mod._SHARE_WINDOW == 3600 def test_share_buckets_is_mutable_dict(self): - assert isinstance(sandbox_router_mod._share_buckets, dict) + assert isinstance(share_router_mod._share_buckets, dict) diff --git a/services/ingestion/tests/test_sandbox_share_router.py b/services/ingestion/tests/test_sandbox_share_router.py index 489393b45..ac69c91ae 100644 --- a/services/ingestion/tests/test_sandbox_share_router.py +++ b/services/ingestion/tests/test_sandbox_share_router.py @@ -26,7 +26,11 @@ pytest.importorskip("fastapi") import shared.database as shared_db -from app.sandbox.router import router as sandbox_router + +# #3825: the persisted share endpoints moved out of sandbox/router.py into +# their own module so the stateless evaluator could be promoted into the +# production router allowlist without them. +from app.sandbox.share_router import router as sandbox_router @pytest.fixture() diff --git a/tests/security/test_route_security_manifest.py b/tests/security/test_route_security_manifest.py index 178694b6c..7167b1a5e 100644 --- a/tests/security/test_route_security_manifest.py +++ b/tests/security/test_route_security_manifest.py @@ -76,7 +76,12 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): # +1 tenant-scoped: admin:PUT:/v1/supplier/admin/facilities/{facility_id}/agreed-sending-hours, # tenant Owner or Admin only (tenant.admin), tenant-filtered. # +1 tenant-scoped: CSV diagnostic preview, without event persistence. - "enabled_operation_count": 259, + # +2 public (#3825): sandbox:POST:/api/v1/sandbox/evaluate and + # sandbox:POST:/api/v1/sandbox/trace, promoted into the production + # allowlist for the Inflow Lab CSV evaluator. Both are stateless and + # tenantless; the persisted /share pair stayed disabled under the + # separate sandbox_share owner. + "enabled_operation_count": 261, # 178/161 includes graph:GET:/api/v1/provisions/by-request: it is added # to the graph v1_router AFTER router.include_router(v1_router), so the # eager route copies of fastapi < 0.137 (under which the previous @@ -95,11 +100,13 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): # 180/163 removes the 3 inbound_email operations: that router # resolved a tenant from the recipient address alone, with no sender # allowlist, and was deleted in favour of email_ingest. - "disabled_operation_count": 180, - "disabled_unique_path_count": 163, + # 178/161 moves sandbox evaluate+trace into the enabled set (#3825). + # The 2 sandbox_share operations remain here and must probe 404. + "disabled_operation_count": 178, + "disabled_unique_path_count": 161, "enabled_by_classification": { "global-sysadmin": 16, - "public": 30, + "public": 32, "tenant-scoped": 206, "workload-only": 7, }, @@ -297,9 +304,9 @@ async def unsafe_checkout_handler(): def test_every_omitted_router_path_returns_exact_404(production_route_app, manifest): - # 180 includes agent_org onboard; the 3 inbound_email operations were - # removed with that router — see the comment on disabled_operation_count. - assert probe_omitted_paths_exact_404(production_route_app, manifest) == 180 + # 178 after #3825 moved sandbox evaluate+trace into the production + # surface — see the comment on disabled_operation_count. + assert probe_omitted_paths_exact_404(production_route_app, manifest) == 178 def test_representative_auth_negative_contracts_are_not_vacuous(production_route_app): @@ -311,7 +318,11 @@ def test_representative_auth_negative_contracts_are_not_vacuous(production_route graph = client.post("/api/v1/query/traceability", json={}) overlay = client.get("/v1/overlay/controls") nlp = client.post("/api/v1/analyze", json={}) + # #3825: the stateless evaluator is now part of the production surface, + # so it must answer (400 on an empty body) rather than 404 — while the + # persisted share endpoint it was split from stays unreachable. sandbox = client.post("/api/v1/sandbox/evaluate", json={}) + sandbox_share = client.post("/api/v1/sandbox/share", json={}) assert tenant.status_code == 401 assert metrics.status_code == 403 @@ -320,7 +331,8 @@ def test_representative_auth_negative_contracts_are_not_vacuous(production_route assert graph.status_code == 404 assert overlay.status_code == 404 assert nlp.status_code == 404 - assert sandbox.status_code == 404 + assert sandbox.status_code == 400 + assert sandbox_share.status_code == 404 def test_unowned_operation_fails_manifest_generation(inventory_app): diff --git a/tests/test_router_policy.py b/tests/test_router_policy.py index ba99f4655..ca18eb4db 100644 --- a/tests/test_router_policy.py +++ b/tests/test_router_policy.py @@ -34,6 +34,9 @@ "export_monitoring", "admin", "fsma_compliance", + # #3825: stateless sandbox evaluator (/evaluate, /trace). The persisted + # share endpoints live under the separate, unpromoted "sandbox_share". + "sandbox", } @@ -105,9 +108,12 @@ def test_production_mounts_only_approved_routers(): assert policy.disabled_routers == VALID_ROUTER_NAMES - EXPECTED_PRODUCTION_ROUTERS assert not policy.is_enabled("graph") assert not policy.is_enabled("nlp") - assert not policy.is_enabled("sandbox") assert not policy.is_enabled("recall_simulations") assert not policy.is_enabled("supplier_validation") + # #3825: the stateless sandbox evaluator is promoted; its persisted share + # endpoints are not, and must stay unreachable in production. + assert policy.is_enabled("sandbox") + assert not policy.is_enabled("sandbox_share") def test_nonproduction_retains_broad_behavior_and_denylist(): @@ -119,6 +125,7 @@ def test_nonproduction_retains_broad_behavior_and_denylist(): assert not policy.is_enabled("graph") assert not policy.is_enabled("nlp") assert policy.is_enabled("sandbox") + assert policy.is_enabled("sandbox_share") assert policy.is_enabled("admin") assert policy.experimental_bypass_enabled is True diff --git a/tests/test_server_import_smoke.py b/tests/test_server_import_smoke.py index e47f1f6da..0a9452921 100644 --- a/tests/test_server_import_smoke.py +++ b/tests/test_server_import_smoke.py @@ -130,7 +130,7 @@ def test_railway_cloud_import_disables_public_docs(): if route in { "POST /v1/scrape/cppa", "GET /v1/ingest/discovery/queue", - "POST /api/v1/sandbox/evaluate", + "POST /api/v1/sandbox/share", "POST /api/v1/query/traceability", "GET /api/v1/provisions/by-request", "GET /api/v1/simulations/scenarios", From b813d0bd752de8ab4eda3dfde30e81f0dda467a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:30:02 +0000 Subject: [PATCH 2/2] fix(ci): close the two remaining pins of the production router set (#3825) The repo-root security/RLS/isolation suite caught a pinned copy of the production allowlist that my grep for `^ENABLED_ROUTERS=` missed, because it is a JSON key rather than an env line: Release-evidence validation failed: - docs/security/release-evidence/2026-08-03-security-remediation-candidate.json: $.enabled_routers: must exactly match production policy (missing=['sandbox'], extra=[]) scripts/validate_release_evidence.py requires that field to equal PRODUCTION_ENABLED_ROUTERS exactly, so promoting a router means updating the record. Rather than silently appending to a security artifact, the note now records that sandbox was promoted after this record's 2026-08-26 verification date, was NOT exercised by it, and carries its own promotion-gate evidence. The record is an explicitly unapproved candidate (status not_approved, release_approved false) whose route_manifest counts were already stale against main, so the field is a living constraint rather than a frozen attestation. Also mirrors the share-router split into services/ingestion/main.py. That legacy per-service entrypoint mounts app.sandbox.router directly, so after the split it would have silently dropped both /share endpoints; server/main.py documents that the two mount sites are kept in sync. Verified: validate_release_evidence.py and generate_route_security_manifest.py --check both clean; check_enabled_outbound_http.py passes; the failing job's non-DB steps all green locally (outbound-HTTP guard, disabled-router task handlers, release evidence, anti-stub canonical guard); route parity, router policy, route-security manifest, router-surface doc invariant and import smoke green; sandbox suites green. The job's remaining steps need isolated test databases, which this environment has no Postgres for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PP3CSvvRruKPuYTrLoXzq --- .../2026-08-03-security-remediation-candidate.json | 3 ++- services/ingestion/main.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/security/release-evidence/2026-08-03-security-remediation-candidate.json b/docs/security/release-evidence/2026-08-03-security-remediation-candidate.json index 66d1416ff..b2072aed0 100644 --- a/docs/security/release-evidence/2026-08-03-security-remediation-candidate.json +++ b/docs/security/release-evidence/2026-08-03-security-remediation-candidate.json @@ -69,6 +69,7 @@ "recall", "request_workflow", "rules", + "sandbox", "score", "sla_tracking" ], @@ -241,5 +242,5 @@ "Graph and overlay remain release-disabled; current-main integration must hard-404 graph-backed /api/fsma aliases and all /api/controls aliases before authentication or upstream fetch.", "Candidate staging database roles, the route-complete two-tenant matrix, cross-replica Redis revocation, enumeration timing, and the controlled CSRF/SSRF matrix have not run." ], - "notes": "Source candidate only. The review-driven account-erasure corrections, destructive-database guard, and corrected staging proof are locally verified; independent staging-spec and repaired-P2 code-quality/security re-reviews are approved. Current-main integration remains pending. Alembic revision b71d9e4c2a60 and local PostgreSQL 16 checks are historical source-development evidence, not staging evidence. Current frontend production d9c1d17fc5fcbf233d2362f885f370509aa635ce and Railway deployment 9f9ef063-8c94-4983-9624-ed4726f072b9 were inspected, but neither is this local checkpoint candidate and the rendered public contract differs from candidate source. Variable names only were audited: Vercel retains backend-only database/migration and obsolete generic API-key names, while Railway retains the retired shared administrative and generic API-key names. Values were not read. Promotion remains blocked until current-main integration hard-fails graph/overlay BFF aliases, explicitly defers remediation workers/UI with product/compliance signoff, binds immutable candidate deployments, migrates callers, revokes and removes retired credentials, corrects the environment boundary on a new deployment, and passes every required staging and production check." + "notes": "Source candidate only. The review-driven account-erasure corrections, destructive-database guard, and corrected staging proof are locally verified; independent staging-spec and repaired-P2 code-quality/security re-reviews are approved. Current-main integration remains pending. Alembic revision b71d9e4c2a60 and local PostgreSQL 16 checks are historical source-development evidence, not staging evidence. Current frontend production d9c1d17fc5fcbf233d2362f885f370509aa635ce and Railway deployment 9f9ef063-8c94-4983-9624-ed4726f072b9 were inspected, but neither is this local checkpoint candidate and the rendered public contract differs from candidate source. Variable names only were audited: Vercel retains backend-only database/migration and obsolete generic API-key names, while Railway retains the retired shared administrative and generic API-key names. Values were not read. Promotion remains blocked until current-main integration hard-fails graph/overlay BFF aliases, explicitly defers remediation workers/UI with product/compliance signoff, binds immutable candidate deployments, migrates callers, revokes and removes retired credentials, corrects the environment boundary on a new deployment, and passes every required staging and production check. The sandbox router was promoted into the production allowlist after this record's verification date by #3825 (stateless /api/v1/sandbox/evaluate and /trace only; the persisted share endpoints stayed unpromoted under the separate sandbox_share owner). It is listed in enabled_routers because the release-evidence validator requires that field to match PRODUCTION_ENABLED_ROUTERS exactly, but it was NOT exercised by the 2026-08-26 verification recorded here; its evidence is the promotion-gate suite in services/ingestion/tests/test_sandbox_production_promotion_3825.py." } diff --git a/services/ingestion/main.py b/services/ingestion/main.py index 33bac3eb3..5da1c981b 100644 --- a/services/ingestion/main.py +++ b/services/ingestion/main.py @@ -216,6 +216,7 @@ async def lifespan(app: FastAPI): "readiness", "incidents", "sandbox", + "sandbox_share", "inflow_workbench", "supplier_validation", "disaster_recovery", @@ -351,6 +352,14 @@ async def list_enabled_features(api_key: APIKey = Depends(require_api_key)): app.include_router(sandbox_router) +# #3825: persisted sandbox share links, split out of the evaluator above so the +# stateless surface could be promoted into the production allowlist without +# them. Mirrors server/main.py — keep the two mount sites in sync. +if _router_enabled("sandbox_share"): + from app.sandbox.share_router import router as sandbox_share_router + + app.include_router(sandbox_share_router) + # Inflow Lab Workbench (scenarios, saved runs, fix queue, commit gates) if _router_enabled("inflow_workbench"): from app.inflow_workbench import router as inflow_workbench_router