diff --git a/Dockerfile b/Dockerfile index fb755a3f16b9878d5b419d1c2463994a32f6d669..894e59a355bf4a5cb4898457e12ab1e405dbd120 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,64 +1,7 @@ -# syntax=docker/dockerfile:1.2 -# ---- deps stage: needs git + a credentialed clone of the private ARF repos -# (agentic_reliability_framework, ARF-Bayesian-Pricing-Calculator). -# This stage is discarded after build -- the credential never reaches -# the final image's layers, env, or git config. ---- -# -# GH_PAT is read via a BuildKit secret mount, not `ARG` -- an ARG's value is -# printed in plaintext as part of the logged RUN command that uses it (this -# is exactly how a real, live token ended up visible in a Render deploy log -# this session). A secret mount's value is never written to a log line or -# an image layer. REQUIRES a matching setup step in Render's dashboard -# before this will build: Render's Docker service settings -> Secret Files -# -> add a file named exactly `gh_pat` containing the token value (nothing -# else in the file). The old `GH_PAT` environment variable is no longer -# read by this Dockerfile and can be removed once this is confirmed working. -FROM python:3.12-slim AS deps +FROM python:3.12-slim RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* -RUN --mount=type=secret,id=gh_pat,dst=/etc/secrets/gh_pat \ - git config --global url."https://$(cat /etc/secrets/gh_pat)@github.com/".insteadOf "https://github.com/" -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY requirements.txt . -# torch has no explicit pin anywhere in this dependency tree -- it's pulled in -# transitively by sentence-transformers (for agentic_reliability_framework's -# RAG/semantic-memory features) and, left to the default PyPI index, resolves -# to the CUDA-enabled build (nvidia-cusparselt, cuda-toolkit, nvidia-nccl, ...) -# even though this service runs on CPU-only Render instances. That variant's -# extra weight is a real contributor to out-of-memory deploy failures. -# -# A separate `pip install torch==... --index-url .../cpu` RUN before this one -# does NOT work: it's a distinct resolve that only knows about the CPU wheel; -# the very next `pip install -r requirements.txt`, seeing no --index-url, only -# has the default PyPI index in view and re-resolves torch from there, -# silently replacing the CPU build with the CUDA one at the same version -# number (confirmed happening in a real deploy -- final `pip install` log -# showed plain `torch-2.13.0` plus the full nvidia/cuda-toolkit/triton stack, -# not `torch-2.13.0+cpu`). Putting torch and -r requirements.txt in one -# `pip install` call, with the CPU wheelhouse as the primary --index-url and -# PyPI as --extra-index-url, makes it a single resolve: torch is satisfied -# from the CPU index and nothing later re-derives a different build for it. -# Version pinned to 2.13.0 to match exactly what pip's resolver already chose -# for this dependency tree (confirmed available on the CPU index for -# cp312/manylinux before pinning it here, not assumed). -RUN pip install --no-cache-dir \ - --index-url https://download.pytorch.org/whl/cpu \ - --extra-index-url https://pypi.org/simple \ - torch==2.13.0 \ - -r requirements.txt - -# ---- final stage: just the built venv + app code, no git, no credential ---- -FROM python:3.12-slim -COPY --from=deps /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" -WORKDIR /app +RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Shell form (not exec/JSON-array form) deliberately -- ${PORT:-7860} only -# expands with a real shell interpreting the command; exec form passes -# arguments literally with no variable substitution at all. Render injects -# PORT and expects the app to bind to it (its deploy log explicitly failed -# port-scanning for it: "Bind your service to at least one port"); the -# Hugging Face Space mirror sets no such variable and expects the -# conventional default, 7860. One image, correct on both targets. -CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index ce90db5b8d99331ff03c1488773486b6639b76ad..763527794b991f73ea36af5ad16dca3552a37f72 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,16 @@ ---- -title: ARF API -emoji: 🛡️ -colorFrom: blue -colorTo: gray -sdk: docker -pinned: false ---- - # arf-api ARF API Control Plane (FastAPI) ## Live Demo -**Render is the primary deployment target** (custom domain, real scaling, standard secrets -management -- the multi-stage Docker build in this repo was purpose-built for it). The Hugging -Face Space below is a secondary, publicly-browsable mirror of the same code, not the primary -integration target -- point real pilot/customer integrations at Render once its URL is -confirmed live, not at the Space URL. - -- **HF Space (public mirror)**: [https://arf-ai-agentic-reliability-framework-api.hf.space](https://arf-ai-agentic-reliability-framework-api.hf.space) -- **Interactive Documentation**: [https://arf-ai-agentic-reliability-framework-api.hf.space/docs](https://arf-ai-agentic-reliability-framework-api.hf.space/docs) +The API is deployed and accessible at: +- **Base URL**: [https://a-r-f-agentic-reliability-framework-api.hf.space](https://a-r-f-agentic-reliability-framework-api.hf.space) +- **Interactive Documentation**: [https://a-r-f-agentic-reliability-framework-api.hf.space/docs](https://a-r-f-agentic-reliability-framework-api.hf.space/docs) ## Quick Start (Local Development) 1. **Install dependencies**: - ```bash pip install -r requirements.txt ``` @@ -39,11 +24,9 @@ ARF_HMC_MODEL – path to HMC model JSON (default: models/hmc_model.json) ARF_USE_HYPERPRIORS – true/false -API_KEY – dead setting, not read by any current route (see docs/authentication.md) +API_KEY – optional (currently not enforced) ``` -The settings that actually gate access are `ARF_INTERNAL_API_KEY` and `ARF_ADMIN_API_KEY`, not `API_KEY` above — see [docs/authentication.md](docs/authentication.md) for what's actually enforced and what isn't yet. - 3. **Run the app locally**: ```bash @@ -106,7 +89,9 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content- "effect": -90, "explanation_text": "If we apply restart_container instead of no_action, latency would change from 600.00 to 510.00 (Δ = -90.00). Based on heuristic causal model.", "is_model_based": false, - "warnings": ["Using heuristic causal model (no fitted SCM)."] + "warnings": [ + "Using heuristic causal model (no fitted SCM)." + ] }, "utility_decision": { "best_action": "restart_container", @@ -126,10 +111,11 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content- Tests ----- -Run `pytest`. Tests run against a live Postgres connection (`tests/conftest.py`), matching CI's `postgres` service — not a temporary SQLite DB. +Run `pytest`. Tests use a temporary SQLite DB (`sqlite:///./test.db`) created by the test fixtures. Notes ----- - The governance endpoints use an in-process `RiskEngine` initialized at startup. -- Outcomes are recorded via `POST /api/v1/intents/outcome` (`app/api/routes_governance.py`), tenant-scoped and auth-protected. +- The outcome recording endpoint is not implemented in this repository and returns HTTP 501. + diff --git a/alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py b/alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py deleted file mode 100644 index d57396a261423d4c4a5c43f023ca70dfe0a3a1b1..0000000000000000000000000000000000000000 --- a/alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py +++ /dev/null @@ -1,58 +0,0 @@ -"""create api_keys table (moves API key storage off ephemeral SQLite) - -api_keys previously lived only in a SQLite file per service (arf_usage.db), -which is wiped on every Render deploy/restart on the Free plan and was also -independently duplicated between arf-api and arf-gateway. This creates the -durable, single source of truth in Postgres. Rows are hashed at rest -(pepper-HMAC lookup_hash + salted key_hash) -- no plaintext key column, -since there is no pre-pepper data to migrate here (confirmed with the user: -existing SQLite api_keys rows in both services are safe to discard, keys get -reissued via POST /admin/keys). - -Revision ID: a1f3c9d2e6b7 -Revises: d36deffe7fa2 -Create Date: 2026-08-24 00:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'a1f3c9d2e6b7' -down_revision: Union[str, Sequence[str], None] = 'd36deffe7fa2' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # No FK to tenants.id here, deliberately -- the pre-existing SQLite - # schema this replaces never enforced one either (seeded/demo keys via - # ARF_API_KEYS may reference synthetic tenant_ids that don't have a - # tenants row), and adding one now would be a behavior change beyond - # this migration's scope. - op.create_table( - 'api_keys', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('tenant_id', sa.String(length=64), nullable=False), - sa.Column('tier', sa.String(length=32), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('last_used_at', sa.DateTime(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column('salt', sa.String(length=64), nullable=False), - sa.Column('key_hash', sa.String(length=64), nullable=False), - sa.Column('lookup_hash', sa.String(length=64), nullable=False), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False) - op.create_unique_constraint('uq_api_keys_lookup_hash', 'api_keys', ['lookup_hash']) - - -def downgrade() -> None: - """Downgrade schema.""" - op.drop_constraint('uq_api_keys_lookup_hash', 'api_keys', type_='unique') - op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys') - op.drop_table('api_keys') diff --git a/alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py b/alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py deleted file mode 100644 index 8d91e26b9a8b2bd3e49709cab91f5be21efa9cd1..0000000000000000000000000000000000000000 --- a/alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py +++ /dev/null @@ -1,62 +0,0 @@ -"""create onchain_rationales table - -Stores the plaintext preimage of an anchored `RiskAttestation.rationale_hash` -(arf-onchain's RiskAttestationRegistry / enterprise's -arf_enterprise.onchain.attestation). The chain only ever holds the hash -- -see models_onchain.py's module docstring for why the text has to live here, -keyed by that same hash, or the anchored hash is unverifiable. - -Revision ID: e4a7c1f9b3d2 -Revises: a1f3c9d2e6b7 -Create Date: 2026-09-07 00:00:00.000000 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "e4a7c1f9b3d2" -down_revision: Union[str, Sequence[str], None] = "a1f3c9d2e6b7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - op.create_table( - "onchain_rationales", - sa.Column("id", sa.String(length=64), nullable=False), - sa.Column("rationale_hash", sa.String(length=66), nullable=False), - sa.Column("rationale", sa.Text(), nullable=False), - sa.Column("agent_address", sa.String(length=42), nullable=True), - sa.Column("evaluator_address", sa.String(length=42), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_onchain_rationales_rationale_hash"), - "onchain_rationales", - ["rationale_hash"], - unique=True, - ) - op.create_index( - op.f("ix_onchain_rationales_created_at"), - "onchain_rationales", - ["created_at"], - unique=False, - ) - - -def downgrade() -> None: - """Downgrade schema.""" - op.drop_index( - op.f("ix_onchain_rationales_created_at"), table_name="onchain_rationales" - ) - op.drop_index( - op.f("ix_onchain_rationales_rationale_hash"), table_name="onchain_rationales" - ) - op.drop_table("onchain_rationales") diff --git a/app/api/deps.py b/app/api/deps.py index eada7dc5990063d07fedc82dd7c65ef40e8c16c5..11641a694a66750c3bc29a2acef618c658050c82 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -3,23 +3,17 @@ Dependency injection module for the ARF Agentic Reliability Framework API. Provides FastAPI dependencies for database sessions, rate limiting, and singleton instances of the core ARF engines (RiskEngine, DecisionEngine, -LyapunovStabilityController, CausalEffectEstimator, RAGGraphMemory, and -(v4.3.1) SkillRegistry). All engine dependencies are lazily initialised -and cached for the lifetime of the application process. - -v4.3.2: Added verify_internal_key dependency to secure direct API access. +LyapunovStabilityController, CausalEffectEstimator, and RAGGraphMemory). +All engine dependencies are lazily initialised and cached for the lifetime +of the application process. """ -import os import sys -from typing import Optional from app.database.session import SessionLocal from slowapi import Limiter from slowapi.util import get_remote_address from app.core.config import settings -from fastapi import Header, HTTPException - # ARF core engine imports from agentic_reliability_framework.core.governance.risk_engine import RiskEngine from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine @@ -28,14 +22,6 @@ from agentic_reliability_framework.core.governance.causal_effect_estimator impor from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction -# ── v4.3.1: Skill Registry (optional) ────────────────────────── -try: - from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry - _SKILL_REGISTRY_AVAILABLE = True -except ImportError: - SkillRegistry = None - _SKILL_REGISTRY_AVAILABLE = False - # --------------------------------------------------------------------------- # Database dependency @@ -64,45 +50,6 @@ limiter = Limiter( ) -# --------------------------------------------------------------------------- -# Internal API key verification (v4.3.2) -# --------------------------------------------------------------------------- - -INTERNAL_API_KEY = os.getenv("ARF_INTERNAL_API_KEY", "") - - -async def verify_internal_key(x_internal_key: str = Header(default=None, alias="X-Internal-Key")): - """ - FastAPI dependency that verifies the internal API key header. - - The request must include an X‑Internal‑Key header matching - ARF_INTERNAL_API_KEY. This fails closed: if ARF_INTERNAL_API_KEY is not - configured, every request is rejected with 401 rather than being let - through unauthenticated. - - This guards against direct access to the API when deployed behind - the Go gateway. The gateway is configured to inject this header - for authenticated requests. - """ - if not INTERNAL_API_KEY: - raise HTTPException(status_code=401, detail="Internal API key is not configured") - if x_internal_key is None: - raise HTTPException(status_code=401, detail="Missing internal API key") - # Use a constant‑time comparison to avoid timing attacks. - if not _constant_time_compare(x_internal_key, INTERNAL_API_KEY): - raise HTTPException(status_code=401, detail="Invalid internal API key") - - -def _constant_time_compare(a: str, b: str) -> bool: - """Compare two strings in constant time to prevent timing attacks.""" - if len(a) != len(b): - return False - result = 0 - for x, y in zip(a, b): - result |= ord(x) ^ ord(y) - return result == 0 - - # --------------------------------------------------------------------------- # Singleton engine instances (lazy, cached) # --------------------------------------------------------------------------- @@ -112,7 +59,6 @@ _decision_engine = None _stability_controller = None _causal_explainer = None _rag_graph = None -_skill_registry = None def _seed_rag_graph(rag: RAGGraphMemory) -> None: @@ -211,20 +157,3 @@ def get_causal_explainer() -> CausalEffectEstimator: if _causal_explainer is None: _causal_explainer = CausalEffectEstimator() return _causal_explainer - - -def get_skill_registry() -> "Optional[SkillRegistry]": - """ - Return a singleton SkillRegistry instance (v4.3.1). - - The registry manages procedural skill artefacts, versioning, per‑skill - reliability models (Beta‑Binomial), and the COLLECT‑DIAGNOSE‑REVISE‑PROMOTE - evolution loop. If the SkillRegistry module is not installed, returns None. - """ - global _skill_registry - if not _SKILL_REGISTRY_AVAILABLE: - return None - if _skill_registry is None: - from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry - _skill_registry = SkillRegistry() - return _skill_registry diff --git a/app/api/routes_admin.py b/app/api/routes_admin.py index 089a06acb47411dd999643b7551ecf964b93d724..cf38fedd25cf3ec152696b72e65fc67a427093ba 100644 --- a/app/api/routes_admin.py +++ b/app/api/routes_admin.py @@ -2,37 +2,26 @@ Admin API endpoints for API key management and audit logs. These endpoints should be protected (e.g., by an admin API key) in production. """ -from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body from pydantic import BaseModel from typing import Optional from datetime import datetime -import os -import secrets import uuid -from sqlalchemy.orm import Session -from app.api.deps import get_db -from app.core import usage_tracker -from app.core.usage_tracker import Tier -from app.database.models_intents import TenantDB +from app.core.usage_tracker import tracker, Tier router = APIRouter(prefix="/admin", tags=["admin"]) -# Admin key must be supplied via environment; there is no default. Fail closed -# if it is not configured, rather than falling back to a guessable secret. -ADMIN_API_KEY = os.getenv("ARF_ADMIN_API_KEY") +# Simple in‑memory admin key (replace with proper auth in production) +ADMIN_API_KEY = "admin_secret_change_me" def verify_admin(admin_key: str = Query(..., alias="admin_key")): - if not ADMIN_API_KEY: - raise HTTPException(status_code=403, detail="Admin API is not configured") - if not secrets.compare_digest(admin_key, ADMIN_API_KEY): + if admin_key != ADMIN_API_KEY: raise HTTPException(status_code=403, detail="Invalid admin key") return True class CreateKeyRequest(BaseModel): tier: str - tenant_id: Optional[str] = None # attach to an existing tenant; omit to create a new one - org_name: Optional[str] = None # used only when creating a new tenant class UpdateTierRequest(BaseModel): @@ -40,146 +29,78 @@ class UpdateTierRequest(BaseModel): @router.post("/keys", dependencies=[Depends(verify_admin)]) -async def create_api_key(req: CreateKeyRequest, db: Session = Depends(get_db)): - # Previously called get_or_create_api_key(new_key, tier_enum) -- since - # that function's signature is (key, tenant_id, tier=FREE), tier_enum - # was silently accepted as tenant_id and every key of the same tier - # collided onto one bogus tenant_id (e.g. every "free" key sharing - # tenant_id="free"). tenant_id gates real per-tenant isolation - # elsewhere (BetaStateDB, IntentDB, decision audit log), so this was a - # cross-tenant data bug, not just a mislabeled field. +async def create_api_key(req: CreateKeyRequest): if req.tier not in [t.value for t in Tier]: raise HTTPException( status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}") - tier_enum = Tier(req.tier) - - tenant_id = req.tenant_id - if tenant_id: - if not db.query(TenantDB).filter(TenantDB.id == tenant_id).first(): - raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found") - else: - tenant_id = str(uuid.uuid4()) - db.add(TenantDB( - id=tenant_id, - name=req.org_name or "Default Organization", - created_at=datetime.utcnow(), - created_by="admin", - )) - db.commit() - new_key = f"sk_live_{uuid.uuid4().hex[:24]}" - usage_tracker.tracker.get_or_create_api_key(new_key, tenant_id=tenant_id, tier=tier_enum) - return {"api_key": new_key, "tenant_id": tenant_id, "tier": req.tier} + tier_enum = Tier(req.tier) + tracker.get_or_create_api_key(new_key, tier_enum) + return {"api_key": new_key, "tier": req.tier} -@router.get("/keys", dependencies=[Depends(verify_admin)]) async def list_api_keys(limit: int = 100, offset: int = 0): - """Lists keys by a non-secret `key_id` (the key's pepper-HMAC lookup - hash), never the plaintext key -- there is no plaintext key to show - since the H-2 fix (api_keys are hashed at rest). Use `key_id` in the - tier/deactivate endpoints below. `current_month_usage` is not shown - here: `monthly_counts` is intentionally still keyed by the raw API key - (arf-gateway depends on reading it that way), which this endpoint no - longer has -- query `/admin/keys/{api_key}/audit` with the real key for - per-key usage/audit history instead. - """ - with usage_tracker.tracker._get_pg_conn() as conn: - rows = usage_tracker.tracker._pg_execute( - conn, - "SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys " - "ORDER BY created_at DESC LIMIT %s OFFSET %s", + with tracker._get_conn() as conn: + rows = conn.execute( + "SELECT key, tier, created_at, last_used_at, is_active FROM api_keys ORDER BY created_at DESC LIMIT ? OFFSET ?", # noqa: E501 (limit, offset) - ).fetchall() - conn.commit() - keys = [ - { - "key_id": row["lookup_hash"], - "tier": row["tier"], - "created_at": row["created_at"].isoformat(), - "last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None, - "is_active": bool(row["is_active"]), - } - for row in rows - ] + ).fetchall() # noqa: E501 + keys = [] + for row in rows: + month = tracker._get_month_key() + usage_row = conn.execute( + "SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?", + (row["key"], month) + ).fetchone() + usage = usage_row["count"] if usage_row else 0 + keys.append( + { + "key": row["key"], + "tier": row["tier"], + "created_at": datetime.fromtimestamp( + row["created_at"]).isoformat(), + "last_used_at": datetime.fromtimestamp( + row["last_used_at"]).isoformat() if row["last_used_at"] else None, + "is_active": bool( + row["is_active"]), + "current_month_usage": usage, + }) return {"keys": keys, "total": len(keys)} -@router.patch("/keys/{key_id}/tier", dependencies=[Depends(verify_admin)]) +@router.patch("/keys/{api_key}/tier", dependencies=[Depends(verify_admin)]) async def update_key_tier( - key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"), + api_key: str = Path(..., description="The API key to update"), req: UpdateTierRequest = Body(...), ): if req.tier not in [t.value for t in Tier]: raise HTTPException( status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}") - with usage_tracker.tracker._get_pg_conn() as conn: - row = usage_tracker.tracker._pg_execute( - conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone() + with tracker._get_conn() as conn: + row = conn.execute( + "SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone() if not row: - conn.rollback() raise HTTPException(status_code=404, detail="API key not found") - usage_tracker.tracker._pg_execute( - conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s", (req.tier, key_id)) + conn.execute("UPDATE api_keys SET tier = ? WHERE key = ?", + (req.tier, api_key)) conn.commit() return {"message": f"Tier updated to {req.tier}"} -@router.post("/keys/{key_id}/rotate", dependencies=[Depends(verify_admin)]) -async def rotate_api_key( - key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"), -): - """Atomically deactivate a key and issue a new one on the same tenant - and tier -- the single action a leaked/compromised key actually needs. - Doing this as get-old + deactivate + create separately (the only option - before this endpoint existed) risks losing track of the tenant_id - partway through, or leaving the old key active if a later step fails. - The new plaintext key is returned exactly once, like create_api_key's.""" - with usage_tracker.tracker._get_pg_conn() as conn: - old_row = usage_tracker.tracker._pg_execute( - conn, "SELECT tenant_id, tier FROM api_keys WHERE lookup_hash = %s", (key_id,) - ).fetchone() - if not old_row: - conn.rollback() - raise HTTPException(status_code=404, detail="API key not found") - - new_key = f"sk_live_{uuid.uuid4().hex[:24]}" - salt = secrets.token_hex(16) - usage_tracker.tracker._pg_execute( - conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,)) - usage_tracker.tracker._pg_execute( - conn, - "INSERT INTO api_keys " - "(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) " - "VALUES (%s, %s, %s, %s, %s, %s, %s)", - (old_row["tenant_id"], old_row["tier"], datetime.utcnow(), True, - salt, usage_tracker.tracker._salted_hash(new_key, salt), usage_tracker.tracker._lookup_hash(new_key)), - ) - conn.commit() - - return { - "api_key": new_key, - "tenant_id": old_row["tenant_id"], - "tier": old_row["tier"], - "deactivated_key_id": key_id, - } - - -@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)]) +@router.delete("/keys/{api_key}", dependencies=[Depends(verify_admin)]) async def deactivate_api_key( - key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)")): - with usage_tracker.tracker._get_pg_conn() as conn: - row = usage_tracker.tracker._pg_execute( - conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (key_id,)).fetchone() + api_key: str = Path(..., description="The API key to deactivate")): + with tracker._get_conn() as conn: + row = conn.execute( + "SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone() if not row: - conn.rollback() raise HTTPException(status_code=404, detail="API key not found") - usage_tracker.tracker._pg_execute( - conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,)) + conn.execute( + "UPDATE api_keys SET is_active = 0 WHERE key = ?", (api_key,)) conn.commit() return {"message": "API key deactivated"} -@router.get("/keys/{api_key}/audit", dependencies=[Depends(verify_admin)]) async def get_audit_logs( api_key: str = Path(..., description="The API key to audit"), start_date: Optional[str] = Query(None), @@ -188,23 +109,20 @@ async def get_audit_logs( ): start = datetime.fromisoformat(start_date) if start_date else None end = datetime.fromisoformat(end_date) if end_date else None - logs = usage_tracker.tracker.get_audit_logs(api_key, start, end, limit) + logs = tracker.get_audit_logs(api_key, start, end, limit) return {"api_key": api_key, "logs": logs} -@router.get("/stats", dependencies=[Depends(verify_admin)]) async def get_global_stats(): - with usage_tracker.tracker._get_pg_conn() as pg_conn: - total_keys = usage_tracker.tracker._pg_execute( - pg_conn, "SELECT COUNT(*) FROM api_keys WHERE is_active = true").fetchone()["count"] - pg_conn.commit() - with usage_tracker.tracker._get_conn() as conn: + with tracker._get_conn() as conn: + total_keys = conn.execute( + "SELECT COUNT(*) FROM api_keys WHERE is_active = 1").fetchone()[0] total_requests = conn.execute( "SELECT COUNT(*) FROM usage_log").fetchone()[0] by_tier = conn.execute( "SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier" ).fetchall() - month = usage_tracker.tracker._get_month_key() + month = tracker._get_month_key() current_month_requests = conn.execute( "SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,) ).fetchone()[0] or 0 @@ -214,74 +132,3 @@ async def get_global_stats(): "current_month_evaluations": current_month_requests, "by_tier": [{"tier": row[0], "count": row[1]} for row in by_tier], } - - -# --------------------------------------------------------------------------- -# Enterprise execution approvals (v4.3.4, opt-in -- see routes_governance.py's -# POST /intents/{id}/execute). Without these, the durable approval ledger has -# no way to actually be resolved through the API at all -- present but -# unusable. app.state.approval_store is None whenever ARF_ENABLE_EXECUTION -# is unset/false or arf_enterprise isn't installed (see main.py lifespan). -# --------------------------------------------------------------------------- - -class ResolveApprovalRequest(BaseModel): - approved: bool - note: Optional[str] = None - - -def _require_approval_store(request: Request): - approval_store = getattr(request.app.state, "approval_store", None) - if approval_store is None: - raise HTTPException( - status_code=501, - detail="Enterprise execution approvals are not enabled on this deployment " - "(ARF_ENABLE_EXECUTION unset, or arf_enterprise is not installed)", - ) - return approval_store - - -@router.get("/executions/pending", dependencies=[Depends(verify_admin)]) -async def list_pending_executions(request: Request, limit: int = 100, offset: int = 0): - approval_store = _require_approval_store(request) - pending = approval_store.list_pending(limit=limit, offset=offset) - return { - "pending": [ - { - "approval_id": r.id, - "decision_id": r.decision_id, - "intent_id": r.intent_id, - "level": r.level, - "approval_required": r.approval_required, - "requested_at": r.requested_at.isoformat(), - } - for r in pending - ], - "total": len(pending), - } - - -@router.post("/executions/{approval_id}/resolve", dependencies=[Depends(verify_admin)]) -async def resolve_execution_approval( - request: Request, - req: ResolveApprovalRequest, - approval_id: str = Path(..., description="The approval_id from POST /intents/{id}/execute's 202 response"), -): - # resolved_by is a fixed "admin" constant, not derived from admin_key in - # any way -- even a prefix of that secret has no business being - # persisted into a database row a GET endpoint can read back. Matches - # create_api_key's existing created_by="admin" pattern above: this - # codebase has one shared admin credential, not per-admin identity, so - # there's nothing more specific to record. - approval_store = _require_approval_store(request) - resolved = approval_store.resolve( - approval_id, approved=req.approved, resolved_by="admin", note=req.note - ) - if not resolved: - raise HTTPException( - status_code=404, - detail="Approval not found or already resolved", - ) - return { - "message": f"Approval {'approved' if req.approved else 'rejected'}", - "approval_id": approval_id, - } diff --git a/app/api/routes_governance.py b/app/api/routes_governance.py index 5a16c0a36b905310f60a6d3ffe2e33c8dc36271c..3e910314ca12a5353c703001e3a97a6bd7a887b0 100644 --- a/app/api/routes_governance.py +++ b/app/api/routes_governance.py @@ -5,49 +5,31 @@ This module provides the primary API endpoints for evaluating infrastructure intents and healing decisions. It integrates: - Idempotent quota consumption (usage tracker) -- Tenant isolation (tenant_id resolved server-side from the authenticated API key - via the ``enforce_quota`` dependency; never taken from a client-supplied header) +- Tenant isolation (tenant_id from request.state) - Auditable decision logging (DecisionAuditLogDB) - Pricing telemetry (optional, to arf‑pricing‑calculator) - OpenTelemetry tracing - Optional Rust execution ladder for mechanical enforcement -- **v4.3.1**: Full governance loop produces a Bayesian HealingIntent with skill - posterior parameters (α, β) for the enterprise SkillGate. - Includes persistent stability controller and temporal monitor for - cross‑request state accumulation, and a merging policy evaluator that - respects both external and internal policy violations. - Healing endpoint now optionally accepts skill context for Bayesian - utility‑aware action selection. -- **v4.3.2**: Passes criticality parameter for dynamic gate tuning (Feature 3). - Internal API key verification added to secure direct access. """ from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from pydantic import BaseModel import uuid import logging -import os import time -import datetime -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any from app.models.infrastructure_intents import InfrastructureIntentRequest from app.services.intent_adapter import to_oss_intent -from app.services.risk_service import evaluate_intent_full, evaluate_healing_decision +from app.services.risk_service import evaluate_intent, evaluate_healing_decision from app.services.intent_store import save_evaluated_intent from app.services.outcome_service import record_outcome -from app.api.deps import get_db, get_skill_registry, verify_internal_key # <-- v4.3.2 -from app.database.session import SessionLocal -from app.core.usage_tracker import enforce_quota # <-- tenant resolution -from app.database.models_intents import DecisionAuditLogDB, IntentDB +from app.api.deps import get_db +from app.database.models_intents import DecisionAuditLogDB, TenantDB # <-- NEW from agentic_reliability_framework.core.models.event import ReliabilityEvent -from agentic_reliability_framework.core.governance.policies import ( - PolicyEvaluator, - allow_all, -) +from agentic_reliability_framework.core.governance.healing_intent import HealingIntent # ===== USAGE TRACKER ===== import app.core.usage_tracker @@ -69,57 +51,6 @@ except ImportError: RUST_AVAILABLE = False ExecutionLadder = None -# ===== ENTERPRISE EXECUTOR (optional) ===== -# `arf_enterprise` is not in requirements.txt -- it's a proprietary, -# private-repo package, and unlike agentic_reliability_framework/ -# arf-pricing-calculator (plain git+https URLs with no visible credential -# setup in the Dockerfile) I can't confirm Render's build can actually -# clone it. Stays fully optional/try-except-guarded, same shape as -# RUST_AVAILABLE above, deliberately not added as a hard dependency this -# session -- see POST /intents/{id}/execute below and .env.example for what -# an operator needs to do to actually turn this on. -try: - from arf_enterprise.executor import EnterpriseExecutor - from arf_enterprise.actuators.fake import FakeCloudActuator - from arf_enterprise.config import EnterpriseConfig - from arf_enterprise.store import ApprovalStore, PostgresStore - from arf_enterprise.exceptions import ( - ExecutionError as EnterpriseExecutionError, - PendingApprovalError, - SafetyError as EnterpriseSafetyError, - ) - ENTERPRISE_EXECUTOR_AVAILABLE = True -except ImportError: - ENTERPRISE_EXECUTOR_AVAILABLE = False - EnterpriseExecutor = None - FakeCloudActuator = None - EnterpriseConfig = None - ApprovalStore = None - PostgresStore = None - EnterpriseExecutionError = None - PendingApprovalError = None - EnterpriseSafetyError = None - - -def _trusted_signing_keys() -> List[str]: - """Parse ARF_TRUSTED_SIGNING_KEYS into a list of hex fingerprints. - - Comma-separated, matching EnterpriseConfig.from_env's own parsing. - Splitting matters: the variable holds N fingerprints, and passing the - raw string as a single-element list would register the literal - "abc,def" as one key -- so neither real key would be trusted, and an - unset variable would register the empty string as trusted rather than - trusting nothing. - - An empty result is the correct fail-closed state: the ladder then - rejects every signed intent, which is loud and safe. - """ - raw = os.getenv("ARF_TRUSTED_SIGNING_KEYS", "") - return [k.strip() for k in raw.split(",") if k.strip()] - - -ARF_ENABLE_EXECUTION = os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true" - # ===== OPEN TELEMETRY ===== try: from opentelemetry import trace @@ -131,9 +62,7 @@ except ImportError: _tracer = None logger = logging.getLogger(__name__) - -# v4.3.2: protect all governance endpoints with internal API key verification -router = APIRouter(dependencies=[Depends(verify_internal_key)]) +router = APIRouter() class OutcomeRequest(BaseModel): @@ -141,38 +70,17 @@ class OutcomeRequest(BaseModel): success: bool recorded_by: str notes: str = "" - # v4.3.1: optional skill provenance for reliability feedback - skill_id: Optional[str] = None - skill_version: Optional[int] = None - - -class ExecuteIntentRequest(BaseModel): - """The client already received `healing_intent` in /intents/evaluate's - response -- resubmitting it here (rather than this endpoint trying to - reconstruct a signed, action/component/parameters-bearing intent from - IntentDB's stored oss_payload, which doesn't carry the signature) is - the cheapest correct design; IntentDB is used only to confirm the - intent exists and belongs to the caller's tenant.""" - healing_intent: Dict[str, Any] - human_approved: bool = False - admin_approved: bool = False - # v4.3.1: optional skill provenance, forwarded to record_outcome the - # same way OutcomeRequest already does. - skill_id: Optional[str] = None - skill_version: Optional[int] = None class HealingDecisionRequest(BaseModel): event: ReliabilityEvent - # v4.3.1: optional skill context for Bayesian utility - skill_id: Optional[str] = None - skill_version: Optional[int] = None # -------------------------------------------------------------------------- # Helper: write audit log (idempotent) # -------------------------------------------------------------------------- async def write_audit_log( + db: Session, tenant_id: str, deterministic_id: str, healing_intent: Dict[str, Any], @@ -182,90 +90,67 @@ async def write_audit_log( """ Store a governance decision in the immutable audit log. Idempotent on (tenant_id, deterministic_id) – if already exists, skip. - - Runs as a BackgroundTask, which executes after the response has already - been sent -- and after FastAPI has already torn down the request's - `Depends(get_db)` session. Reusing that session here would mean every - query silently reopens a fresh connection/transaction that nothing then - guarantees gets closed (least of all on the idempotent-skip path below, - which used to return with no commit/rollback/close at all), leaving an - idle-in-transaction connection that blocks any later DDL against these - tables (e.g. test teardown's `Base.metadata.drop_all()`) indefinitely. - Owning and closing our own session here avoids that entirely. """ - db = SessionLocal() - try: - # Check if already logged (idempotency) - existing = db.query(DecisionAuditLogDB).filter( - DecisionAuditLogDB.tenant_id == tenant_id, - DecisionAuditLogDB.deterministic_id == deterministic_id - ).first() - if existing: - logger.info(f"Audit log already exists for {deterministic_id}, skipping.") - return - - # Extract fields that are actually present in DecisionAuditLogDB - risk_score = healing_intent.get("risk_score", 0.5) - action = healing_intent.get("recommended_action", "deny") - justification = healing_intent.get("justification", "") - metadata = healing_intent.get("metadata", {}) - memory_success_rate = metadata.get("memory_success_rate") - memory_weight = metadata.get("memory_weight") - counterfactual = metadata.get("counterfactual") - - audit_entry = DecisionAuditLogDB( - tenant_id=tenant_id, - deterministic_id=deterministic_id, - timestamp=datetime.datetime.utcnow(), - risk_score=risk_score, - action=action, - justification=justification, - memory_success_rate=memory_success_rate, - memory_weight=memory_weight, - counterfactual=counterfactual, - trace_id=trace_id, - ) - db.add(audit_entry) - db.commit() - logger.info(f"Audit log written for {deterministic_id}") - finally: - db.close() - - -# -------------------------------------------------------------------------- -# Policy evaluator that merges external violations with internal checks -# -------------------------------------------------------------------------- -class MergingPolicyEvaluator(PolicyEvaluator): - """ - A policy evaluator that combines a base evaluator (the governance loop's - own policy tree) with a set of pre‑computed violations (e.g., from an - external Rust enforcer or the request body). The effective violation list - is the union of both sources, preserving order and removing duplicates. - """ - def __init__(self, base_evaluator: PolicyEvaluator, pre_violations: List[str]): - # We must call the PolicyEvaluator constructor with a root policy, - # but the base evaluator will be used for actual evaluation. - super().__init__(base_evaluator.get_root_policy()) - self._base = base_evaluator - self._pre = list(pre_violations) - - def evaluate(self, intent, context=None): - base_violations = self._base.evaluate(intent, context) - # Merge with pre‑computed violations, preserving order and removing duplicates - merged = [] - seen = set() - for v in self._pre: - if v not in seen: - merged.append(v) - seen.add(v) - for v in base_violations: - if v not in seen: - merged.append(v) - seen.add(v) - return merged - - def get_root_policy(self): - return self._base.get_root_policy() + # Check if already logged (idempotency) + existing = db.query(DecisionAuditLogDB).filter( + DecisionAuditLogDB.tenant_id == tenant_id, + DecisionAuditLogDB.deterministic_id == deterministic_id + ).first() + if existing: + logger.info(f"Audit log already exists for {deterministic_id}, skipping.") + return + + # Extract fields from HealingIntent (or result dict) + risk_score = healing_intent.get("risk_score", 0.5) + action = healing_intent.get("recommended_action", "deny") # approve/deny/escalate + justification = healing_intent.get("justification", "") + confidence = healing_intent.get("confidence", 0.85) + confidence_dist = healing_intent.get("confidence_distribution", {}) + confidence_lower = confidence_dist.get("p5", confidence - 0.1) + confidence_upper = confidence_dist.get("p95", confidence + 0.1) + cost_projection = healing_intent.get("cost_projection") + policy_violations = healing_intent.get("policy_violations", []) + source = healing_intent.get("source", "advisory_analysis") + parent_intent_id = healing_intent.get("parent_intent_id") + root_intent_id = healing_intent.get("root_intent_id") + ancestor_chain = healing_intent.get("ancestor_chain", []) + + # Memory and causal fields (usually in metadata) + metadata = healing_intent.get("metadata", {}) + memory_success_rate = metadata.get("memory_success_rate") + memory_weight = metadata.get("memory_weight") + counterfactual = metadata.get("counterfactual") + epistemic_uncertainty = metadata.get("epistemic_uncertainty") # could be derived from risk_factors + causal_effect = metadata.get("causal_effect") + + # Build audit entry + audit_entry = DecisionAuditLogDB( + tenant_id=tenant_id, + deterministic_id=deterministic_id, + timestamp=datetime.datetime.utcnow(), + risk_score=risk_score, + action=action, + justification=justification, + recommended_action=action, # same as action for now + confidence=confidence, + confidence_lower=confidence_lower, + confidence_upper=confidence_upper, + memory_success_rate=memory_success_rate, + memory_weight=memory_weight, + counterfactual=counterfactual, + epistemic_uncertainty=epistemic_uncertainty, + causal_effect=causal_effect, + cost_projection=cost_projection, + policy_violations=policy_violations, + source=source, + parent_intent_id=parent_intent_id, + root_intent_id=root_intent_id, + ancestor_chain=ancestor_chain, + trace_id=trace_id, + ) + db.add(audit_entry) + db.commit() + logger.info(f"Audit log written for {deterministic_id}") # -------------------------------------------------------------------------- @@ -278,13 +163,9 @@ async def evaluate_intent_endpoint( background_tasks: BackgroundTasks, db: Session = Depends(get_db), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), - skill_registry=Depends(get_skill_registry), # v4.3.1 - quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key ): """ - Evaluate an infrastructure intent with idempotency, tenant isolation, - full governance loop analysis, Bayesian skill posterior injection, - and optional criticality parameter for dynamic gate tuning (v4.3.2). + Evaluate an infrastructure intent with idempotency, tenant isolation, and audit logging. """ span = None if OTEL_AVAILABLE and _tracer: @@ -293,10 +174,17 @@ async def evaluate_intent_endpoint( span.set_attribute("environment", str(intent_req.environment)) start_time = time.time() - # api_key/tenant_id are resolved server-side by enforce_quota from the - # authenticated principal — never from a client-supplied header. - api_key = quota["api_key"] - tenant_id = quota["tenant_id"] + api_key = request.headers.get("Authorization", "").replace("Bearer ", "") + if not api_key: + api_key = request.query_params.get("api_key", "unknown") + + # Get tenant_id from request.state (set by enforce_quota) + tenant_id = getattr(request.state, "tenant_id", None) + if not tenant_id: + if span: + span.set_status(Status(StatusCode.ERROR, "Missing tenant_id")) + span.end() + raise HTTPException(status_code=403, detail="Tenant not identified") current_tracker = app.core.usage_tracker.tracker if current_tracker is None: @@ -307,7 +195,7 @@ async def evaluate_intent_endpoint( record = UsageRecord( api_key=api_key, - tier=quota["tier"], + tier=None, timestamp=start_time, endpoint="/api/v1/intents/evaluate", request_body=intent_req.model_dump(), @@ -330,71 +218,43 @@ async def evaluate_intent_endpoint( oss_intent = to_oss_intent(intent_req) risk_engine = request.app.state.risk_engine - # Build the base policy evaluator from the app's policy engine (if available) - policy_engine = getattr(request.app.state, "policy_engine", None) - if policy_engine is not None and hasattr(policy_engine, 'root_policy'): - base_evaluator = PolicyEvaluator(policy_engine.root_policy) - else: - base_evaluator = PolicyEvaluator(allow_all()) - - # Wrap it to also include the pre‑computed violations from the request - policy_evaluator = MergingPolicyEvaluator( - base_evaluator, - intent_req.policy_violations - ) - - # Optional components from app state - memory = getattr(request.app.state, "rag_graph", None) - hallucination_probe = getattr(request.app.state, "epistemic_probe", None) - predictive_engine = getattr(request.app.state, "predictive_engine", None) - business_calculator = getattr(request.app.state, "business_calculator", None) - - # Stateful monitors (v4.3.1) - stability_controller = getattr(request.app.state, "stability_controller", None) - temporal_monitor = getattr(request.app.state, "temporal_monitor", None) - - # Run the full governance loop, injecting skill context and criticality if present - result = evaluate_intent_full( + # TODO: Modify risk_service.evaluate_intent to accept tenant_id + # and pass it down to RiskEngine (which will select the correct BetaStore) + result = evaluate_intent( + engine=risk_engine, intent=oss_intent, - risk_engine=risk_engine, - policy_evaluator=policy_evaluator, - memory=memory, - hallucination_probe=hallucination_probe, - predictive_engine=predictive_engine, - business_calculator=business_calculator, - stability_controller=stability_controller, - temporal_monitor=temporal_monitor, - skill_id=intent_req.skill_id, - skill_registry=skill_registry, - tenant_id=tenant_id, - criticality=intent_req.criticality, # v4.3.2 + cost_estimate=intent_req.estimated_cost, + policy_violations=intent_req.policy_violations, + # tenant_id=tenant_id # after modification ) if span: span.set_attribute("risk_score", result["risk_score"]) + span.set_attribute("deterministic_id", str(uuid.uuid4())) - deterministic_id = result.get("deterministic_id", str(uuid.uuid4())) + deterministic_id = str(uuid.uuid4()) api_payload = jsonable_encoder(intent_req.model_dump()) oss_payload = jsonable_encoder(oss_intent.model_dump()) save_evaluated_intent( db=db, deterministic_id=deterministic_id, - tenant_id=tenant_id, intent_type=intent_req.intent_type, api_payload=api_payload, oss_payload=oss_payload, environment=str(intent_req.environment), - risk_score=result["risk_score"], + risk_score=result["risk_score"] ) result["intent_id"] = deterministic_id response_data = result # ---- Write audit log (asynchronously) ---- + # Extract the HealingIntent dictionary from result (if not present, construct minimal) healing_intent_dict = result.get("healing_intent", result) background_tasks.add_task( write_audit_log, + db=db, tenant_id=tenant_id, deterministic_id=deterministic_id, healing_intent=healing_intent_dict, @@ -435,148 +295,11 @@ async def evaluate_intent_endpoint( span.set_status(Status(StatusCode.ERROR, error_msg)) span.record_exception(e) span.end() - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail=error_msg) # -------------------------------------------------------------------------- -# Endpoint: execute a previously evaluated intent (v4.3.4, opt-in) -# -------------------------------------------------------------------------- -@router.post("/intents/{deterministic_id}/execute") -async def execute_intent_endpoint( - request: Request, - deterministic_id: str, - exec_req: ExecuteIntentRequest, - db: Session = Depends(get_db), - skill_registry=Depends(get_skill_registry), - quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key -): - """ - Execute a previously evaluated healing intent through - arf_enterprise.EnterpriseExecutor -- gate re-check (Rust ladder), - optional durable approval, actuation, and independent read-back - verification, whose *verified* result (not a client self-report) feeds - record_outcome the same way /intents/outcome already does. - - Off by default (ARF_ENABLE_EXECUTION unset or false) and a 501 if the - arf_enterprise package isn't importable -- this is new, opt-in - capability, not a replacement for the existing advisory-only flow. - Uses FakeCloudActuator regardless of what's configured elsewhere: - selecting a real cloud actuator (which provider, which credentials) is - a deliberate later step for whoever actually deploys against real - infrastructure, not something this endpoint defaults into. - """ - if not ENTERPRISE_EXECUTOR_AVAILABLE: - raise HTTPException( - status_code=501, - detail="arf_enterprise is not installed on this deployment; execution is unavailable", - ) - if not ARF_ENABLE_EXECUTION: - raise HTTPException( - status_code=501, - detail="Execution is not enabled (set ARF_ENABLE_EXECUTION=true to opt in)", - ) - - tenant_id = quota["tenant_id"] - - # Existence + tenant-ownership check only -- the healing_intent to - # execute comes from the request body (see ExecuteIntentRequest), not - # reconstructed from what's stored here. - intent_row = db.query(IntentDB).filter( - IntentDB.deterministic_id == deterministic_id, - IntentDB.tenant_id == tenant_id, - ).one_or_none() - if not intent_row: - raise HTTPException(status_code=404, detail=f"Intent not found: {deterministic_id}") - - risk_engine = request.app.state.risk_engine - - def _on_verified_outcome(intent: Dict[str, Any], verified_success: bool, context: Dict[str, Any]) -> None: - try: - record_outcome( - db=db, - tenant_id=tenant_id, - deterministic_id=deterministic_id, - success=verified_success, - recorded_by="enterprise_executor", - notes=f"Auto-recorded from verified execution. Observed: {context.get('observed')}", - risk_engine=risk_engine, - skill_id=exec_req.skill_id, - skill_version=exec_req.skill_version, - skill_registry=skill_registry, - ) - except Exception: - # Execution already happened by the time this fires -- a failure - # here must not be raised back through EnterpriseExecutor.execute() - # (which would misreport a real actuation as failed). Logged so - # the risk-engine-not-updated case is visible, not silent. - logger.exception( - "Failed to record verified outcome for intent %s after execution", - deterministic_id, - ) - - # Singleton initialised once at startup (main.py lifespan) rather than - # constructed fresh per request -- None here means either - # ARF_ENABLE_EXECUTION wasn't set (unreachable, checked above) or the - # ledger failed to initialise at startup, in which case boolean-trust - # mode is the documented fallback (see EnterpriseExecutor.execute). - approval_store = getattr(request.app.state, "approval_store", None) - - # A narrow config carrying only the trust anchor. Deliberately NOT - # EnterpriseConfig.from_env(), which would also pick up ARF_CLOUD, - # ARF_MAX_BLAST_RADIUS, ARF_ENFORCE_BUSINESS_HOURS and the audit/safety - # toggles -- turning on guardrails and audit logging as a side effect of - # enabling signing is a behaviour change nobody asked for. Everything - # other than the trusted keys stays on today's defaults. - # - # Without this the executor got EnterpriseConfig() with an empty - # trusted_signing_keys, so the ladder trusted no keys and rejected every - # signed intent as "Untrusted signing key" -- the whole execute path was - # unreachable regardless of ARF_ENABLE_EXECUTION. - trusted_keys = _trusted_signing_keys() - if not trusted_keys: - logger.warning( - "ARF_TRUSTED_SIGNING_KEYS is unset or empty; the execution ladder " - "trusts no signing keys and will reject every signed intent. Set it " - "to the hex fingerprint(s) of the key(s) permitted to sign intents." - ) - - executor = EnterpriseExecutor( - config=EnterpriseConfig(trusted_signing_keys=trusted_keys), - actuator=FakeCloudActuator(), - approval_store=approval_store, - on_verified_outcome=_on_verified_outcome, - ) - - try: - result = await executor.execute( - exec_req.healing_intent, - human_approved=exec_req.human_approved, - admin_approved=exec_req.admin_approved, - ) - return result - except PendingApprovalError as e: - return JSONResponse( - status_code=202, - content={ - "status": "pending_approval", - "approval_id": e.approval_id, - "level": e.level, - "approval_required": e.approval_required, - "detail": str(e), - }, - ) - except (EnterpriseExecutionError, EnterpriseSafetyError) as e: - # A legitimate "did not execute" outcome (ladder denial, safety - # constraint, verification mismatch) -- not a server bug, so not a - # 5xx. - raise HTTPException(status_code=422, detail=str(e)) - except Exception: - logger.exception("Unexpected error in execute_intent_endpoint") - raise HTTPException(status_code=500, detail="Internal server error") - - -# -------------------------------------------------------------------------- -# Endpoint: record outcome (unchanged) +# Endpoint: record outcome (idempotent, pricing) # -------------------------------------------------------------------------- @router.post("/intents/outcome") async def record_outcome_endpoint( @@ -584,25 +307,21 @@ async def record_outcome_endpoint( outcome: OutcomeRequest, db: Session = Depends(get_db), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), - skill_registry=Depends(get_skill_registry), - quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key ): - """Record an outcome for a previously evaluated intent.""" - tenant_id = quota["tenant_id"] + """ + Record an outcome for a previously evaluated intent. + Also updates the pricing calculator's calibration buffer if available. + """ try: risk_engine = request.app.state.risk_engine outcome_record = record_outcome( db=db, - tenant_id=tenant_id, deterministic_id=outcome.deterministic_id, success=outcome.success, recorded_by=outcome.recorded_by, notes=outcome.notes, risk_engine=risk_engine, idempotency_key=idempotency_key, - skill_id=outcome.skill_id, - skill_version=outcome.skill_version, - skill_registry=skill_registry, ) if PRICING_AVAILABLE and add_event is not None: @@ -619,27 +338,22 @@ async def record_outcome_endpoint( logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}") return {"message": "Outcome recorded", "outcome_id": outcome_record.id} - except Exception: - logger.exception("Error recording outcome") - raise HTTPException(status_code=500, detail="Internal server error") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) # -------------------------------------------------------------------------- -# Endpoint: evaluate healing decision (now with skill context) +# Endpoint: evaluate healing decision (with optional Rust enforcement) # -------------------------------------------------------------------------- @router.post("/healing/evaluate") async def evaluate_healing_decision_endpoint( request: Request, decision_req: HealingDecisionRequest, background_tasks: BackgroundTasks, - db: Session = Depends(get_db), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), - skill_registry=Depends(get_skill_registry), # v4.3.1 - quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key ): """ - Evaluate a healing decision, audit it, optionally enforce via Rust ladder, - and now incorporate Bayesian skill reliability if skill context is provided. + Evaluate a healing decision, audit it, and optionally enforce via Rust ladder. """ span = None if OTEL_AVAILABLE and _tracer: @@ -647,10 +361,16 @@ async def evaluate_healing_decision_endpoint( span.set_attribute("component", decision_req.event.component) start_time = time.time() - # api_key/tenant_id are resolved server-side by enforce_quota from the - # authenticated principal — never from a client-supplied header. - api_key = quota["api_key"] - tenant_id = quota["tenant_id"] + api_key = request.headers.get("Authorization", "").replace("Bearer ", "") + if not api_key: + api_key = request.query_params.get("api_key", "unknown") + + tenant_id = getattr(request.state, "tenant_id", None) + if not tenant_id: + if span: + span.set_status(Status(StatusCode.ERROR, "Missing tenant_id")) + span.end() + raise HTTPException(status_code=403, detail="Tenant not identified") current_tracker = app.core.usage_tracker.tracker if current_tracker is None: @@ -661,7 +381,7 @@ async def evaluate_healing_decision_endpoint( record = UsageRecord( api_key=api_key, - tier=quota["tier"], + tier=None, timestamp=start_time, endpoint="/api/v1/healing/evaluate", request_body=decision_req.model_dump(), @@ -693,19 +413,18 @@ async def evaluate_healing_decision_endpoint( rag_graph=rag_graph, model=model, tokenizer=tokenizer, - # v4.3.1: pass skill context if provided - skill_id=decision_req.skill_id, - skill_version=decision_req.skill_version, - skill_registry=skill_registry, ) # ---- Optional Rust enforcement ---- if RUST_AVAILABLE and response_data.get("recommended_action") == "approve": try: + # Convert response_data to a HealingIntent dict (or use the actual HealingIntent object) + # For simplicity, assume response_data contains the same fields as HealingIntent.to_enterprise_request() intent_dict = response_data.get("healing_intent", response_data) ladder = ExecutionLadder() rust_result = ladder.evaluate(intent_dict) if not rust_result.get("allowed", False): + # Override decision response_data["recommended_action"] = "escalate" response_data["justification"] = ( f"Rust enforcement blocked: {rust_result.get('reason', 'gate failure')}" @@ -715,11 +434,12 @@ async def evaluate_healing_decision_endpoint( except Exception as e: logger.warning(f"Rust enforcement failed: {e}") - # ---- Write audit log (asynchronously) ---- + # ---- Write audit log ---- deterministic_id = response_data.get("intent_id", str(uuid.uuid4())) healing_intent_dict = response_data.get("healing_intent", response_data) background_tasks.add_task( write_audit_log, + db=db, tenant_id=tenant_id, deterministic_id=deterministic_id, healing_intent=healing_intent_dict, @@ -760,4 +480,4 @@ async def evaluate_healing_decision_endpoint( span.set_status(Status(StatusCode.ERROR, error_msg)) span.record_exception(e) span.end() - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail=error_msg) diff --git a/app/api/routes_history.py b/app/api/routes_history.py index ddb3e8ab3c1321c5c59b688f96178b8fe6e8f06a..b1425487c7ec69ecd14b6705d58ff6fa9eab6d7e 100644 --- a/app/api/routes_history.py +++ b/app/api/routes_history.py @@ -1,10 +1,9 @@ -from fastapi import APIRouter, Depends -from app.api.deps import verify_internal_key +from fastapi import APIRouter from app.core.storage import incident_history -router = APIRouter(dependencies=[Depends(verify_internal_key)]) +router = APIRouter() @router.get("/history") async def get_history(): - return {"incidents": list(incident_history)} + return {"incidents": incident_history} diff --git a/app/api/routes_incidents.py b/app/api/routes_incidents.py index 083f38ad3b2156c72bee016756d83449e24ad1b8..61c75060f79954162d5f5c6c5f6398ae34c515e7 100644 --- a/app/api/routes_incidents.py +++ b/app/api/routes_incidents.py @@ -30,21 +30,23 @@ from agentic_reliability_framework.core.models.event import ( ReliabilityEvent, ) -from app.api.deps import verify_internal_key from app.causal_explainer import CausalExplainer -from app.core.storage import incident_history -from app.core import usage_tracker -from app.core.usage_tracker import UsageRecord, enforce_quota +from app.core.usage_tracker import UsageRecord, enforce_quota, tracker logger = logging.getLogger(__name__) router = APIRouter() +# --------------------------------------------------------------------------- +# In‑memory incident store (for auditing / debugging only) +# --------------------------------------------------------------------------- +incident_history: list[dict] = [] + # --------------------------------------------------------------------------- # POST /api/v1/report_incident # --------------------------------------------------------------------------- -@router.post("/report_incident", dependencies=[Depends(verify_internal_key)]) +@router.post("/report_incident") async def report_incident(event: ReliabilityEvent) -> dict[str, str]: """ Record a ``ReliabilityEvent`` in the in‑memory incident history. @@ -52,10 +54,7 @@ async def report_incident(event: ReliabilityEvent) -> dict[str, str]: This endpoint is used by internal monitoring tools to feed incident data into the causal explainer and downstream analysis. The event is stored as a JSON‑safe dictionary and is **not** persisted across - API restarts. Requires the same ``X-Internal-Key`` header every other - data-bearing route in this API requires -- previously this endpoint had - no auth dependency at all, so anyone could write into the incident - history that feeds the causal explainer and ``GET /history``. + API restarts. Parameters ---------- @@ -228,7 +227,7 @@ async def evaluate_incident( # ------------------------------------------------------------------ # Asynchronous usage logging # ------------------------------------------------------------------ - if usage_tracker.tracker: + if tracker: record = UsageRecord( api_key=api_key, tier=tier, @@ -238,7 +237,7 @@ async def evaluate_incident( response=response_data, processing_ms=(time.time() - start_time) * 1000, ) - await usage_tracker.tracker.increment_usage_async(record, background_tasks) + await tracker.increment_usage_async(record, background_tasks) logger.warning( "Deprecated endpoint /v1/incidents/evaluate called by key %s", @@ -250,8 +249,7 @@ async def evaluate_incident( raise except Exception as exc: error_msg = str(exc) - logger.exception("Error in evaluate_incident (deprecated endpoint)") - if usage_tracker.tracker: + if tracker: record = UsageRecord( api_key=api_key, tier=tier, @@ -261,5 +259,5 @@ async def evaluate_incident( error=error_msg, processing_ms=(time.time() - start_time) * 1000, ) - await usage_tracker.tracker.increment_usage_async(record, background_tasks) - raise HTTPException(status_code=500, detail="Internal server error") + await tracker.increment_usage_async(record, background_tasks) + raise HTTPException(status_code=500, detail=error_msg) diff --git a/app/api/routes_intents.py b/app/api/routes_intents.py index 4cf2031b60c3a08394bf2ae57a6380aad1899a80..594946d874de8d67d2a29eeb1d7fa0e87f67ad29 100644 --- a/app/api/routes_intents.py +++ b/app/api/routes_intents.py @@ -1,13 +1,8 @@ -import logging - -from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import verify_internal_key +from fastapi import APIRouter, HTTPException from app.models.intent_models import IntentSimulation, IntentSimulationResponse from app.services.intent_service import simulate_intent -logger = logging.getLogger(__name__) - -router = APIRouter(dependencies=[Depends(verify_internal_key)]) +router = APIRouter() @router.post("/simulate_intent", response_model=IntentSimulationResponse) @@ -15,6 +10,5 @@ async def simulate_intent_endpoint(intent: IntentSimulation): try: result = simulate_intent(intent) return IntentSimulationResponse(**result) - except Exception: - logger.exception("simulate_intent failed") - raise HTTPException(status_code=500, detail="Internal server error") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/api/routes_memory.py b/app/api/routes_memory.py index 6172b188aa628c560ff0200d0b796ba62b36e349..1e562f1dafa10e1122f44dcee6ab951fef43973a 100644 --- a/app/api/routes_memory.py +++ b/app/api/routes_memory.py @@ -1,7 +1,6 @@ -from fastapi import APIRouter, Depends, Request -from app.api.deps import verify_internal_key +from fastapi import APIRouter, Request -router = APIRouter(dependencies=[Depends(verify_internal_key)]) +router = APIRouter() @router.get("/stats") diff --git a/app/api/routes_onchain.py b/app/api/routes_onchain.py deleted file mode 100644 index c0e56b3475e2ccd2858fa1aa11312370fb0e1da3..0000000000000000000000000000000000000000 --- a/app/api/routes_onchain.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Routes for on-chain attestation rationale. - -`RiskAttestationRegistry` (arf-onchain) anchors only a `rationale_hash` -- -never the reasoning itself, to keep operational detail about a customer's -infrastructure off a public chain. These endpoints are the off-chain half: -a reference risk evaluator (`arf_enterprise.onchain.evaluator`) persists the -plaintext here immediately after signing an attestation, keyed by the same -hash it put on-chain, and an auditor who reads a `DecisionAnchored` or -`AttestationIssued` event can fetch the reasoning behind it here. - -Internal-key gated like `routes_governance.py`: this is a service-to-service -surface for the evaluator process and for auditor tooling, not a -tenant-scoped customer endpoint -- see `OnchainRationaleDB`'s docstring for -why there is no tenant_id to enforce here. -""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, Response -from pydantic import BaseModel, field_validator -from sqlalchemy.orm import Session - -from app.api.deps import get_db, verify_internal_key -from app.database.models_onchain import OnchainRationaleDB - -logger = logging.getLogger(__name__) - -router = APIRouter(dependencies=[Depends(verify_internal_key)]) - - -def _validate_hex_hash(value: str) -> str: - text = value.strip() - if not text.startswith("0x") or len(text) != 66: - raise ValueError("rationale_hash must be a 0x-prefixed 32-byte hex string") - try: - int(text, 16) - except ValueError: - raise ValueError("rationale_hash is not valid hex") from None - return text.lower() - - -class RationaleRequest(BaseModel): - rationale_hash: str - rationale: str - agent_address: str | None = None - evaluator_address: str | None = None - - @field_validator("rationale_hash") - @classmethod - def _validate_hash(cls, value: str) -> str: - return _validate_hex_hash(value) - - @field_validator("rationale") - @classmethod - def _validate_rationale(cls, value: str) -> str: - if not value.strip(): - raise ValueError("rationale must not be empty") - return value - - -class RationaleResponse(BaseModel): - rationale_hash: str - rationale: str - agent_address: str | None - evaluator_address: str | None - - -@router.post("/onchain/rationale", status_code=201) -async def persist_rationale( - req: RationaleRequest, - response: Response, - db: Session = Depends(get_db), -): - """Store the plaintext behind an anchored `rationale_hash`. - - Idempotent on `rationale_hash`: signing the same decision twice (a - retry after a network error, for instance) posts the same hash and - text, so the second call is a no-op rather than a uniqueness-constraint - error. A *different* text arriving for a hash already on record is - refused -- that would mean either hash collision or a caller bug, and - silently overwriting an anchored record's preimage is the one thing - this table must never do. - """ - existing = ( - db.query(OnchainRationaleDB) - .filter(OnchainRationaleDB.rationale_hash == req.rationale_hash) - .one_or_none() - ) - if existing is not None: - if existing.rationale != req.rationale: - raise HTTPException( - status_code=409, - detail=( - "rationale_hash already recorded with different text; " - "an anchored hash's preimage cannot be overwritten" - ), - ) - # The route decorator's status_code=201 is FastAPI's default for - # every plain-dict return from this handler, including this one -- - # it must be overridden explicitly here or a replayed post reports - # itself as newly Created. - response.status_code = 200 - return {"status": "already_recorded", "rationale_hash": req.rationale_hash} - - row = OnchainRationaleDB( - rationale_hash=req.rationale_hash, - rationale=req.rationale, - agent_address=req.agent_address, - evaluator_address=req.evaluator_address, - ) - db.add(row) - db.commit() - logger.info("persisted rationale for hash %s", req.rationale_hash) - return {"status": "recorded", "rationale_hash": req.rationale_hash} - - -@router.get("/onchain/rationale/{rationale_hash}", response_model=RationaleResponse) -async def get_rationale( - rationale_hash: str, - db: Session = Depends(get_db), -): - """Fetch the plaintext behind an anchored `rationale_hash`. - - What an auditor calls after reading a `DecisionAnchored` event off-chain - -- the hash from the event is the only key this endpoint accepts, by - design: there is no listing or search here, only lookup by the exact - value that was signed and anchored. - """ - try: - normalized = _validate_hex_hash(rationale_hash) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - - row = ( - db.query(OnchainRationaleDB) - .filter(OnchainRationaleDB.rationale_hash == normalized) - .one_or_none() - ) - if row is None: - raise HTTPException( - status_code=404, detail="no rationale recorded for this hash" - ) - - return RationaleResponse( - rationale_hash=row.rationale_hash, - rationale=row.rationale, - agent_address=row.agent_address, - evaluator_address=row.evaluator_address, - ) diff --git a/app/api/routes_payments.py b/app/api/routes_payments.py index 947cb739f5a31bc400420e347ae32b60a445892f..1a5d314c830b65624db25a28b0492e54f1132783 100644 --- a/app/api/routes_payments.py +++ b/app/api/routes_payments.py @@ -2,16 +2,12 @@ Payment endpoints – Stripe Checkout integration. """ -import logging import os import stripe -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from app.core import usage_tracker -from app.core.usage_tracker import Tier, resolve_api_key_identity - -logger = logging.getLogger(__name__) +from app.core.usage_tracker import tracker, Tier router = APIRouter(prefix="/payments", tags=["payments"]) @@ -21,46 +17,24 @@ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") class CheckoutRequest(BaseModel): + api_key: str + success_url: str cancel_url: str @router.post("/create-checkout-session") -async def create_checkout_session( - req: CheckoutRequest, - identity: dict = Depends(resolve_api_key_identity), -): - """Create a Stripe Checkout session for the Pro tier. - - Identity comes from `resolve_api_key_identity` (Authorization: Bearer - header, same pepper-HMAC lookup every other authenticated endpoint - uses) rather than from a caller-supplied field in the request body -- - previously this endpoint took `api_key` as a JSON field with no - `Depends()` gate at all, so nothing distinguished "the caller proved - they hold this key" from "the caller typed this string into a - request." `enforce_quota` is deliberately not used here: a FREE-tier - caller who has exhausted their monthly quota must still be able to - reach this endpoint, since upgrading is often exactly what they are - trying to do. - """ +async def create_checkout_session(req: CheckoutRequest): + """Create a Stripe Checkout session for the Pro tier.""" if not stripe.api_key: raise HTTPException(status_code=500, detail="Stripe not configured") - if not usage_tracker.tracker: - raise HTTPException(status_code=503, detail="Usage tracking service not initialised") - if identity["tier"] != Tier.FREE: + # Verify the API key exists and is free tier + tier = tracker.get_tier(req.api_key) if tracker else None + if tier != Tier.FREE: raise HTTPException(status_code=400, detail="Only free tier keys can be upgraded") - # tenant_id, not the raw api_key, is what travels to Stripe from here - # on. api_key is a bearer secret -- usage_tracker.py exists specifically - # to never store it in plaintext (pepper-HMAC lookup + salted - # verification hash), and Stripe's dashboard/webhook logs/API are a - # third party with no reason to ever see it. tenant_id is an opaque, - # non-secret row identifier and is exactly what the webhook needs to - # look up which tenant's keys to retier. - tenant_id = identity["tenant_id"] - try: checkout_session = stripe.checkout.Session.create( payment_method_types=["card"], @@ -74,17 +48,9 @@ async def create_checkout_session( mode="subscription", success_url=req.success_url, cancel_url=req.cancel_url, - metadata={"tenant_id": tenant_id}, - client_reference_id=tenant_id, - # checkout.session.completed carries this metadata via - # session.metadata (handled below), but customer.subscription.* - # events only carry the *subscription's own* metadata -- Stripe - # does not copy Session.metadata onto the Subscription it - # creates. Without this, cancellations can't be traced back to - # a tenant and PRO tier never downgrades. - subscription_data={"metadata": {"tenant_id": tenant_id}}, + metadata={"api_key": req.api_key}, + client_reference_id=req.api_key, ) return {"sessionId": checkout_session.id, "url": checkout_session.url} - except Exception: - logger.exception("create_checkout_session failed") - raise HTTPException(status_code=500, detail="Internal server error") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/api/routes_pricing.py b/app/api/routes_pricing.py index 5755b1f474cb4298966385754d4c074d827e314b..a2e5c1646059a1f2c6cef17659dcaaf1835bebc7 100644 --- a/app/api/routes_pricing.py +++ b/app/api/routes_pricing.py @@ -57,26 +57,48 @@ async def run_pricing( quota: dict = Depends(enforce_quota), ): """ - Multi‑run pricing with cooldown and buffer persistence. TEMPORARILY DISABLED. - - This endpoint used to persist each run's "outcome" as - `random.random() > risk_score` -- a fabricated result, not a real deal - outcome -- into a calibration buffer with no customer_id scoping, so - every customer's calls read and wrote the same file. Net effect: every - customer's price was shaped by every other customer's randomly-generated - outcomes, not just their own. Disabled until both are fixed: (1) a real - outcome-ingestion path (this endpoint must not invent one), and (2) the - buffer scoped per customer. See AUDIT_arf-bayesian-pricing-calculator.md - and AUDIT_arf-api.md (workspace root) for the original findings and - recommended fix. `Depends(enforce_quota)` stays active so this still - requires the same auth it always did -- only authenticated callers reach - the disabled-notice below; everyone else still gets the normal 401/403. + Multi‑run pricing with cooldown and buffer persistence. + Each run’s simulated outcome is added to the buffer, so subsequent runs + see an updated posterior. """ - raise HTTPException( - status_code=503, - detail=( - "This endpoint is temporarily disabled while a data-integrity issue is " - "fixed. Use POST /api/v1/pricing/estimate for a single price estimate " - "with no persisted learning in the meantime." - ), - ) + # We need to reuse the same buffer across runs; we'll load it per request. + # For simplicity, we'll load from the default location. + from arf_pricing_calculator.storage.buffer import load_buffer, add_event + from arf_pricing_calculator.orchestration.cooldown import enforce_cooldown, is_cooldown_active + + outputs = [] + buffer = load_buffer() # loads from calibration_buffer.json + + for i in range(req.runs): + if not req.force and is_cooldown_active( + req.customer_id, req.cooldown_hours): + raise HTTPException(status_code=429, + detail=f"Cooldown active after {i} runs") + + pricing_input = parse_input_dict(req.input) + engine = PricingEngine(calibration_buffer=buffer) + out = engine.estimate(pricing_input) + + # Simulate an outcome (in real use, this would come from the actual + # deal) + import random + outcome = "success" if random.random() > out.risk_score else "failure" # nosec B311 + + event = { + "run_id": out.run_history_id, + "customer_id": req.customer_id, + "outcome": outcome, + "price": out.recommended_price, + "value": out.expected_value, + "risk_score": out.risk_score, + "run_number": i + 1, + } + add_event(event) + buffer = load_buffer() # reload after update + + outputs.append(out) + + if i < req.runs - 1: + enforce_cooldown(req.customer_id, req.cooldown_hours) + + return outputs diff --git a/app/api/routes_risk.py b/app/api/routes_risk.py index 160f796fed0840ab4c7ddb27fd8b4dc465b2d8bb..f4cf76c86de0924eb79539972bd24e6ef727680c 100644 --- a/app/api/routes_risk.py +++ b/app/api/routes_risk.py @@ -1,13 +1,8 @@ -import logging - -from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import verify_internal_key +from fastapi import APIRouter, HTTPException from app.models.risk_models import RiskResponse from app.services.risk_service import get_system_risk -logger = logging.getLogger(__name__) - -router = APIRouter(dependencies=[Depends(verify_internal_key)]) +router = APIRouter() @router.get("/get_risk", response_model=RiskResponse) @@ -18,9 +13,8 @@ async def get_risk(): raise HTTPException( status_code=501, detail="This endpoint is deprecated and not implemented") - except Exception: - logger.exception("get_risk failed") - raise HTTPException(status_code=500, detail="Internal server error") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) if risk < 0.3: status = "low" @@ -31,3 +25,13 @@ async def get_risk(): else: status = "critical" return RiskResponse(system_risk=risk, status=status) + + +@router.get("/history") +async def get_risk_history(): + import random + import datetime + now = datetime.datetime.now() + data = [{"time": (now - datetime.timedelta(hours=i)).isoformat(), + "risk": round(random.uniform(0.2, 0.8), 2)} for i in range(24, 0, -1)] + return data diff --git a/app/api/routes_users.py b/app/api/routes_users.py index f2d76b1b71e69671e9c7e64fa444e63c5e3e7650..d45a8464fd273ecb7d378fd275acabb9cc12f1e7 100644 --- a/app/api/routes_users.py +++ b/app/api/routes_users.py @@ -9,8 +9,7 @@ from sqlalchemy.orm import Session from slowapi import Limiter from slowapi.util import get_remote_address -from app.core import usage_tracker -from app.core.usage_tracker import enforce_quota, Tier +from app.core.usage_tracker import tracker, enforce_quota, Tier from app.api.deps import get_db from app.database.models_intents import TenantDB # <-- NEW @@ -31,7 +30,7 @@ async def register_user( Public endpoint to create a new free‑tier API key and a new tenant. Rate‑limited to 5 requests per hour per IP address. """ - if usage_tracker.tracker is None: + if tracker is None: raise HTTPException(status_code=503, detail="Usage tracking service not initialised") # 1. Create a new tenant in the main database @@ -49,7 +48,7 @@ async def register_user( # 2. Generate a new API key for this tenant new_key = f"sk_free_{uuid.uuid4().hex[:24]}" - success = usage_tracker.tracker.get_or_create_api_key(api_key=new_key, tenant_id=tenant_id, tier=Tier.FREE) + success = tracker.get_or_create_api_key(api_key=new_key, tenant_id=tenant_id, tier=Tier.FREE) if not success: # Rollback tenant creation if key creation fails db.delete(new_tenant) diff --git a/app/api/webhooks.py b/app/api/webhooks.py index 14bbea65b7cf5eb651be2dfc65be7fe7c87c97b4..74667a67e5b791c9a7447a763d340466d3672d7b 100644 --- a/app/api/webhooks.py +++ b/app/api/webhooks.py @@ -2,24 +2,16 @@ Stripe webhook handler – updates API key tier on subscription events. """ -import logging import os import stripe from fastapi import APIRouter, Request, HTTPException -from app.core.usage_tracker import update_key_tier_by_tenant_id, Tier - -logger = logging.getLogger(__name__) +from app.core.usage_tracker import update_key_tier, Tier router = APIRouter(prefix="/webhooks", tags=["webhooks"]) STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") stripe.api_key = os.getenv("STRIPE_SECRET_KEY") -# Subscription statuses that mean "not entitled to Pro anymore". Deliberately -# does NOT include "past_due" -- whether a failed-payment grace period keeps -# Pro access is a dunning-policy decision, not a bug, and isn't decided here. -_DOWNGRADE_STATUSES = {"canceled", "unpaid", "incomplete_expired"} - @router.post("/stripe") async def stripe_webhook(request: Request): @@ -38,33 +30,20 @@ async def stripe_webhook(request: Request): except stripe.error.SignatureVerificationError: raise HTTPException(status_code=400, detail="Invalid signature") - # tenant_id (not api_key) is what Checkout was given -- see - # routes_payments.py's create_checkout_session for why the raw bearer - # key must never round-trip through a third party. + # Handle subscription events if event["type"] == "checkout.session.completed": session = event["data"]["object"] - # For card payments this is already "paid" by the time this event - # fires; for delayed-notification payment methods (bank debits, - # etc.) it can still be "unpaid" here. Upgrading on an unpaid - # session would grant Pro access before payment actually clears. - if session.get("payment_status") != "paid": - logger.info( - "checkout.session.completed with payment_status=%r; not upgrading yet", - session.get("payment_status"), - ) - return {"status": "ok"} - tenant_id = session.get("client_reference_id") or session.get( - "metadata", {}).get("tenant_id") - if tenant_id: - update_key_tier_by_tenant_id(tenant_id, Tier.PRO) - elif event["type"] in ("customer.subscription.deleted", "customer.subscription.updated"): + api_key = session.get("client_reference_id") or session.get( + "metadata", {}).get("api_key") + if api_key: + update_key_tier(api_key, Tier.PRO) + elif event["type"] == "customer.subscription.deleted": subscription = event["data"]["object"] - tenant_id = subscription.get("metadata", {}).get("tenant_id") - if not tenant_id: - return {"status": "ok"} - if event["type"] == "customer.subscription.deleted" or ( - subscription.get("status") in _DOWNGRADE_STATUSES - ): - update_key_tier_by_tenant_id(tenant_id, Tier.FREE) + # You need to store a mapping from subscription ID to API key. + # For simplicity, we assume you stored it in metadata during checkout. + # Alternatively, look up by customer ID. + api_key = subscription.get("metadata", {}).get("api_key") + if api_key: + update_key_tier(api_key, Tier.FREE) return {"status": "ok"} diff --git a/app/core/config.py b/app/core/config.py index d5a24be1a3fec5aebe9e3ee1b7ecd994a4db0916..62d61adc0817942b9887c6d5c2ebc3f5c1ebe385 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -14,7 +14,6 @@ class Settings(BaseSettings): ARF_USAGE_DB_PATH: str = "arf_usage.db" ARF_REDIS_URL: Optional[str] = None ARF_API_KEYS: str = "{}" # JSON string of {key: tier} - ARF_KEY_PEPPER: Optional[str] = None # required if ARF_USAGE_TRACKING is true # Tracing (OpenTelemetry) OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None diff --git a/app/core/storage.py b/app/core/storage.py index 033cc79efac3be30d8afacd50b1c9d8cab7b6b4c..2af427ccb40221b13f0b8a1c36a9b3ba62eea327 100644 --- a/app/core/storage.py +++ b/app/core/storage.py @@ -1,16 +1,2 @@ -"""In-memory store for recent incident reports. - -Bounded (maxlen), not persisted across restarts -- exists to give the -causal explainer and GET /history recent context, not as a durable audit -trail. The cap protects against unbounded memory growth from -POST /report_incident, which can be called repeatedly by anything holding -a valid internal key. - -Shared by app.api.routes_incidents (writes, via report_incident) and -app.api.routes_history (reads, via GET /history) -- both must import this -same object rather than declaring their own list, or writes and reads -silently operate on two different lists. -""" -from collections import deque - -incident_history: deque = deque(maxlen=10_000) +# Simple in-memory list for incident history +incident_history = [] diff --git a/app/core/usage_tracker.py b/app/core/usage_tracker.py index 33609c235f60534116314a737f45fecf1ea079e5..dc39778dd1bf58a4ffce38d0cf8f2de50bbfbb0d 100644 --- a/app/core/usage_tracker.py +++ b/app/core/usage_tracker.py @@ -4,53 +4,15 @@ Thread‑safe, atomic quota consumption, idempotent, fail‑closed. Extended for multi‑tenancy: each API key is linked to a tenant ID. Tenant ID is stored in the `api_keys` table and used for resource isolation. - -API keys in `api_keys` are never stored in plaintext. The lookup index is -HMAC-SHA256(pepper, key) -- a deterministic but one-way value computed with -a server-only secret (ARF_KEY_PEPPER), so a leaked database alone does not -expose usable keys. A per-row random salt plus a second SHA-256 check is a -defense-in-depth verification layer after the row is found by lookup hash. -This mirrors arf-gateway's internal/auth/apikey.go. - -`api_keys` itself lives in Postgres (DATABASE_URL), not in the SQLite file -the rest of this module uses -- a single durable table that both arf-api -and arf-gateway point at, instead of each service's own local SQLite copy -(which on Render's Free plan is wiped on every deploy/restart anyway). - -`usage_log` and `idempotency_keys` remain SQLite-only, local to this -service -- arf-gateway never reads them. `monthly_counts` is different: -arf-gateway's Go code (internal/auth/apikey.go) queries it from Postgres -directly, via the pgx driver against the same DATABASE_URL, to compute -each key's remaining quota. It never had a way to read this service's -local SQLite file (a different process, a different disk, a different -protocol), so a prior version of this module -- which wrote -`monthly_counts` to SQLite only -- left that Postgres table permanently -empty, and arf-gateway's quota check silently treated every key as having -consumed nothing all month, every month. `consume_quota_and_log` now -mirrors every successfully-counted call into the Postgres `monthly_counts` -table too (`_record_pg_monthly_count`), best-effort and logged loudly on -failure rather than raised -- this service's own quota decision is still -made from its local SQLite/Redis count and must not fail because a -mirroring write to a peer service's view did. Still keyed by the raw API -key (not hashed), matching the existing SQLite schema and a separately -tracked plaintext-storage gap -- not addressed by this fix. """ -import hashlib -import hmac import json -import logging -import os -import secrets import sqlite3 import threading import time - -import psycopg2 -import psycopg2.extras from contextlib import contextmanager from datetime import datetime, timedelta from dataclasses import dataclass -from typing import Dict, Any, Optional, List, Tuple +from typing import Dict, Any, Optional, List, Tuple, Callable from enum import Enum from fastapi import BackgroundTasks, HTTPException, Request @@ -106,51 +68,15 @@ class UsageRecord: processing_ms: Optional[float] = None -# Bounded retry for the initial Postgres connect -- see _get_pg_conn's -# docstring. 5 attempts with exponential backoff (1+2+4+8 = 15s of sleep, -# worst case) comfortably fits inside a container's normal boot window -# without turning a real outage into a long hang. -_PG_CONNECT_MAX_ATTEMPTS = 5 -_PG_CONNECT_BACKOFF_BASE = 1.0 - -logger = logging.getLogger(__name__) - - class UsageTracker: """ Thread‑safe usage tracker with atomic quota consumption and idempotency. Extended to support tenant isolation: each API key is linked to a tenant. """ - # Whether the Postgres api_keys schema check has already run in this - # process. Class-level, not per-instance: the schema is a property of - # the database, not of a tracker object, and every instance in a - # process points at the same DATABASE_URL. Guarded by a lock because - # _get_pg_conn is called from request threads. - _pg_schema_ready: bool = False - _pg_schema_lock = threading.Lock() - def __init__(self, db_path: str = "arf_usage.db", - redis_url: Optional[str] = None, - pepper: Optional[str] = None): + redis_url: Optional[str] = None): self.db_path = db_path - self._pepper = pepper if pepper is not None else os.getenv("ARF_KEY_PEPPER", "") - if not self._pepper: - raise RuntimeError( - "ARF_KEY_PEPPER is not set -- refusing to start without it, " - "since it is required to look up or verify any API key." - ) - if len(self._pepper) < 32: - raise RuntimeError( - f"ARF_KEY_PEPPER is too short ({len(self._pepper)} chars); " - "use at least 32 random characters." - ) - self._pg_dsn = os.getenv("DATABASE_URL", "") - if not self._pg_dsn: - raise RuntimeError( - "DATABASE_URL is not set -- refusing to start without it, " - "since api_keys is stored in Postgres, not SQLite." - ) self._local = threading.local() self._init_db() @@ -160,40 +86,9 @@ class UsageTracker: elif redis_url: raise ImportError("Redis client not installed. Run: pip install redis") - def _lookup_hash(self, key: str) -> str: - """Deterministic pepper-HMAC used to find a key's row without ever - storing or querying by the plaintext key.""" - return hmac.new(self._pepper.encode(), key.encode(), hashlib.sha256).hexdigest() - - @staticmethod - def _salted_hash(key: str, salt_hex: str) -> str: - """Per-row salted verification hash, checked after a row has - already been found via lookup hash.""" - return hashlib.sha256(bytes.fromhex(salt_hex) + key.encode()).hexdigest() - - def _verify_key(self, conn, api_key: str) -> Optional[dict]: - """Look up a row by pepper-HMAC, then verify with the salted hash. - `conn` is a Postgres connection from _get_pg_conn. Returns the row - (tenant_id, tier, is_active, salt, key_hash) if the key is valid and - active, else None.""" - row = self._pg_execute( - conn, - "SELECT tenant_id, tier, is_active, salt, key_hash FROM api_keys " - "WHERE lookup_hash = %s", - (self._lookup_hash(api_key),) - ).fetchone() - if not row or not row["is_active"]: - return None - if not hmac.compare_digest(self._salted_hash(api_key, row["salt"]), row["key_hash"]): - return None - return row - @contextmanager def _get_conn(self): - """Get a thread‑local SQLite connection with WAL and immediate transactions. - - Backs usage_log/monthly_counts/idempotency_keys only -- api_keys - lives in Postgres, see _get_pg_conn below.""" + """Get a thread‑local SQLite connection with WAL and immediate transactions.""" if not hasattr(self._local, "conn"): self._local.conn = sqlite3.connect( self.db_path, check_same_thread=False, isolation_level=None) @@ -201,160 +96,20 @@ class UsageTracker: self._local.conn.execute("PRAGMA journal_mode=WAL") yield self._local.conn - @contextmanager - def _get_pg_conn(self): - """Get a thread-local Postgres connection for the api_keys table. - Rows come back as dict-like objects (row["col"]) via RealDictCursor, - matching the sqlite3.Row access pattern used elsewhere in this file. - - Retries the initial connect with backoff, but how patiently depends - on who is asking: - - - **Startup** (`warm_up`, `retries=True`): a short DNS blip during a - cold container boot has been observed on Render, so a few seconds - of retrying is worth it to come up cleanly. - - **Request path** (the default, `retries=False`): fails fast. A - request thread that blocks for 15s on a database outage doesn't - make the request succeed; it holds a worker thread hostage, and - under any concurrency the pool is exhausted and the whole service - stops responding -- including its health endpoint. A prompt 503 is - strictly better than a slow one. - - Genuine connection errors (bad credentials, wrong host) still raise - either way, preserving fail-closed.""" - if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed: - self._connect_pg(retries=False) - yield self._local.pg_conn - - def _connect_pg(self, retries: bool) -> None: - """Open this thread's Postgres connection, optionally retrying.""" - attempts = _PG_CONNECT_MAX_ATTEMPTS if retries else 1 - last_exc: Optional[psycopg2.OperationalError] = None - for attempt in range(attempts): - try: - self._local.pg_conn = psycopg2.connect( - self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor) - last_exc = None - break - except psycopg2.OperationalError as exc: - last_exc = exc - if attempt < attempts - 1: - time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt)) - if last_exc is not None: - raise last_exc - self._ensure_pg_schema(self._local.pg_conn) - - def warm_up(self) -> bool: - """Best-effort startup connection, with retries. - - Returns True if Postgres is reachable and the api_keys schema is - ready. Returns False -- rather than raising -- when it isn't, so a - caller can log the degradation and still start serving. That - asymmetry is the point: an unreachable database at boot should cost - api_keys-backed functionality, not the entire service. - """ - try: - self._connect_pg(retries=True) - return True - except psycopg2.OperationalError: - return False - - def _ensure_pg_schema(self, conn) -> None: - """Run the api_keys schema check once per process, on the first - connection that actually succeeds. - - Guarded by a process-wide flag rather than done in __init__ so a - database that is unreachable at startup doesn't prevent the tracker - from existing -- it just means the first request that needs - Postgres pays for the schema check, and requests before that get a - clean 503 from enforce_quota instead of the whole service being - down. The statements are all IF NOT EXISTS, so re-running them on a - later process is harmless.""" - if UsageTracker._pg_schema_ready: - return - with UsageTracker._pg_schema_lock: - if UsageTracker._pg_schema_ready: - return - self._create_pg_schema(conn) - UsageTracker._pg_schema_ready = True - - @staticmethod - def _pg_execute(conn, sql: str, params: tuple = ()): - """Run a query against a Postgres connection and return the cursor, - so callers can chain .fetchone()/.fetchall() the same way sqlite3's - conn.execute(...) is used elsewhere in this file.""" - cur = conn.cursor() - cur.execute(sql, params) - return cur - - def _create_pg_schema(self, conn): - """Idempotently ensure the Postgres api_keys table/index exist. - - Takes an already-open connection rather than acquiring one, because - its only caller is _ensure_pg_schema, which runs *from inside* - _get_pg_conn -- acquiring another connection here would recurse. - - The canonical schema is the Alembic migration - (alembic/versions/*_create_api_keys_table.py) -- but nothing in this - codebase runs `alembic upgrade head` automatically on deploy (a - known gap, tracked separately), and arf-gateway's Go code needs the - same table without going through Python/Alembic at all. Mirroring - the same CREATE TABLE IF NOT EXISTS self-healing pattern this file - already uses for its SQLite tables keeps both services (and tests) - working whether or not the migration has actually been applied. - Column set/types must stay in sync with that migration.""" - self._pg_execute(conn, """ - CREATE TABLE IF NOT EXISTS api_keys ( - id SERIAL PRIMARY KEY, - tenant_id VARCHAR(64) NOT NULL, - tier VARCHAR(32) NOT NULL, - created_at TIMESTAMP NOT NULL, - last_used_at TIMESTAMP, - is_active BOOLEAN NOT NULL DEFAULT true, - salt VARCHAR(64) NOT NULL, - key_hash VARCHAR(64) NOT NULL, - lookup_hash VARCHAR(64) NOT NULL - ) - """) - self._pg_execute(conn, """ - CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash - ON api_keys (lookup_hash) - """) - self._pg_execute(conn, """ - CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id - ON api_keys (tenant_id) - """) - # Mirrors arf-gateway's self-healing CREATE TABLE IF NOT EXISTS for - # this same table (internal/auth/apikey.go's NewValidator) -- either - # service may be the first to connect to a fresh database. - self._pg_execute(conn, """ - CREATE TABLE IF NOT EXISTS monthly_counts ( - api_key TEXT NOT NULL, - year_month TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (api_key, year_month) - ) - """) - conn.commit() - def _init_db(self): - """Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys. - - Deliberately does NOT touch Postgres. The api_keys schema is - ensured lazily on the first successful Postgres connection instead - (see _ensure_pg_schema) so that constructing a UsageTracker never - depends on the database being reachable *at that instant*. - - This is the difference between a degraded service and no service. - Every other subsystem in main.py's lifespan already degrades - gracefully when Postgres is unreachable -- the Beta-state loader - logs a warning and continues -- but init_tracker raised, and - main.py turns that into RuntimeError, killing the process. On - Render that produced a crash loop that outlived the port-detection - window, so a DNS failure lasting seconds took the whole deploy - down and left the previous release serving. - """ + """Initialise SQLite tables with tenant_id support.""" with self._get_conn() as conn: + # Modified: api_keys now has tenant_id column + conn.execute(""" + CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tier TEXT NOT NULL, + created_at REAL NOT NULL, + last_used_at REAL, + is_active INTEGER DEFAULT 1 + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS usage_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -397,84 +152,65 @@ class UsageTracker: Register a new API key for a given tenant. Args: - key: The API key (plain text -- hashed before storage, never persisted as-is). + key: The API key (plain text, will be hashed in production). tenant_id: UUID of the tenant (must already exist in main DB). tier: Initial tier for the key. Returns: True if key was created (or already exists for the same tenant). """ - lookup_hash = self._lookup_hash(key) - with self._get_pg_conn() as conn: - row = self._pg_execute( - conn, "SELECT tenant_id FROM api_keys WHERE lookup_hash = %s", (lookup_hash,) - ).fetchone() + with self._get_conn() as conn: + row = conn.execute( + "SELECT key FROM api_keys WHERE key = ?", (key,)).fetchone() if row: # Key already exists – ensure it belongs to the same tenant - if row["tenant_id"] != tenant_id: - conn.rollback() + existing_tenant = conn.execute( + "SELECT tenant_id FROM api_keys WHERE key = ?", (key,)).fetchone() + if existing_tenant["tenant_id"] != tenant_id: raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.") - conn.commit() return True - salt = secrets.token_hex(16) - self._pg_execute( - conn, - "INSERT INTO api_keys " - "(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) " - "VALUES (%s, %s, %s, %s, %s, %s, %s)", - (tenant_id, tier.value, datetime.utcnow(), True, - salt, self._salted_hash(key, salt), lookup_hash) + conn.execute( + "INSERT INTO api_keys (key, tenant_id, tier, created_at, is_active) VALUES (?, ?, ?, ?, ?)", + (key, tenant_id, tier.value, time.time(), 1) ) conn.commit() return True def get_tier(self, api_key: str) -> Optional[Tier]: """Return the tier for a given API key, or None if key invalid/inactive.""" - with self._get_pg_conn() as conn: - row = self._verify_key(conn, api_key) - return Tier(row["tier"]) if row else None + with self._get_conn() as conn: + row = conn.execute( + "SELECT tier FROM api_keys WHERE key = ? AND is_active = 1", + (api_key,) + ).fetchone() + if not row: + return None + return Tier(row["tier"]) def get_tenant_id(self, api_key: str) -> Optional[str]: """Return the tenant ID associated with the API key, or None if key invalid.""" - with self._get_pg_conn() as conn: - row = self._verify_key(conn, api_key) - return row["tenant_id"] if row else None + with self._get_conn() as conn: + row = conn.execute( + "SELECT tenant_id FROM api_keys WHERE key = ? AND is_active = 1", + (api_key,) + ).fetchone() + if not row: + return None + return row["tenant_id"] def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool: """Update the tier of an existing API key. Returns True if successful.""" - lookup_hash = self._lookup_hash(api_key) - with self._get_pg_conn() as conn: - row = self._pg_execute( - conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (lookup_hash,) - ).fetchone() + with self._get_conn() as conn: + row = conn.execute( + "SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone() if not row: - conn.rollback() return False - self._pg_execute( - conn, "UPDATE api_keys SET tier = %s WHERE lookup_hash = %s", - (new_tier.value, lookup_hash)) + conn.execute( + "UPDATE api_keys SET tier = ? WHERE key = ?", + (new_tier.value, api_key)) conn.commit() return True - def update_tier_by_tenant_id(self, tenant_id: str, new_tier: Tier) -> bool: - """Update the tier of every active API key belonging to tenant_id. - - Used by the Stripe webhook, which must never handle a raw API key - (Stripe's own systems -- dashboard, logs, webhook payloads -- are a - third party; the plaintext bearer secret has no business being - stored there, which is exactly what passing it as Checkout - metadata used to do). tenant_id is not a secret -- it's an opaque - row identifier -- so it's safe to round-trip through Stripe.""" - with self._get_pg_conn() as conn: - cur = self._pg_execute( - conn, - "UPDATE api_keys SET tier = %s WHERE tenant_id = %s AND is_active = true", - (new_tier.value, tenant_id), - ) - updated = cur.rowcount > 0 - conn.commit() - return updated - # -------------------------------------------------------------------------- # Atomic quota consumption (unchanged, but uses api_key which links to tenant) # -------------------------------------------------------------------------- @@ -535,34 +271,6 @@ class UsageTracker: result = self._redis_client.eval(lua_script, 1, redis_key, limit) return result == 1 - def _record_pg_monthly_count(self, api_key: str, month: str) -> None: - """Mirror one successfully-counted call into Postgres `monthly_counts`, - the table arf-gateway's Go code actually reads to compute quota - remaining (see this module's docstring). Best-effort: this service's - own quota decision was already made from SQLite/Redis before this is - called, so a failure here must not fail the request that already - legitimately counted against quota -- but it also must not fail - *silently*, since a swallowed error here is exactly how arf-gateway's - quota check went blind for every key in the first place. Logged at - ERROR, not raised.""" - try: - with self._get_pg_conn() as conn: - self._pg_execute( - conn, - "INSERT INTO monthly_counts (api_key, year_month, count) " - "VALUES (%s, %s, 1) ON CONFLICT (api_key, year_month) " - "DO UPDATE SET count = monthly_counts.count + 1", - (api_key, month), - ) - conn.commit() - except Exception: - logger.error( - "Failed to mirror monthly_counts to Postgres for api_key=%s month=%s -- " - "arf-gateway's quota check will undercount usage for this key until this " - "is resolved.", - api_key, month, exc_info=True, - ) - # -------------------------------------------------------------------------- # Idempotency handling (unchanged) # -------------------------------------------------------------------------- @@ -597,18 +305,15 @@ class UsageTracker: if not quota_ok: return False, None - self._record_pg_monthly_count(record.api_key, month) - try: with self._get_conn() as conn: conn.execute( """INSERT INTO usage_log - (api_key, tier, timestamp, endpoint, request_body, response, error, - processing_ms, idempotency_key) + (api_key, tier, timestamp, endpoint, request_body, response, error, processing_ms, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (record.api_key, record.tier.value, record.timestamp, record.endpoint, - json.dumps(record.request_body, default=str) if record.request_body else None, - json.dumps(record.response, default=str) if record.response else None, + json.dumps(record.request_body) if record.request_body else None, + json.dumps(record.response) if record.response else None, record.error, record.processing_ms, idempotency_key) ) conn.commit() @@ -621,40 +326,6 @@ class UsageTracker: self._mark_idempotent_key_used(idempotency_key) return True, None - def _insert_audit_log(self, record: UsageRecord) -> None: - """Insert a standalone usage_log row for a call whose quota was - already consumed at request time (see consume_quota_and_log) -- - used by routes_governance.py's background tasks to record the - response body once it's known, under a distinct endpoint suffix - (e.g. ".../response"). Best-effort and logged, not raised, for the - same reason _record_pg_monthly_count is: this runs after the - response has already been sent to the caller, so it must not - surface as a request failure -- a background task exception here - is otherwise swallowed silently. `record.tier` is None at both real - call sites (tier only matters for quota consumption, already done - by the earlier consume_quota_and_log call for the same request), - but usage_log.tier is NOT NULL, so an absent tier is recorded as - "unknown" rather than raising or silently guessing a real tier.""" - tier_value = record.tier.value if record.tier else "unknown" - try: - with self._get_conn() as conn: - conn.execute( - """INSERT INTO usage_log - (api_key, tier, timestamp, endpoint, request_body, response, error, - processing_ms, idempotency_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (record.api_key, tier_value, record.timestamp, record.endpoint, - json.dumps(record.request_body, default=str) if record.request_body else None, - json.dumps(record.response, default=str) if record.response else None, - record.error, record.processing_ms, None) - ) - conn.commit() - except Exception: - logger.error( - "Failed to insert audit log for api_key=%s endpoint=%s", - record.api_key, record.endpoint, exc_info=True, - ) - # -------------------------------------------------------------------------- # Legacy interface (kept for compatibility) # -------------------------------------------------------------------------- @@ -722,17 +393,6 @@ class UsageTracker: # -------------------------------------------------------------------------- # Global instance and FastAPI dependency # -------------------------------------------------------------------------- -# Rebound by init_tracker() during the app lifespan, which means consumers -# MUST reach it through the module -- `from app.core import usage_tracker`, -# then `usage_tracker.tracker`. Never `from app.core.usage_tracker import -# tracker`: that copies the *binding* (None) at import time, and init_tracker -# rebinding this global does not update the importer's copy. Five modules -# did exactly that (main, routes_admin, routes_incidents, routes_payments, -# routes_users) and every one of them saw None forever -- silently skipping -# metering and disabling signup/checkout, and crashing the Render deploy -# outright once main.py called a method on it. Functions defined *in* this -# module (enforce_quota, update_key_tier*) are safe to import by name: they -# resolve `tracker` here, at call time. tracker: Optional[UsageTracker] = None @@ -747,36 +407,20 @@ def update_key_tier(api_key: str, new_tier: Tier) -> bool: return tracker.update_api_key_tier(api_key, new_tier) -def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool: - if tracker is None: - return False - return tracker.update_tier_by_tenant_id(tenant_id, new_tier) - - -def _extract_api_key(request: Request, api_key: str = None) -> str: - if api_key: - return api_key - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - return auth_header[7:] - return request.query_params.get("api_key") - - -async def resolve_api_key_identity(request: Request, api_key: str = None): +async def enforce_quota(request: Request, api_key: str = None): """ - FastAPI dependency that authenticates an API key and attaches tenant_id - to request state, without enforcing monthly quota. - - Deliberately separate from `enforce_quota`: a caller whose quota is - already exhausted must still be able to reach an endpoint like - `/payments/create-checkout-session` (upgrading tier is often exactly - what a rate-limited caller is trying to do) -- gating that path behind - `enforce_quota` would 429 the one action that lets them fix it. + FastAPI dependency that enforces quota and attaches tenant_id to request state. """ if tracker is None: raise HTTPException(status_code=503, detail="Usage tracking service not initialised.") - api_key = _extract_api_key(request, api_key) + if api_key is None: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + api_key = auth_header[7:] + else: + api_key = request.query_params.get("api_key") + if not api_key: raise HTTPException(status_code=401, detail="Missing API key") @@ -784,6 +428,11 @@ async def resolve_api_key_identity(request: Request, api_key: str = None): if tier is None: raise HTTPException(status_code=403, detail="Invalid or inactive API key") + remaining = tracker.get_remaining_quota(api_key, tier) + if remaining is not None and remaining <= 0: + raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded") + + # Retrieve tenant_id tenant_id = tracker.get_tenant_id(api_key) if not tenant_id: raise HTTPException(status_code=403, detail="API key not associated with a tenant") @@ -792,18 +441,4 @@ async def resolve_api_key_identity(request: Request, api_key: str = None): request.state.tier = tier request.state.tenant_id = tenant_id - return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id} - - -async def enforce_quota(request: Request, api_key: str = None): - """ - FastAPI dependency that enforces quota and attaches tenant_id to request state. - """ - identity = await resolve_api_key_identity(request, api_key) - api_key, tier, tenant_id = identity["api_key"], identity["tier"], identity["tenant_id"] - - remaining = tracker.get_remaining_quota(api_key, tier) - if remaining is not None and remaining <= 0: - raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded") - return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining} diff --git a/app/database/models_intents.py b/app/database/models_intents.py index abbc711b4fb0445ccd6f56ac6e44f6e4825869c5..a828dda6f6ffb39c6b66ea099dc628a9680d345a 100644 --- a/app/database/models_intents.py +++ b/app/database/models_intents.py @@ -3,18 +3,14 @@ Database models for the ARF API Control Plane. This module defines the SQLAlchemy ORM models for: - Tenants (multi‑tenant isolation root) + - API keys (per‑tenant, tier‑based) + - Usage logs (immutable records of API calls) - Intents (InfrastructureIntent evaluations) - Outcomes (recorded results of executed intents) - Beta state (conjugate Bayesian posteriors per tenant and category) - Audit logs (immutable decision records for compliance) All tables include a `tenant_id` column to enforce data partitioning. - -API keys and usage/quota logs are tracked separately in -`app/core/usage_tracker.py` (SQLite, pepper-HMAC hashed) -- an -`APIKeyDB`/`UsageLogDB` pair used to live here as a second, unused, -plaintext-keyed parallel schema; removed 2026-08-23 since nothing -referenced them. """ import uuid @@ -50,11 +46,74 @@ class TenantDB(Base): created_by = Column(String(128), nullable=True) # Relationships + api_keys = relationship("APIKeyDB", back_populates="tenant", cascade="all, delete-orphan") intents = relationship("IntentDB", back_populates="tenant") beta_states = relationship("BetaStateDB", back_populates="tenant") audit_logs = relationship("DecisionAuditLogDB", back_populates="tenant") +# ============================================================================ +# API keys (extended with tenant_id) +# ============================================================================ + +class APIKeyDB(Base): + """ + Stores API keys for authentication and tiered quota. Each key belongs + to exactly one tenant. The `tier` determines monthly evaluation limits. + + Attributes: + key (str): The hashed API key (primary key). + tenant_id (str): Foreign key to `tenants.id`. + tier (str): Tier enumeration value (free, pro, premium, enterprise). + created_at (datetime): UTC creation time. + last_used_at (datetime, optional): Timestamp of last successful request. + is_active (bool): Soft‑delete flag. + """ + __tablename__ = "api_keys" + + key = Column(String(256), primary_key=True, index=True) + tenant_id = Column(String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + tier = Column(String(32), nullable=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + last_used_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + + # Relationships + tenant = relationship("TenantDB", back_populates="api_keys") + usage_logs = relationship("UsageLogDB", back_populates="api_key_rel", cascade="all, delete-orphan") + + +# ============================================================================ +# Usage logs – each API call +# ============================================================================ + +class UsageLogDB(Base): + """ + Immutable record of each API call for quota tracking and billing. + + Attributes: + id (int): Primary key. + api_key (str): Foreign key to `api_keys.key`. + tier (str): Tier at the time of the call. + timestamp (float): Unix timestamp of the request. + endpoint (str): URL or route of the endpoint hit. + request_body (JSON, optional): Request payload (sanitised). + response (JSON, optional): Response metadata (e.g., status code). + """ + __tablename__ = "usage_logs" + + id = Column(Integer, primary_key=True, index=True) + api_key = Column(String(256), ForeignKey("api_keys.key", ondelete="CASCADE"), nullable=False) + tier = Column(String(32), nullable=False) + timestamp = Column(Float, nullable=False) + endpoint = Column(String(512), nullable=True) + request_body = Column(JSON, nullable=True) + response = Column(JSON, nullable=True) + + # Relationship back to API key + api_key_rel = relationship("APIKeyDB", back_populates="usage_logs") + + # ============================================================================ # Intents (evaluations) – now tenant‑scoped # ============================================================================ diff --git a/app/database/models_onchain.py b/app/database/models_onchain.py deleted file mode 100644 index 939b1122381215821d0df015e1bf41e72ce91826..0000000000000000000000000000000000000000 --- a/app/database/models_onchain.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Database models for on-chain governance attestations. - -``RiskAttestation.rationale_hash`` (see arf-onchain's ``AttestationLib.sol`` -and enterprise's ``arf_enterprise.onchain.attestation``) is a ``keccak256`` -digest anchored on Monad -- the chain deliberately never stores the rationale -text itself, only its hash, to keep operational detail about a customer's -infrastructure off a public ledger. That means the text has to live -somewhere off-chain, keyed by the same hash, or the anchored hash proves -nothing: nobody could ever produce the preimage to check it against. - -This table is that store. It is intentionally separate from -``DecisionAuditLogDB`` (``models_intents.py``) rather than an extension of -it: that table is written for every governance decision, on-chain or not, -and already has its own signature column for a different purpose (Ed25519 -intent-signing, not the secp256k1 EIP-712 signature the guard verifies). -Conflating the two would mean a column that is only sometimes meaningful -depending on whether the decision was ever attested on-chain. -""" - -import uuid -import datetime - -from sqlalchemy import Column, String, DateTime, Text - -from .base import Base - - -class OnchainRationaleDB(Base): - """The plaintext preimage of an anchored ``rationale_hash``. - - Keyed by the hash itself (unique, indexed) rather than by an - auto-incrementing id: a lookup always starts from a hash read off-chain - (from `DecisionAnchored` or `AttestationIssued`), never from a row id - nothing on-chain knows about. - - No ``tenant_id`` / foreign key to ``tenants``: an on-chain agent is - identified by its wallet address, not by this service's tenant concept, - and the two are not yet bridged. `evaluator_address` and `agent_address` - are recorded instead so a row can still be attributed and audited - without assuming a tenant relationship that may not exist. - """ - - __tablename__ = "onchain_rationales" - - id = Column(String(64), primary_key=True, default=lambda: str(uuid.uuid4())) - rationale_hash = Column( - String(66), nullable=False, unique=True, index=True - ) # "0x" + 64 hex chars - rationale = Column(Text, nullable=False) - agent_address = Column(String(42), nullable=True) - evaluator_address = Column(String(42), nullable=True) - created_at = Column( - DateTime, default=datetime.datetime.utcnow, nullable=False, index=True - ) diff --git a/app/main.py b/app/main.py index b13440e1b011811bf7bb4dc85f96fd7755bb5157..bea87cdd2030b99e7e185adf5acef110e3f4dd34 100644 --- a/app/main.py +++ b/app/main.py @@ -9,8 +9,7 @@ enterprise clients, and monitoring infrastructure). It is responsible for: * **Lifetime management** of the Bayesian risk engine, policy engine, - semantic memory (RAG graph), epistemic models, and (new in v4.3.1) - the stability controller and temporal reliability monitor. + semantic memory (RAG graph), and epistemic models. * **Observability** via optional OpenTelemetry tracing and Prometheus metrics (the latter exposed automatically by ``prometheus-fastapi-instrumentator`` on ``/metrics``). @@ -25,7 +24,6 @@ All heavy components are loaded **lazily and best‑effort** – if a dependency is missing the API continues to serve health‑check and status endpoints, degrading gracefully rather than crashing. """ -import hashlib import logging import os import sys @@ -35,9 +33,8 @@ import time as _time from contextlib import asynccontextmanager from typing import Dict -from fastapi import FastAPI, Request +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse # ── Optional: Prometheus metrics ───────────────────────────── try: @@ -74,24 +71,14 @@ except ImportError: RAGGraphMemory = None MemoryConstants = None -# ── Stability & temporal monitors ─────────────────────────── -from agentic_reliability_framework.core.governance.stability_controller import ( - LyapunovStabilityController, -) -from agentic_reliability_framework.core.temporal_reliability import ( - TemporalReliabilityMonitor, -) - # ── Usage tracker ──────────────────────────────────────────── -from app.core import usage_tracker -from app.core.usage_tracker import init_tracker, Tier +from app.core.usage_tracker import init_tracker, tracker, Tier from app.api import ( routes_governance, routes_history, routes_incidents, routes_intents, - routes_onchain, routes_risk, routes_memory, routes_admin, @@ -122,12 +109,11 @@ async def lifespan(app: FastAPI): Initialisation order: 1. Risk engine (Bayesian scoring + HMC). - 2. Load persisted conjugate posterior state per tenant. + 2. **NEW: Load persisted conjugate posterior state per tenant**. 3. OpenTelemetry tracing (console exporter by default). 4. Policy engine, RAG memory, and epistemic model. - 5. Stability controller & temporal monitor (v4.3.1). - 6. Usage tracker (SQLite / Redis). - 7. Wilson confidence monitor for Rust enforcer canary promotion. + 5. Usage tracker (SQLite / Redis). + 6. Wilson confidence monitor for Rust enforcer canary promotion. """ logger.info("🚀 Starting ARF API Control Plane") logger.debug(f"Python path: {sys.path}") @@ -241,27 +227,12 @@ async def lifespan(app: FastAPI): ) app.state.epistemic_model = None app.state.epistemic_tokenizer = None - - # ── 5. Stability controller & temporal monitor (v4.3.1) ─ - try: - app.state.stability_controller = LyapunovStabilityController() - logger.info("✅ LyapunovStabilityController initialized.") - except Exception as e: - logger.warning(f"Stability controller initialization failed: {e}") - app.state.stability_controller = None - - try: - app.state.temporal_monitor = TemporalReliabilityMonitor() - logger.info("✅ TemporalReliabilityMonitor initialized.") - except Exception as e: - logger.warning(f"Temporal monitor initialization failed: {e}") - app.state.temporal_monitor = None else: logger.warning( - "agentic_reliability_framework not installed; risk engine, policy engine, RAG, stability, drift disabled." + "agentic_reliability_framework not installed; risk engine, policy engine, RAG disabled." ) - # ── 6. Usage tracker ────────────────────────────────────── + # ── 5. Usage tracker ────────────────────────────────────── usage_tracking_disabled = ( os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false" ) @@ -272,65 +243,14 @@ async def lifespan(app: FastAPI): db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"), redis_url=os.getenv("ARF_REDIS_URL"), ) - - # Constructing the tracker no longer touches Postgres, so warm - # it deliberately here: a few seconds of retrying is worth it to - # come up with the api_keys schema ready. - # - # A failure is logged and survived, NOT raised. Everything above - # in this lifespan already degrades that way -- the Beta-state - # loader logs a warning on the identical error and continues -- - # and the asymmetry here is what turned a database blip into a - # total outage: raising crash-loops the process, which on Render - # exhausts the port-detection window, fails the deploy, and - # leaves the previous release serving. Starting degraded means - # the health endpoint answers, the deploy succeeds, and API - # requests get a clean 503 from enforce_quota until the database - # is reachable -- at which point they recover with no redeploy. - postgres_ready = usage_tracker.tracker.warm_up() - if not postgres_ready: - logger.error( - "Usage tracker started WITHOUT a Postgres connection: api_keys " - "is unreachable, so API-key validation and quota enforcement " - "will fail (503) until it recovers. Check that DATABASE_URL's " - "host resolves from this service, and that the database is " - "running and in the same region." - ) - - # Seed initial API keys from environment variable (for testing - # / demo). Skipped entirely when Postgres is unreachable: every - # get_or_create_api_key below is a write to api_keys, so - # attempting it would raise straight back into the handler that - # kills the process -- reintroducing the crash loop the warm-up - # above exists to prevent. + # Seed initial API keys from environment variable (for testing / demo) api_keys_json = os.getenv("ARF_API_KEYS", "{}") - if not postgres_ready and api_keys_json not in ("", "{}"): - logger.warning( - "Skipping ARF_API_KEYS seeding: Postgres is unreachable. " - "Seeded keys will not exist until the database recovers " - "and the service is restarted." - ) - api_keys_json = "{}" try: api_keys = json.loads(api_keys_json) for key, tier_str in api_keys.items(): try: tier = Tier(tier_str.lower()) - # Previously called get_or_create_api_key(key, tier) - # -- tier was silently accepted as tenant_id, so - # every seeded key of the same tier collided onto - # one bogus tenant_id. These are demo/env-seeded - # keys with no real TenantDB row, but each still - # needs its own tenant_id to avoid cross-key - # contamination in tenant-scoped state elsewhere - # (BetaStateDB, IntentDB, decision audit log). A - # fixed-length key prefix isn't safe here: keys - # generated elsewhere in this codebase share an - # 8-char prefix ("sk_live_"/"sk_free_"), so a - # prefix-based id would collide the same way the - # original bug did. Hash the whole key instead. - tenant_id = "env-seed-" + hashlib.sha256(key.encode()).hexdigest()[:16] - usage_tracker.tracker.get_or_create_api_key(key, tenant_id=tenant_id, tier=tier) + tracker.get_or_create_api_key(key, tier) logger.info(f"Seeded API key for tier {tier.value}") except ValueError: logger.warning( @@ -340,52 +260,16 @@ async def lifespan(app: FastAPI): logger.warning( "ARF_API_KEYS environment variable is not valid JSON; skipping seeding." ) - app.state.usage_tracker = usage_tracker.tracker - if postgres_ready: - logger.info("✅ Usage tracker ready.") - else: - logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).") + app.state.usage_tracker = tracker + logger.info("✅ Usage tracker ready.") except Exception as e: - # Still fail closed on genuine configuration errors -- a missing - # or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never - # self-resolve, and starting without them would mean serving with - # broken API-key hashing. Database *reachability* is handled - # above and no longer reaches here. logger.critical(f"Failed to initialise usage tracker: {e}") raise RuntimeError("Usage tracker initialisation failed") from e else: logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.") app.state.usage_tracker = None - # ── 6b. Enterprise execution approval ledger (optional) ─── - # Singleton for the same reason usage_tracker/risk_engine are: a fresh - # PostgresStore() per request would open a brand-new DB connection (and - # re-run its schema check) on every call to POST - # /intents/{id}/execute instead of reusing one across requests handled - # by the same worker. Only initialised when execution is actually - # opted into (ARF_ENABLE_EXECUTION=true) and arf_enterprise is - # importable -- always sets app.state.approval_store (to None if - # either condition isn't met) so downstream code never needs a - # hasattr/getattr guard. - app.state.approval_store = None - if os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true": - try: - from arf_enterprise.store import ApprovalStore, PostgresStore - app.state.approval_store = ApprovalStore(PostgresStore()) - logger.info("✅ Enterprise execution approval ledger ready.") - except ImportError: - logger.warning( - "ARF_ENABLE_EXECUTION=true but arf_enterprise is not installed; " - "POST /intents/{id}/execute will return 501." - ) - except Exception as e: - logger.error( - "Failed to initialise the enterprise approval ledger (%s); " - "POST /intents/{id}/execute will fall back to boolean-trust " - "mode for approvals rather than failing startup entirely.", e - ) - - # ── 7. Wilson confidence monitor ────────────────────────── + # ── 6. Wilson confidence monitor ────────────────────────── try: from app.services.wilson_monitor import update as wilson_update from prometheus_client import REGISTRY @@ -447,19 +331,6 @@ def create_app() -> FastAPI: ) logger.debug("CORS middleware configured") - # ── Generic exception handler ──────────────────────────── - # Defense-in-depth for anything that escapes a route's own try/except - # uncaught (HTTPException instances are unaffected -- FastAPI's own, - # more specific handler for those still takes precedence). Logs the - # real exception server-side and returns a generic message: routes - # that catch their own exceptions have each been fixed to do the same, - # but this exists so a bug that skips that pattern doesn't leak raw - # exception text (paths, internals, third-party SDK details) to callers. - @app.exception_handler(Exception) - async def unhandled_exception_handler(request: Request, exc: Exception): - logger.exception("Unhandled exception on %s %s", request.method, request.url.path) - return JSONResponse(status_code=500, content={"detail": "Internal server error"}) - # ── Rate limiter ────────────────────────────────────────── if SLOWAPI_AVAILABLE: app.state.limiter = limiter @@ -492,9 +363,6 @@ def create_app() -> FastAPI: app.include_router( routes_governance.router, prefix="/api/v1", tags=["governance"] ) - app.include_router( - routes_onchain.router, prefix="/api/v1", tags=["onchain"] - ) app.include_router( routes_memory.router, prefix="/v1/memory", tags=["memory"] ) diff --git a/app/models/infrastructure_intents.py b/app/models/infrastructure_intents.py index afdde80ff34c9fdce0cb893a5c0124bbf88d175a..bf41c22c79e0ca228104763b12748905ea71f360 100644 --- a/app/models/infrastructure_intents.py +++ b/app/models/infrastructure_intents.py @@ -15,10 +15,6 @@ class BaseIntentRequest(BaseModel): policy_violations: List[str] = Field(default_factory=list) requester: str = Field(...) provenance: Dict[str, Any] = Field(default_factory=dict) - # v4.3.1: optional skill identifier for Bayesian promotion gate - skill_id: Optional[str] = None - # v4.3.2: optional criticality parameter for dynamic gate tuning (Feature 3) - criticality: Optional[float] = Field(None, ge=0, le=1) class ProvisionResourceRequest(BaseIntentRequest): diff --git a/app/services/outcome_service.py b/app/services/outcome_service.py index f9bd835a2d9786b5364258957141f005faddf72a..2d681824adfcb78eb68bdf1c05253dbad84f25b6 100644 --- a/app/services/outcome_service.py +++ b/app/services/outcome_service.py @@ -1,6 +1,4 @@ -"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps. -Also updates per‑skill reliability models when skill provenance is present (v4.3.1). -""" +"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps.""" import datetime import logging @@ -20,19 +18,11 @@ from app.database.models_intents import IntentDB, OutcomeDB, BetaStateDB logger = logging.getLogger(__name__) -# ── v4.3.1: optional skill registry integration ────────────── -try: - from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry - SKILL_REGISTRY_AVAILABLE = True -except ImportError: - SkillRegistry = None - SKILL_REGISTRY_AVAILABLE = False - # --------------------------------------------------------------------------- -# Helper: persist the conjugate posterior state +# NEW: small helper to persist the conjugate posterior state # --------------------------------------------------------------------------- -def _persist_beta_state(db: Session, tenant_id: str, risk_engine: RiskEngine) -> None: +def _persist_beta_state(db: Session, risk_engine: RiskEngine) -> None: """ Write the current Beta posterior parameters to the beta_state table. This is called after every outcome update so that online learning @@ -41,19 +31,8 @@ def _persist_beta_state(db: Session, tenant_id: str, risk_engine: RiskEngine) -> try: state = risk_engine.beta_store.get_state() for cat, (alpha, beta) in state.items(): - # Upsert on (tenant_id, category): merge() matches on primary key - # only, and these rows are always constructed without an `id`, - # so merge() would always attempt an INSERT and collide with the - # unique constraint on the second write for the same pair. - row = db.query(BetaStateDB).filter( - BetaStateDB.tenant_id == tenant_id, - BetaStateDB.category == cat.value, - ).first() - if row is not None: - row.alpha = alpha - row.beta = beta - else: - db.add(BetaStateDB(tenant_id=tenant_id, category=cat.value, alpha=alpha, beta=beta)) + # Upsert: if the category already exists, update it + db.merge(BetaStateDB(category=cat.value, alpha=alpha, beta=beta)) db.commit() logger.debug("Persisted Beta posterior parameters to database.") except Exception as e: @@ -83,16 +62,12 @@ def reconstruct_oss_intent_from_json( def record_outcome( db: Session, - tenant_id: str, deterministic_id: str, success: bool, recorded_by: Optional[str], notes: Optional[str], risk_engine: RiskEngine, idempotency_key: Optional[str] = None, - skill_id: Optional[str] = None, # v4.3.1 - skill_version: Optional[int] = None, # v4.3.1 - skill_registry: Optional["SkillRegistry"] = None, # v4.3.1 ) -> OutcomeDB: """ Record an outcome for a previously evaluated intent. @@ -103,52 +78,25 @@ def record_outcome( No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine is NOT updated – we log an error and still record the outcome. - The intent lookup is scoped to `tenant_id` so a caller can only record outcomes for - intents owned by their own tenant, even if they know or guess another tenant's - deterministic_id. - - Parameters - ---------- - db : Session - SQLAlchemy session. - tenant_id : str - Tenant of the authenticated caller. Must match the intent's owning tenant. - deterministic_id : str - Unique identifier of the original intent. - success : bool - Whether the action succeeded (True) or failed (False). - recorded_by : str or None - Optional user or system identifier. - notes : str or None - Optional human-readable notes. - risk_engine : RiskEngine - ARF risk engine instance (may be updated). - idempotency_key : str or None - Optional caller-provided idempotency token. - skill_id : str or None (v4.3.1) - Identifier of the procedural skill that guided the action. - skill_version : int or None (v4.3.1) - Version number of that skill. - skill_registry : SkillRegistry or None (v4.3.1) - Optional skill registry instance to update per‑skill reliability. - - Returns - ------- - OutcomeDB - The recorded outcome object. - - Raises - ------ - ValueError - If intent not found or reconstruction fails fatally. - OutcomeConflictError - If a conflicting outcome already exists. + Args: + db: SQLAlchemy session. + deterministic_id: Unique identifier of the original intent. + success: Whether the action succeeded (True) or failed (False). + recorded_by: Optional user or system identifier. + notes: Optional human-readable notes. + risk_engine: ARF risk engine instance (may be updated). + idempotency_key: Optional caller-provided idempotency token. + + Returns: + The recorded OutcomeDB object. + + Raises: + ValueError: If intent not found or reconstruction fails fatally. + OutcomeConflictError: If a conflicting outcome already exists. """ - # 1. Fetch the original intent record, scoped to the caller's tenant + # 1. Fetch the original intent record intent = db.query(IntentDB).filter( - IntentDB.deterministic_id == deterministic_id, - IntentDB.tenant_id == tenant_id, - ).one_or_none() + IntentDB.deterministic_id == deterministic_id).one_or_none() if not intent: raise ValueError(f"Intent not found: {deterministic_id}") @@ -215,7 +163,7 @@ def record_outcome( # ---------------------------------------------------------------- # PERSISTENCE: after updating the conjugate posterior, write it # ---------------------------------------------------------------- - _persist_beta_state(db, tenant_id, risk_engine) + _persist_beta_state(db, risk_engine) except Exception as e: logger.exception( @@ -228,18 +176,4 @@ def record_outcome( deterministic_id ) - # 6. v4.3.1: Update per‑skill reliability model if provenance is provided - if SKILL_REGISTRY_AVAILABLE and skill_registry is not None and skill_id is not None and skill_version is not None: - try: - skill_registry.observe_outcome(skill_id, skill_version, success) - logger.debug( - "Skill reliability updated for '%s' v%d (success=%s)", - skill_id, skill_version, success, - ) - except Exception as e: - logger.warning( - "Failed to update skill reliability for '%s' v%d: %s", - skill_id, skill_version, e, exc_info=True, - ) - return outcome diff --git a/app/services/risk_service.py b/app/services/risk_service.py index 92a19e9cbf2bc24cc9cac6d0f92d18c269e215e3..e74b9c0e0577a6765e5da372164e0864d36901ae 100644 --- a/app/services/risk_service.py +++ b/app/services/risk_service.py @@ -2,11 +2,7 @@ Risk service – integrates ARF Bayesian risk engine, policy engine, and decision engine. Deterministic, no random fallbacks, explicit error handling. Tenant‑aware. -Version: 2026-07-06 – added evaluate_intent_full with GovernanceLoop integration, -skill context injection, and full HealingIntent serialisation. -v4.3.1 – healing decision now optionally incorporates skill reliability -for Bayesian utility‑aware action selection. -v4.3.2 – passes criticality parameter for dynamic gate tuning (Feature 3). +Version: 2026-06-07 – added tenant_id propagation, improved Rust enforcer integration. """ import json @@ -23,14 +19,6 @@ from agentic_reliability_framework.core.decision.decision_engine import Decision from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory from agentic_reliability_framework.core.research.eclipse_probe import compute_epistemic_risk -# ── Governance loop integration ────────────────────────────── -from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop -from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator -from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all -from agentic_reliability_framework.core.governance.stability_controller import LyapunovStabilityController -from agentic_reliability_framework.core.temporal_reliability import TemporalReliabilityMonitor -from agentic_reliability_framework.core.governance.healing_intent import HealingIntent - # ── optional tracing ───────────────────────────────────────── try: from opentelemetry import trace @@ -110,7 +98,7 @@ def evaluate_intent( intent: InfrastructureIntent, cost_estimate: Optional[float], policy_violations: List[str], - tenant_id: Optional[str] = None, + tenant_id: Optional[str] = None, # <-- NEW: tenant isolation ) -> dict: """ Evaluate an infrastructure intent using the Bayesian risk engine. @@ -155,7 +143,7 @@ def evaluate_intent( "region": getattr(intent, "region", None), "resource_type": getattr(intent, "resource_type", None), "permission_level": getattr(intent, "permission_level", None), - "tenant_id": tenant_id, + "tenant_id": tenant_id, # pass tenant for logging "extra": {} } rust_raw = _rust_evaluator.evaluate( @@ -181,6 +169,10 @@ def evaluate_intent( # ── Core risk evaluation ────────────────────────────────── try: + # Note: The RiskEngine must be modified to accept tenant_id and use + # a per‑tenant BetaStore. This change is expected in the core engine. + # Here we pass the tenant_id as a keyword argument; the engine will + # ignore it if not yet implemented, but we log a warning. if hasattr(engine, "set_tenant"): engine.set_tenant(tenant_id) elif tenant_id: @@ -216,174 +208,6 @@ def evaluate_intent( } -def evaluate_intent_full( - intent: InfrastructureIntent, - *, - risk_engine: RiskEngine, - cost_estimator: Optional[CostEstimator] = None, - policy_evaluator: Optional[PolicyEvaluator] = None, - memory: Optional[RAGGraphMemory] = None, - enable_epistemic: bool = False, - hallucination_probe: Optional[Any] = None, - predictive_engine: Optional[Any] = None, - business_calculator: Optional[Any] = None, - use_rust_enforcer: bool = False, - stability_controller: Optional[LyapunovStabilityController] = None, - temporal_monitor: Optional[TemporalReliabilityMonitor] = None, - tenant_id: Optional[str] = None, - skill_id: Optional[str] = None, - skill_registry: Optional[Any] = None, - context_extra: Optional[Dict[str, Any]] = None, - criticality: Optional[float] = None, # v4.3.2 -) -> Dict[str, Any]: - """ - Run the full governance loop and return a structured response containing - the serialised HealingIntent with Bayesian skill posterior parameters. - - If stability_controller or temporal_monitor are None (the default), - the governance loop will simply skip those checks. Pass stateful - instances from the app state to accumulate cross‑request state. - - Parameters - ---------- - intent : InfrastructureIntent - The original infrastructure request. - risk_engine : RiskEngine - Bayesian risk engine (tenant‑aware). - cost_estimator : CostEstimator, optional - Monthly cost estimator; a default instance is created if None. - policy_evaluator : PolicyEvaluator, optional - Policy tree evaluator; defaults to `allow_all` if None. - memory : RAGGraphMemory, optional - Semantic memory for similar‑incident retrieval. - enable_epistemic : bool - Whether to run the ECLIPSE hallucination probe and CUDL attribution. - hallucination_probe : HallucinationRisk, optional - Pre‑configured probe instance. - predictive_engine : SimplePredictiveEngine, optional - Time‑series forecasting engine. - business_calculator : BusinessImpactCalculator, optional - Revenue impact estimator. - use_rust_enforcer : bool - Whether to run the Rust policy evaluator in shadow mode. - stability_controller : LyapunovStabilityController, optional - Passive stability monitor; if None, stability checks are skipped. - temporal_monitor : TemporalReliabilityMonitor, optional - Drift detector; if None, drift detection is skipped. - tenant_id : str, optional - Tenant UUID for multi‑tenant state. - skill_id : str, optional - Skill identifier; if provided, the skill's current posterior - parameters are injected into the governance loop. - skill_registry : SkillRegistry, optional - Instance of the skill registry (required if skill_id is given). - context_extra : dict, optional - Additional key‑value pairs to merge into the loop context. - criticality : float, optional - Criticality of the operation (0 = low, 1 = critical). Passed to the - governance loop for dynamic gate threshold tuning (v4.3.2). - - Returns - ------- - dict - Keys: - - risk_score : float - - explanation : str - - contributions : dict (empty; full trace is in healing_intent) - - healing_intent : dict (serialised HealingIntent) - - recommended_action : str - - deterministic_id : str - """ - t0 = time.monotonic() - span = None - if OTEL_AVAILABLE and _tracer: - span = _tracer.start_span("risk_service.evaluate_intent_full") - span.set_attribute("intent_type", type(intent).__name__) - if tenant_id: - span.set_attribute("tenant_id", tenant_id) - - # Default components if not provided - if policy_evaluator is None: - policy_evaluator = PolicyEvaluator(allow_all()) - if cost_estimator is None: - cost_estimator = CostEstimator() - # stability_controller and temporal_monitor are NOT defaulted here; - # they remain None unless explicitly passed. The GovernanceLoop will skip - # those checks gracefully. - - loop = GovernanceLoop( - policy_evaluator=policy_evaluator, - cost_estimator=cost_estimator, - risk_engine=risk_engine, - memory=memory, - enable_epistemic=enable_epistemic, - hallucination_probe=hallucination_probe, - predictive_engine=predictive_engine, - business_calculator=business_calculator, - use_rust_enforcer=use_rust_enforcer, - stability_controller=stability_controller, - temporal_monitor=temporal_monitor, - ) - - # ── Build context with skill posterior parameters ───────── - context: Dict[str, Any] = dict(context_extra) if context_extra else {} - if skill_id and skill_registry is not None: - try: - # Fetch the latest version for the skill - versions = skill_registry.list_skill_versions(skill_id) - version = versions[-1] if versions else 1 - # Use public get_model() instead of direct _models access - model = skill_registry.get_model(skill_id, version) - if model is not None: - alpha = model.alpha - beta = model.beta - reliability = model.mean() - else: - # Use default prior if no model exists yet - alpha = skill_registry.default_prior_alpha - beta = skill_registry.default_prior_beta - reliability = alpha / (alpha + beta) - context.update({ - "skill_id": skill_id, - "skill_version": version, - "skill_ate": skill_registry.get_ate(skill_id, version), - "skill_reliability_score": reliability, - "skill_alpha": alpha, - "skill_beta": beta, - }) - except Exception as e: - logger.warning("Failed to inject skill context for '%s': %s", skill_id, e) - - # v4.3.2: inject criticality into context for dynamic gate tuning - if criticality is not None: - context["criticality"] = criticality - - # ── Execute governance loop ─────────────────────────────── - healing_intent: HealingIntent = loop.run(intent, context=context) - healing_dict = healing_intent.to_dict(include_advisory_context=True) - - risk_score = healing_intent.risk_score or 0.0 - explanation = healing_intent.justification or "" - - # ── Metrics & span finalisation ─────────────────────────── - _EVAL_COUNTER.labels(engine="governance_loop", status="success").inc() - _EVAL_DURATION.labels(engine="governance_loop").observe(time.monotonic() - t0) - - if span: - span.set_attribute("risk_score", risk_score) - span.set_attribute("recommended_action", healing_dict.get("recommended_action")) - span.end() - - return { - "risk_score": risk_score, - "explanation": explanation, - "contributions": {}, # full trace is in healing_intent - "healing_intent": healing_dict, - "recommended_action": healing_dict.get("recommended_action"), - "deterministic_id": healing_intent.deterministic_id, - } - - def evaluate_healing_decision( event: ReliabilityEvent, policy_engine: PolicyEngine, @@ -391,26 +215,11 @@ def evaluate_healing_decision( rag_graph: Optional[RAGGraphMemory] = None, model=None, tokenizer=None, - tenant_id: Optional[str] = None, - # ── v4.3.1: skill context ────────────────────────────────── - skill_id: Optional[str] = None, - skill_version: Optional[int] = None, - skill_registry: Optional[Any] = None, + tenant_id: Optional[str] = None, # <-- NEW for audit context ) -> Dict[str, Any]: """ Evaluate healing actions for a given reliability event using decision‑theoretic selection. - Includes epistemic risk signals from the eclipse probe and, optionally, skill reliability - information to bias the utility towards actions from trusted skills. - - The utility of each candidate action a is extended with two additional terms: - - U(a) = U_base(a) + w_skill · μ_skill − w_σ · σ_skill - - where μ_skill = α/(α+β) is the posterior mean reliability of the skill that - authored the action, and σ_skill = sqrt(αβ / ((α+β)²(α+β+1))) is its - posterior standard deviation. These terms are computed from the conjugate - Beta posterior tracked by the SkillRegistry. When no skill context is - provided, the utility falls back to the original formulation. + Includes epistemic risk signals from the eclipse probe. Parameters ---------- @@ -419,26 +228,19 @@ def evaluate_healing_decision( policy_engine : PolicyEngine The ARF healing policy engine with configured policies. decision_engine : DecisionEngine, optional - If omitted, a default instance is created. If provided, it is used as‑is - (its internal skill registry is not modified). + If omitted, a default instance is created. rag_graph : RAGGraphMemory, optional Semantic memory for similar incident retrieval. model, tokenizer : optional HuggingFace model and tokenizer for epistemic risk computation. tenant_id : str, optional - Tenant UUID for logging and metrics. - skill_id : str, optional - Skill identifier to incorporate into utility. - skill_version : int, optional - Version of the skill. - skill_registry : SkillRegistry, optional - Registry to fetch the skill's posterior parameters. + Tenant UUID for logging and metrics (not used in core logic yet). Returns ------- dict Keys: risk_score, selected_action, expected_utility, alternatives, - explanation, epistemic_signals, plus skill_id/skill_version if present. + explanation, epistemic_signals. """ t0 = time.monotonic() span = None @@ -452,10 +254,10 @@ def evaluate_healing_decision( if decision_engine is None and hasattr(policy_engine, 'decision_engine'): decision_engine = policy_engine.decision_engine - # If still None, create a minimal one (global stats only), passing skill registry if available + # If still None, create a minimal one (global stats only) if decision_engine is None: logger.debug("No DecisionEngine provided; creating default instance") - decision_engine = DecisionEngine(rag_graph=rag_graph, skill_registry=skill_registry) + decision_engine = DecisionEngine(rag_graph=rag_graph) # Get raw candidate actions (by temporarily disabling decision engine) orig_use = policy_engine.use_decision_engine @@ -472,7 +274,7 @@ def evaluate_healing_decision( span.end() _EVAL_COUNTER.labels(engine="python", status="success").inc() _EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0) - no_action_result = { + return { "risk_score": 0.0, "selected_action": HealingAction.NO_ACTION.value, "expected_utility": 0.0, @@ -480,10 +282,6 @@ def evaluate_healing_decision( "explanation": "No candidate actions triggered.", "epistemic_signals": None, } - if skill_id: - no_action_result["skill_id"] = skill_id - no_action_result["skill_version"] = skill_version - return no_action_result # Build reasoning text from policies that triggered the actions reasoning_parts = [] @@ -530,14 +328,10 @@ def evaluate_healing_decision( "hallucination_risk": 0.0, } - # ── Decision with skill context ────────────────────────── + # Run decision engine to get best action and alternatives decision = decision_engine.select_optimal_action( - raw_actions, - event, - component=event.component, - epistemic_signals=epistemic_signals, - skill_id=skill_id, - skill_version=skill_version, + raw_actions, event, component=event.component, + epistemic_signals=epistemic_signals ) # Extract risk of the selected action @@ -570,7 +364,7 @@ def evaluate_healing_decision( span.set_attribute("expected_utility", decision.expected_utility) span.end() - result = { + return { "risk_score": risk_score, "selected_action": decision.best_action.value, "expected_utility": decision.expected_utility, @@ -579,10 +373,6 @@ def evaluate_healing_decision( "raw_decision": decision.raw_data, "epistemic_signals": epistemic_signals, } - if skill_id: - result["skill_id"] = skill_id - result["skill_version"] = skill_version - return result def get_system_risk() -> float: diff --git a/deploy/kubernetes/arf-api/configmap.yaml b/deploy/kubernetes/arf-api/configmap.yaml deleted file mode 100644 index cf2504bb71d81055b1a443df39b1b64ee0bd7df7..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/configmap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: arf-api-config - namespace: arf-system -data: - ARF_HMC_MODEL: "models/hmc_model.json" - ARF_USE_HYPERPRIORS: "false" - ARF_USAGE_TRACKING: "true" - ARF_USE_RUST_ENFORCER: "false" - EPISTEMIC_MODEL: "" diff --git a/deploy/kubernetes/arf-api/deployment.yaml b/deploy/kubernetes/arf-api/deployment.yaml deleted file mode 100644 index 664a9fec2aae45480ebeb9fb33fb066f54c7a7af..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/deployment.yaml +++ /dev/null @@ -1,65 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: arf-api - namespace: arf-system - labels: - app: arf-api - version: v4.3.2 -spec: - replicas: 3 - strategy: - type: RollingUpdate - rollingUpdate: - maxUnavailable: 1 - maxSurge: 1 - selector: - matchLabels: - app: arf-api - template: - metadata: - labels: - app: arf-api - version: v4.3.2 - spec: - serviceAccountName: arf-api - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 - containers: - - name: arf-api - image: arf-api:latest # Replace with specific tag in production - imagePullPolicy: Always - ports: - - containerPort: 8000 - protocol: TCP - envFrom: - - configMapRef: - name: arf-api-config - - secretRef: - name: arf-api-secrets - resources: - requests: - cpu: 500m - memory: 512Mi - limits: - cpu: 2000m - memory: 2Gi - livenessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 2 - terminationGracePeriodSeconds: 30 diff --git a/deploy/kubernetes/arf-api/hpa.yaml b/deploy/kubernetes/arf-api/hpa.yaml deleted file mode 100644 index 18200ffbc92c2c1b55bf33807cb08b37eeba6d5e..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/hpa.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: arf-api-hpa - namespace: arf-system -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: arf-api - minReplicas: 3 - maxReplicas: 10 - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: 80 diff --git a/deploy/kubernetes/arf-api/networkpolicy.yaml b/deploy/kubernetes/arf-api/networkpolicy.yaml deleted file mode 100644 index f77b2de3627bcb849a08cf5c1978f86d95de0f39..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/networkpolicy.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: arf-api-ingress - namespace: arf-system -spec: - podSelector: - matchLabels: - app: arf-api - policyTypes: - - Ingress - ingress: - # Allow traffic only from the gateway pods on port 8000 - - from: - - podSelector: - matchLabels: - app: arf-gateway - ports: - - port: 8000 - protocol: TCP diff --git a/deploy/kubernetes/arf-api/secret.yaml b/deploy/kubernetes/arf-api/secret.yaml deleted file mode 100644 index f2e1fefd54beb754b4524f12c1c81e53bf849e41..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/secret.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# DO NOT apply this file as-is. Every value below is a placeholder, not a -# real secret -- ARF_INTERNAL_API_KEY in particular is a fixed, publicly -# visible string in this repo's git history. Applying it unmodified means -# the "secret" is a known value, not a secret. -# -# Generate real values instead, e.g.: -# kubectl create secret generic arf-api-secrets -n arf-system \ -# --from-literal=DATABASE_URL=... \ -# --from-literal=ARF_INTERNAL_API_KEY=$(openssl rand -hex 32) \ -# --from-literal=ARF_API_KEYS='{}' \ -# --from-literal=ARF_REDIS_URL=... -# or manage this via a secrets operator (External Secrets, Sealed Secrets, -# SOPS) rather than a plain committed manifest. -apiVersion: v1 -kind: Secret -metadata: - name: arf-api-secrets - namespace: arf-system -type: Opaque -stringData: - # Placeholders only -- see warning above. Replace before applying. - DATABASE_URL: "postgresql://user:password@host:5432/arf" - ARF_INTERNAL_API_KEY: "change-me-to-a-strong-random-key" - ARF_API_KEYS: '{}' - ARF_REDIS_URL: "" diff --git a/deploy/kubernetes/arf-api/service.yaml b/deploy/kubernetes/arf-api/service.yaml deleted file mode 100644 index c434fc9ba8a87290bf15036143f3e3b05ba7eae9..0000000000000000000000000000000000000000 --- a/deploy/kubernetes/arf-api/service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: arf-api - namespace: arf-system - labels: - app: arf-api -spec: - type: ClusterIP - ports: - - port: 8000 - targetPort: 8000 - protocol: TCP - name: http - selector: - app: arf-api diff --git a/docs/authentication.md b/docs/authentication.md index fbfb2658b46f6b6b81357479dcabd86e9e920bc3..3eb807a52b4ac7f128a7cdb7a36bac55eecad0c7 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -4,48 +4,22 @@ This page describes how to authenticate with the ARF API. Current status -- `routes_governance.py`, `routes_risk.py`, `routes_intents.py`, `routes_history.py`, - `routes_memory.py`: the entire router requires the `X-Internal-Key` header, verified against - `ARF_INTERNAL_API_KEY` (`app/api/deps.py::verify_internal_key`). This fails closed — requests - are rejected with 401 if the env var is unset, if the header is missing, or if it doesn't - match (constant-time comparison). This is the header arf-gateway injects when proxying to - this service. The last four were unauthenticated until this was fixed — see - `tests/test_deps.py` for the tests that verify the dependency itself actually rejects what - it should, not just that it's wired in. -- `routes_incidents.py`'s `POST /report_incident` requires the same `X-Internal-Key` - dependency as above — it did not until a later audit found it had none at all, unlike every - other route in this file, despite its own docstring saying it's meant for internal monitoring - tools only. Anyone could previously write arbitrary events into the incident history that - feeds the causal explainer and `GET /history`. That history is now also a bounded - `deque(maxlen=10_000)` (`app/core/storage.py`), not an unbounded list — the same audit found - `POST /report_incident` and `GET /history` were reading/writing two _different_ Python lists - with the same name, so `GET /history` had in fact always returned empty regardless of what was - reported; both routers now share the one list in `app.core.storage`. -- `routes_admin.py`: individual `/admin/*` endpoints require an `admin_key` query parameter, - verified against `ARF_ADMIN_API_KEY` (`app/api/deps.py`, or the local `verify_admin` - dependency in that router). Also fails closed if unset. Includes - `POST /admin/keys/{key_id}/rotate` — deactivates a key and issues a new one on the same - tenant/tier in one transaction, for when a key needs to be revoked without losing the - tenant's identity or history. `api_keys` itself lives in Postgres (`DATABASE_URL`), shared - with arf-gateway (both must use the identical `ARF_KEY_PEPPER`) — see the comments on those - two variables in `.env.example` for why. -- `routes_pricing.py`: individual `/pricing/*` endpoints require a real per-customer API key - (`Authorization: Bearer ` or `?api_key=`), verified against the tracked/tenant-scoped - `enforce_quota` dependency (`app/core/usage_tracker.py`) — a different mechanism from - `X-Internal-Key`, since pricing estimates are meant to be reachable by a customer directly, - not only via the gateway. +- There is no route-level or global authentication enforced by the API code in this repository. The API routes (including governance endpoints) do not validate API keys, tokens, or other credentials. What the code provides -- `app/core/config.py` exposes an `api_key` setting read from `.env`, but no current route - checks it — it is not the mechanism in use. The real mechanisms are `X-Internal-Key`, - `ARF_ADMIN_API_KEY`, and per-customer API keys (`enforce_quota`), all checked in - `app/api/deps.py` / `app/core/usage_tracker.py`. +- The configuration model (app/core/config.py) exposes an optional `api_key` setting. This can be provided via environment variables or a `.env` file (the BaseSettings `env_file` is configured to read `.env`). + +What this means for you + +- Setting `API_KEY` in a `.env` file or environment variable will populate the `settings.api_key`, but the current route implementations do not check this value. +- If you require authentication, add a FastAPI dependency or middleware that checks `settings.api_key` (or another auth mechanism) and then apply it to routes or include it in a dependency override. + +Suggested minimal approach to enable API key checking + +- Implement a dependency in `app.api.deps` (e.g., `get_api_key`) that compares a header value to `settings.api_key` and raise `HTTPException(401)` when missing/invalid. +- Add that dependency to routers or individual endpoints where auth is required. Notes -- Tests run against a real Postgres connection (`tests/conftest.py`), not SQLite; see the top-level README's Tests section. -- `tests/conftest.py` globally overrides `verify_internal_key` for the test suite (so routes - behind it can be exercised without the gateway-injected header) — this means the app-level - test suite alone can't confirm the dependency actually fails closed. `tests/test_deps.py` - calls it directly, bypassing that override, specifically to verify that. +- Tests and example code in this repo currently run without auth. diff --git a/docs/development.md b/docs/development.md index 599984d52aa1844ba25560472e867ca21208609d..44772d054d30ca5b2f59343447d9f4b4474060e0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,11 +20,10 @@ Quick start python -m venv .venv source .venv/bin/activate # or .\.venv\Scripts\activate on Windows pip install -r requirements.txt - pip install -r requirements-dev.txt # needed to run the test suite (pytest, etc.) 3. Configure environment variables (optional): - - The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`). Copy `.env.example` to `.env` and fill in real values locally. + - The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`). Create a `.env` file to set values locally. Relevant environment variables used by the code: - ARF_HMC_MODEL (default: `models/hmc_model.json`) — path to HMC model JSON used by RiskEngine. diff --git a/render.yaml b/render.yaml index 07e02b0749f4ae8c67830f7289571e0b64916271..6a5d4288348505c09905640f0a0c7a10f690cc6c 100644 --- a/render.yaml +++ b/render.yaml @@ -11,8 +11,6 @@ services: property: connectionString - key: API_KEY sync: false - - key: ARF_KEY_PEPPER - sync: false - key: ENVIRONMENT value: production databases: diff --git a/requirements-dev.txt b/requirements-dev.txt index 2e47703d9000ee02681a2e3f2b1b0ad1b1e45701..9ae6547de663ff4d0735a80961816f447edd07a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,3 @@ -pytest>=9.0.3 pytest-cov>=7.0.0 jsonschema>=4.0.0 pytest-asyncio>=0.24.0 -pytest-timeout>=2.3.1 diff --git a/requirements.txt b/requirements.txt index 3324f58bcc0632a80bb66ba706bbdaaabd9a42b2..4efe44e77a03c697b44d8bee5cc85e65561ae04f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,19 @@ fastapi==0.115.12 uvicorn[standard]==0.34.0 pydantic>=2.13.2 -agentic-reliability-framework @ git+https://github.com/arf-foundation/agentic_reliability_framework@main +agentic-reliability-framework @ git+https://github.com/arf-foundation/agentic-reliability-framework@main arf-pricing-calculator @ git+https://github.com/arf-foundation/ARF-Bayesian-Pricing-Calculator@main +pytest==8.3.5 +pytest==8.3.5 httpx==0.28.1 alembic pydantic-settings sqlalchemy psycopg2-binary==2.9.10 slowapi==0.1.9 -limits==3.3.1 # slowapi 0.1.9's own poetry.lock pins this; left unpinned it resolves - # to a much newer major version whose internal API to slowapi breaks - # (Limiter._check_request_limit raises a plain ValueError instead of - # RateLimitExceeded, which slowapi's middleware mishandles as an - # unhandled 500 on every request touching a rate limit) prometheus-fastapi-instrumentator==7.1.0 flake8==7.2.0 -cryptography==50.0.0 +cryptography==47.0.0 sentence-transformers>=2.2.0 scikit-learn redis>=4.0.0 # optional, for faster counters diff --git a/tests/conftest.py b/tests/conftest.py index 8ba8af026d7f6eed35883e0937bf1777bd0c5cfd..51d0ea8e67425772790cb0086a2f00af20cf40b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,13 +3,11 @@ pytest configuration and fixtures for ARF API tests. """ from app.core.usage_tracker import enforce_quota, Tier -from app.api.deps import get_db, verify_internal_key +from app.api.deps import get_db from app.database.base import Base -from app.database.models_intents import IntentDB, TenantDB, BetaStateDB, DecisionAuditLogDB # noqa: E501,F401 -- imported for their side effect of registering these tables on Base.metadata from app.main import app as fastapi_app from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from fastapi import Request from fastapi.testclient import TestClient import app.core.usage_tracker import os @@ -18,10 +16,6 @@ import pytest # ===== STEP 1: Set environment variables BEFORE any app imports ===== os.environ["ARF_USAGE_TRACKING"] = "false" -# UsageTracker now requires a real pepper to hash/verify API keys (H-2 fix); -# tests never touch a production key, so a fixed test-only value is fine. -os.environ.setdefault("ARF_KEY_PEPPER", "test-only-pepper-not-for-production-use-32chars") - # Force the correct database URL for tests os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb" os.environ["TEST_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb" @@ -46,9 +40,6 @@ class MockTracker: return 1000 - def get_tenant_id(self, api_key): - return "test-tenant" - def consume_quota_and_log(self, record, idempotency_key=None): return (True, None) @@ -107,19 +98,10 @@ fastapi_app.dependency_overrides[get_db] = override_get_db # Override enforce_quota dependency -async def mock_enforce_quota(request: Request, api_key: str = None): - return {"api_key": "test_key", "tier": Tier.PRO, "tenant_id": "test-tenant", "remaining": 1000} +async def mock_enforce_quota(request, api_key=None): + return {"api_key": "test_key", "tier": Tier.PRO, "remaining": 1000} fastapi_app.dependency_overrides[enforce_quota] = mock_enforce_quota -# Override verify_internal_key: production fails closed when -# ARF_INTERNAL_API_KEY is unset, but tests exercise routes directly without -# the gateway-injected X-Internal-Key header. - - -async def mock_verify_internal_key(): - return None -fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key - @pytest.fixture(scope="session", autouse=True) def setup_database(): @@ -137,15 +119,10 @@ def client(): @pytest.fixture(scope="function") def db_session(): - """Provide a database session for each test. - - Schema lifecycle is owned entirely by the session-scoped - `setup_database` fixture. Dropping tables here would blow away the - shared schema for any test that runs afterwards without itself - depending on `db_session` (e.g. tests that build their own bare - TestClient), leaving them with a database that has no tables at all. - """ + """Provide a clean database session for each test.""" + Base.metadata.create_all(bind=engine) session = TestingSessionLocal() yield session session.rollback() session.close() + Base.metadata.drop_all(bind=engine) diff --git a/tests/test_deps.py b/tests/test_deps.py index 8635efc6bc104675139470b090007384be469cbd..b6982ece0565f02abda88a46fcaed1a437f52c6e 100644 --- a/tests/test_deps.py +++ b/tests/test_deps.py @@ -1,10 +1,5 @@ -import importlib -from unittest.mock import MagicMock, patch - import pytest -from fastapi import HTTPException - -import app.api.deps as deps +from unittest.mock import patch, MagicMock from app.api.deps import get_db @@ -18,72 +13,3 @@ def test_get_db_closes_session(): with pytest.raises(Exception): db_gen.throw(Exception("test error")) mock_session.close.assert_called_once() - - -# verify_internal_key tests below are called directly, not through -# TestClient: tests/conftest.py globally overrides verify_internal_key -# (`fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key`) -# so that already-protected routes (routes_governance.py) can be exercised -# in tests without the gateway-injected X-Internal-Key header. That override -# makes the real fail-closed behavior untestable through the app for any -# router that uses it -- this is the only place it's actually verified to -# reject what it should reject, rather than just trusted to work because -# it's wired in. -# -# Newly relevant as of the auth fix to routes_risk.py, routes_intents.py, -# routes_history.py, routes_memory.py (see docs/authentication.md) -- those -# four routers now depend on this function passing correctly. - -_NEWLY_PROTECTED_ROUTER_MODULES = [ - "app.api.routes_risk", - "app.api.routes_intents", - "app.api.routes_history", - "app.api.routes_memory", -] - - -@pytest.mark.asyncio -async def test_verify_internal_key_rejects_missing_header(monkeypatch): - monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret") - with pytest.raises(HTTPException) as exc_info: - await deps.verify_internal_key(x_internal_key=None) - assert exc_info.value.status_code == 401 - - -@pytest.mark.asyncio -async def test_verify_internal_key_rejects_wrong_key(monkeypatch): - monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret") - with pytest.raises(HTTPException) as exc_info: - await deps.verify_internal_key(x_internal_key="wrong-key") - assert exc_info.value.status_code == 401 - - -@pytest.mark.asyncio -async def test_verify_internal_key_fails_closed_when_unset(monkeypatch): - """The env var being unset must reject every request, not let them - through -- this is the specific property that makes this safe to add - to a router without also needing to guarantee the env var is always - set.""" - monkeypatch.setattr(deps, "INTERNAL_API_KEY", "") - with pytest.raises(HTTPException) as exc_info: - await deps.verify_internal_key(x_internal_key="anything") - assert exc_info.value.status_code == 401 - - -@pytest.mark.asyncio -async def test_verify_internal_key_accepts_correct_key(monkeypatch): - monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret") - result = await deps.verify_internal_key(x_internal_key="real-secret") - assert result is None - - -@pytest.mark.parametrize("router_module_name", _NEWLY_PROTECTED_ROUTER_MODULES) -def test_router_requires_verify_internal_key(router_module_name): - """Structural check, independent of the function-level tests above: - proves each router actually declares verify_internal_key as a - router-level dependency, not just that the function itself works in - isolation. Mirrors the pattern routes_governance.py already uses - (`APIRouter(dependencies=[Depends(verify_internal_key)])`).""" - module = importlib.import_module(router_module_name) - dependency_callables = [d.dependency for d in module.router.dependencies] - assert deps.verify_internal_key in dependency_callables diff --git a/tests/test_governance.py b/tests/test_governance.py index 22ac986ff7294d3e149b1100c9faf74ee2e9b248..c4b23b5a680e1a77f34b4d9b4c1422afd7bce904 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -1,21 +1,6 @@ """ Tests for governance endpoints: /api/v1/intents/evaluate """ -import tempfile - -import pytest -import app.core.usage_tracker as usage_tracker_module -from app.core.usage_tracker import UsageTracker -from app.database.models_intents import TenantDB - - -@pytest.fixture(autouse=True) -def seed_tenant(db_session): - """Ensure the tenant 'test-tenant' exists before each test.""" - tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first() - if not tenant: - db_session.add(TenantDB(id="test-tenant", name="Test Tenant")) - db_session.commit() def test_evaluate_provision_intent(client): @@ -88,65 +73,3 @@ def test_invalid_intent_type(client): response = client.post("/api/v1/intents/evaluate", json=payload, headers={"X-Tenant-ID": "test-tenant"}) assert response.status_code == 422 - - -def test_evaluate_with_criticality(client): - """v4.3.2: criticality is accepted and a context_hash is generated.""" - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {}, - "criticality": 0.85 - } - response = client.post("/api/v1/intents/evaluate", json=payload, - headers={"X-Tenant-ID": "test-tenant"}) - assert response.status_code == 200, response.text - data = response.json() - assert "risk_score" in data - # The healing_intent dict should contain the new fields. - healing = data.get("healing_intent", {}) - # criticality is passed through - assert healing.get("criticality") == 0.85 - # context_hash is computed by the governance loop (a 64‑char hex string) - ctx_hash = healing.get("context_hash") - assert isinstance(ctx_hash, str) and len(ctx_hash) == 64 - - -def test_evaluate_intent_against_real_tracker_does_not_crash(client, monkeypatch): - """Every other test in this file runs against tests/conftest.py's - MockTracker, whose consume_quota_and_log ignores record.tier entirely - and always returns (True, None) -- so none of them would have noticed - that this endpoint hardcoded tier=None instead of using quota["tier"] - (already resolved by the enforce_quota dependency). Against the real - UsageTracker, consume_quota_and_log evaluates tier.monthly_evaluation_limit - unconditionally, so a None tier raised AttributeError on every real - call, outside any try/except, surfacing as a raw 500. This test swaps - in a real UsageTracker (a throwaway SQLite file; the same real - Postgres the CI service provides backs api_keys/monthly_counts) to - prove the endpoint no longer crashes.""" - with tempfile.NamedTemporaryFile(suffix=".db") as tmp: - monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name)) - - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {} - } - response = client.post("/api/v1/intents/evaluate", json=payload, - headers={"X-Tenant-ID": "test-tenant"}) - assert response.status_code == 200, response.text - assert "risk_score" in response.json() diff --git a/tests/test_healing_endpoint.py b/tests/test_healing_endpoint.py index 858814bae0eeef3a02857f876e05746de93ce6f8..fee89195fd24ecca896c8eb294c79aa191180707 100644 --- a/tests/test_healing_endpoint.py +++ b/tests/test_healing_endpoint.py @@ -1,9 +1,5 @@ -import tempfile - from fastapi.testclient import TestClient from app.main import app -import app.core.usage_tracker as usage_tracker_module -from app.core.usage_tracker import UsageTracker client = TestClient(app) @@ -22,27 +18,3 @@ def test_healing_evaluate_endpoint(): response = client.post("/api/v1/healing/evaluate", json=payload, headers={"X-Tenant-ID": "test-tenant"}) assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" - - -def test_healing_evaluate_against_real_tracker_does_not_crash(monkeypatch): - """Same regression as test_governance.py's equivalent test, for this - endpoint's own hardcoded tier=None (routes_governance.py's - /healing/evaluate handler) -- see that test's docstring for the full - explanation. Swaps in a real UsageTracker to prove - consume_quota_and_log no longer crashes on a None tier here either.""" - payload = { - "event": { - "component": "my-service", - "latency_p99": 450.0, - "error_rate": 0.25, - "service_mesh": "default", - "cpu_util": 0.85, - "memory_util": 0.90 - } - } - with tempfile.NamedTemporaryFile(suffix=".db") as tmp: - monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name)) - - response = client.post("/api/v1/healing/evaluate", json=payload, - headers={"X-Tenant-ID": "test-tenant"}) - assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}" diff --git a/tests/test_history.py b/tests/test_history.py index 3ecfa47b16867e58ef7980ccb4f8f2b9474316cb..9a9a535329d08951d2cb36ec45f7d1b2d39465c0 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -8,5 +8,9 @@ def test_history(): response = client.get("/api/v1/history") assert response.status_code == 200 data = response.json() - assert "incidents" in data - assert isinstance(data["incidents"], list) + # The endpoint returns a list of risk points, not an object with an + # "incidents" key + assert isinstance(data, list) + if data: # if not empty, verify the structure of the first item + assert "risk" in data[0] + assert "time" in data[0] diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index f2c1729e5fff99fa7c5aa806e889345fc7427f6b..0000000000000000000000000000000000000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,305 +0,0 @@ -""" -End‑to‑end integration tests for the ARF governance pipeline. - -These tests exercise the full path from HTTP request to HealingIntent -response, validating that every layer – API, governance loop, policy -engine, risk engine, audit log, and optional skill/criticality features – -behaves correctly under realistic conditions. - -v4.3.2: Covers basic evaluation, skill context, criticality, and audit -trace verification. -""" -import pytest -import time -from app.database.models_intents import TenantDB, DecisionAuditLogDB - - -@pytest.fixture(autouse=True) -def seed_tenant(db_session): - """Ensure the tenant 'test-tenant' exists before each test.""" - tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first() - if not tenant: - db_session.add(TenantDB(id="test-tenant", name="Test Tenant")) - db_session.commit() - - -class TestFullPipeline: - """End‑to‑end tests for the /intents/evaluate endpoint.""" - - def test_basic_provision_evaluation(self, client): - """A minimal valid request returns 200 and a well‑formed HealingIntent.""" - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {} - } - response = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200, response.text - data = response.json() - # Top‑level fields - assert "risk_score" in data - assert "explanation" in data - assert "deterministic_id" in data - assert "recommended_action" in data - assert isinstance(data["risk_score"], float) - assert 0.0 <= data["risk_score"] <= 1.0 - # HealingIntent contract - healing = data.get("healing_intent", {}) - assert healing.get("action") is not None - assert healing.get("component") is not None - assert "justification" in healing - assert "confidence" in healing - assert "version" in healing - assert healing["version"] == "2.6.0" - # v4.3.2: context_hash must be present (64 hex chars) - ctx_hash = healing.get("context_hash") - assert isinstance(ctx_hash, str) and len(ctx_hash) == 64, ( - f"context_hash missing or invalid: {ctx_hash}" - ) - - def test_audit_log_written(self, client, db_session): - """A successful evaluation writes a row to the decision audit log. - - deterministic_id is a hash of (action, component, parameters, - incident_id, oss_edition) only -- and provision_resource requests - have no field that varies component or parameters -- so a minimal - provision_resource payload here would collide with the identical - one in test_basic_provision_evaluation and hit the audit log's - idempotency skip. Checking for the specific row by the response's - own deterministic_id (rather than a tenant-wide count delta) tests - the actual claim -- "this decision got audited" -- without being - sensitive to what other tests already wrote for the same decision. - """ - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {} - } - response = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - deterministic_id = response.json()["deterministic_id"] - # The write_audit_log runs as a background task; give it a moment. - time.sleep(0.5) - entry = ( - db_session.query(DecisionAuditLogDB) - .filter_by(tenant_id="test-tenant", deterministic_id=deterministic_id) - .first() - ) - assert entry is not None, ( - f"Expected an audit log entry for deterministic_id={deterministic_id}" - ) - - def test_skill_context_injection(self, client): - """When skill_id is provided, the response includes skill posterior data.""" - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {}, - "skill_id": "pdf-skill", - } - response = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - healing = response.json().get("healing_intent", {}) - # Skill fields should be present in the HealingIntent - assert "skill_id" in healing - assert healing["skill_id"] == "pdf-skill" - # Because the skill registry is a singleton, the skill may or may not - # already exist. In either case, the fields are populated with either - # the posterior or the default prior. - assert "skill_alpha" in healing - assert "skill_beta" in healing - assert "skill_reliability_score" in healing - assert "skill_version" in healing - - def test_criticality_parameter(self, client): - """The criticality field is accepted and flows into the HealingIntent.""" - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {}, - "criticality": 0.85, - } - response = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - healing = response.json().get("healing_intent", {}) - assert healing.get("criticality") == 0.85, ( - f"criticality should be 0.85, got {healing.get('criticality')}" - ) - - def test_policy_violation_denial(self, client): - """An intent with a policy violation returns DENY.""" - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "westus", # not in default allowed set - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": ["Region 'westus' not allowed"], # pre‑computed - "requester": "alice", - "provenance": {}, - "configuration": {} - } - response = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - data = response.json() - assert data.get("recommended_action") == "deny", ( - f"Expected action=deny, got {data.get('recommended_action')}" - ) - - -class TestHealingPipeline: - """End‑to‑end tests for the /healing/evaluate endpoint.""" - - def test_basic_healing_evaluation(self, client): - """A reliability event triggers candidate healing actions.""" - payload = { - "event": { - "component": "checkout-service", - "latency_p99": 600.0, - "error_rate": 0.25, - "service_mesh": "default", - "cpu_util": 0.85, - "memory_util": 0.90, - } - } - response = client.post( - "/api/v1/healing/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - data = response.json() - assert "selected_action" in data - assert data["selected_action"] != "NO_ACTION", ( - "Expected at least one healing action to be triggered" - ) - - def test_healing_with_skill_context(self, client): - """Skill context biases the healing decision utility.""" - payload = { - "event": { - "component": "checkout-service", - "latency_p99": 600.0, - "error_rate": 0.25, - "service_mesh": "default", - "cpu_util": 0.85, - "memory_util": 0.90, - }, - "skill_id": "pdf-skill", - "skill_version": 1, - } - response = client.post( - "/api/v1/healing/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert response.status_code == 200 - data = response.json() - # The response should echo the skill context back - assert data.get("skill_id") == "pdf-skill" - assert data.get("skill_version") == 1 - assert "selected_action" in data - - -class TestOutcomeRecording: - """End‑to‑end tests for the /intents/outcome endpoint.""" - - def test_record_outcome_updates_risk_engine(self, client, db_session): - """Recording a successful outcome for a previously evaluated intent - updates the conjugate posterior and skill registry.""" - # Step 1: evaluate an intent to create a record - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {}, - } - eval_resp = client.post( - "/api/v1/intents/evaluate", - json=payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert eval_resp.status_code == 200 - deterministic_id = eval_resp.json()["deterministic_id"] - - # Step 2: record a successful outcome - outcome_payload = { - "deterministic_id": deterministic_id, - "success": True, - "recorded_by": "tester", - "notes": "integration test", - } - outcome_resp = client.post( - "/api/v1/intents/outcome", - json=outcome_payload, - headers={"X-Tenant-ID": "test-tenant"}, - ) - assert outcome_resp.status_code == 200 - assert "outcome_id" in outcome_resp.json() - - # Step 3: verify that the outcome row exists in the database - from app.database.models_intents import OutcomeDB - outcome = ( - db_session.query(OutcomeDB) - .filter_by(idempotency_key=None) # we didn't send one - .order_by(OutcomeDB.id.desc()) - .first() - ) - assert outcome is not None - assert outcome.success is True - assert outcome.recorded_by == "tester" diff --git a/tests/test_intent_store.py b/tests/test_intent_store.py index 751793c33e3d601982fe90449c71d50c154b939e..4dd2d5552ecb1833020c107018a3bdeb8897ac14 100644 --- a/tests/test_intent_store.py +++ b/tests/test_intent_store.py @@ -21,11 +21,11 @@ def test_save_intent(db_session): saved = save_evaluated_intent( db=db_session, deterministic_id=det_id, - tenant_id="test-tenant", intent_type="ProvisionResourceIntent", api_payload={"foo": "bar"}, oss_payload={"intent_type": "provision_resource"}, environment="prod", + tenant_id="test-tenant", risk_score=0.42, ) assert saved.deterministic_id == det_id @@ -38,9 +38,9 @@ def test_save_intent(db_session): def test_update_existing_intent(db_session): det_id = "intent_123" - # Positional order: db, deterministic_id, tenant_id, intent_type, api_payload, oss_payload, environment, risk_score - save_evaluated_intent(db_session, det_id, "test-tenant", "Type", {}, {}, "prod", 0.5) - updated = save_evaluated_intent(db_session, det_id, "test-tenant", "Type", {}, {}, "prod", 0.7) + # tenant_id is the 7th positional argument, risk_score is the 8th + save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.5) + updated = save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.7) assert updated.risk_score == "0.7" count = db_session.query(IntentDB).filter( IntentDB.deterministic_id == det_id).count() diff --git a/tests/test_outcome_service.py b/tests/test_outcome_service.py index 94e0b28908c934c0dc41da30c8c1bc17e9b24d9a..a86aad9ba55f2d20bee62c2005dc9932d216520d 100644 --- a/tests/test_outcome_service.py +++ b/tests/test_outcome_service.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.database.base import Base -from app.database.models_intents import IntentDB, TenantDB +from app.database.models_intents import IntentDB from app.services.outcome_service import record_outcome, OutcomeConflictError from agentic_reliability_framework.core.governance.intents import ( ProvisionResourceIntent, @@ -18,10 +18,6 @@ def db_session(): TestingSessionLocal = sessionmaker(bind=engine, future=True) Base.metadata.create_all(bind=engine) sess = TestingSessionLocal() - # Ensure a tenant exists for foreign key constraints - if not sess.query(TenantDB).filter_by(id="test-tenant").first(): - sess.add(TenantDB(id="test-tenant", name="Test Tenant")) - sess.commit() yield sess sess.close() @@ -46,7 +42,6 @@ def test_record_outcome_creates_row_and_updates_engine( intent = IntentDB( deterministic_id="intent_abc", - tenant_id="test-tenant", # <-- required intent_type="ProvisionResourceIntent", payload={}, oss_payload=oss_payload, @@ -58,7 +53,6 @@ def test_record_outcome_creates_row_and_updates_engine( outcome = record_outcome( db=db_session, - tenant_id="test-tenant", deterministic_id="intent_abc", success=True, recorded_by="tester", @@ -75,7 +69,6 @@ def test_record_outcome_creates_row_and_updates_engine( # call engine again outcome2 = record_outcome( db=db_session, - tenant_id="test-tenant", deterministic_id="intent_abc", success=True, recorded_by="tester", @@ -90,7 +83,6 @@ def test_record_outcome_creates_row_and_updates_engine( def test_conflict_different_result(db_session, mock_risk_engine): intent = IntentDB( deterministic_id="intent_def", - tenant_id="test-tenant", # <-- required intent_type="ProvisionResourceIntent", payload={}, created_at=datetime.datetime.utcnow() @@ -100,7 +92,6 @@ def test_conflict_different_result(db_session, mock_risk_engine): record_outcome( db_session, - "test-tenant", "intent_def", True, None, @@ -109,7 +100,6 @@ def test_conflict_different_result(db_session, mock_risk_engine): with pytest.raises(OutcomeConflictError): record_outcome( db_session, - "test-tenant", "intent_def", False, None, @@ -121,7 +111,6 @@ def test_nonexistent_intent(db_session, mock_risk_engine): with pytest.raises(ValueError): record_outcome( db_session, - "test-tenant", "missing", True, None, @@ -134,7 +123,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine( # Create an intent with invalid oss_payload (missing required fields) intent = IntentDB( deterministic_id="intent_bad", - tenant_id="test-tenant", # <-- required intent_type="ProvisionResourceIntent", payload={}, oss_payload={"intent_type": "provision_resource"}, # missing fields @@ -146,7 +134,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine( # This should NOT call risk_engine.update_outcome (no dummy fallback) outcome = record_outcome( db=db_session, - tenant_id="test-tenant", deterministic_id="intent_bad", success=True, recorded_by="tester", diff --git a/tests/test_payments.py b/tests/test_payments.py index 05d6a6a9a1e29171922f6d3ca0cace569fd142a9..c90cfe70d3b9eddda099b6dfd3cc1def3808adc7 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -1,19 +1,17 @@ +import os import pytest from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient from app.main import app -# Every Stripe call in this module is mocked -- nothing here reaches the -# network, and no real credentials are involved. This module used to skip -# entirely whenever STRIPE_SECRET_KEY was unset (always, in CI), so none of -# it ran; stripe.api_key is patched below instead of relying on env vars, -# since routes_payments.py reads it at import time. client = TestClient(app) - -@pytest.fixture(autouse=True) -def stripe_configured(monkeypatch): - monkeypatch.setattr("stripe.api_key", "sk_test_fake") +# Skip all tests in this module if Stripe secret key is not set +STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") +if not STRIPE_SECRET_KEY: + pytest.skip( + "Stripe not configured – skipping payment tests", + allow_module_level=True) @pytest.fixture @@ -23,13 +21,11 @@ def mock_stripe(): def test_create_checkout_session_missing_stripe_key(monkeypatch): - # routes_payments.py sets stripe.api_key from the environment at import - # time and checks `stripe.api_key`, so setenv here would be a no-op. - monkeypatch.setattr("stripe.api_key", None) + monkeypatch.setenv("STRIPE_SECRET_KEY", "") response = client.post( "/api/v1/payments/create-checkout-session", - headers={"Authorization": "Bearer test_key"}, json={ + "api_key": "test_key", "success_url": "https://example.com/success", "cancel_url": "https://example.com/cancel"}) assert response.status_code == 500 @@ -40,13 +36,12 @@ def test_create_checkout_session_free_key(mock_stripe): # Mock tracker.get_tier to return Tier.FREE with patch("app.core.usage_tracker.tracker") as mock_tracker: mock_tracker.get_tier.return_value = "free" - mock_tracker.get_tenant_id.return_value = "tenant_test_123" mock_stripe.return_value = MagicMock( id="cs_test_123", url="https://checkout.stripe.com/pay") response = client.post( "/api/v1/payments/create-checkout-session", - headers={"Authorization": "Bearer test_key"}, json={ + "api_key": "test_key", "success_url": "https://example.com/success", "cancel_url": "https://example.com/cancel"}) assert response.status_code == 200 @@ -60,39 +55,10 @@ def test_create_checkout_session_pro_key(): mock_tracker.get_tier.return_value = "pro" response = client.post( "/api/v1/payments/create-checkout-session", - headers={"Authorization": "Bearer test_key"}, json={ + "api_key": "test_key", "success_url": "https://example.com/success", "cancel_url": "https://example.com/cancel"}) assert response.status_code == 400 assert "Only free tier keys can be upgraded" in response.json()[ "detail"] - - -def test_create_checkout_session_requires_authentication(mock_stripe): - # Regression test: this endpoint used to take `api_key` as a plain JSON - # body field with no Depends() gate at all, so any caller could request - # a session for any tenant_id-bearing key string without proving they - # held it. No Authorization header (and no api_key in the body -- the - # field no longer exists on CheckoutRequest) must be rejected before - # ever reaching Stripe. - response = client.post( - "/api/v1/payments/create-checkout-session", - json={ - "success_url": "https://example.com/success", - "cancel_url": "https://example.com/cancel"}) - assert response.status_code == 401 - mock_stripe.assert_not_called() - - -def test_create_checkout_session_rejects_invalid_key(mock_stripe): - with patch("app.core.usage_tracker.tracker") as mock_tracker: - mock_tracker.get_tier.return_value = None - response = client.post( - "/api/v1/payments/create-checkout-session", - headers={"Authorization": "Bearer not-a-real-key"}, - json={ - "success_url": "https://example.com/success", - "cancel_url": "https://example.com/cancel"}) - assert response.status_code == 403 - mock_stripe.assert_not_called() diff --git a/tests/test_performance.py b/tests/test_performance.py deleted file mode 100644 index a49e5fe777cdc1075180267fad8a84835f0f54b4..0000000000000000000000000000000000000000 --- a/tests/test_performance.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Performance benchmarks for the ARF governance pipeline. - -These tests measure the latency of key operations and assert that -they remain within the target thresholds for pilot readiness. - -Targets (v4.3.2): - - Full governance loop (single intent): p50 < 50 ms, p99 < 100 ms - - Policy evaluation alone: p50 < 1 ms - - Conjugate update: p50 < 0.1 ms - - HealingIntent serialization: p50 < 5 ms -""" -import time -import pytest -import numpy as np - -from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop -from agentic_reliability_framework.core.governance.intents import ( - ProvisionResourceIntent, - ResourceType, -) -from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all -from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator -from agentic_reliability_framework.core.governance.risk_engine import RiskEngine - - -# Number of warmup iterations and measured iterations -WARMUP = 10 -MEASURED = 50 - - -def _measure_latency(fn, *args, **kwargs): - """Run fn MEASURED times after WARMUP warmups, return (p50, p99, p100) in seconds.""" - times = [] - for _ in range(WARMUP): - fn(*args, **kwargs) - for _ in range(MEASURED): - t0 = time.perf_counter() - fn(*args, **kwargs) - times.append(time.perf_counter() - t0) - arr = np.array(times) * 1000 # convert to milliseconds - return np.percentile(arr, 50), np.percentile(arr, 99), arr.max() - - -@pytest.fixture(scope="module") -def sample_intent(): - return ProvisionResourceIntent( - resource_type=ResourceType.VM, - region="eastus", - size="Standard_D2s_v3", - requester="perf-test", - environment="dev", - ) - - -@pytest.fixture(scope="module") -def governance_loop(): - return GovernanceLoop( - policy_evaluator=PolicyEvaluator(allow_all()), - cost_estimator=CostEstimator(), - risk_engine=RiskEngine(), - enable_epistemic=False, - ) - - -class TestGovernanceLoopPerformance: - """Latency benchmarks for the full governance loop.""" - - def test_full_loop_latency(self, governance_loop, sample_intent): - """The full loop should complete within 100 ms at p99.""" - p50, p99, p100 = _measure_latency( - governance_loop.run, sample_intent, context={"service_name": "perf-svc"} - ) - assert p50 < 100, f"p50 latency {p50:.1f} ms exceeds 100 ms target" - assert p99 < 200, f"p99 latency {p99:.1f} ms exceeds 200 ms target" - - -class TestHealingIntentSerialization: - """Serialization performance.""" - - def test_to_enterprise_request_latency(self, governance_loop, sample_intent): - """Serializing a HealingIntent to the enterprise request dict should be fast.""" - intent = governance_loop.run(sample_intent, context={"service_name": "perf-svc"}) - p50, p99, p100 = _measure_latency(intent.to_enterprise_request) - assert p50 < 10, f"p50 serialization latency {p50:.1f} ms exceeds 10 ms target" - - -class TestRiskEnginePerformance: - """Conjugate update latency.""" - - def test_risk_calculation_latency(self, governance_loop, sample_intent): - """A single risk calculation should be sub‑millisecond.""" - engine = governance_loop.risk_engine - p50, p99, p100 = _measure_latency( - engine.calculate_risk, - intent=sample_intent, - cost_estimate=None, - policy_violations=[], - ) - assert p50 < 10, f"p50 risk calculation latency {p50:.1f} ms exceeds 10 ms target" diff --git a/tests/test_risk.py b/tests/test_risk.py index e176d695a4ddc8769a6535c80435d44e8139a72d..fbe4ba3bb21c4ad0caba189c6629b3178b836872 100644 --- a/tests/test_risk.py +++ b/tests/test_risk.py @@ -31,8 +31,4 @@ def test_get_risk_internal_error(client, monkeypatch): "X-API-Key": "test-key"}) assert response.status_code == 500 data = response.json() - # The raw exception message must NOT reach the caller -- routes_risk.py - # logs the real exception server-side and returns a generic detail - # instead (information-disclosure fix from this session's audit). - assert data.get("detail") == "Internal server error" - assert "test error" not in data.get("detail", "") + assert "test error" in data.get("detail", "") diff --git a/tests/test_routes_admin.py b/tests/test_routes_admin.py deleted file mode 100644 index dd5f55c5f669776200c9922ccb9a4b16cacae6a2..0000000000000000000000000000000000000000 --- a/tests/test_routes_admin.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Route-level tests for the /admin/keys endpoints against the real, -Postgres-backed UsageTracker -- these routes are normally exercised through -the module-level `tracker` singleton (conftest.py replaces it globally with -MockTracker for every other test), so `tracker` is patched on -`app.core.usage_tracker` and `ADMIN_API_KEY` on `app.api.routes_admin` for -the duration of this module. - -`tracker` is patched on the *defining* module, not on routes_admin, because -routes_admin no longer holds its own binding: it reaches the singleton -through `usage_tracker.tracker` so that init_tracker()'s rebinding is -visible to it. That is also what makes one patch here sufficient where five -would otherwise be needed. - -This is the main regression coverage for the api_keys-on-Postgres -migration: it exercises the exact raw SQL routes_admin.py runs against -`usage_tracker.tracker._get_pg_conn()`. -""" -import hashlib -import hmac -import os - -import pytest - -from app.core import usage_tracker -from app.core.usage_tracker import UsageTracker -import app.api.routes_admin as routes_admin - -TEST_ADMIN_KEY = "test-admin-key-for-routes-admin-tests" -TEST_PEPPER = os.environ["ARF_KEY_PEPPER"] # set in conftest.py before app import - - -def _key_id(raw_key: str) -> str: - """Reproduce UsageTracker._lookup_hash without a tracker instance, so - tests can locate the row created for a given raw key deterministically.""" - return hmac.new(TEST_PEPPER.encode(), raw_key.encode(), hashlib.sha256).hexdigest() - - -@pytest.fixture(autouse=True) -def real_tracker(monkeypatch): - real = UsageTracker(db_path=":memory:") - monkeypatch.setattr(usage_tracker, "tracker", real) - monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY) - yield real - - -def test_create_list_update_deactivate_key(client): - create_resp = client.post( - "/api/v1/admin/keys", - params={"admin_key": TEST_ADMIN_KEY}, - json={"tier": "free", "org_name": "Test Org"}, - ) - assert create_resp.status_code == 200 - body = create_resp.json() - api_key = body["api_key"] - assert body["tier"] == "free" - key_id = _key_id(api_key) - - list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY}) - assert list_resp.status_code == 200 - keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]} - assert key_id in keys_by_id - assert keys_by_id[key_id]["tier"] == "free" - assert keys_by_id[key_id]["is_active"] is True - - patch_resp = client.patch( - f"/api/v1/admin/keys/{key_id}/tier", - params={"admin_key": TEST_ADMIN_KEY}, - json={"tier": "pro"}, - ) - assert patch_resp.status_code == 200 - - list_resp2 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY}) - assert list_resp2.json()["keys"][0] # non-empty, sanity check - keys_by_id2 = {row["key_id"]: row for row in list_resp2.json()["keys"]} - assert keys_by_id2[key_id]["tier"] == "pro" - - delete_resp = client.delete(f"/api/v1/admin/keys/{key_id}", params={"admin_key": TEST_ADMIN_KEY}) - assert delete_resp.status_code == 200 - - list_resp3 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY}) - keys_by_id3 = {row["key_id"]: row for row in list_resp3.json()["keys"]} - assert keys_by_id3[key_id]["is_active"] is False - - -def test_update_nonexistent_key_returns_404(client): - resp = client.patch( - "/api/v1/admin/keys/does-not-exist/tier", - params={"admin_key": TEST_ADMIN_KEY}, - json={"tier": "pro"}, - ) - assert resp.status_code == 404 - - -def test_rotate_key_deactivates_old_and_creates_new_on_same_tenant(client): - create_resp = client.post( - "/api/v1/admin/keys", - params={"admin_key": TEST_ADMIN_KEY}, - json={"tier": "pro", "org_name": "Rotate Test Org"}, - ) - assert create_resp.status_code == 200 - old_body = create_resp.json() - old_key_id = _key_id(old_body["api_key"]) - tenant_id = old_body["tenant_id"] - - rotate_resp = client.post( - f"/api/v1/admin/keys/{old_key_id}/rotate", params={"admin_key": TEST_ADMIN_KEY}) - assert rotate_resp.status_code == 200 - rotated = rotate_resp.json() - assert rotated["tenant_id"] == tenant_id - assert rotated["tier"] == "pro" - assert rotated["deactivated_key_id"] == old_key_id - new_key_id = _key_id(rotated["api_key"]) - assert new_key_id != old_key_id - - list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY}) - keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]} - assert keys_by_id[old_key_id]["is_active"] is False - assert keys_by_id[new_key_id]["is_active"] is True - assert keys_by_id[new_key_id]["tier"] == "pro" - - -def test_rotate_nonexistent_key_returns_404(client): - resp = client.post( - "/api/v1/admin/keys/does-not-exist/rotate", params={"admin_key": TEST_ADMIN_KEY}) - assert resp.status_code == 404 diff --git a/tests/test_routes_governance_execute.py b/tests/test_routes_governance_execute.py deleted file mode 100644 index 2819cd57197c521322254afb88e009e32aafccb4..0000000000000000000000000000000000000000 --- a/tests/test_routes_governance_execute.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Tests for POST /intents/{id}/execute and POST /admin/executions/{id}/resolve. - -arf_enterprise is not installed in this test environment (by design -- it's -an optional, proprietary package; see routes_governance.py's -ENTERPRISE_EXECUTOR_AVAILABLE guard). The 501 "not available"/"not enabled" -paths are real, unconditional behavior and tested as such. For the -success/pending/error paths, the enterprise classes referenced in -routes_governance.py (EnterpriseExecutor, FakeCloudActuator, -PendingApprovalError, EnterpriseExecutionError, EnterpriseSafetyError) are -monkeypatched with lightweight stand-ins -- this tests arf-api's own glue -code (existence/tenant check, exception-to-HTTP-status mapping, response -shaping), not EnterpriseExecutor's internals, which are already covered by -the enterprise repo's own test suite. -""" -import pytest -from app.database.models_intents import TenantDB -import app.api.routes_governance as routes_governance -import app.api.routes_admin as routes_admin - -TENANT_ID = "test-tenant" - - -@pytest.fixture(autouse=True) -def seed_tenant(db_session): - tenant = db_session.query(TenantDB).filter_by(id=TENANT_ID).first() - if not tenant: - db_session.add(TenantDB(id=TENANT_ID, name="Test Tenant")) - db_session.commit() - - -def _evaluate_intent(client): - payload = { - "intent_type": "provision_resource", - "environment": "prod", - "resource_type": "database", - "region": "eastus", - "size": "Standard", - "estimated_cost": 1200, - "policy_violations": [], - "requester": "alice", - "provenance": {}, - "configuration": {}, - } - resp = client.post( - "/api/v1/intents/evaluate", json=payload, headers={"X-Tenant-ID": TENANT_ID} - ) - assert resp.status_code == 200, resp.text - data = resp.json() - # data["intent_id"] (top level, set by evaluate_intent_endpoint right - # before returning: result["intent_id"] = deterministic_id) is what - # save_evaluated_intent actually persisted to IntentDB.deterministic_id. - # data["healing_intent"]["intent_id"] is a separate, independently - # generated id belonging to the HealingIntent object itself -- using it - # here instead was the exact bug that made every test in this file 404. - return data["intent_id"], data["healing_intent"] - - -class _FakePendingApprovalError(Exception): - def __init__(self, message, level, approval_required, approval_id=None): - super().__init__(message) - self.level = level - self.approval_required = approval_required - self.approval_id = approval_id - - -class _FakeExecutionError(Exception): - pass - - -class _FakeSafetyError(Exception): - pass - - -class _FakeConfig: - """Stands in for EnterpriseConfig, which is None in the import fallback - whenever arf_enterprise isn't installed -- as it isn't in CI, since it's - a private-repo package deliberately kept out of requirements.txt. - - Records what it was constructed with so a test can assert that - ARF_TRUSTED_SIGNING_KEYS actually reaches the executor. Without that - the trust store could silently go back to empty and every signed intent - would be rejected as "Untrusted signing key" with nothing failing here. - """ - last_trusted_signing_keys = None - - def __init__(self, trusted_signing_keys=None, **kwargs): - self.trusted_signing_keys = trusted_signing_keys - type(self).last_trusted_signing_keys = trusted_signing_keys - - -class _FakeExecutor: - """Stands in for EnterpriseExecutor. Behavior is selected via a - class-level `mode` set by each test before the request is made.""" - mode = "success" - last_config = None - - def __init__(self, config=None, actuator=None, approval_store=None, - on_verified_outcome=None): - self._on_verified_outcome = on_verified_outcome - type(self).last_config = config - - async def execute(self, intent, human_approved=False, admin_approved=False): - if self.mode == "success": - if self._on_verified_outcome: - self._on_verified_outcome(intent, True, {"observed": {"status": "running"}}) - return {"status": "success", "verified": {"status": "running"}, "compensating_action": None} - if self.mode == "pending": - raise _FakePendingApprovalError( - "needs human approval", level="HumanInLoop", approval_required="human", - approval_id="appr_test123", - ) - if self.mode == "execution_error": - raise _FakeExecutionError("ladder denied this intent") - if self.mode == "safety_error": - raise _FakeSafetyError("blast radius exceeded") - raise RuntimeError(f"unhandled test mode: {self.mode}") - - -@pytest.fixture -def enterprise_execution_enabled(monkeypatch): - monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True) - monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", True) - monkeypatch.setattr(routes_governance, "EnterpriseExecutor", _FakeExecutor) - monkeypatch.setattr(routes_governance, "EnterpriseConfig", _FakeConfig) - monkeypatch.setattr(routes_governance, "FakeCloudActuator", lambda: None) - monkeypatch.setattr(routes_governance, "PendingApprovalError", _FakePendingApprovalError) - monkeypatch.setattr(routes_governance, "EnterpriseExecutionError", _FakeExecutionError) - monkeypatch.setattr(routes_governance, "EnterpriseSafetyError", _FakeSafetyError) - _FakeExecutor.mode = "success" - yield - _FakeExecutor.mode = "success" - - -def test_execute_returns_501_when_enterprise_package_not_available(client): - resp = client.post( - "/api/v1/intents/does-not-matter/execute", - json={"healing_intent": {}}, - ) - assert resp.status_code == 501 - assert "not installed" in resp.json()["detail"] - - -def test_execute_returns_501_when_not_enabled(client, monkeypatch): - monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True) - monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", False) - resp = client.post( - "/api/v1/intents/does-not-matter/execute", - json={"healing_intent": {}}, - ) - assert resp.status_code == 501 - assert "not enabled" in resp.json()["detail"] - - -def test_execute_returns_404_for_unknown_intent(client, enterprise_execution_enabled): - resp = client.post( - "/api/v1/intents/does-not-exist-at-all/execute", - json={"healing_intent": {}}, - ) - assert resp.status_code == 404 - - -def test_execute_success_path(client, enterprise_execution_enabled): - deterministic_id, healing_intent = _evaluate_intent(client) - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent, "human_approved": True}, - ) - assert resp.status_code == 200, resp.text - assert resp.json()["status"] == "success" - - -def test_trusted_signing_keys_reach_the_executor_split_not_raw( - client, enterprise_execution_enabled, monkeypatch -): - """ARF_TRUSTED_SIGNING_KEYS holds N comma-separated fingerprints. - Passing the raw string through as a one-element list would register the - literal "abc,def" as a single key -- trusting neither real one -- and - would look identical from the outside, since both spellings produce a - non-empty list and a 200 here.""" - monkeypatch.setenv("ARF_TRUSTED_SIGNING_KEYS", " abc123 , def456 ,, ") - deterministic_id, healing_intent = _evaluate_intent(client) - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent, "human_approved": True}, - ) - assert resp.status_code == 200, resp.text - assert _FakeConfig.last_trusted_signing_keys == ["abc123", "def456"] - assert _FakeExecutor.last_config is not None - - -def test_unset_trusted_signing_keys_trusts_nothing_rather_than_the_empty_string( - client, enterprise_execution_enabled, monkeypatch -): - """Fail closed. `"".split(",")` is `[""]`, so the naive parse would - register the empty string as a trusted fingerprint -- trusting a key - nobody holds is harmless, but it makes "trusts nothing" and - "misconfigured" indistinguishable in the logs.""" - monkeypatch.delenv("ARF_TRUSTED_SIGNING_KEYS", raising=False) - deterministic_id, healing_intent = _evaluate_intent(client) - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent, "human_approved": True}, - ) - assert resp.status_code == 200, resp.text - assert _FakeConfig.last_trusted_signing_keys == [] - - -def test_execute_pending_approval_returns_202_with_approval_id(client, enterprise_execution_enabled): - deterministic_id, healing_intent = _evaluate_intent(client) - _FakeExecutor.mode = "pending" - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent}, - ) - assert resp.status_code == 202 - body = resp.json() - assert body["approval_id"] == "appr_test123" - assert body["level"] == "HumanInLoop" - - -def test_execute_execution_error_returns_422(client, enterprise_execution_enabled): - deterministic_id, healing_intent = _evaluate_intent(client) - _FakeExecutor.mode = "execution_error" - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent, "human_approved": True}, - ) - assert resp.status_code == 422 - assert "ladder denied" in resp.json()["detail"] - - -def test_execute_safety_error_returns_422(client, enterprise_execution_enabled): - deterministic_id, healing_intent = _evaluate_intent(client) - _FakeExecutor.mode = "safety_error" - resp = client.post( - f"/api/v1/intents/{deterministic_id}/execute", - json={"healing_intent": healing_intent, "human_approved": True}, - ) - assert resp.status_code == 422 - assert "blast radius" in resp.json()["detail"] - - -def test_execute_belongs_to_different_tenant_returns_404(client, enterprise_execution_enabled, db_session): - """A deterministic_id that exists but under a different tenant must not - be executable by this caller -- same tenant-scoping guarantee - record_outcome already provides for /intents/outcome.""" - other_tenant = "other-tenant" - if not db_session.query(TenantDB).filter_by(id=other_tenant).first(): - db_session.add(TenantDB(id=other_tenant, name="Other Tenant")) - db_session.commit() - - from app.database.models_intents import IntentDB - import datetime - db_session.add(IntentDB( - deterministic_id="belongs-to-other-tenant", - tenant_id=other_tenant, - intent_type="provision_resource", - payload={}, - oss_payload={}, - environment="prod", - evaluated_at=datetime.datetime.utcnow(), - risk_score="0.1", - )) - db_session.commit() - - resp = client.post( - "/api/v1/intents/belongs-to-other-tenant/execute", - json={"healing_intent": {}}, - ) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# Admin resolve/list endpoints -# --------------------------------------------------------------------------- - -TEST_ADMIN_KEY = "test-admin-key-for-governance-execute-tests" - - -class _FakeApprovalRecord: - def __init__(self, id, decision_id, intent_id, level, approval_required, requested_at): - self.id = id - self.decision_id = decision_id - self.intent_id = intent_id - self.level = level - self.approval_required = approval_required - self.requested_at = requested_at - - -class _FakeApprovalStore: - def __init__(self): - import datetime - self._pending = { - "appr_1": _FakeApprovalRecord( - "appr_1", "dec_1", "intent-1", "HumanInLoop", "human", datetime.datetime.utcnow() - ) - } - self.resolved = [] - - def list_pending(self, limit=100, offset=0): - return list(self._pending.values())[:limit] - - def resolve(self, approval_id, approved, resolved_by, note=None): - if approval_id not in self._pending: - return False - del self._pending[approval_id] - self.resolved.append((approval_id, approved, resolved_by, note)) - return True - - -@pytest.fixture -def admin_with_approval_store(monkeypatch, client): - monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY) - fake_store = _FakeApprovalStore() - client.app.state.approval_store = fake_store - yield fake_store - client.app.state.approval_store = None - - -def test_list_pending_executions_returns_501_without_store(client, monkeypatch): - monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY) - client.app.state.approval_store = None - resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY}) - assert resp.status_code == 501 - - -def test_list_pending_executions(client, admin_with_approval_store): - resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY}) - assert resp.status_code == 200 - body = resp.json() - assert body["total"] == 1 - assert body["pending"][0]["approval_id"] == "appr_1" - - -def test_resolve_execution_approval(client, admin_with_approval_store): - resp = client.post( - "/api/v1/admin/executions/appr_1/resolve", - params={"admin_key": TEST_ADMIN_KEY}, - json={"approved": True, "note": "looks fine"}, - ) - assert resp.status_code == 200 - assert admin_with_approval_store.resolved == [("appr_1", True, "admin", "looks fine")] - - -def test_resolve_unknown_approval_returns_404(client, admin_with_approval_store): - resp = client.post( - "/api/v1/admin/executions/does-not-exist/resolve", - params={"admin_key": TEST_ADMIN_KEY}, - json={"approved": True}, - ) - assert resp.status_code == 404 diff --git a/tests/test_routes_onchain.py b/tests/test_routes_onchain.py deleted file mode 100644 index 22670ceb28e33ded18f62355f7e695a8fdf7f246..0000000000000000000000000000000000000000 --- a/tests/test_routes_onchain.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Tests for POST/GET /api/v1/onchain/rationale. - -`verify_internal_key` and `get_db` are already overridden in conftest.py the -same way every other route test relies on -- this exercises the endpoint's -own logic (idempotent-on-hash write, conflict on a differing text, 404 on an -unknown hash, hash format validation), not authentication or the database -layer itself. -""" - -VALID_HASH = "0x" + "ab" * 32 - - -def _post( - client, - rationale_hash=VALID_HASH, - rationale="the volume's final backup was skipped", - **extra, -): - payload = {"rationale_hash": rationale_hash, "rationale": rationale, **extra} - return client.post("/api/v1/onchain/rationale", json=payload) - - -class TestPersistRationale: - def test_a_new_hash_is_recorded(self, client): - resp = _post(client) - assert resp.status_code == 201, resp.text - assert resp.json() == {"status": "recorded", "rationale_hash": VALID_HASH} - - def test_posting_the_same_hash_and_text_again_is_a_no_op(self, client): - # A hash of its own -- the `client` fixture is session-scoped against - # a real, persistent Postgres with no truncation between tests, so - # reusing VALID_HASH here would collide with whichever other test in - # this file claims it first. - hash_ = "0x" + "22" * 32 - first = _post(client, rationale_hash=hash_) - assert first.status_code == 201 - second = _post(client, rationale_hash=hash_) - assert second.status_code == 200 - assert second.json()["status"] == "already_recorded" - - def test_the_same_hash_with_different_text_is_refused(self, client): - hash_ = "0x" + "33" * 32 - first = _post(client, rationale_hash=hash_) - assert first.status_code == 201 - second = _post(client, rationale_hash=hash_, rationale="a different reason entirely") - assert second.status_code == 409 - - def test_a_malformed_hash_is_rejected(self, client): - resp = _post(client, rationale_hash="not-a-hash") - assert resp.status_code == 422 - - def test_a_short_hash_is_rejected(self, client): - resp = _post(client, rationale_hash="0x" + "ab" * 16) - assert resp.status_code == 422 - - def test_empty_rationale_is_rejected(self, client): - resp = _post(client, rationale=" ") - assert resp.status_code == 422 - - def test_agent_and_evaluator_addresses_are_stored(self, client): - resp = _post( - client, - rationale_hash="0x" + "cd" * 32, - agent_address="0x00000000000000000000000000000000000000A1", - evaluator_address="0x00000000000000000000000000000000000000B2", - ) - assert resp.status_code == 201 - fetched = client.get(f"/api/v1/onchain/rationale/{'0x' + 'cd' * 32}") - assert fetched.status_code == 200 - body = fetched.json() - assert body["agent_address"] == "0x00000000000000000000000000000000000000A1" - assert body["evaluator_address"] == "0x00000000000000000000000000000000000000B2" - - -class TestGetRationale: - def test_fetching_a_recorded_hash_returns_its_text(self, client): - _post(client) - resp = client.get(f"/api/v1/onchain/rationale/{VALID_HASH}") - assert resp.status_code == 200 - assert resp.json()["rationale"] == "the volume's final backup was skipped" - - def test_fetching_an_unknown_hash_is_404(self, client): - resp = client.get(f"/api/v1/onchain/rationale/{'0x' + 'ef' * 32}") - assert resp.status_code == 404 - - def test_fetching_a_malformed_hash_is_422_not_404(self, client): - resp = client.get("/api/v1/onchain/rationale/not-a-hash") - assert resp.status_code == 422 diff --git a/tests/test_routes_pricing.py b/tests/test_routes_pricing.py deleted file mode 100644 index 909d6675715ff6fdc6779fe457aa1417c3b6ac9b..0000000000000000000000000000000000000000 --- a/tests/test_routes_pricing.py +++ /dev/null @@ -1,47 +0,0 @@ -from fastapi.testclient import TestClient -from app.main import app - -client = TestClient(app) - - -def test_run_pricing_is_temporarily_disabled(): - """/pricing/run was disabled after AUDIT_arf-bayesian-pricing-calculator.md's - Critical finding: it persisted a fabricated random.random() outcome into a - calibration buffer with no customer_id scoping, so every customer's price - was shaped by every other customer's fabricated outcomes. This asserts the - disabled state itself, not the old (buggy) behavior -- update this test - when the endpoint is actually fixed and re-enabled, not before.""" - response = client.post( - "/api/v1/pricing/run", - json={"input": {}, "customer_id": "test-customer", "runs": 1}, - ) - assert response.status_code == 503 - assert "temporarily disabled" in response.json()["detail"].lower() - - -def test_run_pricing_still_requires_auth(): - """Disabling the endpoint must not also disable its auth -- conftest.py's - mock_enforce_quota makes every request "authenticated" for this test - client, so this only confirms the Depends(enforce_quota) dependency is - still declared and still runs before the handler body (i.e. it wasn't - accidentally dropped along with the rest of the function body); it does - not exercise the real 401/403 paths, which are covered by test_deps.py - and usage_tracker's own tests.""" - response = client.post( - "/api/v1/pricing/run", - json={"input": {}, "customer_id": "test-customer", "runs": 1}, - ) - # Reaching the 503 (not erroring before it) proves enforce_quota resolved - # successfully -- if the dependency were missing or broken, this would be - # a 401/422/500 instead. - assert response.status_code == 503 - - -def test_estimate_pricing_route_still_registered(): - """/pricing/estimate was not touched by the disable -- confirm it's still - a distinct, reachable route (a malformed request should 400, not 503/404), - so a caller following the "use /pricing/estimate instead" guidance in the - 503 detail message actually has somewhere to go.""" - response = client.post("/api/v1/pricing/estimate", json={"input": {}}) - assert response.status_code != 503 - assert response.status_code != 404 diff --git a/tests/test_tracker_binding.py b/tests/test_tracker_binding.py deleted file mode 100644 index 0367fc2636d1499b12f6eeddd5f17f63ab99f2c2..0000000000000000000000000000000000000000 --- a/tests/test_tracker_binding.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Guard against re-introducing the stale `tracker` from-import. - -`app.core.usage_tracker.tracker` starts as None and is rebound by -`init_tracker()` during the lifespan. `from app.core.usage_tracker import -tracker` copies the *binding* -- None -- at import time, and the later -rebinding never reaches the importer. Five modules did this: main.py -crashed the Render deploy with "'NoneType' object has no attribute -'warm_up'", routes_admin's 20 unguarded uses would have 500'd, and -routes_incidents/payments/users each had a `if tracker` guard that could -only ever take the None branch -- so usage went unmetered and signup and -checkout were permanently disabled, silently. - -This is a source-level check on purpose. Reproducing the bug at runtime -needs a real UsageTracker (Postgres, pepper) and would only cover the -modules the test happened to import; parsing every module catches the -next one too, and costs nothing. -""" - -import ast -import re -import pathlib - -import pytest - -APP = pathlib.Path(__file__).resolve().parent.parent / "app" - -# Names safe to import directly: functions defined in usage_tracker.py -# resolve the module global at call time, so they always see the live -# instance. Only the mutable module-level object itself is unsafe. -UNSAFE_NAMES = {"tracker"} - - -def _source_files(): - return sorted(p for p in APP.rglob("*.py")) - - -TESTS = pathlib.Path(__file__).resolve().parent - -# `patch("app.api.routes_admin.tracker")` and -# `monkeypatch.setattr(routes_payments, "tracker", ...)` both target an -# attribute that only exists while the broken from-import does. They passed -# for as long as the bug was there and broke the moment it was fixed -- -# and, worse, while the bug was there they were patching a name the route -# code was already reading as None, so they proved nothing. -# -# conftest.py has always done the right thing (`app.core.usage_tracker -# .tracker = MockTracker()`), which is why this is the correct target: one -# patch on the defining module reaches every consumer. -_BAD_PATCH_TARGETS = re.compile( - r"""["']app\.api\.routes_\w+\.tracker["']""" - r"""|setattr\(\s*routes_\w+\s*,\s*["']tracker["']""" -) - - -def _test_files(): - """Every test module but this one. - - The check is textual, so this file trips it on the comment above that - quotes the bad forms. Excluding self rather than contorting the regex - keeps the pattern readable and the examples literal -- and the cost is - only that this file cannot police itself, which it has no reason to. - """ - here = pathlib.Path(__file__).resolve() - return sorted(p for p in TESTS.glob("test_*.py") if p.resolve() != here) - - -@pytest.mark.parametrize("path", _test_files(), ids=lambda p: p.name) -def test_no_test_patches_tracker_on_a_route_module(path): - src = path.read_text(encoding="utf-8") - for lineno, line in enumerate(src.splitlines(), start=1): - assert not _BAD_PATCH_TARGETS.search(line), ( - f"{path.name}:{lineno} patches `tracker` on a route module. Route " - "modules reference `usage_tracker.tracker` and hold no binding of " - 'their own -- patch "app.core.usage_tracker.tracker" instead, ' - "which is what conftest.py already does." - ) - - -@pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name) -def test_tracker_is_never_imported_by_name(path): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for node in ast.walk(tree): - if not isinstance(node, ast.ImportFrom): - continue - if node.module != "app.core.usage_tracker": - continue - offenders = sorted( - {a.name for a in node.names} & UNSAFE_NAMES - ) - assert not offenders, ( - f"{path.name}:{node.lineno} imports {offenders} by name from " - "app.core.usage_tracker. That binding is None at import time and " - "init_tracker() will not update it. Use `from app.core import " - "usage_tracker` and reference `usage_tracker.tracker` instead." - ) diff --git a/tests/test_usage_tracker.py b/tests/test_usage_tracker.py index bc2091bd9f5d39c5a772dc6f7bbc373bd47daf2b..6de7f56937af5086d1318761ccaf12a8e4a499f1 100644 --- a/tests/test_usage_tracker.py +++ b/tests/test_usage_tracker.py @@ -1,8 +1,3 @@ -import json -import os -from datetime import datetime - -import psycopg2 import pytest import tempfile import time @@ -16,15 +11,14 @@ def tracker(): def test_get_or_create_api_key(tracker): - # Updated: pass tenant_id as keyword argument (new signature) - assert tracker.get_or_create_api_key("test_key", tenant_id="test") is True + assert tracker.get_or_create_api_key("test_key", Tier.FREE, tenant_id="test") is True assert tracker.get_tier("test_key") == Tier.FREE # Second call should return True without error assert tracker.get_or_create_api_key("test_key", tenant_id="test") is True def test_update_api_key_tier(tracker): - tracker.get_or_create_api_key("test_key", tenant_id="test") + tracker.get_or_create_api_key("test_key", Tier.FREE, tenant_id="test") assert tracker.update_api_key_tier("test_key", Tier.PRO) is True assert tracker.get_tier("test_key") == Tier.PRO # Non-existent key @@ -32,7 +26,7 @@ def test_update_api_key_tier(tracker): def test_get_remaining_quota_free(tracker): - tracker.get_or_create_api_key("free_key", tenant_id="test") + tracker.get_or_create_api_key("free_key", Tier.FREE, tenant_id="test") # Initially 1000 remaining remaining = tracker.get_remaining_quota("free_key", Tier.FREE) assert remaining == 1000 @@ -49,13 +43,13 @@ def test_get_remaining_quota_free(tracker): def test_get_remaining_quota_enterprise(tracker): - tracker.get_or_create_api_key("ent_key", tenant_id="test") + tracker.get_or_create_api_key("ent_key", Tier.ENTERPRISE, tenant_id="test") remaining = tracker.get_remaining_quota("ent_key", Tier.ENTERPRISE) assert remaining is None def test_increment_usage_sync(tracker): - tracker.get_or_create_api_key("test_key", tenant_id="test") + tracker.get_or_create_api_key("test_key", Tier.FREE, tenant_id="test") record = UsageRecord( api_key="test_key", tier=Tier.FREE, @@ -70,7 +64,7 @@ def test_increment_usage_sync(tracker): def test_get_audit_logs(tracker): - tracker.get_or_create_api_key("test_key", tenant_id="test") + tracker.get_or_create_api_key("test_key", Tier.FREE, tenant_id="test") record = UsageRecord( api_key="test_key", tier=Tier.FREE, @@ -83,126 +77,3 @@ def test_get_audit_logs(tracker): logs = tracker.get_audit_logs("test_key", limit=10) assert len(logs) == 1 assert logs[0]["endpoint"] == "/test" - - -def _pg_monthly_count(api_key: str, month: str) -> int: - """Direct Postgres read, bypassing UsageTracker entirely -- this is - exactly the query arf-gateway's Go code runs against the same table.""" - conn = psycopg2.connect(os.environ["DATABASE_URL"]) - try: - with conn.cursor() as cur: - cur.execute( - "SELECT COALESCE(count, 0) FROM monthly_counts WHERE api_key = %s AND year_month = %s", - (api_key, month), - ) - row = cur.fetchone() - return row[0] if row else 0 - finally: - conn.close() - - -def test_increment_usage_sync_mirrors_to_postgres_monthly_counts(tracker): - """Regresses arf-gateway-001: arf-gateway's quota check reads - monthly_counts from Postgres, not from this service's local SQLite - file. Every successfully-counted call must be visible there.""" - tracker.get_or_create_api_key("pg-mirror-key", tenant_id="test") - month = tracker._get_month_key() - - # No truncate fixture exists for this Postgres-resident table (CI's - # Postgres service is ephemeral per run, but a local repeat run against - # a persistent database could have leftover rows) -- assert the delta, - # not an absolute count. - baseline = _pg_monthly_count("pg-mirror-key", month) - - record = UsageRecord( - api_key="pg-mirror-key", tier=Tier.FREE, timestamp=time.time(), endpoint="/test", - ) - tracker.increment_usage_sync(record) - assert _pg_monthly_count("pg-mirror-key", month) == baseline + 1 - - tracker.increment_usage_sync(record) - assert _pg_monthly_count("pg-mirror-key", month) == baseline + 2 - - -def test_increment_usage_sync_succeeds_even_if_postgres_mirror_fails(tracker, monkeypatch): - """The local quota decision (SQLite/Redis) must not fail just because - the best-effort mirror write to Postgres did -- see - _record_pg_monthly_count's docstring. A degraded mirror write should - not turn into a degraded evaluate/healing endpoint for the caller.""" - def _boom(*args, **kwargs): - raise psycopg2.OperationalError("simulated Postgres outage") - - # get_or_create_api_key also goes through _get_pg_conn (it persists to - # the real api_keys table), so it must run before the patch below -- the - # outage this test simulates is specific to the monthly_counts mirror - # write, not to Postgres as a whole. - tracker.get_or_create_api_key("mirror-fail-key", tenant_id="test") - - # Patch what _record_pg_monthly_count calls internally, not the method - # itself -- replacing the whole method would bypass its own try/except - # and prove nothing about that error-handling actually working. - monkeypatch.setattr(tracker, "_get_pg_conn", _boom) - - record = UsageRecord( - api_key="mirror-fail-key", tier=Tier.FREE, timestamp=time.time(), endpoint="/test", - ) - - # Must not raise, and the local quota decision must still succeed. - result = tracker.increment_usage_sync(record) - assert result is True - assert tracker.get_remaining_quota("mirror-fail-key", Tier.FREE) == 999 - - -def test_insert_audit_log_writes_response_row(tracker): - """routes_governance.py schedules current_tracker._insert_audit_log as - a background task (background_tasks.add_task) to record the response - body once it's known, at app/api/routes_governance.py:407 and :738 -- - always with tier=None, since quota was already consumed by an earlier - consume_quota_and_log call for the same request. The real UsageTracker - had no such method (only tests/conftest.py's MockTracker did), so every - real call raised AttributeError inside the background task (arf-api-002).""" - record = UsageRecord( - api_key="audit-log-key", - tier=None, - timestamp=time.time(), - endpoint="/api/v1/intents/evaluate/response", - request_body=None, - response={"recommended_action": "approve"}, - processing_ms=12.5, - ) - - tracker._insert_audit_log(record) - - logs = tracker.get_audit_logs("audit-log-key", limit=10) - assert len(logs) == 1 - assert logs[0]["endpoint"] == "/api/v1/intents/evaluate/response" - assert logs[0]["tier"] == "unknown" - assert json.loads(logs[0]["response"]) == {"recommended_action": "approve"} - - -def test_consume_quota_and_log_handles_non_json_native_request_body(tracker): - """request_body/response are whatever a Pydantic model's plain - .model_dump() returns, e.g. ReliabilityEvent.timestamp in - app/api/routes_governance.py's HealingDecisionRequest -- a raw - datetime, not the ISO string model_dump(mode="json") would produce. - json.dumps has no default encoder for datetime, so any real request - carrying one raised TypeError here, outside any try/except in the - /healing/evaluate handler, on every call (surfaced while verifying the - tier=None fix for that same endpoint: fixing tier alone still crashed, - one layer deeper, on this). default=str makes the insert tolerant of - datetime and any other type json.dumps doesn't natively handle.""" - tracker.get_or_create_api_key("datetime-body-key", tenant_id="test") - record = UsageRecord( - api_key="datetime-body-key", - tier=Tier.FREE, - timestamp=time.time(), - endpoint="/api/v1/healing/evaluate", - request_body={"event": {"component": "svc", "timestamp": datetime.now()}}, - ) - - result = tracker.increment_usage_sync(record) - assert result is True - - logs = tracker.get_audit_logs("datetime-body-key", limit=10) - assert len(logs) == 1 - assert "timestamp" in json.loads(logs[0]["request_body"])["event"] diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 719258ec9911d78567282197450732e4b3dd1026..244dbbf8fe3c1e8bbdd14062597868a61f61217f 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,25 +1,17 @@ +import os import pytest from unittest.mock import patch from fastapi.testclient import TestClient from app.main import app -# Every Stripe call in this module is mocked -- nothing here reaches the -# network, and no real credentials are involved. -# -# This module used to skip entirely (pytest.skip(allow_module_level=True)) -# whenever STRIPE_* env vars were unset, which is always in CI -- so none of -# it was ever exercised anywhere. webhooks.py reads its config into -# module-level globals at import time, so env vars set in conftest.py can't -# help (conftest's imports run before its os.environ assignments); the -# fixture below patches those globals directly instead, which works -# regardless of import order. client = TestClient(app) - -@pytest.fixture(autouse=True) -def stripe_configured(monkeypatch): - monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "whsec_test_fake") - monkeypatch.setattr("stripe.api_key", "sk_test_fake") +# Skip all tests in this module if Stripe webhook secret is not set +STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") +if not STRIPE_WEBHOOK_SECRET: + pytest.skip( + "Stripe webhook not configured – skipping webhook tests", + allow_module_level=True) @pytest.fixture @@ -29,9 +21,7 @@ def mock_stripe_webhook(): def test_webhook_missing_secret(monkeypatch): - # webhooks.py reads STRIPE_WEBHOOK_SECRET into a module-level global at - # import time, so setenv here would be a no-op -- patch the global. - monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "") + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "") response = client.post( "/webhooks/stripe", json={}, @@ -41,7 +31,8 @@ def test_webhook_missing_secret(monkeypatch): assert "Stripe not configured" in response.json()["detail"] -def test_webhook_invalid_payload(mock_stripe_webhook): +def test_webhook_invalid_payload(mock_stripe_webhook, monkeypatch): + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test") mock_stripe_webhook.side_effect = ValueError("Invalid payload") response = client.post( "/webhooks/stripe", @@ -52,12 +43,9 @@ def test_webhook_invalid_payload(mock_stripe_webhook): assert "Invalid payload" in response.json()["detail"] -def test_webhook_invalid_signature(mock_stripe_webhook): - import stripe - # Must be the real exception type the route catches -- a bare Exception - # would propagate as a 500 instead of the 400 this asserts. - mock_stripe_webhook.side_effect = stripe.error.SignatureVerificationError( - "Invalid signature", "sig_header") +def test_webhook_invalid_signature(mock_stripe_webhook, monkeypatch): + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test") + mock_stripe_webhook.side_effect = Exception("Invalid signature") response = client.post( "/webhooks/stripe", json={}, @@ -67,92 +55,35 @@ def test_webhook_invalid_signature(mock_stripe_webhook): assert "Invalid signature" in response.json()["detail"] -def test_webhook_checkout_completed(): +def test_webhook_checkout_completed(monkeypatch): + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test") + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test") with patch("stripe.Webhook.construct_event") as mock_construct, \ - patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update: + patch("app.core.usage_tracker.update_key_tier") as mock_update: mock_construct.return_value = { "type": "checkout.session.completed", "data": { "object": { - "client_reference_id": "tenant_123", - "payment_status": "paid", + "client_reference_id": "test_key", "metadata": { - "tenant_id": "tenant_123"}}}} - response = client.post( - "/webhooks/stripe", - json={}, - headers={"stripe-signature": "test"} - ) - assert response.status_code == 200 - mock_update.assert_called_once_with("tenant_123", "pro") - - -def test_webhook_checkout_completed_not_yet_paid_does_not_upgrade(): - """checkout.session.completed can fire before payment actually clears - for delayed-notification payment methods -- must not grant Pro yet.""" - with patch("stripe.Webhook.construct_event") as mock_construct, \ - patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update: - mock_construct.return_value = { - "type": "checkout.session.completed", - "data": { - "object": { - "client_reference_id": "tenant_123", - "payment_status": "unpaid", - "metadata": {"tenant_id": "tenant_123"}}}} + "api_key": "test_key"}}}} response = client.post( "/webhooks/stripe", json={}, headers={"stripe-signature": "test"} ) assert response.status_code == 200 - mock_update.assert_not_called() + mock_update.assert_called_once_with("test_key", "pro") -def test_webhook_subscription_deleted(): +def test_webhook_subscription_deleted(monkeypatch): + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test") + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test") with patch("stripe.Webhook.construct_event") as mock_construct, \ - patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update: + patch("app.core.usage_tracker.update_key_tier") as mock_update: mock_construct.return_value = { "type": "customer.subscription.deleted", - "data": {"object": {"metadata": {"tenant_id": "tenant_123"}}} - } - response = client.post( - "/webhooks/stripe", - json={}, - headers={"stripe-signature": "test"} - ) - assert response.status_code == 200 - mock_update.assert_called_once_with("tenant_123", "free") - - -def test_webhook_subscription_updated_canceled_downgrades(): - """customer.subscription.updated (not just .deleted) must also - downgrade -- a status transition to canceled/unpaid can arrive this - way, and .deleted is not the only terminal event Stripe sends.""" - with patch("stripe.Webhook.construct_event") as mock_construct, \ - patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update: - mock_construct.return_value = { - "type": "customer.subscription.updated", - "data": {"object": { - "status": "canceled", - "metadata": {"tenant_id": "tenant_123"}}} - } - response = client.post( - "/webhooks/stripe", - json={}, - headers={"stripe-signature": "test"} - ) - assert response.status_code == 200 - mock_update.assert_called_once_with("tenant_123", "free") - - -def test_webhook_subscription_updated_active_does_not_downgrade(): - with patch("stripe.Webhook.construct_event") as mock_construct, \ - patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update: - mock_construct.return_value = { - "type": "customer.subscription.updated", - "data": {"object": { - "status": "active", - "metadata": {"tenant_id": "tenant_123"}}} + "data": {"object": {"metadata": {"api_key": "test_key"}}} } response = client.post( "/webhooks/stripe", @@ -160,4 +91,4 @@ def test_webhook_subscription_updated_active_does_not_downgrade(): headers={"stripe-signature": "test"} ) assert response.status_code == 200 - mock_update.assert_not_called() + mock_update.assert_called_once_with("test_key", "free")