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/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/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/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 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",