Spaces:
Build error
Build error
Upload folder using huggingface_hub
#24
by petter2025 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- Dockerfile +3 -60
- README.md +10 -24
- alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py +0 -58
- alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py +0 -62
- app/api/deps.py +3 -74
- app/api/routes_admin.py +52 -205
- app/api/routes_governance.py +118 -399
- app/api/routes_history.py +3 -4
- app/api/routes_incidents.py +13 -15
- app/api/routes_intents.py +4 -10
- app/api/routes_memory.py +2 -3
- app/api/routes_onchain.py +0 -149
- app/api/routes_payments.py +13 -47
- app/api/routes_pricing.py +44 -22
- app/api/routes_risk.py +14 -10
- app/api/routes_users.py +3 -4
- app/api/webhooks.py +13 -34
- app/core/config.py +0 -1
- app/core/storage.py +2 -16
- app/core/usage_tracker.py +64 -429
- app/database/models_intents.py +65 -6
- app/database/models_onchain.py +0 -54
- app/main.py +13 -145
- app/models/infrastructure_intents.py +0 -4
- app/services/outcome_service.py +23 -89
- app/services/risk_service.py +19 -229
- deploy/kubernetes/arf-api/configmap.yaml +0 -11
- deploy/kubernetes/arf-api/deployment.yaml +0 -65
- deploy/kubernetes/arf-api/hpa.yaml +0 -25
- deploy/kubernetes/arf-api/networkpolicy.yaml +0 -20
- deploy/kubernetes/arf-api/secret.yaml +0 -25
- deploy/kubernetes/arf-api/service.yaml +0 -16
- docs/authentication.md +13 -39
- docs/development.md +1 -2
- render.yaml +0 -2
- requirements-dev.txt +0 -2
- requirements.txt +4 -7
- tests/conftest.py +6 -29
- tests/test_deps.py +1 -75
- tests/test_governance.py +0 -77
- tests/test_healing_endpoint.py +0 -28
- tests/test_history.py +6 -2
- tests/test_integration.py +0 -305
- tests/test_intent_store.py +4 -4
- tests/test_outcome_service.py +1 -14
- tests/test_payments.py +11 -45
- tests/test_performance.py +0 -100
- tests/test_risk.py +1 -5
- tests/test_routes_admin.py +0 -126
- tests/test_routes_governance_execute.py +0 -352
Dockerfile
CHANGED
|
@@ -1,64 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
# ---- deps stage: needs git + a credentialed clone of the private ARF repos
|
| 3 |
-
# (agentic_reliability_framework, ARF-Bayesian-Pricing-Calculator).
|
| 4 |
-
# This stage is discarded after build -- the credential never reaches
|
| 5 |
-
# the final image's layers, env, or git config. ----
|
| 6 |
-
#
|
| 7 |
-
# GH_PAT is read via a BuildKit secret mount, not `ARG` -- an ARG's value is
|
| 8 |
-
# printed in plaintext as part of the logged RUN command that uses it (this
|
| 9 |
-
# is exactly how a real, live token ended up visible in a Render deploy log
|
| 10 |
-
# this session). A secret mount's value is never written to a log line or
|
| 11 |
-
# an image layer. REQUIRES a matching setup step in Render's dashboard
|
| 12 |
-
# before this will build: Render's Docker service settings -> Secret Files
|
| 13 |
-
# -> add a file named exactly `gh_pat` containing the token value (nothing
|
| 14 |
-
# else in the file). The old `GH_PAT` environment variable is no longer
|
| 15 |
-
# read by this Dockerfile and can be removed once this is confirmed working.
|
| 16 |
-
FROM python:3.12-slim AS deps
|
| 17 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 18 |
-
RUN --mount=type=secret,id=gh_pat,dst=/etc/secrets/gh_pat \
|
| 19 |
-
git config --global url."https://$(cat /etc/secrets/gh_pat)@github.com/".insteadOf "https://github.com/"
|
| 20 |
-
RUN python -m venv /opt/venv
|
| 21 |
-
ENV PATH="/opt/venv/bin:$PATH"
|
| 22 |
WORKDIR /app
|
| 23 |
COPY requirements.txt .
|
| 24 |
-
|
| 25 |
-
# transitively by sentence-transformers (for agentic_reliability_framework's
|
| 26 |
-
# RAG/semantic-memory features) and, left to the default PyPI index, resolves
|
| 27 |
-
# to the CUDA-enabled build (nvidia-cusparselt, cuda-toolkit, nvidia-nccl, ...)
|
| 28 |
-
# even though this service runs on CPU-only Render instances. That variant's
|
| 29 |
-
# extra weight is a real contributor to out-of-memory deploy failures.
|
| 30 |
-
#
|
| 31 |
-
# A separate `pip install torch==... --index-url .../cpu` RUN before this one
|
| 32 |
-
# does NOT work: it's a distinct resolve that only knows about the CPU wheel;
|
| 33 |
-
# the very next `pip install -r requirements.txt`, seeing no --index-url, only
|
| 34 |
-
# has the default PyPI index in view and re-resolves torch from there,
|
| 35 |
-
# silently replacing the CPU build with the CUDA one at the same version
|
| 36 |
-
# number (confirmed happening in a real deploy -- final `pip install` log
|
| 37 |
-
# showed plain `torch-2.13.0` plus the full nvidia/cuda-toolkit/triton stack,
|
| 38 |
-
# not `torch-2.13.0+cpu`). Putting torch and -r requirements.txt in one
|
| 39 |
-
# `pip install` call, with the CPU wheelhouse as the primary --index-url and
|
| 40 |
-
# PyPI as --extra-index-url, makes it a single resolve: torch is satisfied
|
| 41 |
-
# from the CPU index and nothing later re-derives a different build for it.
|
| 42 |
-
# Version pinned to 2.13.0 to match exactly what pip's resolver already chose
|
| 43 |
-
# for this dependency tree (confirmed available on the CPU index for
|
| 44 |
-
# cp312/manylinux before pinning it here, not assumed).
|
| 45 |
-
RUN pip install --no-cache-dir \
|
| 46 |
-
--index-url https://download.pytorch.org/whl/cpu \
|
| 47 |
-
--extra-index-url https://pypi.org/simple \
|
| 48 |
-
torch==2.13.0 \
|
| 49 |
-
-r requirements.txt
|
| 50 |
-
|
| 51 |
-
# ---- final stage: just the built venv + app code, no git, no credential ----
|
| 52 |
-
FROM python:3.12-slim
|
| 53 |
-
COPY --from=deps /opt/venv /opt/venv
|
| 54 |
-
ENV PATH="/opt/venv/bin:$PATH"
|
| 55 |
-
WORKDIR /app
|
| 56 |
COPY . .
|
| 57 |
-
|
| 58 |
-
# expands with a real shell interpreting the command; exec form passes
|
| 59 |
-
# arguments literally with no variable substitution at all. Render injects
|
| 60 |
-
# PORT and expects the app to bind to it (its deploy log explicitly failed
|
| 61 |
-
# port-scanning for it: "Bind your service to at least one port"); the
|
| 62 |
-
# Hugging Face Space mirror sets no such variable and expects the
|
| 63 |
-
# conventional default, 7860. One image, correct on both targets.
|
| 64 |
-
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
COPY . .
|
| 7 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,31 +1,16 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: ARF API
|
| 3 |
-
emoji: 🛡️
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: gray
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
# arf-api
|
| 11 |
|
| 12 |
ARF API Control Plane (FastAPI)
|
| 13 |
|
| 14 |
## Live Demo
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
integration target -- point real pilot/customer integrations at Render once its URL is
|
| 20 |
-
confirmed live, not at the Space URL.
|
| 21 |
-
|
| 22 |
-
- **HF Space (public mirror)**: [https://arf-ai-agentic-reliability-framework-api.hf.space](https://arf-ai-agentic-reliability-framework-api.hf.space)
|
| 23 |
-
- **Interactive Documentation**: [https://arf-ai-agentic-reliability-framework-api.hf.space/docs](https://arf-ai-agentic-reliability-framework-api.hf.space/docs)
|
| 24 |
|
| 25 |
## Quick Start (Local Development)
|
| 26 |
|
| 27 |
1. **Install dependencies**:
|
| 28 |
-
|
| 29 |
```bash
|
| 30 |
pip install -r requirements.txt
|
| 31 |
```
|
|
@@ -39,11 +24,9 @@ ARF_HMC_MODEL – path to HMC model JSON (default: models/hmc_model.json)
|
|
| 39 |
|
| 40 |
ARF_USE_HYPERPRIORS – true/false
|
| 41 |
|
| 42 |
-
API_KEY –
|
| 43 |
```
|
| 44 |
|
| 45 |
-
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.
|
| 46 |
-
|
| 47 |
3. **Run the app locally**:
|
| 48 |
|
| 49 |
```bash
|
|
@@ -106,7 +89,9 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 106 |
"effect": -90,
|
| 107 |
"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.",
|
| 108 |
"is_model_based": false,
|
| 109 |
-
"warnings": [
|
|
|
|
|
|
|
| 110 |
},
|
| 111 |
"utility_decision": {
|
| 112 |
"best_action": "restart_container",
|
|
@@ -126,10 +111,11 @@ curl -X POST "http://localhost:8000/api/v1/v1/incidents/evaluate" -H "Content-
|
|
| 126 |
Tests
|
| 127 |
-----
|
| 128 |
|
| 129 |
-
Run `pytest`. Tests
|
| 130 |
|
| 131 |
Notes
|
| 132 |
-----
|
| 133 |
|
| 134 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 135 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# arf-api
|
| 2 |
|
| 3 |
ARF API Control Plane (FastAPI)
|
| 4 |
|
| 5 |
## Live Demo
|
| 6 |
|
| 7 |
+
The API is deployed and accessible at:
|
| 8 |
+
- **Base URL**: [https://a-r-f-agentic-reliability-framework-api.hf.space](https://a-r-f-agentic-reliability-framework-api.hf.space)
|
| 9 |
+
- **Interactive Documentation**: [https://a-r-f-agentic-reliability-framework-api.hf.space/docs](https://a-r-f-agentic-reliability-framework-api.hf.space/docs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
## Quick Start (Local Development)
|
| 12 |
|
| 13 |
1. **Install dependencies**:
|
|
|
|
| 14 |
```bash
|
| 15 |
pip install -r requirements.txt
|
| 16 |
```
|
|
|
|
| 24 |
|
| 25 |
ARF_USE_HYPERPRIORS – true/false
|
| 26 |
|
| 27 |
+
API_KEY – optional (currently not enforced)
|
| 28 |
```
|
| 29 |
|
|
|
|
|
|
|
| 30 |
3. **Run the app locally**:
|
| 31 |
|
| 32 |
```bash
|
|
|
|
| 89 |
"effect": -90,
|
| 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.",
|
| 91 |
"is_model_based": false,
|
| 92 |
+
"warnings": [
|
| 93 |
+
"Using heuristic causal model (no fitted SCM)."
|
| 94 |
+
]
|
| 95 |
},
|
| 96 |
"utility_decision": {
|
| 97 |
"best_action": "restart_container",
|
|
|
|
| 111 |
Tests
|
| 112 |
-----
|
| 113 |
|
| 114 |
+
Run `pytest`. Tests use a temporary SQLite DB (`sqlite:///./test.db`) created by the test fixtures.
|
| 115 |
|
| 116 |
Notes
|
| 117 |
-----
|
| 118 |
|
| 119 |
- The governance endpoints use an in-process `RiskEngine` initialized at startup.
|
| 120 |
+
- The outcome recording endpoint is not implemented in this repository and returns HTTP 501.
|
| 121 |
+
|
alembic/versions/a1f3c9d2e6b7_create_api_keys_table.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
"""create api_keys table (moves API key storage off ephemeral SQLite)
|
| 2 |
-
|
| 3 |
-
api_keys previously lived only in a SQLite file per service (arf_usage.db),
|
| 4 |
-
which is wiped on every Render deploy/restart on the Free plan and was also
|
| 5 |
-
independently duplicated between arf-api and arf-gateway. This creates the
|
| 6 |
-
durable, single source of truth in Postgres. Rows are hashed at rest
|
| 7 |
-
(pepper-HMAC lookup_hash + salted key_hash) -- no plaintext key column,
|
| 8 |
-
since there is no pre-pepper data to migrate here (confirmed with the user:
|
| 9 |
-
existing SQLite api_keys rows in both services are safe to discard, keys get
|
| 10 |
-
reissued via POST /admin/keys).
|
| 11 |
-
|
| 12 |
-
Revision ID: a1f3c9d2e6b7
|
| 13 |
-
Revises: d36deffe7fa2
|
| 14 |
-
Create Date: 2026-08-24 00:00:00.000000
|
| 15 |
-
|
| 16 |
-
"""
|
| 17 |
-
from typing import Sequence, Union
|
| 18 |
-
|
| 19 |
-
from alembic import op
|
| 20 |
-
import sqlalchemy as sa
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# revision identifiers, used by Alembic.
|
| 24 |
-
revision: str = 'a1f3c9d2e6b7'
|
| 25 |
-
down_revision: Union[str, Sequence[str], None] = 'd36deffe7fa2'
|
| 26 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 27 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def upgrade() -> None:
|
| 31 |
-
"""Upgrade schema."""
|
| 32 |
-
# No FK to tenants.id here, deliberately -- the pre-existing SQLite
|
| 33 |
-
# schema this replaces never enforced one either (seeded/demo keys via
|
| 34 |
-
# ARF_API_KEYS may reference synthetic tenant_ids that don't have a
|
| 35 |
-
# tenants row), and adding one now would be a behavior change beyond
|
| 36 |
-
# this migration's scope.
|
| 37 |
-
op.create_table(
|
| 38 |
-
'api_keys',
|
| 39 |
-
sa.Column('id', sa.Integer(), nullable=False),
|
| 40 |
-
sa.Column('tenant_id', sa.String(length=64), nullable=False),
|
| 41 |
-
sa.Column('tier', sa.String(length=32), nullable=False),
|
| 42 |
-
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 43 |
-
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
| 44 |
-
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 45 |
-
sa.Column('salt', sa.String(length=64), nullable=False),
|
| 46 |
-
sa.Column('key_hash', sa.String(length=64), nullable=False),
|
| 47 |
-
sa.Column('lookup_hash', sa.String(length=64), nullable=False),
|
| 48 |
-
sa.PrimaryKeyConstraint('id'),
|
| 49 |
-
)
|
| 50 |
-
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
|
| 51 |
-
op.create_unique_constraint('uq_api_keys_lookup_hash', 'api_keys', ['lookup_hash'])
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def downgrade() -> None:
|
| 55 |
-
"""Downgrade schema."""
|
| 56 |
-
op.drop_constraint('uq_api_keys_lookup_hash', 'api_keys', type_='unique')
|
| 57 |
-
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
|
| 58 |
-
op.drop_table('api_keys')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
alembic/versions/e4a7c1f9b3d2_create_onchain_rationales_table.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
"""create onchain_rationales table
|
| 2 |
-
|
| 3 |
-
Stores the plaintext preimage of an anchored `RiskAttestation.rationale_hash`
|
| 4 |
-
(arf-onchain's RiskAttestationRegistry / enterprise's
|
| 5 |
-
arf_enterprise.onchain.attestation). The chain only ever holds the hash --
|
| 6 |
-
see models_onchain.py's module docstring for why the text has to live here,
|
| 7 |
-
keyed by that same hash, or the anchored hash is unverifiable.
|
| 8 |
-
|
| 9 |
-
Revision ID: e4a7c1f9b3d2
|
| 10 |
-
Revises: a1f3c9d2e6b7
|
| 11 |
-
Create Date: 2026-09-07 00:00:00.000000
|
| 12 |
-
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from typing import Sequence, Union
|
| 16 |
-
|
| 17 |
-
from alembic import op
|
| 18 |
-
import sqlalchemy as sa
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# revision identifiers, used by Alembic.
|
| 22 |
-
revision: str = "e4a7c1f9b3d2"
|
| 23 |
-
down_revision: Union[str, Sequence[str], None] = "a1f3c9d2e6b7"
|
| 24 |
-
branch_labels: Union[str, Sequence[str], None] = None
|
| 25 |
-
depends_on: Union[str, Sequence[str], None] = None
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def upgrade() -> None:
|
| 29 |
-
"""Upgrade schema."""
|
| 30 |
-
op.create_table(
|
| 31 |
-
"onchain_rationales",
|
| 32 |
-
sa.Column("id", sa.String(length=64), nullable=False),
|
| 33 |
-
sa.Column("rationale_hash", sa.String(length=66), nullable=False),
|
| 34 |
-
sa.Column("rationale", sa.Text(), nullable=False),
|
| 35 |
-
sa.Column("agent_address", sa.String(length=42), nullable=True),
|
| 36 |
-
sa.Column("evaluator_address", sa.String(length=42), nullable=True),
|
| 37 |
-
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 38 |
-
sa.PrimaryKeyConstraint("id"),
|
| 39 |
-
)
|
| 40 |
-
op.create_index(
|
| 41 |
-
op.f("ix_onchain_rationales_rationale_hash"),
|
| 42 |
-
"onchain_rationales",
|
| 43 |
-
["rationale_hash"],
|
| 44 |
-
unique=True,
|
| 45 |
-
)
|
| 46 |
-
op.create_index(
|
| 47 |
-
op.f("ix_onchain_rationales_created_at"),
|
| 48 |
-
"onchain_rationales",
|
| 49 |
-
["created_at"],
|
| 50 |
-
unique=False,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def downgrade() -> None:
|
| 55 |
-
"""Downgrade schema."""
|
| 56 |
-
op.drop_index(
|
| 57 |
-
op.f("ix_onchain_rationales_created_at"), table_name="onchain_rationales"
|
| 58 |
-
)
|
| 59 |
-
op.drop_index(
|
| 60 |
-
op.f("ix_onchain_rationales_rationale_hash"), table_name="onchain_rationales"
|
| 61 |
-
)
|
| 62 |
-
op.drop_table("onchain_rationales")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/deps.py
CHANGED
|
@@ -3,23 +3,17 @@ Dependency injection module for the ARF Agentic Reliability Framework API.
|
|
| 3 |
|
| 4 |
Provides FastAPI dependencies for database sessions, rate limiting, and
|
| 5 |
singleton instances of the core ARF engines (RiskEngine, DecisionEngine,
|
| 6 |
-
LyapunovStabilityController, CausalEffectEstimator,
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
v4.3.2: Added verify_internal_key dependency to secure direct API access.
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
import os
|
| 14 |
import sys
|
| 15 |
-
from typing import Optional
|
| 16 |
from app.database.session import SessionLocal
|
| 17 |
from slowapi import Limiter
|
| 18 |
from slowapi.util import get_remote_address
|
| 19 |
from app.core.config import settings
|
| 20 |
|
| 21 |
-
from fastapi import Header, HTTPException
|
| 22 |
-
|
| 23 |
# ARF core engine imports
|
| 24 |
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 25 |
from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine
|
|
@@ -28,14 +22,6 @@ from agentic_reliability_framework.core.governance.causal_effect_estimator impor
|
|
| 28 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 29 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction
|
| 30 |
|
| 31 |
-
# ── v4.3.1: Skill Registry (optional) ──────────────────────────
|
| 32 |
-
try:
|
| 33 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 34 |
-
_SKILL_REGISTRY_AVAILABLE = True
|
| 35 |
-
except ImportError:
|
| 36 |
-
SkillRegistry = None
|
| 37 |
-
_SKILL_REGISTRY_AVAILABLE = False
|
| 38 |
-
|
| 39 |
|
| 40 |
# ---------------------------------------------------------------------------
|
| 41 |
# Database dependency
|
|
@@ -64,45 +50,6 @@ limiter = Limiter(
|
|
| 64 |
)
|
| 65 |
|
| 66 |
|
| 67 |
-
# ---------------------------------------------------------------------------
|
| 68 |
-
# Internal API key verification (v4.3.2)
|
| 69 |
-
# ---------------------------------------------------------------------------
|
| 70 |
-
|
| 71 |
-
INTERNAL_API_KEY = os.getenv("ARF_INTERNAL_API_KEY", "")
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
async def verify_internal_key(x_internal_key: str = Header(default=None, alias="X-Internal-Key")):
|
| 75 |
-
"""
|
| 76 |
-
FastAPI dependency that verifies the internal API key header.
|
| 77 |
-
|
| 78 |
-
The request must include an X‑Internal‑Key header matching
|
| 79 |
-
ARF_INTERNAL_API_KEY. This fails closed: if ARF_INTERNAL_API_KEY is not
|
| 80 |
-
configured, every request is rejected with 401 rather than being let
|
| 81 |
-
through unauthenticated.
|
| 82 |
-
|
| 83 |
-
This guards against direct access to the API when deployed behind
|
| 84 |
-
the Go gateway. The gateway is configured to inject this header
|
| 85 |
-
for authenticated requests.
|
| 86 |
-
"""
|
| 87 |
-
if not INTERNAL_API_KEY:
|
| 88 |
-
raise HTTPException(status_code=401, detail="Internal API key is not configured")
|
| 89 |
-
if x_internal_key is None:
|
| 90 |
-
raise HTTPException(status_code=401, detail="Missing internal API key")
|
| 91 |
-
# Use a constant‑time comparison to avoid timing attacks.
|
| 92 |
-
if not _constant_time_compare(x_internal_key, INTERNAL_API_KEY):
|
| 93 |
-
raise HTTPException(status_code=401, detail="Invalid internal API key")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _constant_time_compare(a: str, b: str) -> bool:
|
| 97 |
-
"""Compare two strings in constant time to prevent timing attacks."""
|
| 98 |
-
if len(a) != len(b):
|
| 99 |
-
return False
|
| 100 |
-
result = 0
|
| 101 |
-
for x, y in zip(a, b):
|
| 102 |
-
result |= ord(x) ^ ord(y)
|
| 103 |
-
return result == 0
|
| 104 |
-
|
| 105 |
-
|
| 106 |
# ---------------------------------------------------------------------------
|
| 107 |
# Singleton engine instances (lazy, cached)
|
| 108 |
# ---------------------------------------------------------------------------
|
|
@@ -112,7 +59,6 @@ _decision_engine = None
|
|
| 112 |
_stability_controller = None
|
| 113 |
_causal_explainer = None
|
| 114 |
_rag_graph = None
|
| 115 |
-
_skill_registry = None
|
| 116 |
|
| 117 |
|
| 118 |
def _seed_rag_graph(rag: RAGGraphMemory) -> None:
|
|
@@ -211,20 +157,3 @@ def get_causal_explainer() -> CausalEffectEstimator:
|
|
| 211 |
if _causal_explainer is None:
|
| 212 |
_causal_explainer = CausalEffectEstimator()
|
| 213 |
return _causal_explainer
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
def get_skill_registry() -> "Optional[SkillRegistry]":
|
| 217 |
-
"""
|
| 218 |
-
Return a singleton SkillRegistry instance (v4.3.1).
|
| 219 |
-
|
| 220 |
-
The registry manages procedural skill artefacts, versioning, per‑skill
|
| 221 |
-
reliability models (Beta‑Binomial), and the COLLECT‑DIAGNOSE‑REVISE‑PROMOTE
|
| 222 |
-
evolution loop. If the SkillRegistry module is not installed, returns None.
|
| 223 |
-
"""
|
| 224 |
-
global _skill_registry
|
| 225 |
-
if not _SKILL_REGISTRY_AVAILABLE:
|
| 226 |
-
return None
|
| 227 |
-
if _skill_registry is None:
|
| 228 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 229 |
-
_skill_registry = SkillRegistry()
|
| 230 |
-
return _skill_registry
|
|
|
|
| 3 |
|
| 4 |
Provides FastAPI dependencies for database sessions, rate limiting, and
|
| 5 |
singleton instances of the core ARF engines (RiskEngine, DecisionEngine,
|
| 6 |
+
LyapunovStabilityController, CausalEffectEstimator, and RAGGraphMemory).
|
| 7 |
+
All engine dependencies are lazily initialised and cached for the lifetime
|
| 8 |
+
of the application process.
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
|
|
|
| 11 |
import sys
|
|
|
|
| 12 |
from app.database.session import SessionLocal
|
| 13 |
from slowapi import Limiter
|
| 14 |
from slowapi.util import get_remote_address
|
| 15 |
from app.core.config import settings
|
| 16 |
|
|
|
|
|
|
|
| 17 |
# ARF core engine imports
|
| 18 |
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 19 |
from agentic_reliability_framework.core.decision.decision_engine import DecisionEngine
|
|
|
|
| 22 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 23 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent, HealingAction
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
# Database dependency
|
|
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
# Singleton engine instances (lazy, cached)
|
| 55 |
# ---------------------------------------------------------------------------
|
|
|
|
| 59 |
_stability_controller = None
|
| 60 |
_causal_explainer = None
|
| 61 |
_rag_graph = None
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def _seed_rag_graph(rag: RAGGraphMemory) -> None:
|
|
|
|
| 157 |
if _causal_explainer is None:
|
| 158 |
_causal_explainer = CausalEffectEstimator()
|
| 159 |
return _causal_explainer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_admin.py
CHANGED
|
@@ -2,37 +2,26 @@
|
|
| 2 |
Admin API endpoints for API key management and audit logs.
|
| 3 |
These endpoints should be protected (e.g., by an admin API key) in production.
|
| 4 |
"""
|
| 5 |
-
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
| 9 |
-
import os
|
| 10 |
-
import secrets
|
| 11 |
import uuid
|
| 12 |
-
from
|
| 13 |
-
from app.api.deps import get_db
|
| 14 |
-
from app.core import usage_tracker
|
| 15 |
-
from app.core.usage_tracker import Tier
|
| 16 |
-
from app.database.models_intents import TenantDB
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 19 |
-
#
|
| 20 |
-
|
| 21 |
-
ADMIN_API_KEY = os.getenv("ARF_ADMIN_API_KEY")
|
| 22 |
|
| 23 |
|
| 24 |
def verify_admin(admin_key: str = Query(..., alias="admin_key")):
|
| 25 |
-
if
|
| 26 |
-
raise HTTPException(status_code=403, detail="Admin API is not configured")
|
| 27 |
-
if not secrets.compare_digest(admin_key, ADMIN_API_KEY):
|
| 28 |
raise HTTPException(status_code=403, detail="Invalid admin key")
|
| 29 |
return True
|
| 30 |
|
| 31 |
|
| 32 |
class CreateKeyRequest(BaseModel):
|
| 33 |
tier: str
|
| 34 |
-
tenant_id: Optional[str] = None # attach to an existing tenant; omit to create a new one
|
| 35 |
-
org_name: Optional[str] = None # used only when creating a new tenant
|
| 36 |
|
| 37 |
|
| 38 |
class UpdateTierRequest(BaseModel):
|
|
@@ -40,146 +29,78 @@ class UpdateTierRequest(BaseModel):
|
|
| 40 |
|
| 41 |
|
| 42 |
@router.post("/keys", dependencies=[Depends(verify_admin)])
|
| 43 |
-
async def create_api_key(req: CreateKeyRequest
|
| 44 |
-
# Previously called get_or_create_api_key(new_key, tier_enum) -- since
|
| 45 |
-
# that function's signature is (key, tenant_id, tier=FREE), tier_enum
|
| 46 |
-
# was silently accepted as tenant_id and every key of the same tier
|
| 47 |
-
# collided onto one bogus tenant_id (e.g. every "free" key sharing
|
| 48 |
-
# tenant_id="free"). tenant_id gates real per-tenant isolation
|
| 49 |
-
# elsewhere (BetaStateDB, IntentDB, decision audit log), so this was a
|
| 50 |
-
# cross-tenant data bug, not just a mislabeled field.
|
| 51 |
if req.tier not in [t.value for t in Tier]:
|
| 52 |
raise HTTPException(
|
| 53 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 54 |
-
tier_enum = Tier(req.tier)
|
| 55 |
-
|
| 56 |
-
tenant_id = req.tenant_id
|
| 57 |
-
if tenant_id:
|
| 58 |
-
if not db.query(TenantDB).filter(TenantDB.id == tenant_id).first():
|
| 59 |
-
raise HTTPException(status_code=404, detail=f"Tenant {tenant_id} not found")
|
| 60 |
-
else:
|
| 61 |
-
tenant_id = str(uuid.uuid4())
|
| 62 |
-
db.add(TenantDB(
|
| 63 |
-
id=tenant_id,
|
| 64 |
-
name=req.org_name or "Default Organization",
|
| 65 |
-
created_at=datetime.utcnow(),
|
| 66 |
-
created_by="admin",
|
| 67 |
-
))
|
| 68 |
-
db.commit()
|
| 69 |
-
|
| 70 |
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
-
@router.get("/keys", dependencies=[Depends(verify_admin)])
|
| 76 |
async def list_api_keys(limit: int = 100, offset: int = 0):
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
tier/deactivate endpoints below. `current_month_usage` is not shown
|
| 81 |
-
here: `monthly_counts` is intentionally still keyed by the raw API key
|
| 82 |
-
(arf-gateway depends on reading it that way), which this endpoint no
|
| 83 |
-
longer has -- query `/admin/keys/{api_key}/audit` with the real key for
|
| 84 |
-
per-key usage/audit history instead.
|
| 85 |
-
"""
|
| 86 |
-
with usage_tracker.tracker._get_pg_conn() as conn:
|
| 87 |
-
rows = usage_tracker.tracker._pg_execute(
|
| 88 |
-
conn,
|
| 89 |
-
"SELECT lookup_hash, tier, created_at, last_used_at, is_active FROM api_keys "
|
| 90 |
-
"ORDER BY created_at DESC LIMIT %s OFFSET %s",
|
| 91 |
(limit, offset)
|
| 92 |
-
).fetchall()
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
"
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return {"keys": keys, "total": len(keys)}
|
| 105 |
|
| 106 |
|
| 107 |
-
@router.patch("/keys/{
|
| 108 |
async def update_key_tier(
|
| 109 |
-
|
| 110 |
req: UpdateTierRequest = Body(...),
|
| 111 |
):
|
| 112 |
if req.tier not in [t.value for t in Tier]:
|
| 113 |
raise HTTPException(
|
| 114 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 115 |
-
with
|
| 116 |
-
row =
|
| 117 |
-
|
| 118 |
if not row:
|
| 119 |
-
conn.rollback()
|
| 120 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
conn.commit()
|
| 124 |
return {"message": f"Tier updated to {req.tier}"}
|
| 125 |
|
| 126 |
|
| 127 |
-
@router.
|
| 128 |
-
async def rotate_api_key(
|
| 129 |
-
key_id: str = Path(..., description="The key_id from GET /admin/keys (not the raw API key)"),
|
| 130 |
-
):
|
| 131 |
-
"""Atomically deactivate a key and issue a new one on the same tenant
|
| 132 |
-
and tier -- the single action a leaked/compromised key actually needs.
|
| 133 |
-
Doing this as get-old + deactivate + create separately (the only option
|
| 134 |
-
before this endpoint existed) risks losing track of the tenant_id
|
| 135 |
-
partway through, or leaving the old key active if a later step fails.
|
| 136 |
-
The new plaintext key is returned exactly once, like create_api_key's."""
|
| 137 |
-
with usage_tracker.tracker._get_pg_conn() as conn:
|
| 138 |
-
old_row = usage_tracker.tracker._pg_execute(
|
| 139 |
-
conn, "SELECT tenant_id, tier FROM api_keys WHERE lookup_hash = %s", (key_id,)
|
| 140 |
-
).fetchone()
|
| 141 |
-
if not old_row:
|
| 142 |
-
conn.rollback()
|
| 143 |
-
raise HTTPException(status_code=404, detail="API key not found")
|
| 144 |
-
|
| 145 |
-
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 146 |
-
salt = secrets.token_hex(16)
|
| 147 |
-
usage_tracker.tracker._pg_execute(
|
| 148 |
-
conn, "UPDATE api_keys SET is_active = false WHERE lookup_hash = %s", (key_id,))
|
| 149 |
-
usage_tracker.tracker._pg_execute(
|
| 150 |
-
conn,
|
| 151 |
-
"INSERT INTO api_keys "
|
| 152 |
-
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 153 |
-
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 154 |
-
(old_row["tenant_id"], old_row["tier"], datetime.utcnow(), True,
|
| 155 |
-
salt, usage_tracker.tracker._salted_hash(new_key, salt), usage_tracker.tracker._lookup_hash(new_key)),
|
| 156 |
-
)
|
| 157 |
-
conn.commit()
|
| 158 |
-
|
| 159 |
-
return {
|
| 160 |
-
"api_key": new_key,
|
| 161 |
-
"tenant_id": old_row["tenant_id"],
|
| 162 |
-
"tier": old_row["tier"],
|
| 163 |
-
"deactivated_key_id": key_id,
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
@router.delete("/keys/{key_id}", dependencies=[Depends(verify_admin)])
|
| 168 |
async def deactivate_api_key(
|
| 169 |
-
|
| 170 |
-
with
|
| 171 |
-
row =
|
| 172 |
-
|
| 173 |
if not row:
|
| 174 |
-
conn.rollback()
|
| 175 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 176 |
-
|
| 177 |
-
|
| 178 |
conn.commit()
|
| 179 |
return {"message": "API key deactivated"}
|
| 180 |
|
| 181 |
|
| 182 |
-
@router.get("/keys/{api_key}/audit", dependencies=[Depends(verify_admin)])
|
| 183 |
async def get_audit_logs(
|
| 184 |
api_key: str = Path(..., description="The API key to audit"),
|
| 185 |
start_date: Optional[str] = Query(None),
|
|
@@ -188,23 +109,20 @@ async def get_audit_logs(
|
|
| 188 |
):
|
| 189 |
start = datetime.fromisoformat(start_date) if start_date else None
|
| 190 |
end = datetime.fromisoformat(end_date) if end_date else None
|
| 191 |
-
logs =
|
| 192 |
return {"api_key": api_key, "logs": logs}
|
| 193 |
|
| 194 |
|
| 195 |
-
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
| 196 |
async def get_global_stats():
|
| 197 |
-
with
|
| 198 |
-
total_keys =
|
| 199 |
-
|
| 200 |
-
pg_conn.commit()
|
| 201 |
-
with usage_tracker.tracker._get_conn() as conn:
|
| 202 |
total_requests = conn.execute(
|
| 203 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 204 |
by_tier = conn.execute(
|
| 205 |
"SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier"
|
| 206 |
).fetchall()
|
| 207 |
-
month =
|
| 208 |
current_month_requests = conn.execute(
|
| 209 |
"SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,)
|
| 210 |
).fetchone()[0] or 0
|
|
@@ -214,74 +132,3 @@ async def get_global_stats():
|
|
| 214 |
"current_month_evaluations": current_month_requests,
|
| 215 |
"by_tier": [{"tier": row[0], "count": row[1]} for row in by_tier],
|
| 216 |
}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
# ---------------------------------------------------------------------------
|
| 220 |
-
# Enterprise execution approvals (v4.3.4, opt-in -- see routes_governance.py's
|
| 221 |
-
# POST /intents/{id}/execute). Without these, the durable approval ledger has
|
| 222 |
-
# no way to actually be resolved through the API at all -- present but
|
| 223 |
-
# unusable. app.state.approval_store is None whenever ARF_ENABLE_EXECUTION
|
| 224 |
-
# is unset/false or arf_enterprise isn't installed (see main.py lifespan).
|
| 225 |
-
# ---------------------------------------------------------------------------
|
| 226 |
-
|
| 227 |
-
class ResolveApprovalRequest(BaseModel):
|
| 228 |
-
approved: bool
|
| 229 |
-
note: Optional[str] = None
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
def _require_approval_store(request: Request):
|
| 233 |
-
approval_store = getattr(request.app.state, "approval_store", None)
|
| 234 |
-
if approval_store is None:
|
| 235 |
-
raise HTTPException(
|
| 236 |
-
status_code=501,
|
| 237 |
-
detail="Enterprise execution approvals are not enabled on this deployment "
|
| 238 |
-
"(ARF_ENABLE_EXECUTION unset, or arf_enterprise is not installed)",
|
| 239 |
-
)
|
| 240 |
-
return approval_store
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
@router.get("/executions/pending", dependencies=[Depends(verify_admin)])
|
| 244 |
-
async def list_pending_executions(request: Request, limit: int = 100, offset: int = 0):
|
| 245 |
-
approval_store = _require_approval_store(request)
|
| 246 |
-
pending = approval_store.list_pending(limit=limit, offset=offset)
|
| 247 |
-
return {
|
| 248 |
-
"pending": [
|
| 249 |
-
{
|
| 250 |
-
"approval_id": r.id,
|
| 251 |
-
"decision_id": r.decision_id,
|
| 252 |
-
"intent_id": r.intent_id,
|
| 253 |
-
"level": r.level,
|
| 254 |
-
"approval_required": r.approval_required,
|
| 255 |
-
"requested_at": r.requested_at.isoformat(),
|
| 256 |
-
}
|
| 257 |
-
for r in pending
|
| 258 |
-
],
|
| 259 |
-
"total": len(pending),
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
@router.post("/executions/{approval_id}/resolve", dependencies=[Depends(verify_admin)])
|
| 264 |
-
async def resolve_execution_approval(
|
| 265 |
-
request: Request,
|
| 266 |
-
req: ResolveApprovalRequest,
|
| 267 |
-
approval_id: str = Path(..., description="The approval_id from POST /intents/{id}/execute's 202 response"),
|
| 268 |
-
):
|
| 269 |
-
# resolved_by is a fixed "admin" constant, not derived from admin_key in
|
| 270 |
-
# any way -- even a prefix of that secret has no business being
|
| 271 |
-
# persisted into a database row a GET endpoint can read back. Matches
|
| 272 |
-
# create_api_key's existing created_by="admin" pattern above: this
|
| 273 |
-
# codebase has one shared admin credential, not per-admin identity, so
|
| 274 |
-
# there's nothing more specific to record.
|
| 275 |
-
approval_store = _require_approval_store(request)
|
| 276 |
-
resolved = approval_store.resolve(
|
| 277 |
-
approval_id, approved=req.approved, resolved_by="admin", note=req.note
|
| 278 |
-
)
|
| 279 |
-
if not resolved:
|
| 280 |
-
raise HTTPException(
|
| 281 |
-
status_code=404,
|
| 282 |
-
detail="Approval not found or already resolved",
|
| 283 |
-
)
|
| 284 |
-
return {
|
| 285 |
-
"message": f"Approval {'approved' if req.approved else 'rejected'}",
|
| 286 |
-
"approval_id": approval_id,
|
| 287 |
-
}
|
|
|
|
| 2 |
Admin API endpoints for API key management and audit logs.
|
| 3 |
These endpoints should be protected (e.g., by an admin API key) in production.
|
| 4 |
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
|
|
|
|
|
|
| 9 |
import uuid
|
| 10 |
+
from app.core.usage_tracker import tracker, Tier
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/admin", tags=["admin"])
|
| 13 |
+
# Simple in‑memory admin key (replace with proper auth in production)
|
| 14 |
+
ADMIN_API_KEY = "admin_secret_change_me"
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def verify_admin(admin_key: str = Query(..., alias="admin_key")):
|
| 18 |
+
if admin_key != ADMIN_API_KEY:
|
|
|
|
|
|
|
| 19 |
raise HTTPException(status_code=403, detail="Invalid admin key")
|
| 20 |
return True
|
| 21 |
|
| 22 |
|
| 23 |
class CreateKeyRequest(BaseModel):
|
| 24 |
tier: str
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class UpdateTierRequest(BaseModel):
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@router.post("/keys", dependencies=[Depends(verify_admin)])
|
| 32 |
+
async def create_api_key(req: CreateKeyRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if req.tier not in [t.value for t in Tier]:
|
| 34 |
raise HTTPException(
|
| 35 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
new_key = f"sk_live_{uuid.uuid4().hex[:24]}"
|
| 37 |
+
tier_enum = Tier(req.tier)
|
| 38 |
+
tracker.get_or_create_api_key(new_key, tier_enum)
|
| 39 |
+
return {"api_key": new_key, "tier": req.tier}
|
| 40 |
|
| 41 |
|
|
|
|
| 42 |
async def list_api_keys(limit: int = 100, offset: int = 0):
|
| 43 |
+
with tracker._get_conn() as conn:
|
| 44 |
+
rows = conn.execute(
|
| 45 |
+
"SELECT key, tier, created_at, last_used_at, is_active FROM api_keys ORDER BY created_at DESC LIMIT ? OFFSET ?", # noqa: E501
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
(limit, offset)
|
| 47 |
+
).fetchall() # noqa: E501
|
| 48 |
+
keys = []
|
| 49 |
+
for row in rows:
|
| 50 |
+
month = tracker._get_month_key()
|
| 51 |
+
usage_row = conn.execute(
|
| 52 |
+
"SELECT count FROM monthly_counts WHERE api_key = ? AND year_month = ?",
|
| 53 |
+
(row["key"], month)
|
| 54 |
+
).fetchone()
|
| 55 |
+
usage = usage_row["count"] if usage_row else 0
|
| 56 |
+
keys.append(
|
| 57 |
+
{
|
| 58 |
+
"key": row["key"],
|
| 59 |
+
"tier": row["tier"],
|
| 60 |
+
"created_at": datetime.fromtimestamp(
|
| 61 |
+
row["created_at"]).isoformat(),
|
| 62 |
+
"last_used_at": datetime.fromtimestamp(
|
| 63 |
+
row["last_used_at"]).isoformat() if row["last_used_at"] else None,
|
| 64 |
+
"is_active": bool(
|
| 65 |
+
row["is_active"]),
|
| 66 |
+
"current_month_usage": usage,
|
| 67 |
+
})
|
| 68 |
return {"keys": keys, "total": len(keys)}
|
| 69 |
|
| 70 |
|
| 71 |
+
@router.patch("/keys/{api_key}/tier", dependencies=[Depends(verify_admin)])
|
| 72 |
async def update_key_tier(
|
| 73 |
+
api_key: str = Path(..., description="The API key to update"),
|
| 74 |
req: UpdateTierRequest = Body(...),
|
| 75 |
):
|
| 76 |
if req.tier not in [t.value for t in Tier]:
|
| 77 |
raise HTTPException(
|
| 78 |
status_code=400, detail=f"Invalid tier. Must be one of {[t.value for t in Tier]}")
|
| 79 |
+
with tracker._get_conn() as conn:
|
| 80 |
+
row = conn.execute(
|
| 81 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
| 82 |
if not row:
|
|
|
|
| 83 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 84 |
+
conn.execute("UPDATE api_keys SET tier = ? WHERE key = ?",
|
| 85 |
+
(req.tier, api_key))
|
| 86 |
conn.commit()
|
| 87 |
return {"message": f"Tier updated to {req.tier}"}
|
| 88 |
|
| 89 |
|
| 90 |
+
@router.delete("/keys/{api_key}", dependencies=[Depends(verify_admin)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
async def deactivate_api_key(
|
| 92 |
+
api_key: str = Path(..., description="The API key to deactivate")):
|
| 93 |
+
with tracker._get_conn() as conn:
|
| 94 |
+
row = conn.execute(
|
| 95 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
| 96 |
if not row:
|
|
|
|
| 97 |
raise HTTPException(status_code=404, detail="API key not found")
|
| 98 |
+
conn.execute(
|
| 99 |
+
"UPDATE api_keys SET is_active = 0 WHERE key = ?", (api_key,))
|
| 100 |
conn.commit()
|
| 101 |
return {"message": "API key deactivated"}
|
| 102 |
|
| 103 |
|
|
|
|
| 104 |
async def get_audit_logs(
|
| 105 |
api_key: str = Path(..., description="The API key to audit"),
|
| 106 |
start_date: Optional[str] = Query(None),
|
|
|
|
| 109 |
):
|
| 110 |
start = datetime.fromisoformat(start_date) if start_date else None
|
| 111 |
end = datetime.fromisoformat(end_date) if end_date else None
|
| 112 |
+
logs = tracker.get_audit_logs(api_key, start, end, limit)
|
| 113 |
return {"api_key": api_key, "logs": logs}
|
| 114 |
|
| 115 |
|
|
|
|
| 116 |
async def get_global_stats():
|
| 117 |
+
with tracker._get_conn() as conn:
|
| 118 |
+
total_keys = conn.execute(
|
| 119 |
+
"SELECT COUNT(*) FROM api_keys WHERE is_active = 1").fetchone()[0]
|
|
|
|
|
|
|
| 120 |
total_requests = conn.execute(
|
| 121 |
"SELECT COUNT(*) FROM usage_log").fetchone()[0]
|
| 122 |
by_tier = conn.execute(
|
| 123 |
"SELECT tier, COUNT(*) as count FROM usage_log GROUP BY tier"
|
| 124 |
).fetchall()
|
| 125 |
+
month = tracker._get_month_key()
|
| 126 |
current_month_requests = conn.execute(
|
| 127 |
"SELECT SUM(count) FROM monthly_counts WHERE year_month = ?", (month,)
|
| 128 |
).fetchone()[0] or 0
|
|
|
|
| 132 |
"current_month_evaluations": current_month_requests,
|
| 133 |
"by_tier": [{"tier": row[0], "count": row[1]} for row in by_tier],
|
| 134 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_governance.py
CHANGED
|
@@ -5,49 +5,31 @@ This module provides the primary API endpoints for evaluating infrastructure
|
|
| 5 |
intents and healing decisions. It integrates:
|
| 6 |
|
| 7 |
- Idempotent quota consumption (usage tracker)
|
| 8 |
-
- Tenant isolation (tenant_id
|
| 9 |
-
via the ``enforce_quota`` dependency; never taken from a client-supplied header)
|
| 10 |
- Auditable decision logging (DecisionAuditLogDB)
|
| 11 |
- Pricing telemetry (optional, to arf‑pricing‑calculator)
|
| 12 |
- OpenTelemetry tracing
|
| 13 |
- Optional Rust execution ladder for mechanical enforcement
|
| 14 |
-
- **v4.3.1**: Full governance loop produces a Bayesian HealingIntent with skill
|
| 15 |
-
posterior parameters (α, β) for the enterprise SkillGate.
|
| 16 |
-
Includes persistent stability controller and temporal monitor for
|
| 17 |
-
cross‑request state accumulation, and a merging policy evaluator that
|
| 18 |
-
respects both external and internal policy violations.
|
| 19 |
-
Healing endpoint now optionally accepts skill context for Bayesian
|
| 20 |
-
utility‑aware action selection.
|
| 21 |
-
- **v4.3.2**: Passes criticality parameter for dynamic gate tuning (Feature 3).
|
| 22 |
-
Internal API key verification added to secure direct access.
|
| 23 |
"""
|
| 24 |
|
| 25 |
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header
|
| 26 |
from fastapi.encoders import jsonable_encoder
|
| 27 |
-
from fastapi.responses import JSONResponse
|
| 28 |
from sqlalchemy.orm import Session
|
| 29 |
from pydantic import BaseModel
|
| 30 |
import uuid
|
| 31 |
import logging
|
| 32 |
-
import os
|
| 33 |
import time
|
| 34 |
-
import
|
| 35 |
-
from typing import Optional, Dict, Any, List
|
| 36 |
|
| 37 |
from app.models.infrastructure_intents import InfrastructureIntentRequest
|
| 38 |
from app.services.intent_adapter import to_oss_intent
|
| 39 |
-
from app.services.risk_service import
|
| 40 |
from app.services.intent_store import save_evaluated_intent
|
| 41 |
from app.services.outcome_service import record_outcome
|
| 42 |
-
from app.api.deps import get_db
|
| 43 |
-
from app.database.
|
| 44 |
-
from app.core.usage_tracker import enforce_quota # <-- tenant resolution
|
| 45 |
-
from app.database.models_intents import DecisionAuditLogDB, IntentDB
|
| 46 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent
|
| 47 |
-
from agentic_reliability_framework.core.governance.
|
| 48 |
-
PolicyEvaluator,
|
| 49 |
-
allow_all,
|
| 50 |
-
)
|
| 51 |
|
| 52 |
# ===== USAGE TRACKER =====
|
| 53 |
import app.core.usage_tracker
|
|
@@ -69,57 +51,6 @@ except ImportError:
|
|
| 69 |
RUST_AVAILABLE = False
|
| 70 |
ExecutionLadder = None
|
| 71 |
|
| 72 |
-
# ===== ENTERPRISE EXECUTOR (optional) =====
|
| 73 |
-
# `arf_enterprise` is not in requirements.txt -- it's a proprietary,
|
| 74 |
-
# private-repo package, and unlike agentic_reliability_framework/
|
| 75 |
-
# arf-pricing-calculator (plain git+https URLs with no visible credential
|
| 76 |
-
# setup in the Dockerfile) I can't confirm Render's build can actually
|
| 77 |
-
# clone it. Stays fully optional/try-except-guarded, same shape as
|
| 78 |
-
# RUST_AVAILABLE above, deliberately not added as a hard dependency this
|
| 79 |
-
# session -- see POST /intents/{id}/execute below and .env.example for what
|
| 80 |
-
# an operator needs to do to actually turn this on.
|
| 81 |
-
try:
|
| 82 |
-
from arf_enterprise.executor import EnterpriseExecutor
|
| 83 |
-
from arf_enterprise.actuators.fake import FakeCloudActuator
|
| 84 |
-
from arf_enterprise.config import EnterpriseConfig
|
| 85 |
-
from arf_enterprise.store import ApprovalStore, PostgresStore
|
| 86 |
-
from arf_enterprise.exceptions import (
|
| 87 |
-
ExecutionError as EnterpriseExecutionError,
|
| 88 |
-
PendingApprovalError,
|
| 89 |
-
SafetyError as EnterpriseSafetyError,
|
| 90 |
-
)
|
| 91 |
-
ENTERPRISE_EXECUTOR_AVAILABLE = True
|
| 92 |
-
except ImportError:
|
| 93 |
-
ENTERPRISE_EXECUTOR_AVAILABLE = False
|
| 94 |
-
EnterpriseExecutor = None
|
| 95 |
-
FakeCloudActuator = None
|
| 96 |
-
EnterpriseConfig = None
|
| 97 |
-
ApprovalStore = None
|
| 98 |
-
PostgresStore = None
|
| 99 |
-
EnterpriseExecutionError = None
|
| 100 |
-
PendingApprovalError = None
|
| 101 |
-
EnterpriseSafetyError = None
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _trusted_signing_keys() -> List[str]:
|
| 105 |
-
"""Parse ARF_TRUSTED_SIGNING_KEYS into a list of hex fingerprints.
|
| 106 |
-
|
| 107 |
-
Comma-separated, matching EnterpriseConfig.from_env's own parsing.
|
| 108 |
-
Splitting matters: the variable holds N fingerprints, and passing the
|
| 109 |
-
raw string as a single-element list would register the literal
|
| 110 |
-
"abc,def" as one key -- so neither real key would be trusted, and an
|
| 111 |
-
unset variable would register the empty string as trusted rather than
|
| 112 |
-
trusting nothing.
|
| 113 |
-
|
| 114 |
-
An empty result is the correct fail-closed state: the ladder then
|
| 115 |
-
rejects every signed intent, which is loud and safe.
|
| 116 |
-
"""
|
| 117 |
-
raw = os.getenv("ARF_TRUSTED_SIGNING_KEYS", "")
|
| 118 |
-
return [k.strip() for k in raw.split(",") if k.strip()]
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
ARF_ENABLE_EXECUTION = os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true"
|
| 122 |
-
|
| 123 |
# ===== OPEN TELEMETRY =====
|
| 124 |
try:
|
| 125 |
from opentelemetry import trace
|
|
@@ -131,9 +62,7 @@ except ImportError:
|
|
| 131 |
_tracer = None
|
| 132 |
|
| 133 |
logger = logging.getLogger(__name__)
|
| 134 |
-
|
| 135 |
-
# v4.3.2: protect all governance endpoints with internal API key verification
|
| 136 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 137 |
|
| 138 |
|
| 139 |
class OutcomeRequest(BaseModel):
|
|
@@ -141,38 +70,17 @@ class OutcomeRequest(BaseModel):
|
|
| 141 |
success: bool
|
| 142 |
recorded_by: str
|
| 143 |
notes: str = ""
|
| 144 |
-
# v4.3.1: optional skill provenance for reliability feedback
|
| 145 |
-
skill_id: Optional[str] = None
|
| 146 |
-
skill_version: Optional[int] = None
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
class ExecuteIntentRequest(BaseModel):
|
| 150 |
-
"""The client already received `healing_intent` in /intents/evaluate's
|
| 151 |
-
response -- resubmitting it here (rather than this endpoint trying to
|
| 152 |
-
reconstruct a signed, action/component/parameters-bearing intent from
|
| 153 |
-
IntentDB's stored oss_payload, which doesn't carry the signature) is
|
| 154 |
-
the cheapest correct design; IntentDB is used only to confirm the
|
| 155 |
-
intent exists and belongs to the caller's tenant."""
|
| 156 |
-
healing_intent: Dict[str, Any]
|
| 157 |
-
human_approved: bool = False
|
| 158 |
-
admin_approved: bool = False
|
| 159 |
-
# v4.3.1: optional skill provenance, forwarded to record_outcome the
|
| 160 |
-
# same way OutcomeRequest already does.
|
| 161 |
-
skill_id: Optional[str] = None
|
| 162 |
-
skill_version: Optional[int] = None
|
| 163 |
|
| 164 |
|
| 165 |
class HealingDecisionRequest(BaseModel):
|
| 166 |
event: ReliabilityEvent
|
| 167 |
-
# v4.3.1: optional skill context for Bayesian utility
|
| 168 |
-
skill_id: Optional[str] = None
|
| 169 |
-
skill_version: Optional[int] = None
|
| 170 |
|
| 171 |
|
| 172 |
# --------------------------------------------------------------------------
|
| 173 |
# Helper: write audit log (idempotent)
|
| 174 |
# --------------------------------------------------------------------------
|
| 175 |
async def write_audit_log(
|
|
|
|
| 176 |
tenant_id: str,
|
| 177 |
deterministic_id: str,
|
| 178 |
healing_intent: Dict[str, Any],
|
|
@@ -182,90 +90,67 @@ async def write_audit_log(
|
|
| 182 |
"""
|
| 183 |
Store a governance decision in the immutable audit log.
|
| 184 |
Idempotent on (tenant_id, deterministic_id) – if already exists, skip.
|
| 185 |
-
|
| 186 |
-
Runs as a BackgroundTask, which executes after the response has already
|
| 187 |
-
been sent -- and after FastAPI has already torn down the request's
|
| 188 |
-
`Depends(get_db)` session. Reusing that session here would mean every
|
| 189 |
-
query silently reopens a fresh connection/transaction that nothing then
|
| 190 |
-
guarantees gets closed (least of all on the idempotent-skip path below,
|
| 191 |
-
which used to return with no commit/rollback/close at all), leaving an
|
| 192 |
-
idle-in-transaction connection that blocks any later DDL against these
|
| 193 |
-
tables (e.g. test teardown's `Base.metadata.drop_all()`) indefinitely.
|
| 194 |
-
Owning and closing our own session here avoids that entirely.
|
| 195 |
-
"""
|
| 196 |
-
db = SessionLocal()
|
| 197 |
-
try:
|
| 198 |
-
# Check if already logged (idempotency)
|
| 199 |
-
existing = db.query(DecisionAuditLogDB).filter(
|
| 200 |
-
DecisionAuditLogDB.tenant_id == tenant_id,
|
| 201 |
-
DecisionAuditLogDB.deterministic_id == deterministic_id
|
| 202 |
-
).first()
|
| 203 |
-
if existing:
|
| 204 |
-
logger.info(f"Audit log already exists for {deterministic_id}, skipping.")
|
| 205 |
-
return
|
| 206 |
-
|
| 207 |
-
# Extract fields that are actually present in DecisionAuditLogDB
|
| 208 |
-
risk_score = healing_intent.get("risk_score", 0.5)
|
| 209 |
-
action = healing_intent.get("recommended_action", "deny")
|
| 210 |
-
justification = healing_intent.get("justification", "")
|
| 211 |
-
metadata = healing_intent.get("metadata", {})
|
| 212 |
-
memory_success_rate = metadata.get("memory_success_rate")
|
| 213 |
-
memory_weight = metadata.get("memory_weight")
|
| 214 |
-
counterfactual = metadata.get("counterfactual")
|
| 215 |
-
|
| 216 |
-
audit_entry = DecisionAuditLogDB(
|
| 217 |
-
tenant_id=tenant_id,
|
| 218 |
-
deterministic_id=deterministic_id,
|
| 219 |
-
timestamp=datetime.datetime.utcnow(),
|
| 220 |
-
risk_score=risk_score,
|
| 221 |
-
action=action,
|
| 222 |
-
justification=justification,
|
| 223 |
-
memory_success_rate=memory_success_rate,
|
| 224 |
-
memory_weight=memory_weight,
|
| 225 |
-
counterfactual=counterfactual,
|
| 226 |
-
trace_id=trace_id,
|
| 227 |
-
)
|
| 228 |
-
db.add(audit_entry)
|
| 229 |
-
db.commit()
|
| 230 |
-
logger.info(f"Audit log written for {deterministic_id}")
|
| 231 |
-
finally:
|
| 232 |
-
db.close()
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
# --------------------------------------------------------------------------
|
| 236 |
-
# Policy evaluator that merges external violations with internal checks
|
| 237 |
-
# --------------------------------------------------------------------------
|
| 238 |
-
class MergingPolicyEvaluator(PolicyEvaluator):
|
| 239 |
"""
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
|
| 271 |
# --------------------------------------------------------------------------
|
|
@@ -278,13 +163,9 @@ async def evaluate_intent_endpoint(
|
|
| 278 |
background_tasks: BackgroundTasks,
|
| 279 |
db: Session = Depends(get_db),
|
| 280 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 281 |
-
skill_registry=Depends(get_skill_registry), # v4.3.1
|
| 282 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 283 |
):
|
| 284 |
"""
|
| 285 |
-
Evaluate an infrastructure intent with idempotency, tenant isolation,
|
| 286 |
-
full governance loop analysis, Bayesian skill posterior injection,
|
| 287 |
-
and optional criticality parameter for dynamic gate tuning (v4.3.2).
|
| 288 |
"""
|
| 289 |
span = None
|
| 290 |
if OTEL_AVAILABLE and _tracer:
|
|
@@ -293,10 +174,19 @@ async def evaluate_intent_endpoint(
|
|
| 293 |
span.set_attribute("environment", str(intent_req.environment))
|
| 294 |
|
| 295 |
start_time = time.time()
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
current_tracker = app.core.usage_tracker.tracker
|
| 302 |
if current_tracker is None:
|
|
@@ -307,7 +197,7 @@ async def evaluate_intent_endpoint(
|
|
| 307 |
|
| 308 |
record = UsageRecord(
|
| 309 |
api_key=api_key,
|
| 310 |
-
tier=
|
| 311 |
timestamp=start_time,
|
| 312 |
endpoint="/api/v1/intents/evaluate",
|
| 313 |
request_body=intent_req.model_dump(),
|
|
@@ -330,62 +220,30 @@ async def evaluate_intent_endpoint(
|
|
| 330 |
oss_intent = to_oss_intent(intent_req)
|
| 331 |
risk_engine = request.app.state.risk_engine
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
if policy_engine is not None and hasattr(policy_engine, 'root_policy'):
|
| 336 |
-
base_evaluator = PolicyEvaluator(policy_engine.root_policy)
|
| 337 |
-
else:
|
| 338 |
-
base_evaluator = PolicyEvaluator(allow_all())
|
| 339 |
-
|
| 340 |
-
# Wrap it to also include the pre‑computed violations from the request
|
| 341 |
-
policy_evaluator = MergingPolicyEvaluator(
|
| 342 |
-
base_evaluator,
|
| 343 |
-
intent_req.policy_violations
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
-
# Optional components from app state
|
| 347 |
-
memory = getattr(request.app.state, "rag_graph", None)
|
| 348 |
-
hallucination_probe = getattr(request.app.state, "epistemic_probe", None)
|
| 349 |
-
predictive_engine = getattr(request.app.state, "predictive_engine", None)
|
| 350 |
-
business_calculator = getattr(request.app.state, "business_calculator", None)
|
| 351 |
-
|
| 352 |
-
# Stateful monitors (v4.3.1)
|
| 353 |
-
stability_controller = getattr(request.app.state, "stability_controller", None)
|
| 354 |
-
temporal_monitor = getattr(request.app.state, "temporal_monitor", None)
|
| 355 |
-
|
| 356 |
-
# Run the full governance loop, injecting skill context and criticality if present
|
| 357 |
-
result = evaluate_intent_full(
|
| 358 |
intent=oss_intent,
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
memory=memory,
|
| 362 |
-
hallucination_probe=hallucination_probe,
|
| 363 |
-
predictive_engine=predictive_engine,
|
| 364 |
-
business_calculator=business_calculator,
|
| 365 |
-
stability_controller=stability_controller,
|
| 366 |
-
temporal_monitor=temporal_monitor,
|
| 367 |
-
skill_id=intent_req.skill_id,
|
| 368 |
-
skill_registry=skill_registry,
|
| 369 |
-
tenant_id=tenant_id,
|
| 370 |
-
criticality=intent_req.criticality, # v4.3.2
|
| 371 |
)
|
| 372 |
|
| 373 |
if span:
|
| 374 |
span.set_attribute("risk_score", result["risk_score"])
|
|
|
|
| 375 |
|
| 376 |
-
deterministic_id =
|
| 377 |
api_payload = jsonable_encoder(intent_req.model_dump())
|
| 378 |
oss_payload = jsonable_encoder(oss_intent.model_dump())
|
| 379 |
|
| 380 |
save_evaluated_intent(
|
| 381 |
db=db,
|
| 382 |
deterministic_id=deterministic_id,
|
| 383 |
-
tenant_id=tenant_id,
|
| 384 |
intent_type=intent_req.intent_type,
|
| 385 |
api_payload=api_payload,
|
| 386 |
oss_payload=oss_payload,
|
| 387 |
environment=str(intent_req.environment),
|
| 388 |
risk_score=result["risk_score"],
|
|
|
|
| 389 |
)
|
| 390 |
|
| 391 |
result["intent_id"] = deterministic_id
|
|
@@ -395,6 +253,7 @@ async def evaluate_intent_endpoint(
|
|
| 395 |
healing_intent_dict = result.get("healing_intent", result)
|
| 396 |
background_tasks.add_task(
|
| 397 |
write_audit_log,
|
|
|
|
| 398 |
tenant_id=tenant_id,
|
| 399 |
deterministic_id=deterministic_id,
|
| 400 |
healing_intent=healing_intent_dict,
|
|
@@ -435,148 +294,11 @@ async def evaluate_intent_endpoint(
|
|
| 435 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 436 |
span.record_exception(e)
|
| 437 |
span.end()
|
| 438 |
-
raise HTTPException(status_code=500, detail=
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
# --------------------------------------------------------------------------
|
| 442 |
-
# Endpoint: execute a previously evaluated intent (v4.3.4, opt-in)
|
| 443 |
-
# --------------------------------------------------------------------------
|
| 444 |
-
@router.post("/intents/{deterministic_id}/execute")
|
| 445 |
-
async def execute_intent_endpoint(
|
| 446 |
-
request: Request,
|
| 447 |
-
deterministic_id: str,
|
| 448 |
-
exec_req: ExecuteIntentRequest,
|
| 449 |
-
db: Session = Depends(get_db),
|
| 450 |
-
skill_registry=Depends(get_skill_registry),
|
| 451 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 452 |
-
):
|
| 453 |
-
"""
|
| 454 |
-
Execute a previously evaluated healing intent through
|
| 455 |
-
arf_enterprise.EnterpriseExecutor -- gate re-check (Rust ladder),
|
| 456 |
-
optional durable approval, actuation, and independent read-back
|
| 457 |
-
verification, whose *verified* result (not a client self-report) feeds
|
| 458 |
-
record_outcome the same way /intents/outcome already does.
|
| 459 |
-
|
| 460 |
-
Off by default (ARF_ENABLE_EXECUTION unset or false) and a 501 if the
|
| 461 |
-
arf_enterprise package isn't importable -- this is new, opt-in
|
| 462 |
-
capability, not a replacement for the existing advisory-only flow.
|
| 463 |
-
Uses FakeCloudActuator regardless of what's configured elsewhere:
|
| 464 |
-
selecting a real cloud actuator (which provider, which credentials) is
|
| 465 |
-
a deliberate later step for whoever actually deploys against real
|
| 466 |
-
infrastructure, not something this endpoint defaults into.
|
| 467 |
-
"""
|
| 468 |
-
if not ENTERPRISE_EXECUTOR_AVAILABLE:
|
| 469 |
-
raise HTTPException(
|
| 470 |
-
status_code=501,
|
| 471 |
-
detail="arf_enterprise is not installed on this deployment; execution is unavailable",
|
| 472 |
-
)
|
| 473 |
-
if not ARF_ENABLE_EXECUTION:
|
| 474 |
-
raise HTTPException(
|
| 475 |
-
status_code=501,
|
| 476 |
-
detail="Execution is not enabled (set ARF_ENABLE_EXECUTION=true to opt in)",
|
| 477 |
-
)
|
| 478 |
-
|
| 479 |
-
tenant_id = quota["tenant_id"]
|
| 480 |
-
|
| 481 |
-
# Existence + tenant-ownership check only -- the healing_intent to
|
| 482 |
-
# execute comes from the request body (see ExecuteIntentRequest), not
|
| 483 |
-
# reconstructed from what's stored here.
|
| 484 |
-
intent_row = db.query(IntentDB).filter(
|
| 485 |
-
IntentDB.deterministic_id == deterministic_id,
|
| 486 |
-
IntentDB.tenant_id == tenant_id,
|
| 487 |
-
).one_or_none()
|
| 488 |
-
if not intent_row:
|
| 489 |
-
raise HTTPException(status_code=404, detail=f"Intent not found: {deterministic_id}")
|
| 490 |
-
|
| 491 |
-
risk_engine = request.app.state.risk_engine
|
| 492 |
-
|
| 493 |
-
def _on_verified_outcome(intent: Dict[str, Any], verified_success: bool, context: Dict[str, Any]) -> None:
|
| 494 |
-
try:
|
| 495 |
-
record_outcome(
|
| 496 |
-
db=db,
|
| 497 |
-
tenant_id=tenant_id,
|
| 498 |
-
deterministic_id=deterministic_id,
|
| 499 |
-
success=verified_success,
|
| 500 |
-
recorded_by="enterprise_executor",
|
| 501 |
-
notes=f"Auto-recorded from verified execution. Observed: {context.get('observed')}",
|
| 502 |
-
risk_engine=risk_engine,
|
| 503 |
-
skill_id=exec_req.skill_id,
|
| 504 |
-
skill_version=exec_req.skill_version,
|
| 505 |
-
skill_registry=skill_registry,
|
| 506 |
-
)
|
| 507 |
-
except Exception:
|
| 508 |
-
# Execution already happened by the time this fires -- a failure
|
| 509 |
-
# here must not be raised back through EnterpriseExecutor.execute()
|
| 510 |
-
# (which would misreport a real actuation as failed). Logged so
|
| 511 |
-
# the risk-engine-not-updated case is visible, not silent.
|
| 512 |
-
logger.exception(
|
| 513 |
-
"Failed to record verified outcome for intent %s after execution",
|
| 514 |
-
deterministic_id,
|
| 515 |
-
)
|
| 516 |
-
|
| 517 |
-
# Singleton initialised once at startup (main.py lifespan) rather than
|
| 518 |
-
# constructed fresh per request -- None here means either
|
| 519 |
-
# ARF_ENABLE_EXECUTION wasn't set (unreachable, checked above) or the
|
| 520 |
-
# ledger failed to initialise at startup, in which case boolean-trust
|
| 521 |
-
# mode is the documented fallback (see EnterpriseExecutor.execute).
|
| 522 |
-
approval_store = getattr(request.app.state, "approval_store", None)
|
| 523 |
-
|
| 524 |
-
# A narrow config carrying only the trust anchor. Deliberately NOT
|
| 525 |
-
# EnterpriseConfig.from_env(), which would also pick up ARF_CLOUD,
|
| 526 |
-
# ARF_MAX_BLAST_RADIUS, ARF_ENFORCE_BUSINESS_HOURS and the audit/safety
|
| 527 |
-
# toggles -- turning on guardrails and audit logging as a side effect of
|
| 528 |
-
# enabling signing is a behaviour change nobody asked for. Everything
|
| 529 |
-
# other than the trusted keys stays on today's defaults.
|
| 530 |
-
#
|
| 531 |
-
# Without this the executor got EnterpriseConfig() with an empty
|
| 532 |
-
# trusted_signing_keys, so the ladder trusted no keys and rejected every
|
| 533 |
-
# signed intent as "Untrusted signing key" -- the whole execute path was
|
| 534 |
-
# unreachable regardless of ARF_ENABLE_EXECUTION.
|
| 535 |
-
trusted_keys = _trusted_signing_keys()
|
| 536 |
-
if not trusted_keys:
|
| 537 |
-
logger.warning(
|
| 538 |
-
"ARF_TRUSTED_SIGNING_KEYS is unset or empty; the execution ladder "
|
| 539 |
-
"trusts no signing keys and will reject every signed intent. Set it "
|
| 540 |
-
"to the hex fingerprint(s) of the key(s) permitted to sign intents."
|
| 541 |
-
)
|
| 542 |
-
|
| 543 |
-
executor = EnterpriseExecutor(
|
| 544 |
-
config=EnterpriseConfig(trusted_signing_keys=trusted_keys),
|
| 545 |
-
actuator=FakeCloudActuator(),
|
| 546 |
-
approval_store=approval_store,
|
| 547 |
-
on_verified_outcome=_on_verified_outcome,
|
| 548 |
-
)
|
| 549 |
-
|
| 550 |
-
try:
|
| 551 |
-
result = await executor.execute(
|
| 552 |
-
exec_req.healing_intent,
|
| 553 |
-
human_approved=exec_req.human_approved,
|
| 554 |
-
admin_approved=exec_req.admin_approved,
|
| 555 |
-
)
|
| 556 |
-
return result
|
| 557 |
-
except PendingApprovalError as e:
|
| 558 |
-
return JSONResponse(
|
| 559 |
-
status_code=202,
|
| 560 |
-
content={
|
| 561 |
-
"status": "pending_approval",
|
| 562 |
-
"approval_id": e.approval_id,
|
| 563 |
-
"level": e.level,
|
| 564 |
-
"approval_required": e.approval_required,
|
| 565 |
-
"detail": str(e),
|
| 566 |
-
},
|
| 567 |
-
)
|
| 568 |
-
except (EnterpriseExecutionError, EnterpriseSafetyError) as e:
|
| 569 |
-
# A legitimate "did not execute" outcome (ladder denial, safety
|
| 570 |
-
# constraint, verification mismatch) -- not a server bug, so not a
|
| 571 |
-
# 5xx.
|
| 572 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 573 |
-
except Exception:
|
| 574 |
-
logger.exception("Unexpected error in execute_intent_endpoint")
|
| 575 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 576 |
|
| 577 |
|
| 578 |
# --------------------------------------------------------------------------
|
| 579 |
-
# Endpoint: record outcome (
|
| 580 |
# --------------------------------------------------------------------------
|
| 581 |
@router.post("/intents/outcome")
|
| 582 |
async def record_outcome_endpoint(
|
|
@@ -584,25 +306,21 @@ async def record_outcome_endpoint(
|
|
| 584 |
outcome: OutcomeRequest,
|
| 585 |
db: Session = Depends(get_db),
|
| 586 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 587 |
-
skill_registry=Depends(get_skill_registry),
|
| 588 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 589 |
):
|
| 590 |
-
"""
|
| 591 |
-
|
|
|
|
|
|
|
| 592 |
try:
|
| 593 |
risk_engine = request.app.state.risk_engine
|
| 594 |
outcome_record = record_outcome(
|
| 595 |
db=db,
|
| 596 |
-
tenant_id=tenant_id,
|
| 597 |
deterministic_id=outcome.deterministic_id,
|
| 598 |
success=outcome.success,
|
| 599 |
recorded_by=outcome.recorded_by,
|
| 600 |
notes=outcome.notes,
|
| 601 |
risk_engine=risk_engine,
|
| 602 |
idempotency_key=idempotency_key,
|
| 603 |
-
skill_id=outcome.skill_id,
|
| 604 |
-
skill_version=outcome.skill_version,
|
| 605 |
-
skill_registry=skill_registry,
|
| 606 |
)
|
| 607 |
|
| 608 |
if PRICING_AVAILABLE and add_event is not None:
|
|
@@ -619,27 +337,22 @@ async def record_outcome_endpoint(
|
|
| 619 |
logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
|
| 620 |
|
| 621 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 622 |
-
except Exception:
|
| 623 |
-
|
| 624 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 625 |
|
| 626 |
|
| 627 |
# --------------------------------------------------------------------------
|
| 628 |
-
# Endpoint: evaluate healing decision (
|
| 629 |
# --------------------------------------------------------------------------
|
| 630 |
@router.post("/healing/evaluate")
|
| 631 |
async def evaluate_healing_decision_endpoint(
|
| 632 |
request: Request,
|
| 633 |
decision_req: HealingDecisionRequest,
|
| 634 |
background_tasks: BackgroundTasks,
|
| 635 |
-
db: Session = Depends(get_db),
|
| 636 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
| 637 |
-
skill_registry=Depends(get_skill_registry), # v4.3.1
|
| 638 |
-
quota: dict = Depends(enforce_quota), # tenant resolved from authenticated API key
|
| 639 |
):
|
| 640 |
"""
|
| 641 |
-
Evaluate a healing decision, audit it, optionally enforce via Rust ladder
|
| 642 |
-
and now incorporate Bayesian skill reliability if skill context is provided.
|
| 643 |
"""
|
| 644 |
span = None
|
| 645 |
if OTEL_AVAILABLE and _tracer:
|
|
@@ -647,10 +360,19 @@ async def evaluate_healing_decision_endpoint(
|
|
| 647 |
span.set_attribute("component", decision_req.event.component)
|
| 648 |
|
| 649 |
start_time = time.time()
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
|
| 655 |
current_tracker = app.core.usage_tracker.tracker
|
| 656 |
if current_tracker is None:
|
|
@@ -661,7 +383,7 @@ async def evaluate_healing_decision_endpoint(
|
|
| 661 |
|
| 662 |
record = UsageRecord(
|
| 663 |
api_key=api_key,
|
| 664 |
-
tier=
|
| 665 |
timestamp=start_time,
|
| 666 |
endpoint="/api/v1/healing/evaluate",
|
| 667 |
request_body=decision_req.model_dump(),
|
|
@@ -693,10 +415,6 @@ async def evaluate_healing_decision_endpoint(
|
|
| 693 |
rag_graph=rag_graph,
|
| 694 |
model=model,
|
| 695 |
tokenizer=tokenizer,
|
| 696 |
-
# v4.3.1: pass skill context if provided
|
| 697 |
-
skill_id=decision_req.skill_id,
|
| 698 |
-
skill_version=decision_req.skill_version,
|
| 699 |
-
skill_registry=skill_registry,
|
| 700 |
)
|
| 701 |
|
| 702 |
# ---- Optional Rust enforcement ----
|
|
@@ -715,11 +433,12 @@ async def evaluate_healing_decision_endpoint(
|
|
| 715 |
except Exception as e:
|
| 716 |
logger.warning(f"Rust enforcement failed: {e}")
|
| 717 |
|
| 718 |
-
# ---- Write audit log
|
| 719 |
deterministic_id = response_data.get("intent_id", str(uuid.uuid4()))
|
| 720 |
healing_intent_dict = response_data.get("healing_intent", response_data)
|
| 721 |
background_tasks.add_task(
|
| 722 |
write_audit_log,
|
|
|
|
| 723 |
tenant_id=tenant_id,
|
| 724 |
deterministic_id=deterministic_id,
|
| 725 |
healing_intent=healing_intent_dict,
|
|
@@ -760,4 +479,4 @@ async def evaluate_healing_decision_endpoint(
|
|
| 760 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 761 |
span.record_exception(e)
|
| 762 |
span.end()
|
| 763 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 5 |
intents and healing decisions. It integrates:
|
| 6 |
|
| 7 |
- Idempotent quota consumption (usage tracker)
|
| 8 |
+
- Tenant isolation (tenant_id from request.state, with fallback to X-Tenant-ID header)
|
|
|
|
| 9 |
- Auditable decision logging (DecisionAuditLogDB)
|
| 10 |
- Pricing telemetry (optional, to arf‑pricing‑calculator)
|
| 11 |
- OpenTelemetry tracing
|
| 12 |
- Optional Rust execution ladder for mechanical enforcement
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks, Header
|
| 16 |
from fastapi.encoders import jsonable_encoder
|
|
|
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
from pydantic import BaseModel
|
| 19 |
import uuid
|
| 20 |
import logging
|
|
|
|
| 21 |
import time
|
| 22 |
+
from typing import Optional, Dict, Any
|
|
|
|
| 23 |
|
| 24 |
from app.models.infrastructure_intents import InfrastructureIntentRequest
|
| 25 |
from app.services.intent_adapter import to_oss_intent
|
| 26 |
+
from app.services.risk_service import evaluate_intent, evaluate_healing_decision
|
| 27 |
from app.services.intent_store import save_evaluated_intent
|
| 28 |
from app.services.outcome_service import record_outcome
|
| 29 |
+
from app.api.deps import get_db
|
| 30 |
+
from app.database.models_intents import DecisionAuditLogDB, TenantDB # <-- NEW
|
|
|
|
|
|
|
| 31 |
from agentic_reliability_framework.core.models.event import ReliabilityEvent
|
| 32 |
+
from agentic_reliability_framework.core.governance.healing_intent import HealingIntent
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# ===== USAGE TRACKER =====
|
| 35 |
import app.core.usage_tracker
|
|
|
|
| 51 |
RUST_AVAILABLE = False
|
| 52 |
ExecutionLadder = None
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# ===== OPEN TELEMETRY =====
|
| 55 |
try:
|
| 56 |
from opentelemetry import trace
|
|
|
|
| 62 |
_tracer = None
|
| 63 |
|
| 64 |
logger = logging.getLogger(__name__)
|
| 65 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
class OutcomeRequest(BaseModel):
|
|
|
|
| 70 |
success: bool
|
| 71 |
recorded_by: str
|
| 72 |
notes: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
class HealingDecisionRequest(BaseModel):
|
| 76 |
event: ReliabilityEvent
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
# --------------------------------------------------------------------------
|
| 80 |
# Helper: write audit log (idempotent)
|
| 81 |
# --------------------------------------------------------------------------
|
| 82 |
async def write_audit_log(
|
| 83 |
+
db: Session,
|
| 84 |
tenant_id: str,
|
| 85 |
deterministic_id: str,
|
| 86 |
healing_intent: Dict[str, Any],
|
|
|
|
| 90 |
"""
|
| 91 |
Store a governance decision in the immutable audit log.
|
| 92 |
Idempotent on (tenant_id, deterministic_id) – if already exists, skip.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
"""
|
| 94 |
+
# Check if already logged (idempotency)
|
| 95 |
+
existing = db.query(DecisionAuditLogDB).filter(
|
| 96 |
+
DecisionAuditLogDB.tenant_id == tenant_id,
|
| 97 |
+
DecisionAuditLogDB.deterministic_id == deterministic_id
|
| 98 |
+
).first()
|
| 99 |
+
if existing:
|
| 100 |
+
logger.info(f"Audit log already exists for {deterministic_id}, skipping.")
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
# Extract fields from HealingIntent (or result dict)
|
| 104 |
+
risk_score = healing_intent.get("risk_score", 0.5)
|
| 105 |
+
action = healing_intent.get("recommended_action", "deny") # approve/deny/escalate
|
| 106 |
+
justification = healing_intent.get("justification", "")
|
| 107 |
+
confidence = healing_intent.get("confidence", 0.85)
|
| 108 |
+
confidence_dist = healing_intent.get("confidence_distribution", {})
|
| 109 |
+
confidence_lower = confidence_dist.get("p5", confidence - 0.1)
|
| 110 |
+
confidence_upper = confidence_dist.get("p95", confidence + 0.1)
|
| 111 |
+
cost_projection = healing_intent.get("cost_projection")
|
| 112 |
+
policy_violations = healing_intent.get("policy_violations", [])
|
| 113 |
+
source = healing_intent.get("source", "advisory_analysis")
|
| 114 |
+
parent_intent_id = healing_intent.get("parent_intent_id")
|
| 115 |
+
root_intent_id = healing_intent.get("root_intent_id")
|
| 116 |
+
ancestor_chain = healing_intent.get("ancestor_chain", [])
|
| 117 |
+
|
| 118 |
+
# Memory and causal fields (usually in metadata)
|
| 119 |
+
metadata = healing_intent.get("metadata", {})
|
| 120 |
+
memory_success_rate = metadata.get("memory_success_rate")
|
| 121 |
+
memory_weight = metadata.get("memory_weight")
|
| 122 |
+
counterfactual = metadata.get("counterfactual")
|
| 123 |
+
epistemic_uncertainty = metadata.get("epistemic_uncertainty")
|
| 124 |
+
causal_effect = metadata.get("causal_effect")
|
| 125 |
+
|
| 126 |
+
# Build audit entry
|
| 127 |
+
audit_entry = DecisionAuditLogDB(
|
| 128 |
+
tenant_id=tenant_id,
|
| 129 |
+
deterministic_id=deterministic_id,
|
| 130 |
+
timestamp=datetime.datetime.utcnow(),
|
| 131 |
+
risk_score=risk_score,
|
| 132 |
+
action=action,
|
| 133 |
+
justification=justification,
|
| 134 |
+
recommended_action=action, # same as action for now
|
| 135 |
+
confidence=confidence,
|
| 136 |
+
confidence_lower=confidence_lower,
|
| 137 |
+
confidence_upper=confidence_upper,
|
| 138 |
+
memory_success_rate=memory_success_rate,
|
| 139 |
+
memory_weight=memory_weight,
|
| 140 |
+
counterfactual=counterfactual,
|
| 141 |
+
epistemic_uncertainty=epistemic_uncertainty,
|
| 142 |
+
causal_effect=causal_effect,
|
| 143 |
+
cost_projection=cost_projection,
|
| 144 |
+
policy_violations=policy_violations,
|
| 145 |
+
source=source,
|
| 146 |
+
parent_intent_id=parent_intent_id,
|
| 147 |
+
root_intent_id=root_intent_id,
|
| 148 |
+
ancestor_chain=ancestor_chain,
|
| 149 |
+
trace_id=trace_id,
|
| 150 |
+
)
|
| 151 |
+
db.add(audit_entry)
|
| 152 |
+
db.commit()
|
| 153 |
+
logger.info(f"Audit log written for {deterministic_id}")
|
| 154 |
|
| 155 |
|
| 156 |
# --------------------------------------------------------------------------
|
|
|
|
| 163 |
background_tasks: BackgroundTasks,
|
| 164 |
db: Session = Depends(get_db),
|
| 165 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 166 |
):
|
| 167 |
"""
|
| 168 |
+
Evaluate an infrastructure intent with idempotency, tenant isolation, and audit logging.
|
|
|
|
|
|
|
| 169 |
"""
|
| 170 |
span = None
|
| 171 |
if OTEL_AVAILABLE and _tracer:
|
|
|
|
| 174 |
span.set_attribute("environment", str(intent_req.environment))
|
| 175 |
|
| 176 |
start_time = time.time()
|
| 177 |
+
api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 178 |
+
if not api_key:
|
| 179 |
+
api_key = request.query_params.get("api_key", "unknown")
|
| 180 |
+
|
| 181 |
+
# Get tenant_id from request.state or fallback to X-Tenant-ID header
|
| 182 |
+
tenant_id = getattr(request.state, "tenant_id", None)
|
| 183 |
+
if not tenant_id:
|
| 184 |
+
tenant_id = request.headers.get("X-Tenant-ID")
|
| 185 |
+
if not tenant_id:
|
| 186 |
+
if span:
|
| 187 |
+
span.set_status(Status(StatusCode.ERROR, "Missing tenant_id"))
|
| 188 |
+
span.end()
|
| 189 |
+
raise HTTPException(status_code=403, detail="Tenant not identified")
|
| 190 |
|
| 191 |
current_tracker = app.core.usage_tracker.tracker
|
| 192 |
if current_tracker is None:
|
|
|
|
| 197 |
|
| 198 |
record = UsageRecord(
|
| 199 |
api_key=api_key,
|
| 200 |
+
tier=None,
|
| 201 |
timestamp=start_time,
|
| 202 |
endpoint="/api/v1/intents/evaluate",
|
| 203 |
request_body=intent_req.model_dump(),
|
|
|
|
| 220 |
oss_intent = to_oss_intent(intent_req)
|
| 221 |
risk_engine = request.app.state.risk_engine
|
| 222 |
|
| 223 |
+
result = evaluate_intent(
|
| 224 |
+
engine=risk_engine,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
intent=oss_intent,
|
| 226 |
+
cost_estimate=intent_req.estimated_cost,
|
| 227 |
+
policy_violations=intent_req.policy_violations,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
)
|
| 229 |
|
| 230 |
if span:
|
| 231 |
span.set_attribute("risk_score", result["risk_score"])
|
| 232 |
+
span.set_attribute("deterministic_id", str(uuid.uuid4()))
|
| 233 |
|
| 234 |
+
deterministic_id = str(uuid.uuid4())
|
| 235 |
api_payload = jsonable_encoder(intent_req.model_dump())
|
| 236 |
oss_payload = jsonable_encoder(oss_intent.model_dump())
|
| 237 |
|
| 238 |
save_evaluated_intent(
|
| 239 |
db=db,
|
| 240 |
deterministic_id=deterministic_id,
|
|
|
|
| 241 |
intent_type=intent_req.intent_type,
|
| 242 |
api_payload=api_payload,
|
| 243 |
oss_payload=oss_payload,
|
| 244 |
environment=str(intent_req.environment),
|
| 245 |
risk_score=result["risk_score"],
|
| 246 |
+
tenant_id=tenant_id,
|
| 247 |
)
|
| 248 |
|
| 249 |
result["intent_id"] = deterministic_id
|
|
|
|
| 253 |
healing_intent_dict = result.get("healing_intent", result)
|
| 254 |
background_tasks.add_task(
|
| 255 |
write_audit_log,
|
| 256 |
+
db=db,
|
| 257 |
tenant_id=tenant_id,
|
| 258 |
deterministic_id=deterministic_id,
|
| 259 |
healing_intent=healing_intent_dict,
|
|
|
|
| 294 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 295 |
span.record_exception(e)
|
| 296 |
span.end()
|
| 297 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
# --------------------------------------------------------------------------
|
| 301 |
+
# Endpoint: record outcome (idempotent, pricing)
|
| 302 |
# --------------------------------------------------------------------------
|
| 303 |
@router.post("/intents/outcome")
|
| 304 |
async def record_outcome_endpoint(
|
|
|
|
| 306 |
outcome: OutcomeRequest,
|
| 307 |
db: Session = Depends(get_db),
|
| 308 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 309 |
):
|
| 310 |
+
"""
|
| 311 |
+
Record an outcome for a previously evaluated intent.
|
| 312 |
+
Also updates the pricing calculator's calibration buffer if available.
|
| 313 |
+
"""
|
| 314 |
try:
|
| 315 |
risk_engine = request.app.state.risk_engine
|
| 316 |
outcome_record = record_outcome(
|
| 317 |
db=db,
|
|
|
|
| 318 |
deterministic_id=outcome.deterministic_id,
|
| 319 |
success=outcome.success,
|
| 320 |
recorded_by=outcome.recorded_by,
|
| 321 |
notes=outcome.notes,
|
| 322 |
risk_engine=risk_engine,
|
| 323 |
idempotency_key=idempotency_key,
|
|
|
|
|
|
|
|
|
|
| 324 |
)
|
| 325 |
|
| 326 |
if PRICING_AVAILABLE and add_event is not None:
|
|
|
|
| 337 |
logger.warning(f"Failed to update pricing buffer for intent {outcome.deterministic_id}: {e}")
|
| 338 |
|
| 339 |
return {"message": "Outcome recorded", "outcome_id": outcome_record.id}
|
| 340 |
+
except Exception as e:
|
| 341 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 342 |
|
| 343 |
|
| 344 |
# --------------------------------------------------------------------------
|
| 345 |
+
# Endpoint: evaluate healing decision (with optional Rust enforcement)
|
| 346 |
# --------------------------------------------------------------------------
|
| 347 |
@router.post("/healing/evaluate")
|
| 348 |
async def evaluate_healing_decision_endpoint(
|
| 349 |
request: Request,
|
| 350 |
decision_req: HealingDecisionRequest,
|
| 351 |
background_tasks: BackgroundTasks,
|
|
|
|
| 352 |
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
|
|
|
|
|
|
|
| 353 |
):
|
| 354 |
"""
|
| 355 |
+
Evaluate a healing decision, audit it, and optionally enforce via Rust ladder.
|
|
|
|
| 356 |
"""
|
| 357 |
span = None
|
| 358 |
if OTEL_AVAILABLE and _tracer:
|
|
|
|
| 360 |
span.set_attribute("component", decision_req.event.component)
|
| 361 |
|
| 362 |
start_time = time.time()
|
| 363 |
+
api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 364 |
+
if not api_key:
|
| 365 |
+
api_key = request.query_params.get("api_key", "unknown")
|
| 366 |
+
|
| 367 |
+
# Get tenant_id from request.state or fallback to X-Tenant-ID header
|
| 368 |
+
tenant_id = getattr(request.state, "tenant_id", None)
|
| 369 |
+
if not tenant_id:
|
| 370 |
+
tenant_id = request.headers.get("X-Tenant-ID")
|
| 371 |
+
if not tenant_id:
|
| 372 |
+
if span:
|
| 373 |
+
span.set_status(Status(StatusCode.ERROR, "Missing tenant_id"))
|
| 374 |
+
span.end()
|
| 375 |
+
raise HTTPException(status_code=403, detail="Tenant not identified")
|
| 376 |
|
| 377 |
current_tracker = app.core.usage_tracker.tracker
|
| 378 |
if current_tracker is None:
|
|
|
|
| 383 |
|
| 384 |
record = UsageRecord(
|
| 385 |
api_key=api_key,
|
| 386 |
+
tier=None,
|
| 387 |
timestamp=start_time,
|
| 388 |
endpoint="/api/v1/healing/evaluate",
|
| 389 |
request_body=decision_req.model_dump(),
|
|
|
|
| 415 |
rag_graph=rag_graph,
|
| 416 |
model=model,
|
| 417 |
tokenizer=tokenizer,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
)
|
| 419 |
|
| 420 |
# ---- Optional Rust enforcement ----
|
|
|
|
| 433 |
except Exception as e:
|
| 434 |
logger.warning(f"Rust enforcement failed: {e}")
|
| 435 |
|
| 436 |
+
# ---- Write audit log ----
|
| 437 |
deterministic_id = response_data.get("intent_id", str(uuid.uuid4()))
|
| 438 |
healing_intent_dict = response_data.get("healing_intent", response_data)
|
| 439 |
background_tasks.add_task(
|
| 440 |
write_audit_log,
|
| 441 |
+
db=db, # Note: the healing endpoint also needs a DB session, which it currently lacks!
|
| 442 |
tenant_id=tenant_id,
|
| 443 |
deterministic_id=deterministic_id,
|
| 444 |
healing_intent=healing_intent_dict,
|
|
|
|
| 479 |
span.set_status(Status(StatusCode.ERROR, error_msg))
|
| 480 |
span.record_exception(e)
|
| 481 |
span.end()
|
| 482 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
app/api/routes_history.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
from app.api.deps import verify_internal_key
|
| 3 |
from app.core.storage import incident_history
|
| 4 |
|
| 5 |
-
router = APIRouter(
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/history")
|
| 9 |
async def get_history():
|
| 10 |
-
return {"incidents":
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
|
|
|
| 2 |
from app.core.storage import incident_history
|
| 3 |
|
| 4 |
+
router = APIRouter()
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/history")
|
| 8 |
async def get_history():
|
| 9 |
+
return {"incidents": incident_history}
|
app/api/routes_incidents.py
CHANGED
|
@@ -30,21 +30,23 @@ from agentic_reliability_framework.core.models.event import (
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
| 33 |
-
from app.api.deps import verify_internal_key
|
| 34 |
from app.causal_explainer import CausalExplainer
|
| 35 |
-
from app.core.
|
| 36 |
-
from app.core import usage_tracker
|
| 37 |
-
from app.core.usage_tracker import UsageRecord, enforce_quota
|
| 38 |
|
| 39 |
logger = logging.getLogger(__name__)
|
| 40 |
|
| 41 |
router = APIRouter()
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# ---------------------------------------------------------------------------
|
| 45 |
# POST /api/v1/report_incident
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
-
@router.post("/report_incident"
|
| 48 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 49 |
"""
|
| 50 |
Record a ``ReliabilityEvent`` in the in‑memory incident history.
|
|
@@ -52,10 +54,7 @@ async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
|
| 52 |
This endpoint is used by internal monitoring tools to feed incident
|
| 53 |
data into the causal explainer and downstream analysis. The event
|
| 54 |
is stored as a JSON‑safe dictionary and is **not** persisted across
|
| 55 |
-
API restarts.
|
| 56 |
-
data-bearing route in this API requires -- previously this endpoint had
|
| 57 |
-
no auth dependency at all, so anyone could write into the incident
|
| 58 |
-
history that feeds the causal explainer and ``GET /history``.
|
| 59 |
|
| 60 |
Parameters
|
| 61 |
----------
|
|
@@ -228,7 +227,7 @@ async def evaluate_incident(
|
|
| 228 |
# ------------------------------------------------------------------
|
| 229 |
# Asynchronous usage logging
|
| 230 |
# ------------------------------------------------------------------
|
| 231 |
-
if
|
| 232 |
record = UsageRecord(
|
| 233 |
api_key=api_key,
|
| 234 |
tier=tier,
|
|
@@ -238,7 +237,7 @@ async def evaluate_incident(
|
|
| 238 |
response=response_data,
|
| 239 |
processing_ms=(time.time() - start_time) * 1000,
|
| 240 |
)
|
| 241 |
-
await
|
| 242 |
|
| 243 |
logger.warning(
|
| 244 |
"Deprecated endpoint /v1/incidents/evaluate called by key %s",
|
|
@@ -250,8 +249,7 @@ async def evaluate_incident(
|
|
| 250 |
raise
|
| 251 |
except Exception as exc:
|
| 252 |
error_msg = str(exc)
|
| 253 |
-
|
| 254 |
-
if usage_tracker.tracker:
|
| 255 |
record = UsageRecord(
|
| 256 |
api_key=api_key,
|
| 257 |
tier=tier,
|
|
@@ -261,5 +259,5 @@ async def evaluate_incident(
|
|
| 261 |
error=error_msg,
|
| 262 |
processing_ms=(time.time() - start_time) * 1000,
|
| 263 |
)
|
| 264 |
-
await
|
| 265 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 30 |
ReliabilityEvent,
|
| 31 |
)
|
| 32 |
|
|
|
|
| 33 |
from app.causal_explainer import CausalExplainer
|
| 34 |
+
from app.core.usage_tracker import UsageRecord, enforce_quota, tracker
|
|
|
|
|
|
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
router = APIRouter()
|
| 39 |
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# In‑memory incident store (for auditing / debugging only)
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
incident_history: list[dict] = []
|
| 44 |
+
|
| 45 |
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# POST /api/v1/report_incident
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
+
@router.post("/report_incident")
|
| 50 |
async def report_incident(event: ReliabilityEvent) -> dict[str, str]:
|
| 51 |
"""
|
| 52 |
Record a ``ReliabilityEvent`` in the in‑memory incident history.
|
|
|
|
| 54 |
This endpoint is used by internal monitoring tools to feed incident
|
| 55 |
data into the causal explainer and downstream analysis. The event
|
| 56 |
is stored as a JSON‑safe dictionary and is **not** persisted across
|
| 57 |
+
API restarts.
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
Parameters
|
| 60 |
----------
|
|
|
|
| 227 |
# ------------------------------------------------------------------
|
| 228 |
# Asynchronous usage logging
|
| 229 |
# ------------------------------------------------------------------
|
| 230 |
+
if tracker:
|
| 231 |
record = UsageRecord(
|
| 232 |
api_key=api_key,
|
| 233 |
tier=tier,
|
|
|
|
| 237 |
response=response_data,
|
| 238 |
processing_ms=(time.time() - start_time) * 1000,
|
| 239 |
)
|
| 240 |
+
await tracker.increment_usage_async(record, background_tasks)
|
| 241 |
|
| 242 |
logger.warning(
|
| 243 |
"Deprecated endpoint /v1/incidents/evaluate called by key %s",
|
|
|
|
| 249 |
raise
|
| 250 |
except Exception as exc:
|
| 251 |
error_msg = str(exc)
|
| 252 |
+
if tracker:
|
|
|
|
| 253 |
record = UsageRecord(
|
| 254 |
api_key=api_key,
|
| 255 |
tier=tier,
|
|
|
|
| 259 |
error=error_msg,
|
| 260 |
processing_ms=(time.time() - start_time) * 1000,
|
| 261 |
)
|
| 262 |
+
await tracker.increment_usage_async(record, background_tasks)
|
| 263 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
app/api/routes_intents.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
-
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 6 |
from app.services.intent_service import simulate_intent
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
| 13 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
|
@@ -15,6 +10,5 @@ async def simulate_intent_endpoint(intent: IntentSimulation):
|
|
| 15 |
try:
|
| 16 |
result = simulate_intent(intent)
|
| 17 |
return IntentSimulationResponse(**result)
|
| 18 |
-
except Exception:
|
| 19 |
-
|
| 20 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.models.intent_models import IntentSimulation, IntentSimulationResponse
|
| 3 |
from app.services.intent_service import simulate_intent
|
| 4 |
|
| 5 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@router.post("/simulate_intent", response_model=IntentSimulationResponse)
|
|
|
|
| 10 |
try:
|
| 11 |
result = simulate_intent(intent)
|
| 12 |
return IntentSimulationResponse(**result)
|
| 13 |
+
except Exception as e:
|
| 14 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
app/api/routes_memory.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
from fastapi import APIRouter,
|
| 2 |
-
from app.api.deps import verify_internal_key
|
| 3 |
|
| 4 |
-
router = APIRouter(
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("/stats")
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request
|
|
|
|
| 2 |
|
| 3 |
+
router = APIRouter()
|
| 4 |
|
| 5 |
|
| 6 |
@router.get("/stats")
|
app/api/routes_onchain.py
DELETED
|
@@ -1,149 +0,0 @@
|
|
| 1 |
-
"""Routes for on-chain attestation rationale.
|
| 2 |
-
|
| 3 |
-
`RiskAttestationRegistry` (arf-onchain) anchors only a `rationale_hash` --
|
| 4 |
-
never the reasoning itself, to keep operational detail about a customer's
|
| 5 |
-
infrastructure off a public chain. These endpoints are the off-chain half:
|
| 6 |
-
a reference risk evaluator (`arf_enterprise.onchain.evaluator`) persists the
|
| 7 |
-
plaintext here immediately after signing an attestation, keyed by the same
|
| 8 |
-
hash it put on-chain, and an auditor who reads a `DecisionAnchored` or
|
| 9 |
-
`AttestationIssued` event can fetch the reasoning behind it here.
|
| 10 |
-
|
| 11 |
-
Internal-key gated like `routes_governance.py`: this is a service-to-service
|
| 12 |
-
surface for the evaluator process and for auditor tooling, not a
|
| 13 |
-
tenant-scoped customer endpoint -- see `OnchainRationaleDB`'s docstring for
|
| 14 |
-
why there is no tenant_id to enforce here.
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
import logging
|
| 18 |
-
|
| 19 |
-
from fastapi import APIRouter, Depends, HTTPException, Response
|
| 20 |
-
from pydantic import BaseModel, field_validator
|
| 21 |
-
from sqlalchemy.orm import Session
|
| 22 |
-
|
| 23 |
-
from app.api.deps import get_db, verify_internal_key
|
| 24 |
-
from app.database.models_onchain import OnchainRationaleDB
|
| 25 |
-
|
| 26 |
-
logger = logging.getLogger(__name__)
|
| 27 |
-
|
| 28 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _validate_hex_hash(value: str) -> str:
|
| 32 |
-
text = value.strip()
|
| 33 |
-
if not text.startswith("0x") or len(text) != 66:
|
| 34 |
-
raise ValueError("rationale_hash must be a 0x-prefixed 32-byte hex string")
|
| 35 |
-
try:
|
| 36 |
-
int(text, 16)
|
| 37 |
-
except ValueError:
|
| 38 |
-
raise ValueError("rationale_hash is not valid hex") from None
|
| 39 |
-
return text.lower()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class RationaleRequest(BaseModel):
|
| 43 |
-
rationale_hash: str
|
| 44 |
-
rationale: str
|
| 45 |
-
agent_address: str | None = None
|
| 46 |
-
evaluator_address: str | None = None
|
| 47 |
-
|
| 48 |
-
@field_validator("rationale_hash")
|
| 49 |
-
@classmethod
|
| 50 |
-
def _validate_hash(cls, value: str) -> str:
|
| 51 |
-
return _validate_hex_hash(value)
|
| 52 |
-
|
| 53 |
-
@field_validator("rationale")
|
| 54 |
-
@classmethod
|
| 55 |
-
def _validate_rationale(cls, value: str) -> str:
|
| 56 |
-
if not value.strip():
|
| 57 |
-
raise ValueError("rationale must not be empty")
|
| 58 |
-
return value
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
class RationaleResponse(BaseModel):
|
| 62 |
-
rationale_hash: str
|
| 63 |
-
rationale: str
|
| 64 |
-
agent_address: str | None
|
| 65 |
-
evaluator_address: str | None
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
@router.post("/onchain/rationale", status_code=201)
|
| 69 |
-
async def persist_rationale(
|
| 70 |
-
req: RationaleRequest,
|
| 71 |
-
response: Response,
|
| 72 |
-
db: Session = Depends(get_db),
|
| 73 |
-
):
|
| 74 |
-
"""Store the plaintext behind an anchored `rationale_hash`.
|
| 75 |
-
|
| 76 |
-
Idempotent on `rationale_hash`: signing the same decision twice (a
|
| 77 |
-
retry after a network error, for instance) posts the same hash and
|
| 78 |
-
text, so the second call is a no-op rather than a uniqueness-constraint
|
| 79 |
-
error. A *different* text arriving for a hash already on record is
|
| 80 |
-
refused -- that would mean either hash collision or a caller bug, and
|
| 81 |
-
silently overwriting an anchored record's preimage is the one thing
|
| 82 |
-
this table must never do.
|
| 83 |
-
"""
|
| 84 |
-
existing = (
|
| 85 |
-
db.query(OnchainRationaleDB)
|
| 86 |
-
.filter(OnchainRationaleDB.rationale_hash == req.rationale_hash)
|
| 87 |
-
.one_or_none()
|
| 88 |
-
)
|
| 89 |
-
if existing is not None:
|
| 90 |
-
if existing.rationale != req.rationale:
|
| 91 |
-
raise HTTPException(
|
| 92 |
-
status_code=409,
|
| 93 |
-
detail=(
|
| 94 |
-
"rationale_hash already recorded with different text; "
|
| 95 |
-
"an anchored hash's preimage cannot be overwritten"
|
| 96 |
-
),
|
| 97 |
-
)
|
| 98 |
-
# The route decorator's status_code=201 is FastAPI's default for
|
| 99 |
-
# every plain-dict return from this handler, including this one --
|
| 100 |
-
# it must be overridden explicitly here or a replayed post reports
|
| 101 |
-
# itself as newly Created.
|
| 102 |
-
response.status_code = 200
|
| 103 |
-
return {"status": "already_recorded", "rationale_hash": req.rationale_hash}
|
| 104 |
-
|
| 105 |
-
row = OnchainRationaleDB(
|
| 106 |
-
rationale_hash=req.rationale_hash,
|
| 107 |
-
rationale=req.rationale,
|
| 108 |
-
agent_address=req.agent_address,
|
| 109 |
-
evaluator_address=req.evaluator_address,
|
| 110 |
-
)
|
| 111 |
-
db.add(row)
|
| 112 |
-
db.commit()
|
| 113 |
-
logger.info("persisted rationale for hash %s", req.rationale_hash)
|
| 114 |
-
return {"status": "recorded", "rationale_hash": req.rationale_hash}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
@router.get("/onchain/rationale/{rationale_hash}", response_model=RationaleResponse)
|
| 118 |
-
async def get_rationale(
|
| 119 |
-
rationale_hash: str,
|
| 120 |
-
db: Session = Depends(get_db),
|
| 121 |
-
):
|
| 122 |
-
"""Fetch the plaintext behind an anchored `rationale_hash`.
|
| 123 |
-
|
| 124 |
-
What an auditor calls after reading a `DecisionAnchored` event off-chain
|
| 125 |
-
-- the hash from the event is the only key this endpoint accepts, by
|
| 126 |
-
design: there is no listing or search here, only lookup by the exact
|
| 127 |
-
value that was signed and anchored.
|
| 128 |
-
"""
|
| 129 |
-
try:
|
| 130 |
-
normalized = _validate_hex_hash(rationale_hash)
|
| 131 |
-
except ValueError as exc:
|
| 132 |
-
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 133 |
-
|
| 134 |
-
row = (
|
| 135 |
-
db.query(OnchainRationaleDB)
|
| 136 |
-
.filter(OnchainRationaleDB.rationale_hash == normalized)
|
| 137 |
-
.one_or_none()
|
| 138 |
-
)
|
| 139 |
-
if row is None:
|
| 140 |
-
raise HTTPException(
|
| 141 |
-
status_code=404, detail="no rationale recorded for this hash"
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
return RationaleResponse(
|
| 145 |
-
rationale_hash=row.rationale_hash,
|
| 146 |
-
rationale=row.rationale,
|
| 147 |
-
agent_address=row.agent_address,
|
| 148 |
-
evaluator_address=row.evaluator_address,
|
| 149 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/routes_payments.py
CHANGED
|
@@ -2,16 +2,12 @@
|
|
| 2 |
Payment endpoints – Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
-
from fastapi import APIRouter,
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
-
from app.core import
|
| 12 |
-
from app.core.usage_tracker import Tier, resolve_api_key_identity
|
| 13 |
-
|
| 14 |
-
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 17 |
|
|
@@ -21,46 +17,24 @@ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
|
| 21 |
|
| 22 |
|
| 23 |
class CheckoutRequest(BaseModel):
|
|
|
|
|
|
|
| 24 |
success_url: str
|
| 25 |
cancel_url: str
|
| 26 |
|
| 27 |
|
| 28 |
@router.post("/create-checkout-session")
|
| 29 |
-
async def create_checkout_session(
|
| 30 |
-
|
| 31 |
-
identity: dict = Depends(resolve_api_key_identity),
|
| 32 |
-
):
|
| 33 |
-
"""Create a Stripe Checkout session for the Pro tier.
|
| 34 |
-
|
| 35 |
-
Identity comes from `resolve_api_key_identity` (Authorization: Bearer
|
| 36 |
-
header, same pepper-HMAC lookup every other authenticated endpoint
|
| 37 |
-
uses) rather than from a caller-supplied field in the request body --
|
| 38 |
-
previously this endpoint took `api_key` as a JSON field with no
|
| 39 |
-
`Depends()` gate at all, so nothing distinguished "the caller proved
|
| 40 |
-
they hold this key" from "the caller typed this string into a
|
| 41 |
-
request." `enforce_quota` is deliberately not used here: a FREE-tier
|
| 42 |
-
caller who has exhausted their monthly quota must still be able to
|
| 43 |
-
reach this endpoint, since upgrading is often exactly what they are
|
| 44 |
-
trying to do.
|
| 45 |
-
"""
|
| 46 |
if not stripe.api_key:
|
| 47 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
| 48 |
-
if not usage_tracker.tracker:
|
| 49 |
-
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
raise HTTPException(status_code=400,
|
| 53 |
detail="Only free tier keys can be upgraded")
|
| 54 |
|
| 55 |
-
# tenant_id, not the raw api_key, is what travels to Stripe from here
|
| 56 |
-
# on. api_key is a bearer secret -- usage_tracker.py exists specifically
|
| 57 |
-
# to never store it in plaintext (pepper-HMAC lookup + salted
|
| 58 |
-
# verification hash), and Stripe's dashboard/webhook logs/API are a
|
| 59 |
-
# third party with no reason to ever see it. tenant_id is an opaque,
|
| 60 |
-
# non-secret row identifier and is exactly what the webhook needs to
|
| 61 |
-
# look up which tenant's keys to retier.
|
| 62 |
-
tenant_id = identity["tenant_id"]
|
| 63 |
-
|
| 64 |
try:
|
| 65 |
checkout_session = stripe.checkout.Session.create(
|
| 66 |
payment_method_types=["card"],
|
|
@@ -74,17 +48,9 @@ async def create_checkout_session(
|
|
| 74 |
mode="subscription",
|
| 75 |
success_url=req.success_url,
|
| 76 |
cancel_url=req.cancel_url,
|
| 77 |
-
metadata={"
|
| 78 |
-
client_reference_id=
|
| 79 |
-
# checkout.session.completed carries this metadata via
|
| 80 |
-
# session.metadata (handled below), but customer.subscription.*
|
| 81 |
-
# events only carry the *subscription's own* metadata -- Stripe
|
| 82 |
-
# does not copy Session.metadata onto the Subscription it
|
| 83 |
-
# creates. Without this, cancellations can't be traced back to
|
| 84 |
-
# a tenant and PRO tier never downgrades.
|
| 85 |
-
subscription_data={"metadata": {"tenant_id": tenant_id}},
|
| 86 |
)
|
| 87 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 88 |
-
except Exception:
|
| 89 |
-
|
| 90 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
|
|
|
| 2 |
Payment endpoints – Stripe Checkout integration.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
+
from fastapi import APIRouter, HTTPException
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
+
from app.core.usage_tracker import tracker, Tier
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 13 |
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class CheckoutRequest(BaseModel):
|
| 20 |
+
api_key: str
|
| 21 |
+
|
| 22 |
success_url: str
|
| 23 |
cancel_url: str
|
| 24 |
|
| 25 |
|
| 26 |
@router.post("/create-checkout-session")
|
| 27 |
+
async def create_checkout_session(req: CheckoutRequest):
|
| 28 |
+
"""Create a Stripe Checkout session for the Pro tier."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if not stripe.api_key:
|
| 30 |
raise HTTPException(status_code=500, detail="Stripe not configured")
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
# Verify the API key exists and is free tier
|
| 33 |
+
tier = tracker.get_tier(req.api_key) if tracker else None
|
| 34 |
+
if tier != Tier.FREE:
|
| 35 |
raise HTTPException(status_code=400,
|
| 36 |
detail="Only free tier keys can be upgraded")
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
try:
|
| 39 |
checkout_session = stripe.checkout.Session.create(
|
| 40 |
payment_method_types=["card"],
|
|
|
|
| 48 |
mode="subscription",
|
| 49 |
success_url=req.success_url,
|
| 50 |
cancel_url=req.cancel_url,
|
| 51 |
+
metadata={"api_key": req.api_key},
|
| 52 |
+
client_reference_id=req.api_key,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
return {"sessionId": checkout_session.id, "url": checkout_session.url}
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
app/api/routes_pricing.py
CHANGED
|
@@ -57,26 +57,48 @@ async def run_pricing(
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
-
Multi‑run pricing with cooldown and buffer persistence.
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
`random.random() > risk_score` -- a fabricated result, not a real deal
|
| 64 |
-
outcome -- into a calibration buffer with no customer_id scoping, so
|
| 65 |
-
every customer's calls read and wrote the same file. Net effect: every
|
| 66 |
-
customer's price was shaped by every other customer's randomly-generated
|
| 67 |
-
outcomes, not just their own. Disabled until both are fixed: (1) a real
|
| 68 |
-
outcome-ingestion path (this endpoint must not invent one), and (2) the
|
| 69 |
-
buffer scoped per customer. See AUDIT_arf-bayesian-pricing-calculator.md
|
| 70 |
-
and AUDIT_arf-api.md (workspace root) for the original findings and
|
| 71 |
-
recommended fix. `Depends(enforce_quota)` stays active so this still
|
| 72 |
-
requires the same auth it always did -- only authenticated callers reach
|
| 73 |
-
the disabled-notice below; everyone else still gets the normal 401/403.
|
| 74 |
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
quota: dict = Depends(enforce_quota),
|
| 58 |
):
|
| 59 |
"""
|
| 60 |
+
Multi‑run pricing with cooldown and buffer persistence.
|
| 61 |
+
Each run’s simulated outcome is added to the buffer, so subsequent runs
|
| 62 |
+
see an updated posterior.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
+
# We need to reuse the same buffer across runs; we'll load it per request.
|
| 65 |
+
# For simplicity, we'll load from the default location.
|
| 66 |
+
from arf_pricing_calculator.storage.buffer import load_buffer, add_event
|
| 67 |
+
from arf_pricing_calculator.orchestration.cooldown import enforce_cooldown, is_cooldown_active
|
| 68 |
+
|
| 69 |
+
outputs = []
|
| 70 |
+
buffer = load_buffer() # loads from calibration_buffer.json
|
| 71 |
+
|
| 72 |
+
for i in range(req.runs):
|
| 73 |
+
if not req.force and is_cooldown_active(
|
| 74 |
+
req.customer_id, req.cooldown_hours):
|
| 75 |
+
raise HTTPException(status_code=429,
|
| 76 |
+
detail=f"Cooldown active after {i} runs")
|
| 77 |
+
|
| 78 |
+
pricing_input = parse_input_dict(req.input)
|
| 79 |
+
engine = PricingEngine(calibration_buffer=buffer)
|
| 80 |
+
out = engine.estimate(pricing_input)
|
| 81 |
+
|
| 82 |
+
# Simulate an outcome (in real use, this would come from the actual
|
| 83 |
+
# deal)
|
| 84 |
+
import random
|
| 85 |
+
outcome = "success" if random.random() > out.risk_score else "failure" # nosec B311
|
| 86 |
+
|
| 87 |
+
event = {
|
| 88 |
+
"run_id": out.run_history_id,
|
| 89 |
+
"customer_id": req.customer_id,
|
| 90 |
+
"outcome": outcome,
|
| 91 |
+
"price": out.recommended_price,
|
| 92 |
+
"value": out.expected_value,
|
| 93 |
+
"risk_score": out.risk_score,
|
| 94 |
+
"run_number": i + 1,
|
| 95 |
+
}
|
| 96 |
+
add_event(event)
|
| 97 |
+
buffer = load_buffer() # reload after update
|
| 98 |
+
|
| 99 |
+
outputs.append(out)
|
| 100 |
+
|
| 101 |
+
if i < req.runs - 1:
|
| 102 |
+
enforce_cooldown(req.customer_id, req.cooldown_hours)
|
| 103 |
+
|
| 104 |
+
return outputs
|
app/api/routes_risk.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
-
from app.api.deps import verify_internal_key
|
| 5 |
from app.models.risk_models import RiskResponse
|
| 6 |
from app.services.risk_service import get_system_risk
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
router = APIRouter(dependencies=[Depends(verify_internal_key)])
|
| 11 |
|
| 12 |
|
| 13 |
@router.get("/get_risk", response_model=RiskResponse)
|
|
@@ -18,9 +13,8 @@ async def get_risk():
|
|
| 18 |
raise HTTPException(
|
| 19 |
status_code=501,
|
| 20 |
detail="This endpoint is deprecated and not implemented")
|
| 21 |
-
except Exception:
|
| 22 |
-
|
| 23 |
-
raise HTTPException(status_code=500, detail="Internal server error")
|
| 24 |
|
| 25 |
if risk < 0.3:
|
| 26 |
status = "low"
|
|
@@ -31,3 +25,13 @@ async def get_risk():
|
|
| 31 |
else:
|
| 32 |
status = "critical"
|
| 33 |
return RiskResponse(system_risk=risk, status=status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.models.risk_models import RiskResponse
|
| 3 |
from app.services.risk_service import get_system_risk
|
| 4 |
|
| 5 |
+
router = APIRouter()
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
@router.get("/get_risk", response_model=RiskResponse)
|
|
|
|
| 13 |
raise HTTPException(
|
| 14 |
status_code=501,
|
| 15 |
detail="This endpoint is deprecated and not implemented")
|
| 16 |
+
except Exception as e:
|
| 17 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 18 |
|
| 19 |
if risk < 0.3:
|
| 20 |
status = "low"
|
|
|
|
| 25 |
else:
|
| 26 |
status = "critical"
|
| 27 |
return RiskResponse(system_risk=risk, status=status)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/history")
|
| 31 |
+
async def get_risk_history():
|
| 32 |
+
import random
|
| 33 |
+
import datetime
|
| 34 |
+
now = datetime.datetime.now()
|
| 35 |
+
data = [{"time": (now - datetime.timedelta(hours=i)).isoformat(),
|
| 36 |
+
"risk": round(random.uniform(0.2, 0.8), 2)} for i in range(24, 0, -1)]
|
| 37 |
+
return data
|
app/api/routes_users.py
CHANGED
|
@@ -9,8 +9,7 @@ from sqlalchemy.orm import Session
|
|
| 9 |
from slowapi import Limiter
|
| 10 |
from slowapi.util import get_remote_address
|
| 11 |
|
| 12 |
-
from app.core import
|
| 13 |
-
from app.core.usage_tracker import enforce_quota, Tier
|
| 14 |
from app.api.deps import get_db
|
| 15 |
from app.database.models_intents import TenantDB # <-- NEW
|
| 16 |
|
|
@@ -31,7 +30,7 @@ async def register_user(
|
|
| 31 |
Public endpoint to create a new free‑tier API key and a new tenant.
|
| 32 |
Rate‑limited to 5 requests per hour per IP address.
|
| 33 |
"""
|
| 34 |
-
if
|
| 35 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 36 |
|
| 37 |
# 1. Create a new tenant in the main database
|
|
@@ -49,7 +48,7 @@ async def register_user(
|
|
| 49 |
|
| 50 |
# 2. Generate a new API key for this tenant
|
| 51 |
new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
|
| 52 |
-
success =
|
| 53 |
if not success:
|
| 54 |
# Rollback tenant creation if key creation fails
|
| 55 |
db.delete(new_tenant)
|
|
|
|
| 9 |
from slowapi import Limiter
|
| 10 |
from slowapi.util import get_remote_address
|
| 11 |
|
| 12 |
+
from app.core.usage_tracker import tracker, enforce_quota, Tier
|
|
|
|
| 13 |
from app.api.deps import get_db
|
| 14 |
from app.database.models_intents import TenantDB # <-- NEW
|
| 15 |
|
|
|
|
| 30 |
Public endpoint to create a new free‑tier API key and a new tenant.
|
| 31 |
Rate‑limited to 5 requests per hour per IP address.
|
| 32 |
"""
|
| 33 |
+
if tracker is None:
|
| 34 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
|
| 35 |
|
| 36 |
# 1. Create a new tenant in the main database
|
|
|
|
| 48 |
|
| 49 |
# 2. Generate a new API key for this tenant
|
| 50 |
new_key = f"sk_free_{uuid.uuid4().hex[:24]}"
|
| 51 |
+
success = tracker.get_or_create_api_key(api_key=new_key, tenant_id=tenant_id, tier=Tier.FREE)
|
| 52 |
if not success:
|
| 53 |
# Rollback tenant creation if key creation fails
|
| 54 |
db.delete(new_tenant)
|
app/api/webhooks.py
CHANGED
|
@@ -2,24 +2,16 @@
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
import logging
|
| 6 |
import os
|
| 7 |
import stripe
|
| 8 |
from fastapi import APIRouter, Request, HTTPException
|
| 9 |
-
from app.core.usage_tracker import
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 14 |
|
| 15 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 16 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 17 |
|
| 18 |
-
# Subscription statuses that mean "not entitled to Pro anymore". Deliberately
|
| 19 |
-
# does NOT include "past_due" -- whether a failed-payment grace period keeps
|
| 20 |
-
# Pro access is a dunning-policy decision, not a bug, and isn't decided here.
|
| 21 |
-
_DOWNGRADE_STATUSES = {"canceled", "unpaid", "incomplete_expired"}
|
| 22 |
-
|
| 23 |
|
| 24 |
@router.post("/stripe")
|
| 25 |
async def stripe_webhook(request: Request):
|
|
@@ -38,33 +30,20 @@ async def stripe_webhook(request: Request):
|
|
| 38 |
except stripe.error.SignatureVerificationError:
|
| 39 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
# routes_payments.py's create_checkout_session for why the raw bearer
|
| 43 |
-
# key must never round-trip through a third party.
|
| 44 |
if event["type"] == "checkout.session.completed":
|
| 45 |
session = event["data"]["object"]
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
logger.info(
|
| 52 |
-
"checkout.session.completed with payment_status=%r; not upgrading yet",
|
| 53 |
-
session.get("payment_status"),
|
| 54 |
-
)
|
| 55 |
-
return {"status": "ok"}
|
| 56 |
-
tenant_id = session.get("client_reference_id") or session.get(
|
| 57 |
-
"metadata", {}).get("tenant_id")
|
| 58 |
-
if tenant_id:
|
| 59 |
-
update_key_tier_by_tenant_id(tenant_id, Tier.PRO)
|
| 60 |
-
elif event["type"] in ("customer.subscription.deleted", "customer.subscription.updated"):
|
| 61 |
subscription = event["data"]["object"]
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
update_key_tier_by_tenant_id(tenant_id, Tier.FREE)
|
| 69 |
|
| 70 |
return {"status": "ok"}
|
|
|
|
| 2 |
Stripe webhook handler – updates API key tier on subscription events.
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import stripe
|
| 7 |
from fastapi import APIRouter, Request, HTTPException
|
| 8 |
+
from app.core.usage_tracker import update_key_tier, Tier
|
|
|
|
|
|
|
| 9 |
|
| 10 |
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
| 11 |
|
| 12 |
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
| 13 |
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@router.post("/stripe")
|
| 17 |
async def stripe_webhook(request: Request):
|
|
|
|
| 30 |
except stripe.error.SignatureVerificationError:
|
| 31 |
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 32 |
|
| 33 |
+
# Handle subscription events
|
|
|
|
|
|
|
| 34 |
if event["type"] == "checkout.session.completed":
|
| 35 |
session = event["data"]["object"]
|
| 36 |
+
api_key = session.get("client_reference_id") or session.get(
|
| 37 |
+
"metadata", {}).get("api_key")
|
| 38 |
+
if api_key:
|
| 39 |
+
update_key_tier(api_key, Tier.PRO)
|
| 40 |
+
elif event["type"] == "customer.subscription.deleted":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
subscription = event["data"]["object"]
|
| 42 |
+
# You need to store a mapping from subscription ID to API key.
|
| 43 |
+
# For simplicity, we assume you stored it in metadata during checkout.
|
| 44 |
+
# Alternatively, look up by customer ID.
|
| 45 |
+
api_key = subscription.get("metadata", {}).get("api_key")
|
| 46 |
+
if api_key:
|
| 47 |
+
update_key_tier(api_key, Tier.FREE)
|
|
|
|
| 48 |
|
| 49 |
return {"status": "ok"}
|
app/core/config.py
CHANGED
|
@@ -14,7 +14,6 @@ class Settings(BaseSettings):
|
|
| 14 |
ARF_USAGE_DB_PATH: str = "arf_usage.db"
|
| 15 |
ARF_REDIS_URL: Optional[str] = None
|
| 16 |
ARF_API_KEYS: str = "{}" # JSON string of {key: tier}
|
| 17 |
-
ARF_KEY_PEPPER: Optional[str] = None # required if ARF_USAGE_TRACKING is true
|
| 18 |
|
| 19 |
# Tracing (OpenTelemetry)
|
| 20 |
OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None
|
|
|
|
| 14 |
ARF_USAGE_DB_PATH: str = "arf_usage.db"
|
| 15 |
ARF_REDIS_URL: Optional[str] = None
|
| 16 |
ARF_API_KEYS: str = "{}" # JSON string of {key: tier}
|
|
|
|
| 17 |
|
| 18 |
# Tracing (OpenTelemetry)
|
| 19 |
OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None
|
app/core/storage.py
CHANGED
|
@@ -1,16 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
Bounded (maxlen), not persisted across restarts -- exists to give the
|
| 4 |
-
causal explainer and GET /history recent context, not as a durable audit
|
| 5 |
-
trail. The cap protects against unbounded memory growth from
|
| 6 |
-
POST /report_incident, which can be called repeatedly by anything holding
|
| 7 |
-
a valid internal key.
|
| 8 |
-
|
| 9 |
-
Shared by app.api.routes_incidents (writes, via report_incident) and
|
| 10 |
-
app.api.routes_history (reads, via GET /history) -- both must import this
|
| 11 |
-
same object rather than declaring their own list, or writes and reads
|
| 12 |
-
silently operate on two different lists.
|
| 13 |
-
"""
|
| 14 |
-
from collections import deque
|
| 15 |
-
|
| 16 |
-
incident_history: deque = deque(maxlen=10_000)
|
|
|
|
| 1 |
+
# Simple in-memory list for incident history
|
| 2 |
+
incident_history = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/usage_tracker.py
CHANGED
|
@@ -4,53 +4,15 @@ Thread‑safe, atomic quota consumption, idempotent, fail‑closed.
|
|
| 4 |
|
| 5 |
Extended for multi‑tenancy: each API key is linked to a tenant ID.
|
| 6 |
Tenant ID is stored in the `api_keys` table and used for resource isolation.
|
| 7 |
-
|
| 8 |
-
API keys in `api_keys` are never stored in plaintext. The lookup index is
|
| 9 |
-
HMAC-SHA256(pepper, key) -- a deterministic but one-way value computed with
|
| 10 |
-
a server-only secret (ARF_KEY_PEPPER), so a leaked database alone does not
|
| 11 |
-
expose usable keys. A per-row random salt plus a second SHA-256 check is a
|
| 12 |
-
defense-in-depth verification layer after the row is found by lookup hash.
|
| 13 |
-
This mirrors arf-gateway's internal/auth/apikey.go.
|
| 14 |
-
|
| 15 |
-
`api_keys` itself lives in Postgres (DATABASE_URL), not in the SQLite file
|
| 16 |
-
the rest of this module uses -- a single durable table that both arf-api
|
| 17 |
-
and arf-gateway point at, instead of each service's own local SQLite copy
|
| 18 |
-
(which on Render's Free plan is wiped on every deploy/restart anyway).
|
| 19 |
-
|
| 20 |
-
`usage_log` and `idempotency_keys` remain SQLite-only, local to this
|
| 21 |
-
service -- arf-gateway never reads them. `monthly_counts` is different:
|
| 22 |
-
arf-gateway's Go code (internal/auth/apikey.go) queries it from Postgres
|
| 23 |
-
directly, via the pgx driver against the same DATABASE_URL, to compute
|
| 24 |
-
each key's remaining quota. It never had a way to read this service's
|
| 25 |
-
local SQLite file (a different process, a different disk, a different
|
| 26 |
-
protocol), so a prior version of this module -- which wrote
|
| 27 |
-
`monthly_counts` to SQLite only -- left that Postgres table permanently
|
| 28 |
-
empty, and arf-gateway's quota check silently treated every key as having
|
| 29 |
-
consumed nothing all month, every month. `consume_quota_and_log` now
|
| 30 |
-
mirrors every successfully-counted call into the Postgres `monthly_counts`
|
| 31 |
-
table too (`_record_pg_monthly_count`), best-effort and logged loudly on
|
| 32 |
-
failure rather than raised -- this service's own quota decision is still
|
| 33 |
-
made from its local SQLite/Redis count and must not fail because a
|
| 34 |
-
mirroring write to a peer service's view did. Still keyed by the raw API
|
| 35 |
-
key (not hashed), matching the existing SQLite schema and a separately
|
| 36 |
-
tracked plaintext-storage gap -- not addressed by this fix.
|
| 37 |
"""
|
| 38 |
-
import hashlib
|
| 39 |
-
import hmac
|
| 40 |
import json
|
| 41 |
-
import logging
|
| 42 |
-
import os
|
| 43 |
-
import secrets
|
| 44 |
import sqlite3
|
| 45 |
import threading
|
| 46 |
import time
|
| 47 |
-
|
| 48 |
-
import psycopg2
|
| 49 |
-
import psycopg2.extras
|
| 50 |
from contextlib import contextmanager
|
| 51 |
from datetime import datetime, timedelta
|
| 52 |
from dataclasses import dataclass
|
| 53 |
-
from typing import Dict, Any, Optional, List, Tuple
|
| 54 |
from enum import Enum
|
| 55 |
from fastapi import BackgroundTasks, HTTPException, Request
|
| 56 |
|
|
@@ -106,51 +68,15 @@ class UsageRecord:
|
|
| 106 |
processing_ms: Optional[float] = None
|
| 107 |
|
| 108 |
|
| 109 |
-
# Bounded retry for the initial Postgres connect -- see _get_pg_conn's
|
| 110 |
-
# docstring. 5 attempts with exponential backoff (1+2+4+8 = 15s of sleep,
|
| 111 |
-
# worst case) comfortably fits inside a container's normal boot window
|
| 112 |
-
# without turning a real outage into a long hang.
|
| 113 |
-
_PG_CONNECT_MAX_ATTEMPTS = 5
|
| 114 |
-
_PG_CONNECT_BACKOFF_BASE = 1.0
|
| 115 |
-
|
| 116 |
-
logger = logging.getLogger(__name__)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
class UsageTracker:
|
| 120 |
"""
|
| 121 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
| 122 |
Extended to support tenant isolation: each API key is linked to a tenant.
|
| 123 |
"""
|
| 124 |
|
| 125 |
-
# Whether the Postgres api_keys schema check has already run in this
|
| 126 |
-
# process. Class-level, not per-instance: the schema is a property of
|
| 127 |
-
# the database, not of a tracker object, and every instance in a
|
| 128 |
-
# process points at the same DATABASE_URL. Guarded by a lock because
|
| 129 |
-
# _get_pg_conn is called from request threads.
|
| 130 |
-
_pg_schema_ready: bool = False
|
| 131 |
-
_pg_schema_lock = threading.Lock()
|
| 132 |
-
|
| 133 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 134 |
-
redis_url: Optional[str] = None
|
| 135 |
-
pepper: Optional[str] = None):
|
| 136 |
self.db_path = db_path
|
| 137 |
-
self._pepper = pepper if pepper is not None else os.getenv("ARF_KEY_PEPPER", "")
|
| 138 |
-
if not self._pepper:
|
| 139 |
-
raise RuntimeError(
|
| 140 |
-
"ARF_KEY_PEPPER is not set -- refusing to start without it, "
|
| 141 |
-
"since it is required to look up or verify any API key."
|
| 142 |
-
)
|
| 143 |
-
if len(self._pepper) < 32:
|
| 144 |
-
raise RuntimeError(
|
| 145 |
-
f"ARF_KEY_PEPPER is too short ({len(self._pepper)} chars); "
|
| 146 |
-
"use at least 32 random characters."
|
| 147 |
-
)
|
| 148 |
-
self._pg_dsn = os.getenv("DATABASE_URL", "")
|
| 149 |
-
if not self._pg_dsn:
|
| 150 |
-
raise RuntimeError(
|
| 151 |
-
"DATABASE_URL is not set -- refusing to start without it, "
|
| 152 |
-
"since api_keys is stored in Postgres, not SQLite."
|
| 153 |
-
)
|
| 154 |
self._local = threading.local()
|
| 155 |
self._init_db()
|
| 156 |
|
|
@@ -160,40 +86,9 @@ class UsageTracker:
|
|
| 160 |
elif redis_url:
|
| 161 |
raise ImportError("Redis client not installed. Run: pip install redis")
|
| 162 |
|
| 163 |
-
def _lookup_hash(self, key: str) -> str:
|
| 164 |
-
"""Deterministic pepper-HMAC used to find a key's row without ever
|
| 165 |
-
storing or querying by the plaintext key."""
|
| 166 |
-
return hmac.new(self._pepper.encode(), key.encode(), hashlib.sha256).hexdigest()
|
| 167 |
-
|
| 168 |
-
@staticmethod
|
| 169 |
-
def _salted_hash(key: str, salt_hex: str) -> str:
|
| 170 |
-
"""Per-row salted verification hash, checked after a row has
|
| 171 |
-
already been found via lookup hash."""
|
| 172 |
-
return hashlib.sha256(bytes.fromhex(salt_hex) + key.encode()).hexdigest()
|
| 173 |
-
|
| 174 |
-
def _verify_key(self, conn, api_key: str) -> Optional[dict]:
|
| 175 |
-
"""Look up a row by pepper-HMAC, then verify with the salted hash.
|
| 176 |
-
`conn` is a Postgres connection from _get_pg_conn. Returns the row
|
| 177 |
-
(tenant_id, tier, is_active, salt, key_hash) if the key is valid and
|
| 178 |
-
active, else None."""
|
| 179 |
-
row = self._pg_execute(
|
| 180 |
-
conn,
|
| 181 |
-
"SELECT tenant_id, tier, is_active, salt, key_hash FROM api_keys "
|
| 182 |
-
"WHERE lookup_hash = %s",
|
| 183 |
-
(self._lookup_hash(api_key),)
|
| 184 |
-
).fetchone()
|
| 185 |
-
if not row or not row["is_active"]:
|
| 186 |
-
return None
|
| 187 |
-
if not hmac.compare_digest(self._salted_hash(api_key, row["salt"]), row["key_hash"]):
|
| 188 |
-
return None
|
| 189 |
-
return row
|
| 190 |
-
|
| 191 |
@contextmanager
|
| 192 |
def _get_conn(self):
|
| 193 |
-
"""Get a thread‑local SQLite connection with WAL and immediate transactions.
|
| 194 |
-
|
| 195 |
-
Backs usage_log/monthly_counts/idempotency_keys only -- api_keys
|
| 196 |
-
lives in Postgres, see _get_pg_conn below."""
|
| 197 |
if not hasattr(self._local, "conn"):
|
| 198 |
self._local.conn = sqlite3.connect(
|
| 199 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
@@ -201,160 +96,20 @@ class UsageTracker:
|
|
| 201 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 202 |
yield self._local.conn
|
| 203 |
|
| 204 |
-
@contextmanager
|
| 205 |
-
def _get_pg_conn(self):
|
| 206 |
-
"""Get a thread-local Postgres connection for the api_keys table.
|
| 207 |
-
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 208 |
-
matching the sqlite3.Row access pattern used elsewhere in this file.
|
| 209 |
-
|
| 210 |
-
Retries the initial connect with backoff, but how patiently depends
|
| 211 |
-
on who is asking:
|
| 212 |
-
|
| 213 |
-
- **Startup** (`warm_up`, `retries=True`): a short DNS blip during a
|
| 214 |
-
cold container boot has been observed on Render, so a few seconds
|
| 215 |
-
of retrying is worth it to come up cleanly.
|
| 216 |
-
- **Request path** (the default, `retries=False`): fails fast. A
|
| 217 |
-
request thread that blocks for 15s on a database outage doesn't
|
| 218 |
-
make the request succeed; it holds a worker thread hostage, and
|
| 219 |
-
under any concurrency the pool is exhausted and the whole service
|
| 220 |
-
stops responding -- including its health endpoint. A prompt 503 is
|
| 221 |
-
strictly better than a slow one.
|
| 222 |
-
|
| 223 |
-
Genuine connection errors (bad credentials, wrong host) still raise
|
| 224 |
-
either way, preserving fail-closed."""
|
| 225 |
-
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 226 |
-
self._connect_pg(retries=False)
|
| 227 |
-
yield self._local.pg_conn
|
| 228 |
-
|
| 229 |
-
def _connect_pg(self, retries: bool) -> None:
|
| 230 |
-
"""Open this thread's Postgres connection, optionally retrying."""
|
| 231 |
-
attempts = _PG_CONNECT_MAX_ATTEMPTS if retries else 1
|
| 232 |
-
last_exc: Optional[psycopg2.OperationalError] = None
|
| 233 |
-
for attempt in range(attempts):
|
| 234 |
-
try:
|
| 235 |
-
self._local.pg_conn = psycopg2.connect(
|
| 236 |
-
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 237 |
-
last_exc = None
|
| 238 |
-
break
|
| 239 |
-
except psycopg2.OperationalError as exc:
|
| 240 |
-
last_exc = exc
|
| 241 |
-
if attempt < attempts - 1:
|
| 242 |
-
time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
|
| 243 |
-
if last_exc is not None:
|
| 244 |
-
raise last_exc
|
| 245 |
-
self._ensure_pg_schema(self._local.pg_conn)
|
| 246 |
-
|
| 247 |
-
def warm_up(self) -> bool:
|
| 248 |
-
"""Best-effort startup connection, with retries.
|
| 249 |
-
|
| 250 |
-
Returns True if Postgres is reachable and the api_keys schema is
|
| 251 |
-
ready. Returns False -- rather than raising -- when it isn't, so a
|
| 252 |
-
caller can log the degradation and still start serving. That
|
| 253 |
-
asymmetry is the point: an unreachable database at boot should cost
|
| 254 |
-
api_keys-backed functionality, not the entire service.
|
| 255 |
-
"""
|
| 256 |
-
try:
|
| 257 |
-
self._connect_pg(retries=True)
|
| 258 |
-
return True
|
| 259 |
-
except psycopg2.OperationalError:
|
| 260 |
-
return False
|
| 261 |
-
|
| 262 |
-
def _ensure_pg_schema(self, conn) -> None:
|
| 263 |
-
"""Run the api_keys schema check once per process, on the first
|
| 264 |
-
connection that actually succeeds.
|
| 265 |
-
|
| 266 |
-
Guarded by a process-wide flag rather than done in __init__ so a
|
| 267 |
-
database that is unreachable at startup doesn't prevent the tracker
|
| 268 |
-
from existing -- it just means the first request that needs
|
| 269 |
-
Postgres pays for the schema check, and requests before that get a
|
| 270 |
-
clean 503 from enforce_quota instead of the whole service being
|
| 271 |
-
down. The statements are all IF NOT EXISTS, so re-running them on a
|
| 272 |
-
later process is harmless."""
|
| 273 |
-
if UsageTracker._pg_schema_ready:
|
| 274 |
-
return
|
| 275 |
-
with UsageTracker._pg_schema_lock:
|
| 276 |
-
if UsageTracker._pg_schema_ready:
|
| 277 |
-
return
|
| 278 |
-
self._create_pg_schema(conn)
|
| 279 |
-
UsageTracker._pg_schema_ready = True
|
| 280 |
-
|
| 281 |
-
@staticmethod
|
| 282 |
-
def _pg_execute(conn, sql: str, params: tuple = ()):
|
| 283 |
-
"""Run a query against a Postgres connection and return the cursor,
|
| 284 |
-
so callers can chain .fetchone()/.fetchall() the same way sqlite3's
|
| 285 |
-
conn.execute(...) is used elsewhere in this file."""
|
| 286 |
-
cur = conn.cursor()
|
| 287 |
-
cur.execute(sql, params)
|
| 288 |
-
return cur
|
| 289 |
-
|
| 290 |
-
def _create_pg_schema(self, conn):
|
| 291 |
-
"""Idempotently ensure the Postgres api_keys table/index exist.
|
| 292 |
-
|
| 293 |
-
Takes an already-open connection rather than acquiring one, because
|
| 294 |
-
its only caller is _ensure_pg_schema, which runs *from inside*
|
| 295 |
-
_get_pg_conn -- acquiring another connection here would recurse.
|
| 296 |
-
|
| 297 |
-
The canonical schema is the Alembic migration
|
| 298 |
-
(alembic/versions/*_create_api_keys_table.py) -- but nothing in this
|
| 299 |
-
codebase runs `alembic upgrade head` automatically on deploy (a
|
| 300 |
-
known gap, tracked separately), and arf-gateway's Go code needs the
|
| 301 |
-
same table without going through Python/Alembic at all. Mirroring
|
| 302 |
-
the same CREATE TABLE IF NOT EXISTS self-healing pattern this file
|
| 303 |
-
already uses for its SQLite tables keeps both services (and tests)
|
| 304 |
-
working whether or not the migration has actually been applied.
|
| 305 |
-
Column set/types must stay in sync with that migration."""
|
| 306 |
-
self._pg_execute(conn, """
|
| 307 |
-
CREATE TABLE IF NOT EXISTS api_keys (
|
| 308 |
-
id SERIAL PRIMARY KEY,
|
| 309 |
-
tenant_id VARCHAR(64) NOT NULL,
|
| 310 |
-
tier VARCHAR(32) NOT NULL,
|
| 311 |
-
created_at TIMESTAMP NOT NULL,
|
| 312 |
-
last_used_at TIMESTAMP,
|
| 313 |
-
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 314 |
-
salt VARCHAR(64) NOT NULL,
|
| 315 |
-
key_hash VARCHAR(64) NOT NULL,
|
| 316 |
-
lookup_hash VARCHAR(64) NOT NULL
|
| 317 |
-
)
|
| 318 |
-
""")
|
| 319 |
-
self._pg_execute(conn, """
|
| 320 |
-
CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash
|
| 321 |
-
ON api_keys (lookup_hash)
|
| 322 |
-
""")
|
| 323 |
-
self._pg_execute(conn, """
|
| 324 |
-
CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id
|
| 325 |
-
ON api_keys (tenant_id)
|
| 326 |
-
""")
|
| 327 |
-
# Mirrors arf-gateway's self-healing CREATE TABLE IF NOT EXISTS for
|
| 328 |
-
# this same table (internal/auth/apikey.go's NewValidator) -- either
|
| 329 |
-
# service may be the first to connect to a fresh database.
|
| 330 |
-
self._pg_execute(conn, """
|
| 331 |
-
CREATE TABLE IF NOT EXISTS monthly_counts (
|
| 332 |
-
api_key TEXT NOT NULL,
|
| 333 |
-
year_month TEXT NOT NULL,
|
| 334 |
-
count INTEGER NOT NULL DEFAULT 0,
|
| 335 |
-
PRIMARY KEY (api_key, year_month)
|
| 336 |
-
)
|
| 337 |
-
""")
|
| 338 |
-
conn.commit()
|
| 339 |
-
|
| 340 |
def _init_db(self):
|
| 341 |
-
"""Initialise SQLite tables
|
| 342 |
-
|
| 343 |
-
Deliberately does NOT touch Postgres. The api_keys schema is
|
| 344 |
-
ensured lazily on the first successful Postgres connection instead
|
| 345 |
-
(see _ensure_pg_schema) so that constructing a UsageTracker never
|
| 346 |
-
depends on the database being reachable *at that instant*.
|
| 347 |
-
|
| 348 |
-
This is the difference between a degraded service and no service.
|
| 349 |
-
Every other subsystem in main.py's lifespan already degrades
|
| 350 |
-
gracefully when Postgres is unreachable -- the Beta-state loader
|
| 351 |
-
logs a warning and continues -- but init_tracker raised, and
|
| 352 |
-
main.py turns that into RuntimeError, killing the process. On
|
| 353 |
-
Render that produced a crash loop that outlived the port-detection
|
| 354 |
-
window, so a DNS failure lasting seconds took the whole deploy
|
| 355 |
-
down and left the previous release serving.
|
| 356 |
-
"""
|
| 357 |
with self._get_conn() as conn:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
conn.execute("""
|
| 359 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 360 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -397,84 +152,65 @@ class UsageTracker:
|
|
| 397 |
Register a new API key for a given tenant.
|
| 398 |
|
| 399 |
Args:
|
| 400 |
-
key: The API key (plain text
|
| 401 |
tenant_id: UUID of the tenant (must already exist in main DB).
|
| 402 |
tier: Initial tier for the key.
|
| 403 |
|
| 404 |
Returns:
|
| 405 |
True if key was created (or already exists for the same tenant).
|
| 406 |
"""
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
conn, "SELECT tenant_id FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 411 |
-
).fetchone()
|
| 412 |
if row:
|
| 413 |
# Key already exists – ensure it belongs to the same tenant
|
| 414 |
-
|
| 415 |
-
|
|
|
|
| 416 |
raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
|
| 417 |
-
conn.commit()
|
| 418 |
return True
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
"INSERT INTO api_keys "
|
| 423 |
-
"(tenant_id, tier, created_at, is_active, salt, key_hash, lookup_hash) "
|
| 424 |
-
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
| 425 |
-
(tenant_id, tier.value, datetime.utcnow(), True,
|
| 426 |
-
salt, self._salted_hash(key, salt), lookup_hash)
|
| 427 |
)
|
| 428 |
conn.commit()
|
| 429 |
return True
|
| 430 |
|
| 431 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 432 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 433 |
-
with self.
|
| 434 |
-
row =
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
def get_tenant_id(self, api_key: str) -> Optional[str]:
|
| 438 |
"""Return the tenant ID associated with the API key, or None if key invalid."""
|
| 439 |
-
with self.
|
| 440 |
-
row =
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 444 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
conn, "SELECT lookup_hash FROM api_keys WHERE lookup_hash = %s", (lookup_hash,)
|
| 449 |
-
).fetchone()
|
| 450 |
if not row:
|
| 451 |
-
conn.rollback()
|
| 452 |
return False
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
(new_tier.value,
|
| 456 |
conn.commit()
|
| 457 |
return True
|
| 458 |
|
| 459 |
-
def update_tier_by_tenant_id(self, tenant_id: str, new_tier: Tier) -> bool:
|
| 460 |
-
"""Update the tier of every active API key belonging to tenant_id.
|
| 461 |
-
|
| 462 |
-
Used by the Stripe webhook, which must never handle a raw API key
|
| 463 |
-
(Stripe's own systems -- dashboard, logs, webhook payloads -- are a
|
| 464 |
-
third party; the plaintext bearer secret has no business being
|
| 465 |
-
stored there, which is exactly what passing it as Checkout
|
| 466 |
-
metadata used to do). tenant_id is not a secret -- it's an opaque
|
| 467 |
-
row identifier -- so it's safe to round-trip through Stripe."""
|
| 468 |
-
with self._get_pg_conn() as conn:
|
| 469 |
-
cur = self._pg_execute(
|
| 470 |
-
conn,
|
| 471 |
-
"UPDATE api_keys SET tier = %s WHERE tenant_id = %s AND is_active = true",
|
| 472 |
-
(new_tier.value, tenant_id),
|
| 473 |
-
)
|
| 474 |
-
updated = cur.rowcount > 0
|
| 475 |
-
conn.commit()
|
| 476 |
-
return updated
|
| 477 |
-
|
| 478 |
# --------------------------------------------------------------------------
|
| 479 |
# Atomic quota consumption (unchanged, but uses api_key which links to tenant)
|
| 480 |
# --------------------------------------------------------------------------
|
|
@@ -535,34 +271,6 @@ class UsageTracker:
|
|
| 535 |
result = self._redis_client.eval(lua_script, 1, redis_key, limit)
|
| 536 |
return result == 1
|
| 537 |
|
| 538 |
-
def _record_pg_monthly_count(self, api_key: str, month: str) -> None:
|
| 539 |
-
"""Mirror one successfully-counted call into Postgres `monthly_counts`,
|
| 540 |
-
the table arf-gateway's Go code actually reads to compute quota
|
| 541 |
-
remaining (see this module's docstring). Best-effort: this service's
|
| 542 |
-
own quota decision was already made from SQLite/Redis before this is
|
| 543 |
-
called, so a failure here must not fail the request that already
|
| 544 |
-
legitimately counted against quota -- but it also must not fail
|
| 545 |
-
*silently*, since a swallowed error here is exactly how arf-gateway's
|
| 546 |
-
quota check went blind for every key in the first place. Logged at
|
| 547 |
-
ERROR, not raised."""
|
| 548 |
-
try:
|
| 549 |
-
with self._get_pg_conn() as conn:
|
| 550 |
-
self._pg_execute(
|
| 551 |
-
conn,
|
| 552 |
-
"INSERT INTO monthly_counts (api_key, year_month, count) "
|
| 553 |
-
"VALUES (%s, %s, 1) ON CONFLICT (api_key, year_month) "
|
| 554 |
-
"DO UPDATE SET count = monthly_counts.count + 1",
|
| 555 |
-
(api_key, month),
|
| 556 |
-
)
|
| 557 |
-
conn.commit()
|
| 558 |
-
except Exception:
|
| 559 |
-
logger.error(
|
| 560 |
-
"Failed to mirror monthly_counts to Postgres for api_key=%s month=%s -- "
|
| 561 |
-
"arf-gateway's quota check will undercount usage for this key until this "
|
| 562 |
-
"is resolved.",
|
| 563 |
-
api_key, month, exc_info=True,
|
| 564 |
-
)
|
| 565 |
-
|
| 566 |
# --------------------------------------------------------------------------
|
| 567 |
# Idempotency handling (unchanged)
|
| 568 |
# --------------------------------------------------------------------------
|
|
@@ -597,18 +305,15 @@ class UsageTracker:
|
|
| 597 |
if not quota_ok:
|
| 598 |
return False, None
|
| 599 |
|
| 600 |
-
self._record_pg_monthly_count(record.api_key, month)
|
| 601 |
-
|
| 602 |
try:
|
| 603 |
with self._get_conn() as conn:
|
| 604 |
conn.execute(
|
| 605 |
"""INSERT INTO usage_log
|
| 606 |
-
(api_key, tier, timestamp, endpoint, request_body, response, error,
|
| 607 |
-
processing_ms, idempotency_key)
|
| 608 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 609 |
(record.api_key, record.tier.value, record.timestamp, record.endpoint,
|
| 610 |
-
json.dumps(record.request_body
|
| 611 |
-
json.dumps(record.response
|
| 612 |
record.error, record.processing_ms, idempotency_key)
|
| 613 |
)
|
| 614 |
conn.commit()
|
|
@@ -621,40 +326,6 @@ class UsageTracker:
|
|
| 621 |
self._mark_idempotent_key_used(idempotency_key)
|
| 622 |
return True, None
|
| 623 |
|
| 624 |
-
def _insert_audit_log(self, record: UsageRecord) -> None:
|
| 625 |
-
"""Insert a standalone usage_log row for a call whose quota was
|
| 626 |
-
already consumed at request time (see consume_quota_and_log) --
|
| 627 |
-
used by routes_governance.py's background tasks to record the
|
| 628 |
-
response body once it's known, under a distinct endpoint suffix
|
| 629 |
-
(e.g. ".../response"). Best-effort and logged, not raised, for the
|
| 630 |
-
same reason _record_pg_monthly_count is: this runs after the
|
| 631 |
-
response has already been sent to the caller, so it must not
|
| 632 |
-
surface as a request failure -- a background task exception here
|
| 633 |
-
is otherwise swallowed silently. `record.tier` is None at both real
|
| 634 |
-
call sites (tier only matters for quota consumption, already done
|
| 635 |
-
by the earlier consume_quota_and_log call for the same request),
|
| 636 |
-
but usage_log.tier is NOT NULL, so an absent tier is recorded as
|
| 637 |
-
"unknown" rather than raising or silently guessing a real tier."""
|
| 638 |
-
tier_value = record.tier.value if record.tier else "unknown"
|
| 639 |
-
try:
|
| 640 |
-
with self._get_conn() as conn:
|
| 641 |
-
conn.execute(
|
| 642 |
-
"""INSERT INTO usage_log
|
| 643 |
-
(api_key, tier, timestamp, endpoint, request_body, response, error,
|
| 644 |
-
processing_ms, idempotency_key)
|
| 645 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 646 |
-
(record.api_key, tier_value, record.timestamp, record.endpoint,
|
| 647 |
-
json.dumps(record.request_body, default=str) if record.request_body else None,
|
| 648 |
-
json.dumps(record.response, default=str) if record.response else None,
|
| 649 |
-
record.error, record.processing_ms, None)
|
| 650 |
-
)
|
| 651 |
-
conn.commit()
|
| 652 |
-
except Exception:
|
| 653 |
-
logger.error(
|
| 654 |
-
"Failed to insert audit log for api_key=%s endpoint=%s",
|
| 655 |
-
record.api_key, record.endpoint, exc_info=True,
|
| 656 |
-
)
|
| 657 |
-
|
| 658 |
# --------------------------------------------------------------------------
|
| 659 |
# Legacy interface (kept for compatibility)
|
| 660 |
# --------------------------------------------------------------------------
|
|
@@ -722,17 +393,6 @@ class UsageTracker:
|
|
| 722 |
# --------------------------------------------------------------------------
|
| 723 |
# Global instance and FastAPI dependency
|
| 724 |
# --------------------------------------------------------------------------
|
| 725 |
-
# Rebound by init_tracker() during the app lifespan, which means consumers
|
| 726 |
-
# MUST reach it through the module -- `from app.core import usage_tracker`,
|
| 727 |
-
# then `usage_tracker.tracker`. Never `from app.core.usage_tracker import
|
| 728 |
-
# tracker`: that copies the *binding* (None) at import time, and init_tracker
|
| 729 |
-
# rebinding this global does not update the importer's copy. Five modules
|
| 730 |
-
# did exactly that (main, routes_admin, routes_incidents, routes_payments,
|
| 731 |
-
# routes_users) and every one of them saw None forever -- silently skipping
|
| 732 |
-
# metering and disabling signup/checkout, and crashing the Render deploy
|
| 733 |
-
# outright once main.py called a method on it. Functions defined *in* this
|
| 734 |
-
# module (enforce_quota, update_key_tier*) are safe to import by name: they
|
| 735 |
-
# resolve `tracker` here, at call time.
|
| 736 |
tracker: Optional[UsageTracker] = None
|
| 737 |
|
| 738 |
|
|
@@ -747,36 +407,20 @@ def update_key_tier(api_key: str, new_tier: Tier) -> bool:
|
|
| 747 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 748 |
|
| 749 |
|
| 750 |
-
def
|
| 751 |
-
if tracker is None:
|
| 752 |
-
return False
|
| 753 |
-
return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
def _extract_api_key(request: Request, api_key: str = None) -> str:
|
| 757 |
-
if api_key:
|
| 758 |
-
return api_key
|
| 759 |
-
auth_header = request.headers.get("Authorization")
|
| 760 |
-
if auth_header and auth_header.startswith("Bearer "):
|
| 761 |
-
return auth_header[7:]
|
| 762 |
-
return request.query_params.get("api_key")
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
async def resolve_api_key_identity(request: Request, api_key: str = None):
|
| 766 |
"""
|
| 767 |
-
FastAPI dependency that
|
| 768 |
-
to request state, without enforcing monthly quota.
|
| 769 |
-
|
| 770 |
-
Deliberately separate from `enforce_quota`: a caller whose quota is
|
| 771 |
-
already exhausted must still be able to reach an endpoint like
|
| 772 |
-
`/payments/create-checkout-session` (upgrading tier is often exactly
|
| 773 |
-
what a rate-limited caller is trying to do) -- gating that path behind
|
| 774 |
-
`enforce_quota` would 429 the one action that lets them fix it.
|
| 775 |
"""
|
| 776 |
if tracker is None:
|
| 777 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
|
| 778 |
|
| 779 |
-
api_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
if not api_key:
|
| 781 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 782 |
|
|
@@ -784,6 +428,11 @@ async def resolve_api_key_identity(request: Request, api_key: str = None):
|
|
| 784 |
if tier is None:
|
| 785 |
raise HTTPException(status_code=403, detail="Invalid or inactive API key")
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
tenant_id = tracker.get_tenant_id(api_key)
|
| 788 |
if not tenant_id:
|
| 789 |
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):
|
|
| 792 |
request.state.tier = tier
|
| 793 |
request.state.tenant_id = tenant_id
|
| 794 |
|
| 795 |
-
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id}
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
async def enforce_quota(request: Request, api_key: str = None):
|
| 799 |
-
"""
|
| 800 |
-
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
| 801 |
-
"""
|
| 802 |
-
identity = await resolve_api_key_identity(request, api_key)
|
| 803 |
-
api_key, tier, tenant_id = identity["api_key"], identity["tier"], identity["tenant_id"]
|
| 804 |
-
|
| 805 |
-
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 806 |
-
if remaining is not None and remaining <= 0:
|
| 807 |
-
raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
|
| 808 |
-
|
| 809 |
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
|
|
|
|
| 4 |
|
| 5 |
Extended for multi‑tenancy: each API key is linked to a tenant ID.
|
| 6 |
Tenant ID is stored in the `api_keys` table and used for resource isolation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
|
|
|
|
|
|
| 8 |
import json
|
|
|
|
|
|
|
|
|
|
| 9 |
import sqlite3
|
| 10 |
import threading
|
| 11 |
import time
|
|
|
|
|
|
|
|
|
|
| 12 |
from contextlib import contextmanager
|
| 13 |
from datetime import datetime, timedelta
|
| 14 |
from dataclasses import dataclass
|
| 15 |
+
from typing import Dict, Any, Optional, List, Tuple, Callable
|
| 16 |
from enum import Enum
|
| 17 |
from fastapi import BackgroundTasks, HTTPException, Request
|
| 18 |
|
|
|
|
| 68 |
processing_ms: Optional[float] = None
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
class UsageTracker:
|
| 72 |
"""
|
| 73 |
Thread‑safe usage tracker with atomic quota consumption and idempotency.
|
| 74 |
Extended to support tenant isolation: each API key is linked to a tenant.
|
| 75 |
"""
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 78 |
+
redis_url: Optional[str] = None):
|
|
|
|
| 79 |
self.db_path = db_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
self._local = threading.local()
|
| 81 |
self._init_db()
|
| 82 |
|
|
|
|
| 86 |
elif redis_url:
|
| 87 |
raise ImportError("Redis client not installed. Run: pip install redis")
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
@contextmanager
|
| 90 |
def _get_conn(self):
|
| 91 |
+
"""Get a thread‑local SQLite connection with WAL and immediate transactions."""
|
|
|
|
|
|
|
|
|
|
| 92 |
if not hasattr(self._local, "conn"):
|
| 93 |
self._local.conn = sqlite3.connect(
|
| 94 |
self.db_path, check_same_thread=False, isolation_level=None)
|
|
|
|
| 96 |
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
| 97 |
yield self._local.conn
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def _init_db(self):
|
| 100 |
+
"""Initialise SQLite tables with tenant_id support."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
with self._get_conn() as conn:
|
| 102 |
+
# Modified: api_keys now has tenant_id column
|
| 103 |
+
conn.execute("""
|
| 104 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 105 |
+
key TEXT PRIMARY KEY,
|
| 106 |
+
tenant_id TEXT NOT NULL,
|
| 107 |
+
tier TEXT NOT NULL,
|
| 108 |
+
created_at REAL NOT NULL,
|
| 109 |
+
last_used_at REAL,
|
| 110 |
+
is_active INTEGER DEFAULT 1
|
| 111 |
+
)
|
| 112 |
+
""")
|
| 113 |
conn.execute("""
|
| 114 |
CREATE TABLE IF NOT EXISTS usage_log (
|
| 115 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 152 |
Register a new API key for a given tenant.
|
| 153 |
|
| 154 |
Args:
|
| 155 |
+
key: The API key (plain text, will be hashed in production).
|
| 156 |
tenant_id: UUID of the tenant (must already exist in main DB).
|
| 157 |
tier: Initial tier for the key.
|
| 158 |
|
| 159 |
Returns:
|
| 160 |
True if key was created (or already exists for the same tenant).
|
| 161 |
"""
|
| 162 |
+
with self._get_conn() as conn:
|
| 163 |
+
row = conn.execute(
|
| 164 |
+
"SELECT key FROM api_keys WHERE key = ?", (key,)).fetchone()
|
|
|
|
|
|
|
| 165 |
if row:
|
| 166 |
# Key already exists – ensure it belongs to the same tenant
|
| 167 |
+
existing_tenant = conn.execute(
|
| 168 |
+
"SELECT tenant_id FROM api_keys WHERE key = ?", (key,)).fetchone()
|
| 169 |
+
if existing_tenant["tenant_id"] != tenant_id:
|
| 170 |
raise ValueError(f"Key {key[:8]}... already belongs to a different tenant.")
|
|
|
|
| 171 |
return True
|
| 172 |
+
conn.execute(
|
| 173 |
+
"INSERT INTO api_keys (key, tenant_id, tier, created_at, is_active) VALUES (?, ?, ?, ?, ?)",
|
| 174 |
+
(key, tenant_id, tier.value, time.time(), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
conn.commit()
|
| 177 |
return True
|
| 178 |
|
| 179 |
def get_tier(self, api_key: str) -> Optional[Tier]:
|
| 180 |
"""Return the tier for a given API key, or None if key invalid/inactive."""
|
| 181 |
+
with self._get_conn() as conn:
|
| 182 |
+
row = conn.execute(
|
| 183 |
+
"SELECT tier FROM api_keys WHERE key = ? AND is_active = 1",
|
| 184 |
+
(api_key,)
|
| 185 |
+
).fetchone()
|
| 186 |
+
if not row:
|
| 187 |
+
return None
|
| 188 |
+
return Tier(row["tier"])
|
| 189 |
|
| 190 |
def get_tenant_id(self, api_key: str) -> Optional[str]:
|
| 191 |
"""Return the tenant ID associated with the API key, or None if key invalid."""
|
| 192 |
+
with self._get_conn() as conn:
|
| 193 |
+
row = conn.execute(
|
| 194 |
+
"SELECT tenant_id FROM api_keys WHERE key = ? AND is_active = 1",
|
| 195 |
+
(api_key,)
|
| 196 |
+
).fetchone()
|
| 197 |
+
if not row:
|
| 198 |
+
return None
|
| 199 |
+
return row["tenant_id"]
|
| 200 |
|
| 201 |
def update_api_key_tier(self, api_key: str, new_tier: Tier) -> bool:
|
| 202 |
"""Update the tier of an existing API key. Returns True if successful."""
|
| 203 |
+
with self._get_conn() as conn:
|
| 204 |
+
row = conn.execute(
|
| 205 |
+
"SELECT key FROM api_keys WHERE key = ?", (api_key,)).fetchone()
|
|
|
|
|
|
|
| 206 |
if not row:
|
|
|
|
| 207 |
return False
|
| 208 |
+
conn.execute(
|
| 209 |
+
"UPDATE api_keys SET tier = ? WHERE key = ?",
|
| 210 |
+
(new_tier.value, api_key))
|
| 211 |
conn.commit()
|
| 212 |
return True
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
# --------------------------------------------------------------------------
|
| 215 |
# Atomic quota consumption (unchanged, but uses api_key which links to tenant)
|
| 216 |
# --------------------------------------------------------------------------
|
|
|
|
| 271 |
result = self._redis_client.eval(lua_script, 1, redis_key, limit)
|
| 272 |
return result == 1
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
# --------------------------------------------------------------------------
|
| 275 |
# Idempotency handling (unchanged)
|
| 276 |
# --------------------------------------------------------------------------
|
|
|
|
| 305 |
if not quota_ok:
|
| 306 |
return False, None
|
| 307 |
|
|
|
|
|
|
|
| 308 |
try:
|
| 309 |
with self._get_conn() as conn:
|
| 310 |
conn.execute(
|
| 311 |
"""INSERT INTO usage_log
|
| 312 |
+
(api_key, tier, timestamp, endpoint, request_body, response, error, processing_ms, idempotency_key)
|
|
|
|
| 313 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 314 |
(record.api_key, record.tier.value, record.timestamp, record.endpoint,
|
| 315 |
+
json.dumps(record.request_body) if record.request_body else None,
|
| 316 |
+
json.dumps(record.response) if record.response else None,
|
| 317 |
record.error, record.processing_ms, idempotency_key)
|
| 318 |
)
|
| 319 |
conn.commit()
|
|
|
|
| 326 |
self._mark_idempotent_key_used(idempotency_key)
|
| 327 |
return True, None
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
# --------------------------------------------------------------------------
|
| 330 |
# Legacy interface (kept for compatibility)
|
| 331 |
# --------------------------------------------------------------------------
|
|
|
|
| 393 |
# --------------------------------------------------------------------------
|
| 394 |
# Global instance and FastAPI dependency
|
| 395 |
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
tracker: Optional[UsageTracker] = None
|
| 397 |
|
| 398 |
|
|
|
|
| 407 |
return tracker.update_api_key_tier(api_key, new_tier)
|
| 408 |
|
| 409 |
|
| 410 |
+
async def enforce_quota(request: Request, api_key: str = None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
"""
|
| 412 |
+
FastAPI dependency that enforces quota and attaches tenant_id to request state.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
"""
|
| 414 |
if tracker is None:
|
| 415 |
raise HTTPException(status_code=503, detail="Usage tracking service not initialised.")
|
| 416 |
|
| 417 |
+
if api_key is None:
|
| 418 |
+
auth_header = request.headers.get("Authorization")
|
| 419 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 420 |
+
api_key = auth_header[7:]
|
| 421 |
+
else:
|
| 422 |
+
api_key = request.query_params.get("api_key")
|
| 423 |
+
|
| 424 |
if not api_key:
|
| 425 |
raise HTTPException(status_code=401, detail="Missing API key")
|
| 426 |
|
|
|
|
| 428 |
if tier is None:
|
| 429 |
raise HTTPException(status_code=403, detail="Invalid or inactive API key")
|
| 430 |
|
| 431 |
+
remaining = tracker.get_remaining_quota(api_key, tier)
|
| 432 |
+
if remaining is not None and remaining <= 0:
|
| 433 |
+
raise HTTPException(status_code=429, detail="Monthly evaluation quota exceeded")
|
| 434 |
+
|
| 435 |
+
# Retrieve tenant_id
|
| 436 |
tenant_id = tracker.get_tenant_id(api_key)
|
| 437 |
if not tenant_id:
|
| 438 |
raise HTTPException(status_code=403, detail="API key not associated with a tenant")
|
|
|
|
| 441 |
request.state.tier = tier
|
| 442 |
request.state.tenant_id = tenant_id
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
return {"api_key": api_key, "tier": tier, "tenant_id": tenant_id, "remaining": remaining}
|
app/database/models_intents.py
CHANGED
|
@@ -3,18 +3,14 @@ Database models for the ARF API Control Plane.
|
|
| 3 |
|
| 4 |
This module defines the SQLAlchemy ORM models for:
|
| 5 |
- Tenants (multi‑tenant isolation root)
|
|
|
|
|
|
|
| 6 |
- Intents (InfrastructureIntent evaluations)
|
| 7 |
- Outcomes (recorded results of executed intents)
|
| 8 |
- Beta state (conjugate Bayesian posteriors per tenant and category)
|
| 9 |
- Audit logs (immutable decision records for compliance)
|
| 10 |
|
| 11 |
All tables include a `tenant_id` column to enforce data partitioning.
|
| 12 |
-
|
| 13 |
-
API keys and usage/quota logs are tracked separately in
|
| 14 |
-
`app/core/usage_tracker.py` (SQLite, pepper-HMAC hashed) -- an
|
| 15 |
-
`APIKeyDB`/`UsageLogDB` pair used to live here as a second, unused,
|
| 16 |
-
plaintext-keyed parallel schema; removed 2026-08-23 since nothing
|
| 17 |
-
referenced them.
|
| 18 |
"""
|
| 19 |
|
| 20 |
import uuid
|
|
@@ -50,11 +46,74 @@ class TenantDB(Base):
|
|
| 50 |
created_by = Column(String(128), nullable=True)
|
| 51 |
|
| 52 |
# Relationships
|
|
|
|
| 53 |
intents = relationship("IntentDB", back_populates="tenant")
|
| 54 |
beta_states = relationship("BetaStateDB", back_populates="tenant")
|
| 55 |
audit_logs = relationship("DecisionAuditLogDB", back_populates="tenant")
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ============================================================================
|
| 59 |
# Intents (evaluations) – now tenant‑scoped
|
| 60 |
# ============================================================================
|
|
|
|
| 3 |
|
| 4 |
This module defines the SQLAlchemy ORM models for:
|
| 5 |
- Tenants (multi‑tenant isolation root)
|
| 6 |
+
- API keys (per‑tenant, tier‑based)
|
| 7 |
+
- Usage logs (immutable records of API calls)
|
| 8 |
- Intents (InfrastructureIntent evaluations)
|
| 9 |
- Outcomes (recorded results of executed intents)
|
| 10 |
- Beta state (conjugate Bayesian posteriors per tenant and category)
|
| 11 |
- Audit logs (immutable decision records for compliance)
|
| 12 |
|
| 13 |
All tables include a `tenant_id` column to enforce data partitioning.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
import uuid
|
|
|
|
| 46 |
created_by = Column(String(128), nullable=True)
|
| 47 |
|
| 48 |
# Relationships
|
| 49 |
+
api_keys = relationship("APIKeyDB", back_populates="tenant", cascade="all, delete-orphan")
|
| 50 |
intents = relationship("IntentDB", back_populates="tenant")
|
| 51 |
beta_states = relationship("BetaStateDB", back_populates="tenant")
|
| 52 |
audit_logs = relationship("DecisionAuditLogDB", back_populates="tenant")
|
| 53 |
|
| 54 |
|
| 55 |
+
# ============================================================================
|
| 56 |
+
# API keys (extended with tenant_id)
|
| 57 |
+
# ============================================================================
|
| 58 |
+
|
| 59 |
+
class APIKeyDB(Base):
|
| 60 |
+
"""
|
| 61 |
+
Stores API keys for authentication and tiered quota. Each key belongs
|
| 62 |
+
to exactly one tenant. The `tier` determines monthly evaluation limits.
|
| 63 |
+
|
| 64 |
+
Attributes:
|
| 65 |
+
key (str): The hashed API key (primary key).
|
| 66 |
+
tenant_id (str): Foreign key to `tenants.id`.
|
| 67 |
+
tier (str): Tier enumeration value (free, pro, premium, enterprise).
|
| 68 |
+
created_at (datetime): UTC creation time.
|
| 69 |
+
last_used_at (datetime, optional): Timestamp of last successful request.
|
| 70 |
+
is_active (bool): Soft‑delete flag.
|
| 71 |
+
"""
|
| 72 |
+
__tablename__ = "api_keys"
|
| 73 |
+
|
| 74 |
+
key = Column(String(256), primary_key=True, index=True)
|
| 75 |
+
tenant_id = Column(String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
| 76 |
+
tier = Column(String(32), nullable=False)
|
| 77 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
| 78 |
+
last_used_at = Column(DateTime, nullable=True)
|
| 79 |
+
is_active = Column(Boolean, default=True, nullable=False)
|
| 80 |
+
|
| 81 |
+
# Relationships
|
| 82 |
+
tenant = relationship("TenantDB", back_populates="api_keys")
|
| 83 |
+
usage_logs = relationship("UsageLogDB", back_populates="api_key_rel", cascade="all, delete-orphan")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ============================================================================
|
| 87 |
+
# Usage logs – each API call
|
| 88 |
+
# ============================================================================
|
| 89 |
+
|
| 90 |
+
class UsageLogDB(Base):
|
| 91 |
+
"""
|
| 92 |
+
Immutable record of each API call for quota tracking and billing.
|
| 93 |
+
|
| 94 |
+
Attributes:
|
| 95 |
+
id (int): Primary key.
|
| 96 |
+
api_key (str): Foreign key to `api_keys.key`.
|
| 97 |
+
tier (str): Tier at the time of the call.
|
| 98 |
+
timestamp (float): Unix timestamp of the request.
|
| 99 |
+
endpoint (str): URL or route of the endpoint hit.
|
| 100 |
+
request_body (JSON, optional): Request payload (sanitised).
|
| 101 |
+
response (JSON, optional): Response metadata (e.g., status code).
|
| 102 |
+
"""
|
| 103 |
+
__tablename__ = "usage_logs"
|
| 104 |
+
|
| 105 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 106 |
+
api_key = Column(String(256), ForeignKey("api_keys.key", ondelete="CASCADE"), nullable=False)
|
| 107 |
+
tier = Column(String(32), nullable=False)
|
| 108 |
+
timestamp = Column(Float, nullable=False)
|
| 109 |
+
endpoint = Column(String(512), nullable=True)
|
| 110 |
+
request_body = Column(JSON, nullable=True)
|
| 111 |
+
response = Column(JSON, nullable=True)
|
| 112 |
+
|
| 113 |
+
# Relationship back to API key
|
| 114 |
+
api_key_rel = relationship("APIKeyDB", back_populates="usage_logs")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
# ============================================================================
|
| 118 |
# Intents (evaluations) – now tenant‑scoped
|
| 119 |
# ============================================================================
|
app/database/models_onchain.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""Database models for on-chain governance attestations.
|
| 2 |
-
|
| 3 |
-
``RiskAttestation.rationale_hash`` (see arf-onchain's ``AttestationLib.sol``
|
| 4 |
-
and enterprise's ``arf_enterprise.onchain.attestation``) is a ``keccak256``
|
| 5 |
-
digest anchored on Monad -- the chain deliberately never stores the rationale
|
| 6 |
-
text itself, only its hash, to keep operational detail about a customer's
|
| 7 |
-
infrastructure off a public ledger. That means the text has to live
|
| 8 |
-
somewhere off-chain, keyed by the same hash, or the anchored hash proves
|
| 9 |
-
nothing: nobody could ever produce the preimage to check it against.
|
| 10 |
-
|
| 11 |
-
This table is that store. It is intentionally separate from
|
| 12 |
-
``DecisionAuditLogDB`` (``models_intents.py``) rather than an extension of
|
| 13 |
-
it: that table is written for every governance decision, on-chain or not,
|
| 14 |
-
and already has its own signature column for a different purpose (Ed25519
|
| 15 |
-
intent-signing, not the secp256k1 EIP-712 signature the guard verifies).
|
| 16 |
-
Conflating the two would mean a column that is only sometimes meaningful
|
| 17 |
-
depending on whether the decision was ever attested on-chain.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
import uuid
|
| 21 |
-
import datetime
|
| 22 |
-
|
| 23 |
-
from sqlalchemy import Column, String, DateTime, Text
|
| 24 |
-
|
| 25 |
-
from .base import Base
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class OnchainRationaleDB(Base):
|
| 29 |
-
"""The plaintext preimage of an anchored ``rationale_hash``.
|
| 30 |
-
|
| 31 |
-
Keyed by the hash itself (unique, indexed) rather than by an
|
| 32 |
-
auto-incrementing id: a lookup always starts from a hash read off-chain
|
| 33 |
-
(from `DecisionAnchored` or `AttestationIssued`), never from a row id
|
| 34 |
-
nothing on-chain knows about.
|
| 35 |
-
|
| 36 |
-
No ``tenant_id`` / foreign key to ``tenants``: an on-chain agent is
|
| 37 |
-
identified by its wallet address, not by this service's tenant concept,
|
| 38 |
-
and the two are not yet bridged. `evaluator_address` and `agent_address`
|
| 39 |
-
are recorded instead so a row can still be attributed and audited
|
| 40 |
-
without assuming a tenant relationship that may not exist.
|
| 41 |
-
"""
|
| 42 |
-
|
| 43 |
-
__tablename__ = "onchain_rationales"
|
| 44 |
-
|
| 45 |
-
id = Column(String(64), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 46 |
-
rationale_hash = Column(
|
| 47 |
-
String(66), nullable=False, unique=True, index=True
|
| 48 |
-
) # "0x" + 64 hex chars
|
| 49 |
-
rationale = Column(Text, nullable=False)
|
| 50 |
-
agent_address = Column(String(42), nullable=True)
|
| 51 |
-
evaluator_address = Column(String(42), nullable=True)
|
| 52 |
-
created_at = Column(
|
| 53 |
-
DateTime, default=datetime.datetime.utcnow, nullable=False, index=True
|
| 54 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
@@ -9,8 +9,7 @@ enterprise clients, and monitoring infrastructure).
|
|
| 9 |
It is responsible for:
|
| 10 |
|
| 11 |
* **Lifetime management** of the Bayesian risk engine, policy engine,
|
| 12 |
-
semantic memory (RAG graph),
|
| 13 |
-
the stability controller and temporal reliability monitor.
|
| 14 |
* **Observability** via optional OpenTelemetry tracing and Prometheus metrics
|
| 15 |
(the latter exposed automatically by ``prometheus-fastapi-instrumentator``
|
| 16 |
on ``/metrics``).
|
|
@@ -25,7 +24,6 @@ All heavy components are loaded **lazily and best‑effort** – if a dependency
|
|
| 25 |
is missing the API continues to serve health‑check and status endpoints,
|
| 26 |
degrading gracefully rather than crashing.
|
| 27 |
"""
|
| 28 |
-
import hashlib
|
| 29 |
import logging
|
| 30 |
import os
|
| 31 |
import sys
|
|
@@ -35,9 +33,8 @@ import time as _time
|
|
| 35 |
from contextlib import asynccontextmanager
|
| 36 |
from typing import Dict
|
| 37 |
|
| 38 |
-
from fastapi import FastAPI
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
-
from fastapi.responses import JSONResponse
|
| 41 |
|
| 42 |
# ── Optional: Prometheus metrics ─────────────────────────────
|
| 43 |
try:
|
|
@@ -74,24 +71,14 @@ except ImportError:
|
|
| 74 |
RAGGraphMemory = None
|
| 75 |
MemoryConstants = None
|
| 76 |
|
| 77 |
-
# ── Stability & temporal monitors ───────────────────────────
|
| 78 |
-
from agentic_reliability_framework.core.governance.stability_controller import (
|
| 79 |
-
LyapunovStabilityController,
|
| 80 |
-
)
|
| 81 |
-
from agentic_reliability_framework.core.temporal_reliability import (
|
| 82 |
-
TemporalReliabilityMonitor,
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
# ── Usage tracker ────────────────────────────────────────────
|
| 86 |
-
from app.core import
|
| 87 |
-
from app.core.usage_tracker import init_tracker, Tier
|
| 88 |
|
| 89 |
from app.api import (
|
| 90 |
routes_governance,
|
| 91 |
routes_history,
|
| 92 |
routes_incidents,
|
| 93 |
routes_intents,
|
| 94 |
-
routes_onchain,
|
| 95 |
routes_risk,
|
| 96 |
routes_memory,
|
| 97 |
routes_admin,
|
|
@@ -122,12 +109,11 @@ async def lifespan(app: FastAPI):
|
|
| 122 |
|
| 123 |
Initialisation order:
|
| 124 |
1. Risk engine (Bayesian scoring + HMC).
|
| 125 |
-
2. Load persisted conjugate posterior state per tenant.
|
| 126 |
3. OpenTelemetry tracing (console exporter by default).
|
| 127 |
4. Policy engine, RAG memory, and epistemic model.
|
| 128 |
-
5.
|
| 129 |
-
6.
|
| 130 |
-
7. Wilson confidence monitor for Rust enforcer canary promotion.
|
| 131 |
"""
|
| 132 |
logger.info("🚀 Starting ARF API Control Plane")
|
| 133 |
logger.debug(f"Python path: {sys.path}")
|
|
@@ -241,27 +227,12 @@ async def lifespan(app: FastAPI):
|
|
| 241 |
)
|
| 242 |
app.state.epistemic_model = None
|
| 243 |
app.state.epistemic_tokenizer = None
|
| 244 |
-
|
| 245 |
-
# ── 5. Stability controller & temporal monitor (v4.3.1) ─
|
| 246 |
-
try:
|
| 247 |
-
app.state.stability_controller = LyapunovStabilityController()
|
| 248 |
-
logger.info("✅ LyapunovStabilityController initialized.")
|
| 249 |
-
except Exception as e:
|
| 250 |
-
logger.warning(f"Stability controller initialization failed: {e}")
|
| 251 |
-
app.state.stability_controller = None
|
| 252 |
-
|
| 253 |
-
try:
|
| 254 |
-
app.state.temporal_monitor = TemporalReliabilityMonitor()
|
| 255 |
-
logger.info("✅ TemporalReliabilityMonitor initialized.")
|
| 256 |
-
except Exception as e:
|
| 257 |
-
logger.warning(f"Temporal monitor initialization failed: {e}")
|
| 258 |
-
app.state.temporal_monitor = None
|
| 259 |
else:
|
| 260 |
logger.warning(
|
| 261 |
-
"agentic_reliability_framework not installed; risk engine, policy engine, RAG
|
| 262 |
)
|
| 263 |
|
| 264 |
-
# ──
|
| 265 |
usage_tracking_disabled = (
|
| 266 |
os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
|
| 267 |
)
|
|
@@ -272,65 +243,14 @@ async def lifespan(app: FastAPI):
|
|
| 272 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 273 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 274 |
)
|
| 275 |
-
|
| 276 |
-
# Constructing the tracker no longer touches Postgres, so warm
|
| 277 |
-
# it deliberately here: a few seconds of retrying is worth it to
|
| 278 |
-
# come up with the api_keys schema ready.
|
| 279 |
-
#
|
| 280 |
-
# A failure is logged and survived, NOT raised. Everything above
|
| 281 |
-
# in this lifespan already degrades that way -- the Beta-state
|
| 282 |
-
# loader logs a warning on the identical error and continues --
|
| 283 |
-
# and the asymmetry here is what turned a database blip into a
|
| 284 |
-
# total outage: raising crash-loops the process, which on Render
|
| 285 |
-
# exhausts the port-detection window, fails the deploy, and
|
| 286 |
-
# leaves the previous release serving. Starting degraded means
|
| 287 |
-
# the health endpoint answers, the deploy succeeds, and API
|
| 288 |
-
# requests get a clean 503 from enforce_quota until the database
|
| 289 |
-
# is reachable -- at which point they recover with no redeploy.
|
| 290 |
-
postgres_ready = usage_tracker.tracker.warm_up()
|
| 291 |
-
if not postgres_ready:
|
| 292 |
-
logger.error(
|
| 293 |
-
"Usage tracker started WITHOUT a Postgres connection: api_keys "
|
| 294 |
-
"is unreachable, so API-key validation and quota enforcement "
|
| 295 |
-
"will fail (503) until it recovers. Check that DATABASE_URL's "
|
| 296 |
-
"host resolves from this service, and that the database is "
|
| 297 |
-
"running and in the same region."
|
| 298 |
-
)
|
| 299 |
-
|
| 300 |
-
# Seed initial API keys from environment variable (for testing
|
| 301 |
-
# / demo). Skipped entirely when Postgres is unreachable: every
|
| 302 |
-
# get_or_create_api_key below is a write to api_keys, so
|
| 303 |
-
# attempting it would raise straight back into the handler that
|
| 304 |
-
# kills the process -- reintroducing the crash loop the warm-up
|
| 305 |
-
# above exists to prevent.
|
| 306 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
| 307 |
-
if not postgres_ready and api_keys_json not in ("", "{}"):
|
| 308 |
-
logger.warning(
|
| 309 |
-
"Skipping ARF_API_KEYS seeding: Postgres is unreachable. "
|
| 310 |
-
"Seeded keys will not exist until the database recovers "
|
| 311 |
-
"and the service is restarted."
|
| 312 |
-
)
|
| 313 |
-
api_keys_json = "{}"
|
| 314 |
try:
|
| 315 |
api_keys = json.loads(api_keys_json)
|
| 316 |
for key, tier_str in api_keys.items():
|
| 317 |
try:
|
| 318 |
tier = Tier(tier_str.lower())
|
| 319 |
-
|
| 320 |
-
# -- tier was silently accepted as tenant_id, so
|
| 321 |
-
# every seeded key of the same tier collided onto
|
| 322 |
-
# one bogus tenant_id. These are demo/env-seeded
|
| 323 |
-
# keys with no real TenantDB row, but each still
|
| 324 |
-
# needs its own tenant_id to avoid cross-key
|
| 325 |
-
# contamination in tenant-scoped state elsewhere
|
| 326 |
-
# (BetaStateDB, IntentDB, decision audit log). A
|
| 327 |
-
# fixed-length key prefix isn't safe here: keys
|
| 328 |
-
# generated elsewhere in this codebase share an
|
| 329 |
-
# 8-char prefix ("sk_live_"/"sk_free_"), so a
|
| 330 |
-
# prefix-based id would collide the same way the
|
| 331 |
-
# original bug did. Hash the whole key instead.
|
| 332 |
-
tenant_id = "env-seed-" + hashlib.sha256(key.encode()).hexdigest()[:16]
|
| 333 |
-
usage_tracker.tracker.get_or_create_api_key(key, tenant_id=tenant_id, tier=tier)
|
| 334 |
logger.info(f"Seeded API key for tier {tier.value}")
|
| 335 |
except ValueError:
|
| 336 |
logger.warning(
|
|
@@ -340,52 +260,16 @@ async def lifespan(app: FastAPI):
|
|
| 340 |
logger.warning(
|
| 341 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 342 |
)
|
| 343 |
-
app.state.usage_tracker =
|
| 344 |
-
|
| 345 |
-
logger.info("✅ Usage tracker ready.")
|
| 346 |
-
else:
|
| 347 |
-
logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).")
|
| 348 |
except Exception as e:
|
| 349 |
-
# Still fail closed on genuine configuration errors -- a missing
|
| 350 |
-
# or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never
|
| 351 |
-
# self-resolve, and starting without them would mean serving with
|
| 352 |
-
# broken API-key hashing. Database *reachability* is handled
|
| 353 |
-
# above and no longer reaches here.
|
| 354 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 355 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 356 |
else:
|
| 357 |
logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.")
|
| 358 |
app.state.usage_tracker = None
|
| 359 |
|
| 360 |
-
# ──
|
| 361 |
-
# Singleton for the same reason usage_tracker/risk_engine are: a fresh
|
| 362 |
-
# PostgresStore() per request would open a brand-new DB connection (and
|
| 363 |
-
# re-run its schema check) on every call to POST
|
| 364 |
-
# /intents/{id}/execute instead of reusing one across requests handled
|
| 365 |
-
# by the same worker. Only initialised when execution is actually
|
| 366 |
-
# opted into (ARF_ENABLE_EXECUTION=true) and arf_enterprise is
|
| 367 |
-
# importable -- always sets app.state.approval_store (to None if
|
| 368 |
-
# either condition isn't met) so downstream code never needs a
|
| 369 |
-
# hasattr/getattr guard.
|
| 370 |
-
app.state.approval_store = None
|
| 371 |
-
if os.getenv("ARF_ENABLE_EXECUTION", "false").lower() == "true":
|
| 372 |
-
try:
|
| 373 |
-
from arf_enterprise.store import ApprovalStore, PostgresStore
|
| 374 |
-
app.state.approval_store = ApprovalStore(PostgresStore())
|
| 375 |
-
logger.info("✅ Enterprise execution approval ledger ready.")
|
| 376 |
-
except ImportError:
|
| 377 |
-
logger.warning(
|
| 378 |
-
"ARF_ENABLE_EXECUTION=true but arf_enterprise is not installed; "
|
| 379 |
-
"POST /intents/{id}/execute will return 501."
|
| 380 |
-
)
|
| 381 |
-
except Exception as e:
|
| 382 |
-
logger.error(
|
| 383 |
-
"Failed to initialise the enterprise approval ledger (%s); "
|
| 384 |
-
"POST /intents/{id}/execute will fall back to boolean-trust "
|
| 385 |
-
"mode for approvals rather than failing startup entirely.", e
|
| 386 |
-
)
|
| 387 |
-
|
| 388 |
-
# ── 7. Wilson confidence monitor ──────────────────────────
|
| 389 |
try:
|
| 390 |
from app.services.wilson_monitor import update as wilson_update
|
| 391 |
from prometheus_client import REGISTRY
|
|
@@ -447,19 +331,6 @@ def create_app() -> FastAPI:
|
|
| 447 |
)
|
| 448 |
logger.debug("CORS middleware configured")
|
| 449 |
|
| 450 |
-
# ── Generic exception handler ────────────────────────────
|
| 451 |
-
# Defense-in-depth for anything that escapes a route's own try/except
|
| 452 |
-
# uncaught (HTTPException instances are unaffected -- FastAPI's own,
|
| 453 |
-
# more specific handler for those still takes precedence). Logs the
|
| 454 |
-
# real exception server-side and returns a generic message: routes
|
| 455 |
-
# that catch their own exceptions have each been fixed to do the same,
|
| 456 |
-
# but this exists so a bug that skips that pattern doesn't leak raw
|
| 457 |
-
# exception text (paths, internals, third-party SDK details) to callers.
|
| 458 |
-
@app.exception_handler(Exception)
|
| 459 |
-
async def unhandled_exception_handler(request: Request, exc: Exception):
|
| 460 |
-
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
| 461 |
-
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
| 462 |
-
|
| 463 |
# ── Rate limiter ──────────────────────────────────────────
|
| 464 |
if SLOWAPI_AVAILABLE:
|
| 465 |
app.state.limiter = limiter
|
|
@@ -492,9 +363,6 @@ def create_app() -> FastAPI:
|
|
| 492 |
app.include_router(
|
| 493 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 494 |
)
|
| 495 |
-
app.include_router(
|
| 496 |
-
routes_onchain.router, prefix="/api/v1", tags=["onchain"]
|
| 497 |
-
)
|
| 498 |
app.include_router(
|
| 499 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 500 |
)
|
|
|
|
| 9 |
It is responsible for:
|
| 10 |
|
| 11 |
* **Lifetime management** of the Bayesian risk engine, policy engine,
|
| 12 |
+
semantic memory (RAG graph), and epistemic models.
|
|
|
|
| 13 |
* **Observability** via optional OpenTelemetry tracing and Prometheus metrics
|
| 14 |
(the latter exposed automatically by ``prometheus-fastapi-instrumentator``
|
| 15 |
on ``/metrics``).
|
|
|
|
| 24 |
is missing the API continues to serve health‑check and status endpoints,
|
| 25 |
degrading gracefully rather than crashing.
|
| 26 |
"""
|
|
|
|
| 27 |
import logging
|
| 28 |
import os
|
| 29 |
import sys
|
|
|
|
| 33 |
from contextlib import asynccontextmanager
|
| 34 |
from typing import Dict
|
| 35 |
|
| 36 |
+
from fastapi import FastAPI
|
| 37 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 38 |
|
| 39 |
# ── Optional: Prometheus metrics ─────────────────────────────
|
| 40 |
try:
|
|
|
|
| 71 |
RAGGraphMemory = None
|
| 72 |
MemoryConstants = None
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# ── Usage tracker ────────────────────────────────────────────
|
| 75 |
+
from app.core.usage_tracker import init_tracker, tracker, Tier
|
|
|
|
| 76 |
|
| 77 |
from app.api import (
|
| 78 |
routes_governance,
|
| 79 |
routes_history,
|
| 80 |
routes_incidents,
|
| 81 |
routes_intents,
|
|
|
|
| 82 |
routes_risk,
|
| 83 |
routes_memory,
|
| 84 |
routes_admin,
|
|
|
|
| 109 |
|
| 110 |
Initialisation order:
|
| 111 |
1. Risk engine (Bayesian scoring + HMC).
|
| 112 |
+
2. **NEW: Load persisted conjugate posterior state per tenant**.
|
| 113 |
3. OpenTelemetry tracing (console exporter by default).
|
| 114 |
4. Policy engine, RAG memory, and epistemic model.
|
| 115 |
+
5. Usage tracker (SQLite / Redis).
|
| 116 |
+
6. Wilson confidence monitor for Rust enforcer canary promotion.
|
|
|
|
| 117 |
"""
|
| 118 |
logger.info("🚀 Starting ARF API Control Plane")
|
| 119 |
logger.debug(f"Python path: {sys.path}")
|
|
|
|
| 227 |
)
|
| 228 |
app.state.epistemic_model = None
|
| 229 |
app.state.epistemic_tokenizer = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
else:
|
| 231 |
logger.warning(
|
| 232 |
+
"agentic_reliability_framework not installed; risk engine, policy engine, RAG disabled."
|
| 233 |
)
|
| 234 |
|
| 235 |
+
# ── 5. Usage tracker ──────────────────────────────────────
|
| 236 |
usage_tracking_disabled = (
|
| 237 |
os.getenv("ARF_USAGE_TRACKING", "true").lower() == "false"
|
| 238 |
)
|
|
|
|
| 243 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 244 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 245 |
)
|
| 246 |
+
# Seed initial API keys from environment variable (for testing / demo)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
try:
|
| 249 |
api_keys = json.loads(api_keys_json)
|
| 250 |
for key, tier_str in api_keys.items():
|
| 251 |
try:
|
| 252 |
tier = Tier(tier_str.lower())
|
| 253 |
+
tracker.get_or_create_api_key(key, tier)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
logger.info(f"Seeded API key for tier {tier.value}")
|
| 255 |
except ValueError:
|
| 256 |
logger.warning(
|
|
|
|
| 260 |
logger.warning(
|
| 261 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 262 |
)
|
| 263 |
+
app.state.usage_tracker = tracker
|
| 264 |
+
logger.info("✅ Usage tracker ready.")
|
|
|
|
|
|
|
|
|
|
| 265 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 267 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 268 |
else:
|
| 269 |
logger.info("Usage tracking disabled by ARF_USAGE_TRACKING=false.")
|
| 270 |
app.state.usage_tracker = None
|
| 271 |
|
| 272 |
+
# ── 6. Wilson confidence monitor ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
try:
|
| 274 |
from app.services.wilson_monitor import update as wilson_update
|
| 275 |
from prometheus_client import REGISTRY
|
|
|
|
| 331 |
)
|
| 332 |
logger.debug("CORS middleware configured")
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
# ── Rate limiter ──────────────────────────────────────────
|
| 335 |
if SLOWAPI_AVAILABLE:
|
| 336 |
app.state.limiter = limiter
|
|
|
|
| 363 |
app.include_router(
|
| 364 |
routes_governance.router, prefix="/api/v1", tags=["governance"]
|
| 365 |
)
|
|
|
|
|
|
|
|
|
|
| 366 |
app.include_router(
|
| 367 |
routes_memory.router, prefix="/v1/memory", tags=["memory"]
|
| 368 |
)
|
app/models/infrastructure_intents.py
CHANGED
|
@@ -15,10 +15,6 @@ class BaseIntentRequest(BaseModel):
|
|
| 15 |
policy_violations: List[str] = Field(default_factory=list)
|
| 16 |
requester: str = Field(...)
|
| 17 |
provenance: Dict[str, Any] = Field(default_factory=dict)
|
| 18 |
-
# v4.3.1: optional skill identifier for Bayesian promotion gate
|
| 19 |
-
skill_id: Optional[str] = None
|
| 20 |
-
# v4.3.2: optional criticality parameter for dynamic gate tuning (Feature 3)
|
| 21 |
-
criticality: Optional[float] = Field(None, ge=0, le=1)
|
| 22 |
|
| 23 |
|
| 24 |
class ProvisionResourceRequest(BaseIntentRequest):
|
|
|
|
| 15 |
policy_violations: List[str] = Field(default_factory=list)
|
| 16 |
requester: str = Field(...)
|
| 17 |
provenance: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class ProvisionResourceRequest(BaseIntentRequest):
|
app/services/outcome_service.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps.
|
| 2 |
-
Also updates per‑skill reliability models when skill provenance is present (v4.3.1).
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import datetime
|
| 6 |
import logging
|
|
@@ -20,19 +18,11 @@ from app.database.models_intents import IntentDB, OutcomeDB, BetaStateDB
|
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
-
# ── v4.3.1: optional skill registry integration ──────────────
|
| 24 |
-
try:
|
| 25 |
-
from agentic_reliability_framework.core.governance.skill_registry import SkillRegistry
|
| 26 |
-
SKILL_REGISTRY_AVAILABLE = True
|
| 27 |
-
except ImportError:
|
| 28 |
-
SkillRegistry = None
|
| 29 |
-
SKILL_REGISTRY_AVAILABLE = False
|
| 30 |
-
|
| 31 |
|
| 32 |
# ---------------------------------------------------------------------------
|
| 33 |
-
#
|
| 34 |
# ---------------------------------------------------------------------------
|
| 35 |
-
def _persist_beta_state(db: Session,
|
| 36 |
"""
|
| 37 |
Write the current Beta posterior parameters to the beta_state table.
|
| 38 |
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) ->
|
|
| 41 |
try:
|
| 42 |
state = risk_engine.beta_store.get_state()
|
| 43 |
for cat, (alpha, beta) in state.items():
|
| 44 |
-
# Upsert
|
| 45 |
-
|
| 46 |
-
# so merge() would always attempt an INSERT and collide with the
|
| 47 |
-
# unique constraint on the second write for the same pair.
|
| 48 |
-
row = db.query(BetaStateDB).filter(
|
| 49 |
-
BetaStateDB.tenant_id == tenant_id,
|
| 50 |
-
BetaStateDB.category == cat.value,
|
| 51 |
-
).first()
|
| 52 |
-
if row is not None:
|
| 53 |
-
row.alpha = alpha
|
| 54 |
-
row.beta = beta
|
| 55 |
-
else:
|
| 56 |
-
db.add(BetaStateDB(tenant_id=tenant_id, category=cat.value, alpha=alpha, beta=beta))
|
| 57 |
db.commit()
|
| 58 |
logger.debug("Persisted Beta posterior parameters to database.")
|
| 59 |
except Exception as e:
|
|
@@ -83,16 +62,12 @@ def reconstruct_oss_intent_from_json(
|
|
| 83 |
|
| 84 |
def record_outcome(
|
| 85 |
db: Session,
|
| 86 |
-
tenant_id: str,
|
| 87 |
deterministic_id: str,
|
| 88 |
success: bool,
|
| 89 |
recorded_by: Optional[str],
|
| 90 |
notes: Optional[str],
|
| 91 |
risk_engine: RiskEngine,
|
| 92 |
idempotency_key: Optional[str] = None,
|
| 93 |
-
skill_id: Optional[str] = None, # v4.3.1
|
| 94 |
-
skill_version: Optional[int] = None, # v4.3.1
|
| 95 |
-
skill_registry: Optional["SkillRegistry"] = None, # v4.3.1
|
| 96 |
) -> OutcomeDB:
|
| 97 |
"""
|
| 98 |
Record an outcome for a previously evaluated intent.
|
|
@@ -103,52 +78,25 @@ def record_outcome(
|
|
| 103 |
No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine
|
| 104 |
is NOT updated – we log an error and still record the outcome.
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
Optional user or system identifier.
|
| 122 |
-
notes : str or None
|
| 123 |
-
Optional human-readable notes.
|
| 124 |
-
risk_engine : RiskEngine
|
| 125 |
-
ARF risk engine instance (may be updated).
|
| 126 |
-
idempotency_key : str or None
|
| 127 |
-
Optional caller-provided idempotency token.
|
| 128 |
-
skill_id : str or None (v4.3.1)
|
| 129 |
-
Identifier of the procedural skill that guided the action.
|
| 130 |
-
skill_version : int or None (v4.3.1)
|
| 131 |
-
Version number of that skill.
|
| 132 |
-
skill_registry : SkillRegistry or None (v4.3.1)
|
| 133 |
-
Optional skill registry instance to update per‑skill reliability.
|
| 134 |
-
|
| 135 |
-
Returns
|
| 136 |
-
-------
|
| 137 |
-
OutcomeDB
|
| 138 |
-
The recorded outcome object.
|
| 139 |
-
|
| 140 |
-
Raises
|
| 141 |
-
------
|
| 142 |
-
ValueError
|
| 143 |
-
If intent not found or reconstruction fails fatally.
|
| 144 |
-
OutcomeConflictError
|
| 145 |
-
If a conflicting outcome already exists.
|
| 146 |
"""
|
| 147 |
-
# 1. Fetch the original intent record
|
| 148 |
intent = db.query(IntentDB).filter(
|
| 149 |
-
IntentDB.deterministic_id == deterministic_id
|
| 150 |
-
IntentDB.tenant_id == tenant_id,
|
| 151 |
-
).one_or_none()
|
| 152 |
if not intent:
|
| 153 |
raise ValueError(f"Intent not found: {deterministic_id}")
|
| 154 |
|
|
@@ -215,7 +163,7 @@ def record_outcome(
|
|
| 215 |
# ----------------------------------------------------------------
|
| 216 |
# PERSISTENCE: after updating the conjugate posterior, write it
|
| 217 |
# ----------------------------------------------------------------
|
| 218 |
-
_persist_beta_state(db,
|
| 219 |
|
| 220 |
except Exception as e:
|
| 221 |
logger.exception(
|
|
@@ -228,18 +176,4 @@ def record_outcome(
|
|
| 228 |
deterministic_id
|
| 229 |
)
|
| 230 |
|
| 231 |
-
# 6. v4.3.1: Update per‑skill reliability model if provenance is provided
|
| 232 |
-
if SKILL_REGISTRY_AVAILABLE and skill_registry is not None and skill_id is not None and skill_version is not None:
|
| 233 |
-
try:
|
| 234 |
-
skill_registry.observe_outcome(skill_id, skill_version, success)
|
| 235 |
-
logger.debug(
|
| 236 |
-
"Skill reliability updated for '%s' v%d (success=%s)",
|
| 237 |
-
skill_id, skill_version, success,
|
| 238 |
-
)
|
| 239 |
-
except Exception as e:
|
| 240 |
-
logger.warning(
|
| 241 |
-
"Failed to update skill reliability for '%s' v%d: %s",
|
| 242 |
-
skill_id, skill_version, e, exc_info=True,
|
| 243 |
-
)
|
| 244 |
-
|
| 245 |
return outcome
|
|
|
|
| 1 |
+
"""Outcome recording with idempotency, no dummy fallbacks, and timezone-aware timestamps."""
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import datetime
|
| 4 |
import logging
|
|
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# ---------------------------------------------------------------------------
|
| 23 |
+
# NEW: small helper to persist the conjugate posterior state
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
+
def _persist_beta_state(db: Session, risk_engine: RiskEngine) -> None:
|
| 26 |
"""
|
| 27 |
Write the current Beta posterior parameters to the beta_state table.
|
| 28 |
This is called after every outcome update so that online learning
|
|
|
|
| 31 |
try:
|
| 32 |
state = risk_engine.beta_store.get_state()
|
| 33 |
for cat, (alpha, beta) in state.items():
|
| 34 |
+
# Upsert: if the category already exists, update it
|
| 35 |
+
db.merge(BetaStateDB(category=cat.value, alpha=alpha, beta=beta))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
db.commit()
|
| 37 |
logger.debug("Persisted Beta posterior parameters to database.")
|
| 38 |
except Exception as e:
|
|
|
|
| 62 |
|
| 63 |
def record_outcome(
|
| 64 |
db: Session,
|
|
|
|
| 65 |
deterministic_id: str,
|
| 66 |
success: bool,
|
| 67 |
recorded_by: Optional[str],
|
| 68 |
notes: Optional[str],
|
| 69 |
risk_engine: RiskEngine,
|
| 70 |
idempotency_key: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
| 71 |
) -> OutcomeDB:
|
| 72 |
"""
|
| 73 |
Record an outcome for a previously evaluated intent.
|
|
|
|
| 78 |
No dummy intents are created. If the OSS intent cannot be reconstructed, the risk engine
|
| 79 |
is NOT updated – we log an error and still record the outcome.
|
| 80 |
|
| 81 |
+
Args:
|
| 82 |
+
db: SQLAlchemy session.
|
| 83 |
+
deterministic_id: Unique identifier of the original intent.
|
| 84 |
+
success: Whether the action succeeded (True) or failed (False).
|
| 85 |
+
recorded_by: Optional user or system identifier.
|
| 86 |
+
notes: Optional human-readable notes.
|
| 87 |
+
risk_engine: ARF risk engine instance (may be updated).
|
| 88 |
+
idempotency_key: Optional caller-provided idempotency token.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
The recorded OutcomeDB object.
|
| 92 |
+
|
| 93 |
+
Raises:
|
| 94 |
+
ValueError: If intent not found or reconstruction fails fatally.
|
| 95 |
+
OutcomeConflictError: If a conflicting outcome already exists.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
"""
|
| 97 |
+
# 1. Fetch the original intent record
|
| 98 |
intent = db.query(IntentDB).filter(
|
| 99 |
+
IntentDB.deterministic_id == deterministic_id).one_or_none()
|
|
|
|
|
|
|
| 100 |
if not intent:
|
| 101 |
raise ValueError(f"Intent not found: {deterministic_id}")
|
| 102 |
|
|
|
|
| 163 |
# ----------------------------------------------------------------
|
| 164 |
# PERSISTENCE: after updating the conjugate posterior, write it
|
| 165 |
# ----------------------------------------------------------------
|
| 166 |
+
_persist_beta_state(db, risk_engine)
|
| 167 |
|
| 168 |
except Exception as e:
|
| 169 |
logger.exception(
|
|
|
|
| 176 |
deterministic_id
|
| 177 |
)
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
return outcome
|
app/services/risk_service.py
CHANGED
|
@@ -2,11 +2,7 @@
|
|
| 2 |
Risk service – integrates ARF Bayesian risk engine, policy engine, and decision engine.
|
| 3 |
Deterministic, no random fallbacks, explicit error handling. Tenant‑aware.
|
| 4 |
|
| 5 |
-
Version: 2026-
|
| 6 |
-
skill context injection, and full HealingIntent serialisation.
|
| 7 |
-
v4.3.1 – healing decision now optionally incorporates skill reliability
|
| 8 |
-
for Bayesian utility‑aware action selection.
|
| 9 |
-
v4.3.2 – passes criticality parameter for dynamic gate tuning (Feature 3).
|
| 10 |
"""
|
| 11 |
|
| 12 |
import json
|
|
@@ -23,14 +19,6 @@ from agentic_reliability_framework.core.decision.decision_engine import Decision
|
|
| 23 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 24 |
from agentic_reliability_framework.core.research.eclipse_probe import compute_epistemic_risk
|
| 25 |
|
| 26 |
-
# ── Governance loop integration ──────────────────────────────
|
| 27 |
-
from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop
|
| 28 |
-
from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator
|
| 29 |
-
from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all
|
| 30 |
-
from agentic_reliability_framework.core.governance.stability_controller import LyapunovStabilityController
|
| 31 |
-
from agentic_reliability_framework.core.temporal_reliability import TemporalReliabilityMonitor
|
| 32 |
-
from agentic_reliability_framework.core.governance.healing_intent import HealingIntent
|
| 33 |
-
|
| 34 |
# ── optional tracing ─────────────────────────────────────────
|
| 35 |
try:
|
| 36 |
from opentelemetry import trace
|
|
@@ -110,7 +98,7 @@ def evaluate_intent(
|
|
| 110 |
intent: InfrastructureIntent,
|
| 111 |
cost_estimate: Optional[float],
|
| 112 |
policy_violations: List[str],
|
| 113 |
-
tenant_id: Optional[str] = None,
|
| 114 |
) -> dict:
|
| 115 |
"""
|
| 116 |
Evaluate an infrastructure intent using the Bayesian risk engine.
|
|
@@ -155,7 +143,7 @@ def evaluate_intent(
|
|
| 155 |
"region": getattr(intent, "region", None),
|
| 156 |
"resource_type": getattr(intent, "resource_type", None),
|
| 157 |
"permission_level": getattr(intent, "permission_level", None),
|
| 158 |
-
"tenant_id": tenant_id,
|
| 159 |
"extra": {}
|
| 160 |
}
|
| 161 |
rust_raw = _rust_evaluator.evaluate(
|
|
@@ -181,6 +169,10 @@ def evaluate_intent(
|
|
| 181 |
|
| 182 |
# ── Core risk evaluation ──────────────────────────────────
|
| 183 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
if hasattr(engine, "set_tenant"):
|
| 185 |
engine.set_tenant(tenant_id)
|
| 186 |
elif tenant_id:
|
|
@@ -216,174 +208,6 @@ def evaluate_intent(
|
|
| 216 |
}
|
| 217 |
|
| 218 |
|
| 219 |
-
def evaluate_intent_full(
|
| 220 |
-
intent: InfrastructureIntent,
|
| 221 |
-
*,
|
| 222 |
-
risk_engine: RiskEngine,
|
| 223 |
-
cost_estimator: Optional[CostEstimator] = None,
|
| 224 |
-
policy_evaluator: Optional[PolicyEvaluator] = None,
|
| 225 |
-
memory: Optional[RAGGraphMemory] = None,
|
| 226 |
-
enable_epistemic: bool = False,
|
| 227 |
-
hallucination_probe: Optional[Any] = None,
|
| 228 |
-
predictive_engine: Optional[Any] = None,
|
| 229 |
-
business_calculator: Optional[Any] = None,
|
| 230 |
-
use_rust_enforcer: bool = False,
|
| 231 |
-
stability_controller: Optional[LyapunovStabilityController] = None,
|
| 232 |
-
temporal_monitor: Optional[TemporalReliabilityMonitor] = None,
|
| 233 |
-
tenant_id: Optional[str] = None,
|
| 234 |
-
skill_id: Optional[str] = None,
|
| 235 |
-
skill_registry: Optional[Any] = None,
|
| 236 |
-
context_extra: Optional[Dict[str, Any]] = None,
|
| 237 |
-
criticality: Optional[float] = None, # v4.3.2
|
| 238 |
-
) -> Dict[str, Any]:
|
| 239 |
-
"""
|
| 240 |
-
Run the full governance loop and return a structured response containing
|
| 241 |
-
the serialised HealingIntent with Bayesian skill posterior parameters.
|
| 242 |
-
|
| 243 |
-
If stability_controller or temporal_monitor are None (the default),
|
| 244 |
-
the governance loop will simply skip those checks. Pass stateful
|
| 245 |
-
instances from the app state to accumulate cross‑request state.
|
| 246 |
-
|
| 247 |
-
Parameters
|
| 248 |
-
----------
|
| 249 |
-
intent : InfrastructureIntent
|
| 250 |
-
The original infrastructure request.
|
| 251 |
-
risk_engine : RiskEngine
|
| 252 |
-
Bayesian risk engine (tenant‑aware).
|
| 253 |
-
cost_estimator : CostEstimator, optional
|
| 254 |
-
Monthly cost estimator; a default instance is created if None.
|
| 255 |
-
policy_evaluator : PolicyEvaluator, optional
|
| 256 |
-
Policy tree evaluator; defaults to `allow_all` if None.
|
| 257 |
-
memory : RAGGraphMemory, optional
|
| 258 |
-
Semantic memory for similar‑incident retrieval.
|
| 259 |
-
enable_epistemic : bool
|
| 260 |
-
Whether to run the ECLIPSE hallucination probe and CUDL attribution.
|
| 261 |
-
hallucination_probe : HallucinationRisk, optional
|
| 262 |
-
Pre‑configured probe instance.
|
| 263 |
-
predictive_engine : SimplePredictiveEngine, optional
|
| 264 |
-
Time‑series forecasting engine.
|
| 265 |
-
business_calculator : BusinessImpactCalculator, optional
|
| 266 |
-
Revenue impact estimator.
|
| 267 |
-
use_rust_enforcer : bool
|
| 268 |
-
Whether to run the Rust policy evaluator in shadow mode.
|
| 269 |
-
stability_controller : LyapunovStabilityController, optional
|
| 270 |
-
Passive stability monitor; if None, stability checks are skipped.
|
| 271 |
-
temporal_monitor : TemporalReliabilityMonitor, optional
|
| 272 |
-
Drift detector; if None, drift detection is skipped.
|
| 273 |
-
tenant_id : str, optional
|
| 274 |
-
Tenant UUID for multi‑tenant state.
|
| 275 |
-
skill_id : str, optional
|
| 276 |
-
Skill identifier; if provided, the skill's current posterior
|
| 277 |
-
parameters are injected into the governance loop.
|
| 278 |
-
skill_registry : SkillRegistry, optional
|
| 279 |
-
Instance of the skill registry (required if skill_id is given).
|
| 280 |
-
context_extra : dict, optional
|
| 281 |
-
Additional key‑value pairs to merge into the loop context.
|
| 282 |
-
criticality : float, optional
|
| 283 |
-
Criticality of the operation (0 = low, 1 = critical). Passed to the
|
| 284 |
-
governance loop for dynamic gate threshold tuning (v4.3.2).
|
| 285 |
-
|
| 286 |
-
Returns
|
| 287 |
-
-------
|
| 288 |
-
dict
|
| 289 |
-
Keys:
|
| 290 |
-
- risk_score : float
|
| 291 |
-
- explanation : str
|
| 292 |
-
- contributions : dict (empty; full trace is in healing_intent)
|
| 293 |
-
- healing_intent : dict (serialised HealingIntent)
|
| 294 |
-
- recommended_action : str
|
| 295 |
-
- deterministic_id : str
|
| 296 |
-
"""
|
| 297 |
-
t0 = time.monotonic()
|
| 298 |
-
span = None
|
| 299 |
-
if OTEL_AVAILABLE and _tracer:
|
| 300 |
-
span = _tracer.start_span("risk_service.evaluate_intent_full")
|
| 301 |
-
span.set_attribute("intent_type", type(intent).__name__)
|
| 302 |
-
if tenant_id:
|
| 303 |
-
span.set_attribute("tenant_id", tenant_id)
|
| 304 |
-
|
| 305 |
-
# Default components if not provided
|
| 306 |
-
if policy_evaluator is None:
|
| 307 |
-
policy_evaluator = PolicyEvaluator(allow_all())
|
| 308 |
-
if cost_estimator is None:
|
| 309 |
-
cost_estimator = CostEstimator()
|
| 310 |
-
# stability_controller and temporal_monitor are NOT defaulted here;
|
| 311 |
-
# they remain None unless explicitly passed. The GovernanceLoop will skip
|
| 312 |
-
# those checks gracefully.
|
| 313 |
-
|
| 314 |
-
loop = GovernanceLoop(
|
| 315 |
-
policy_evaluator=policy_evaluator,
|
| 316 |
-
cost_estimator=cost_estimator,
|
| 317 |
-
risk_engine=risk_engine,
|
| 318 |
-
memory=memory,
|
| 319 |
-
enable_epistemic=enable_epistemic,
|
| 320 |
-
hallucination_probe=hallucination_probe,
|
| 321 |
-
predictive_engine=predictive_engine,
|
| 322 |
-
business_calculator=business_calculator,
|
| 323 |
-
use_rust_enforcer=use_rust_enforcer,
|
| 324 |
-
stability_controller=stability_controller,
|
| 325 |
-
temporal_monitor=temporal_monitor,
|
| 326 |
-
)
|
| 327 |
-
|
| 328 |
-
# ── Build context with skill posterior parameters ─────────
|
| 329 |
-
context: Dict[str, Any] = dict(context_extra) if context_extra else {}
|
| 330 |
-
if skill_id and skill_registry is not None:
|
| 331 |
-
try:
|
| 332 |
-
# Fetch the latest version for the skill
|
| 333 |
-
versions = skill_registry.list_skill_versions(skill_id)
|
| 334 |
-
version = versions[-1] if versions else 1
|
| 335 |
-
# Use public get_model() instead of direct _models access
|
| 336 |
-
model = skill_registry.get_model(skill_id, version)
|
| 337 |
-
if model is not None:
|
| 338 |
-
alpha = model.alpha
|
| 339 |
-
beta = model.beta
|
| 340 |
-
reliability = model.mean()
|
| 341 |
-
else:
|
| 342 |
-
# Use default prior if no model exists yet
|
| 343 |
-
alpha = skill_registry.default_prior_alpha
|
| 344 |
-
beta = skill_registry.default_prior_beta
|
| 345 |
-
reliability = alpha / (alpha + beta)
|
| 346 |
-
context.update({
|
| 347 |
-
"skill_id": skill_id,
|
| 348 |
-
"skill_version": version,
|
| 349 |
-
"skill_ate": skill_registry.get_ate(skill_id, version),
|
| 350 |
-
"skill_reliability_score": reliability,
|
| 351 |
-
"skill_alpha": alpha,
|
| 352 |
-
"skill_beta": beta,
|
| 353 |
-
})
|
| 354 |
-
except Exception as e:
|
| 355 |
-
logger.warning("Failed to inject skill context for '%s': %s", skill_id, e)
|
| 356 |
-
|
| 357 |
-
# v4.3.2: inject criticality into context for dynamic gate tuning
|
| 358 |
-
if criticality is not None:
|
| 359 |
-
context["criticality"] = criticality
|
| 360 |
-
|
| 361 |
-
# ── Execute governance loop ───────────────────────────────
|
| 362 |
-
healing_intent: HealingIntent = loop.run(intent, context=context)
|
| 363 |
-
healing_dict = healing_intent.to_dict(include_advisory_context=True)
|
| 364 |
-
|
| 365 |
-
risk_score = healing_intent.risk_score or 0.0
|
| 366 |
-
explanation = healing_intent.justification or ""
|
| 367 |
-
|
| 368 |
-
# ── Metrics & span finalisation ───────────────────────────
|
| 369 |
-
_EVAL_COUNTER.labels(engine="governance_loop", status="success").inc()
|
| 370 |
-
_EVAL_DURATION.labels(engine="governance_loop").observe(time.monotonic() - t0)
|
| 371 |
-
|
| 372 |
-
if span:
|
| 373 |
-
span.set_attribute("risk_score", risk_score)
|
| 374 |
-
span.set_attribute("recommended_action", healing_dict.get("recommended_action"))
|
| 375 |
-
span.end()
|
| 376 |
-
|
| 377 |
-
return {
|
| 378 |
-
"risk_score": risk_score,
|
| 379 |
-
"explanation": explanation,
|
| 380 |
-
"contributions": {}, # full trace is in healing_intent
|
| 381 |
-
"healing_intent": healing_dict,
|
| 382 |
-
"recommended_action": healing_dict.get("recommended_action"),
|
| 383 |
-
"deterministic_id": healing_intent.deterministic_id,
|
| 384 |
-
}
|
| 385 |
-
|
| 386 |
-
|
| 387 |
def evaluate_healing_decision(
|
| 388 |
event: ReliabilityEvent,
|
| 389 |
policy_engine: PolicyEngine,
|
|
@@ -391,26 +215,11 @@ def evaluate_healing_decision(
|
|
| 391 |
rag_graph: Optional[RAGGraphMemory] = None,
|
| 392 |
model=None,
|
| 393 |
tokenizer=None,
|
| 394 |
-
tenant_id: Optional[str] = None,
|
| 395 |
-
# ── v4.3.1: skill context ──────────────────────────────────
|
| 396 |
-
skill_id: Optional[str] = None,
|
| 397 |
-
skill_version: Optional[int] = None,
|
| 398 |
-
skill_registry: Optional[Any] = None,
|
| 399 |
) -> Dict[str, Any]:
|
| 400 |
"""
|
| 401 |
Evaluate healing actions for a given reliability event using decision‑theoretic selection.
|
| 402 |
-
Includes epistemic risk signals from the eclipse probe
|
| 403 |
-
information to bias the utility towards actions from trusted skills.
|
| 404 |
-
|
| 405 |
-
The utility of each candidate action a is extended with two additional terms:
|
| 406 |
-
|
| 407 |
-
U(a) = U_base(a) + w_skill · μ_skill − w_σ · σ_skill
|
| 408 |
-
|
| 409 |
-
where μ_skill = α/(α+β) is the posterior mean reliability of the skill that
|
| 410 |
-
authored the action, and σ_skill = sqrt(αβ / ((α+β)²(α+β+1))) is its
|
| 411 |
-
posterior standard deviation. These terms are computed from the conjugate
|
| 412 |
-
Beta posterior tracked by the SkillRegistry. When no skill context is
|
| 413 |
-
provided, the utility falls back to the original formulation.
|
| 414 |
|
| 415 |
Parameters
|
| 416 |
----------
|
|
@@ -419,26 +228,19 @@ def evaluate_healing_decision(
|
|
| 419 |
policy_engine : PolicyEngine
|
| 420 |
The ARF healing policy engine with configured policies.
|
| 421 |
decision_engine : DecisionEngine, optional
|
| 422 |
-
If omitted, a default instance is created.
|
| 423 |
-
(its internal skill registry is not modified).
|
| 424 |
rag_graph : RAGGraphMemory, optional
|
| 425 |
Semantic memory for similar incident retrieval.
|
| 426 |
model, tokenizer : optional
|
| 427 |
HuggingFace model and tokenizer for epistemic risk computation.
|
| 428 |
tenant_id : str, optional
|
| 429 |
-
Tenant UUID for logging and metrics.
|
| 430 |
-
skill_id : str, optional
|
| 431 |
-
Skill identifier to incorporate into utility.
|
| 432 |
-
skill_version : int, optional
|
| 433 |
-
Version of the skill.
|
| 434 |
-
skill_registry : SkillRegistry, optional
|
| 435 |
-
Registry to fetch the skill's posterior parameters.
|
| 436 |
|
| 437 |
Returns
|
| 438 |
-------
|
| 439 |
dict
|
| 440 |
Keys: risk_score, selected_action, expected_utility, alternatives,
|
| 441 |
-
explanation, epistemic_signals
|
| 442 |
"""
|
| 443 |
t0 = time.monotonic()
|
| 444 |
span = None
|
|
@@ -452,10 +254,10 @@ def evaluate_healing_decision(
|
|
| 452 |
if decision_engine is None and hasattr(policy_engine, 'decision_engine'):
|
| 453 |
decision_engine = policy_engine.decision_engine
|
| 454 |
|
| 455 |
-
# If still None, create a minimal one (global stats only)
|
| 456 |
if decision_engine is None:
|
| 457 |
logger.debug("No DecisionEngine provided; creating default instance")
|
| 458 |
-
decision_engine = DecisionEngine(rag_graph=rag_graph
|
| 459 |
|
| 460 |
# Get raw candidate actions (by temporarily disabling decision engine)
|
| 461 |
orig_use = policy_engine.use_decision_engine
|
|
@@ -472,7 +274,7 @@ def evaluate_healing_decision(
|
|
| 472 |
span.end()
|
| 473 |
_EVAL_COUNTER.labels(engine="python", status="success").inc()
|
| 474 |
_EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0)
|
| 475 |
-
|
| 476 |
"risk_score": 0.0,
|
| 477 |
"selected_action": HealingAction.NO_ACTION.value,
|
| 478 |
"expected_utility": 0.0,
|
|
@@ -480,10 +282,6 @@ def evaluate_healing_decision(
|
|
| 480 |
"explanation": "No candidate actions triggered.",
|
| 481 |
"epistemic_signals": None,
|
| 482 |
}
|
| 483 |
-
if skill_id:
|
| 484 |
-
no_action_result["skill_id"] = skill_id
|
| 485 |
-
no_action_result["skill_version"] = skill_version
|
| 486 |
-
return no_action_result
|
| 487 |
|
| 488 |
# Build reasoning text from policies that triggered the actions
|
| 489 |
reasoning_parts = []
|
|
@@ -530,14 +328,10 @@ def evaluate_healing_decision(
|
|
| 530 |
"hallucination_risk": 0.0,
|
| 531 |
}
|
| 532 |
|
| 533 |
-
#
|
| 534 |
decision = decision_engine.select_optimal_action(
|
| 535 |
-
raw_actions,
|
| 536 |
-
|
| 537 |
-
component=event.component,
|
| 538 |
-
epistemic_signals=epistemic_signals,
|
| 539 |
-
skill_id=skill_id,
|
| 540 |
-
skill_version=skill_version,
|
| 541 |
)
|
| 542 |
|
| 543 |
# Extract risk of the selected action
|
|
@@ -570,7 +364,7 @@ def evaluate_healing_decision(
|
|
| 570 |
span.set_attribute("expected_utility", decision.expected_utility)
|
| 571 |
span.end()
|
| 572 |
|
| 573 |
-
|
| 574 |
"risk_score": risk_score,
|
| 575 |
"selected_action": decision.best_action.value,
|
| 576 |
"expected_utility": decision.expected_utility,
|
|
@@ -579,10 +373,6 @@ def evaluate_healing_decision(
|
|
| 579 |
"raw_decision": decision.raw_data,
|
| 580 |
"epistemic_signals": epistemic_signals,
|
| 581 |
}
|
| 582 |
-
if skill_id:
|
| 583 |
-
result["skill_id"] = skill_id
|
| 584 |
-
result["skill_version"] = skill_version
|
| 585 |
-
return result
|
| 586 |
|
| 587 |
|
| 588 |
def get_system_risk() -> float:
|
|
|
|
| 2 |
Risk service – integrates ARF Bayesian risk engine, policy engine, and decision engine.
|
| 3 |
Deterministic, no random fallbacks, explicit error handling. Tenant‑aware.
|
| 4 |
|
| 5 |
+
Version: 2026-06-07 – added tenant_id propagation, improved Rust enforcer integration.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 19 |
from agentic_reliability_framework.runtime.memory.rag_graph import RAGGraphMemory
|
| 20 |
from agentic_reliability_framework.core.research.eclipse_probe import compute_epistemic_risk
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# ── optional tracing ─────────────────────────────────────────
|
| 23 |
try:
|
| 24 |
from opentelemetry import trace
|
|
|
|
| 98 |
intent: InfrastructureIntent,
|
| 99 |
cost_estimate: Optional[float],
|
| 100 |
policy_violations: List[str],
|
| 101 |
+
tenant_id: Optional[str] = None, # <-- NEW: tenant isolation
|
| 102 |
) -> dict:
|
| 103 |
"""
|
| 104 |
Evaluate an infrastructure intent using the Bayesian risk engine.
|
|
|
|
| 143 |
"region": getattr(intent, "region", None),
|
| 144 |
"resource_type": getattr(intent, "resource_type", None),
|
| 145 |
"permission_level": getattr(intent, "permission_level", None),
|
| 146 |
+
"tenant_id": tenant_id, # pass tenant for logging
|
| 147 |
"extra": {}
|
| 148 |
}
|
| 149 |
rust_raw = _rust_evaluator.evaluate(
|
|
|
|
| 169 |
|
| 170 |
# ── Core risk evaluation ──────────────────────────────────
|
| 171 |
try:
|
| 172 |
+
# Note: The RiskEngine must be modified to accept tenant_id and use
|
| 173 |
+
# a per‑tenant BetaStore. This change is expected in the core engine.
|
| 174 |
+
# Here we pass the tenant_id as a keyword argument; the engine will
|
| 175 |
+
# ignore it if not yet implemented, but we log a warning.
|
| 176 |
if hasattr(engine, "set_tenant"):
|
| 177 |
engine.set_tenant(tenant_id)
|
| 178 |
elif tenant_id:
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def evaluate_healing_decision(
|
| 212 |
event: ReliabilityEvent,
|
| 213 |
policy_engine: PolicyEngine,
|
|
|
|
| 215 |
rag_graph: Optional[RAGGraphMemory] = None,
|
| 216 |
model=None,
|
| 217 |
tokenizer=None,
|
| 218 |
+
tenant_id: Optional[str] = None, # <-- NEW for audit context
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
) -> Dict[str, Any]:
|
| 220 |
"""
|
| 221 |
Evaluate healing actions for a given reliability event using decision‑theoretic selection.
|
| 222 |
+
Includes epistemic risk signals from the eclipse probe.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
Parameters
|
| 225 |
----------
|
|
|
|
| 228 |
policy_engine : PolicyEngine
|
| 229 |
The ARF healing policy engine with configured policies.
|
| 230 |
decision_engine : DecisionEngine, optional
|
| 231 |
+
If omitted, a default instance is created.
|
|
|
|
| 232 |
rag_graph : RAGGraphMemory, optional
|
| 233 |
Semantic memory for similar incident retrieval.
|
| 234 |
model, tokenizer : optional
|
| 235 |
HuggingFace model and tokenizer for epistemic risk computation.
|
| 236 |
tenant_id : str, optional
|
| 237 |
+
Tenant UUID for logging and metrics (not used in core logic yet).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
Returns
|
| 240 |
-------
|
| 241 |
dict
|
| 242 |
Keys: risk_score, selected_action, expected_utility, alternatives,
|
| 243 |
+
explanation, epistemic_signals.
|
| 244 |
"""
|
| 245 |
t0 = time.monotonic()
|
| 246 |
span = None
|
|
|
|
| 254 |
if decision_engine is None and hasattr(policy_engine, 'decision_engine'):
|
| 255 |
decision_engine = policy_engine.decision_engine
|
| 256 |
|
| 257 |
+
# If still None, create a minimal one (global stats only)
|
| 258 |
if decision_engine is None:
|
| 259 |
logger.debug("No DecisionEngine provided; creating default instance")
|
| 260 |
+
decision_engine = DecisionEngine(rag_graph=rag_graph)
|
| 261 |
|
| 262 |
# Get raw candidate actions (by temporarily disabling decision engine)
|
| 263 |
orig_use = policy_engine.use_decision_engine
|
|
|
|
| 274 |
span.end()
|
| 275 |
_EVAL_COUNTER.labels(engine="python", status="success").inc()
|
| 276 |
_EVAL_DURATION.labels(engine="python").observe(time.monotonic() - t0)
|
| 277 |
+
return {
|
| 278 |
"risk_score": 0.0,
|
| 279 |
"selected_action": HealingAction.NO_ACTION.value,
|
| 280 |
"expected_utility": 0.0,
|
|
|
|
| 282 |
"explanation": "No candidate actions triggered.",
|
| 283 |
"epistemic_signals": None,
|
| 284 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
# Build reasoning text from policies that triggered the actions
|
| 287 |
reasoning_parts = []
|
|
|
|
| 328 |
"hallucination_risk": 0.0,
|
| 329 |
}
|
| 330 |
|
| 331 |
+
# Run decision engine to get best action and alternatives
|
| 332 |
decision = decision_engine.select_optimal_action(
|
| 333 |
+
raw_actions, event, component=event.component,
|
| 334 |
+
epistemic_signals=epistemic_signals
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
)
|
| 336 |
|
| 337 |
# Extract risk of the selected action
|
|
|
|
| 364 |
span.set_attribute("expected_utility", decision.expected_utility)
|
| 365 |
span.end()
|
| 366 |
|
| 367 |
+
return {
|
| 368 |
"risk_score": risk_score,
|
| 369 |
"selected_action": decision.best_action.value,
|
| 370 |
"expected_utility": decision.expected_utility,
|
|
|
|
| 373 |
"raw_decision": decision.raw_data,
|
| 374 |
"epistemic_signals": epistemic_signals,
|
| 375 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
|
| 378 |
def get_system_risk() -> float:
|
deploy/kubernetes/arf-api/configmap.yaml
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
apiVersion: v1
|
| 2 |
-
kind: ConfigMap
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-config
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
data:
|
| 7 |
-
ARF_HMC_MODEL: "models/hmc_model.json"
|
| 8 |
-
ARF_USE_HYPERPRIORS: "false"
|
| 9 |
-
ARF_USAGE_TRACKING: "true"
|
| 10 |
-
ARF_USE_RUST_ENFORCER: "false"
|
| 11 |
-
EPISTEMIC_MODEL: ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/deployment.yaml
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
apiVersion: apps/v1
|
| 2 |
-
kind: Deployment
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
labels:
|
| 7 |
-
app: arf-api
|
| 8 |
-
version: v4.3.2
|
| 9 |
-
spec:
|
| 10 |
-
replicas: 3
|
| 11 |
-
strategy:
|
| 12 |
-
type: RollingUpdate
|
| 13 |
-
rollingUpdate:
|
| 14 |
-
maxUnavailable: 1
|
| 15 |
-
maxSurge: 1
|
| 16 |
-
selector:
|
| 17 |
-
matchLabels:
|
| 18 |
-
app: arf-api
|
| 19 |
-
template:
|
| 20 |
-
metadata:
|
| 21 |
-
labels:
|
| 22 |
-
app: arf-api
|
| 23 |
-
version: v4.3.2
|
| 24 |
-
spec:
|
| 25 |
-
serviceAccountName: arf-api
|
| 26 |
-
securityContext:
|
| 27 |
-
runAsNonRoot: true
|
| 28 |
-
runAsUser: 1000
|
| 29 |
-
fsGroup: 1000
|
| 30 |
-
containers:
|
| 31 |
-
- name: arf-api
|
| 32 |
-
image: arf-api:latest # Replace with specific tag in production
|
| 33 |
-
imagePullPolicy: Always
|
| 34 |
-
ports:
|
| 35 |
-
- containerPort: 8000
|
| 36 |
-
protocol: TCP
|
| 37 |
-
envFrom:
|
| 38 |
-
- configMapRef:
|
| 39 |
-
name: arf-api-config
|
| 40 |
-
- secretRef:
|
| 41 |
-
name: arf-api-secrets
|
| 42 |
-
resources:
|
| 43 |
-
requests:
|
| 44 |
-
cpu: 500m
|
| 45 |
-
memory: 512Mi
|
| 46 |
-
limits:
|
| 47 |
-
cpu: 2000m
|
| 48 |
-
memory: 2Gi
|
| 49 |
-
livenessProbe:
|
| 50 |
-
httpGet:
|
| 51 |
-
path: /health
|
| 52 |
-
port: 8000
|
| 53 |
-
initialDelaySeconds: 30
|
| 54 |
-
periodSeconds: 10
|
| 55 |
-
timeoutSeconds: 5
|
| 56 |
-
failureThreshold: 3
|
| 57 |
-
readinessProbe:
|
| 58 |
-
httpGet:
|
| 59 |
-
path: /health
|
| 60 |
-
port: 8000
|
| 61 |
-
initialDelaySeconds: 10
|
| 62 |
-
periodSeconds: 5
|
| 63 |
-
timeoutSeconds: 3
|
| 64 |
-
failureThreshold: 2
|
| 65 |
-
terminationGracePeriodSeconds: 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/hpa.yaml
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
apiVersion: autoscaling/v2
|
| 2 |
-
kind: HorizontalPodAutoscaler
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-hpa
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
spec:
|
| 7 |
-
scaleTargetRef:
|
| 8 |
-
apiVersion: apps/v1
|
| 9 |
-
kind: Deployment
|
| 10 |
-
name: arf-api
|
| 11 |
-
minReplicas: 3
|
| 12 |
-
maxReplicas: 10
|
| 13 |
-
metrics:
|
| 14 |
-
- type: Resource
|
| 15 |
-
resource:
|
| 16 |
-
name: cpu
|
| 17 |
-
target:
|
| 18 |
-
type: Utilization
|
| 19 |
-
averageUtilization: 70
|
| 20 |
-
- type: Resource
|
| 21 |
-
resource:
|
| 22 |
-
name: memory
|
| 23 |
-
target:
|
| 24 |
-
type: Utilization
|
| 25 |
-
averageUtilization: 80
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/networkpolicy.yaml
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
apiVersion: networking.k8s.io/v1
|
| 2 |
-
kind: NetworkPolicy
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api-ingress
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
spec:
|
| 7 |
-
podSelector:
|
| 8 |
-
matchLabels:
|
| 9 |
-
app: arf-api
|
| 10 |
-
policyTypes:
|
| 11 |
-
- Ingress
|
| 12 |
-
ingress:
|
| 13 |
-
# Allow traffic only from the gateway pods on port 8000
|
| 14 |
-
- from:
|
| 15 |
-
- podSelector:
|
| 16 |
-
matchLabels:
|
| 17 |
-
app: arf-gateway
|
| 18 |
-
ports:
|
| 19 |
-
- port: 8000
|
| 20 |
-
protocol: TCP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/secret.yaml
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
# DO NOT apply this file as-is. Every value below is a placeholder, not a
|
| 2 |
-
# real secret -- ARF_INTERNAL_API_KEY in particular is a fixed, publicly
|
| 3 |
-
# visible string in this repo's git history. Applying it unmodified means
|
| 4 |
-
# the "secret" is a known value, not a secret.
|
| 5 |
-
#
|
| 6 |
-
# Generate real values instead, e.g.:
|
| 7 |
-
# kubectl create secret generic arf-api-secrets -n arf-system \
|
| 8 |
-
# --from-literal=DATABASE_URL=... \
|
| 9 |
-
# --from-literal=ARF_INTERNAL_API_KEY=$(openssl rand -hex 32) \
|
| 10 |
-
# --from-literal=ARF_API_KEYS='{}' \
|
| 11 |
-
# --from-literal=ARF_REDIS_URL=...
|
| 12 |
-
# or manage this via a secrets operator (External Secrets, Sealed Secrets,
|
| 13 |
-
# SOPS) rather than a plain committed manifest.
|
| 14 |
-
apiVersion: v1
|
| 15 |
-
kind: Secret
|
| 16 |
-
metadata:
|
| 17 |
-
name: arf-api-secrets
|
| 18 |
-
namespace: arf-system
|
| 19 |
-
type: Opaque
|
| 20 |
-
stringData:
|
| 21 |
-
# Placeholders only -- see warning above. Replace before applying.
|
| 22 |
-
DATABASE_URL: "postgresql://user:password@host:5432/arf"
|
| 23 |
-
ARF_INTERNAL_API_KEY: "change-me-to-a-strong-random-key"
|
| 24 |
-
ARF_API_KEYS: '{}'
|
| 25 |
-
ARF_REDIS_URL: ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/kubernetes/arf-api/service.yaml
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
apiVersion: v1
|
| 2 |
-
kind: Service
|
| 3 |
-
metadata:
|
| 4 |
-
name: arf-api
|
| 5 |
-
namespace: arf-system
|
| 6 |
-
labels:
|
| 7 |
-
app: arf-api
|
| 8 |
-
spec:
|
| 9 |
-
type: ClusterIP
|
| 10 |
-
ports:
|
| 11 |
-
- port: 8000
|
| 12 |
-
targetPort: 8000
|
| 13 |
-
protocol: TCP
|
| 14 |
-
name: http
|
| 15 |
-
selector:
|
| 16 |
-
app: arf-api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/authentication.md
CHANGED
|
@@ -4,48 +4,22 @@ This page describes how to authenticate with the ARF API.
|
|
| 4 |
|
| 5 |
Current status
|
| 6 |
|
| 7 |
-
-
|
| 8 |
-
`routes_memory.py`: the entire router requires the `X-Internal-Key` header, verified against
|
| 9 |
-
`ARF_INTERNAL_API_KEY` (`app/api/deps.py::verify_internal_key`). This fails closed — requests
|
| 10 |
-
are rejected with 401 if the env var is unset, if the header is missing, or if it doesn't
|
| 11 |
-
match (constant-time comparison). This is the header arf-gateway injects when proxying to
|
| 12 |
-
this service. The last four were unauthenticated until this was fixed — see
|
| 13 |
-
`tests/test_deps.py` for the tests that verify the dependency itself actually rejects what
|
| 14 |
-
it should, not just that it's wired in.
|
| 15 |
-
- `routes_incidents.py`'s `POST /report_incident` requires the same `X-Internal-Key`
|
| 16 |
-
dependency as above — it did not until a later audit found it had none at all, unlike every
|
| 17 |
-
other route in this file, despite its own docstring saying it's meant for internal monitoring
|
| 18 |
-
tools only. Anyone could previously write arbitrary events into the incident history that
|
| 19 |
-
feeds the causal explainer and `GET /history`. That history is now also a bounded
|
| 20 |
-
`deque(maxlen=10_000)` (`app/core/storage.py`), not an unbounded list — the same audit found
|
| 21 |
-
`POST /report_incident` and `GET /history` were reading/writing two _different_ Python lists
|
| 22 |
-
with the same name, so `GET /history` had in fact always returned empty regardless of what was
|
| 23 |
-
reported; both routers now share the one list in `app.core.storage`.
|
| 24 |
-
- `routes_admin.py`: individual `/admin/*` endpoints require an `admin_key` query parameter,
|
| 25 |
-
verified against `ARF_ADMIN_API_KEY` (`app/api/deps.py`, or the local `verify_admin`
|
| 26 |
-
dependency in that router). Also fails closed if unset. Includes
|
| 27 |
-
`POST /admin/keys/{key_id}/rotate` — deactivates a key and issues a new one on the same
|
| 28 |
-
tenant/tier in one transaction, for when a key needs to be revoked without losing the
|
| 29 |
-
tenant's identity or history. `api_keys` itself lives in Postgres (`DATABASE_URL`), shared
|
| 30 |
-
with arf-gateway (both must use the identical `ARF_KEY_PEPPER`) — see the comments on those
|
| 31 |
-
two variables in `.env.example` for why.
|
| 32 |
-
- `routes_pricing.py`: individual `/pricing/*` endpoints require a real per-customer API key
|
| 33 |
-
(`Authorization: Bearer <key>` or `?api_key=`), verified against the tracked/tenant-scoped
|
| 34 |
-
`enforce_quota` dependency (`app/core/usage_tracker.py`) — a different mechanism from
|
| 35 |
-
`X-Internal-Key`, since pricing estimates are meant to be reachable by a customer directly,
|
| 36 |
-
not only via the gateway.
|
| 37 |
|
| 38 |
What the code provides
|
| 39 |
|
| 40 |
-
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
Notes
|
| 46 |
|
| 47 |
-
- Tests
|
| 48 |
-
- `tests/conftest.py` globally overrides `verify_internal_key` for the test suite (so routes
|
| 49 |
-
behind it can be exercised without the gateway-injected header) — this means the app-level
|
| 50 |
-
test suite alone can't confirm the dependency actually fails closed. `tests/test_deps.py`
|
| 51 |
-
calls it directly, bypassing that override, specifically to verify that.
|
|
|
|
| 4 |
|
| 5 |
Current status
|
| 6 |
|
| 7 |
+
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
What the code provides
|
| 10 |
|
| 11 |
+
- 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`).
|
| 12 |
+
|
| 13 |
+
What this means for you
|
| 14 |
+
|
| 15 |
+
- 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.
|
| 16 |
+
- 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.
|
| 17 |
+
|
| 18 |
+
Suggested minimal approach to enable API key checking
|
| 19 |
+
|
| 20 |
+
- 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.
|
| 21 |
+
- Add that dependency to routers or individual endpoints where auth is required.
|
| 22 |
|
| 23 |
Notes
|
| 24 |
|
| 25 |
+
- Tests and example code in this repo currently run without auth.
|
|
|
|
|
|
|
|
|
|
|
|
docs/development.md
CHANGED
|
@@ -20,11 +20,10 @@ Quick start
|
|
| 20 |
python -m venv .venv
|
| 21 |
source .venv/bin/activate # or .\.venv\Scripts\activate on Windows
|
| 22 |
pip install -r requirements.txt
|
| 23 |
-
pip install -r requirements-dev.txt # needed to run the test suite (pytest, etc.)
|
| 24 |
|
| 25 |
3. Configure environment variables (optional):
|
| 26 |
|
| 27 |
-
- The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`).
|
| 28 |
|
| 29 |
Relevant environment variables used by the code:
|
| 30 |
- ARF_HMC_MODEL (default: `models/hmc_model.json`) — path to HMC model JSON used by RiskEngine.
|
|
|
|
| 20 |
python -m venv .venv
|
| 21 |
source .venv/bin/activate # or .\.venv\Scripts\activate on Windows
|
| 22 |
pip install -r requirements.txt
|
|
|
|
| 23 |
|
| 24 |
3. Configure environment variables (optional):
|
| 25 |
|
| 26 |
+
- The project uses pydantic-settings with `env_file = ".env"` (see `app/core/config.py`). Create a `.env` file to set values locally.
|
| 27 |
|
| 28 |
Relevant environment variables used by the code:
|
| 29 |
- ARF_HMC_MODEL (default: `models/hmc_model.json`) — path to HMC model JSON used by RiskEngine.
|
render.yaml
CHANGED
|
@@ -11,8 +11,6 @@ services:
|
|
| 11 |
property: connectionString
|
| 12 |
- key: API_KEY
|
| 13 |
sync: false
|
| 14 |
-
- key: ARF_KEY_PEPPER
|
| 15 |
-
sync: false
|
| 16 |
- key: ENVIRONMENT
|
| 17 |
value: production
|
| 18 |
databases:
|
|
|
|
| 11 |
property: connectionString
|
| 12 |
- key: API_KEY
|
| 13 |
sync: false
|
|
|
|
|
|
|
| 14 |
- key: ENVIRONMENT
|
| 15 |
value: production
|
| 16 |
databases:
|
requirements-dev.txt
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
pytest>=9.0.3
|
| 2 |
pytest-cov>=7.0.0
|
| 3 |
jsonschema>=4.0.0
|
| 4 |
pytest-asyncio>=0.24.0
|
| 5 |
-
pytest-timeout>=2.3.1
|
|
|
|
|
|
|
| 1 |
pytest-cov>=7.0.0
|
| 2 |
jsonschema>=4.0.0
|
| 3 |
pytest-asyncio>=0.24.0
|
|
|
requirements.txt
CHANGED
|
@@ -1,22 +1,19 @@
|
|
| 1 |
fastapi==0.115.12
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
pydantic>=2.13.2
|
| 4 |
-
agentic-reliability-framework @ git+https://github.com/arf-foundation/
|
| 5 |
arf-pricing-calculator @ git+https://github.com/arf-foundation/ARF-Bayesian-Pricing-Calculator@main
|
|
|
|
|
|
|
| 6 |
httpx==0.28.1
|
| 7 |
alembic
|
| 8 |
pydantic-settings
|
| 9 |
sqlalchemy
|
| 10 |
psycopg2-binary==2.9.10
|
| 11 |
slowapi==0.1.9
|
| 12 |
-
limits==3.3.1 # slowapi 0.1.9's own poetry.lock pins this; left unpinned it resolves
|
| 13 |
-
# to a much newer major version whose internal API to slowapi breaks
|
| 14 |
-
# (Limiter._check_request_limit raises a plain ValueError instead of
|
| 15 |
-
# RateLimitExceeded, which slowapi's middleware mishandles as an
|
| 16 |
-
# unhandled 500 on every request touching a rate limit)
|
| 17 |
prometheus-fastapi-instrumentator==7.1.0
|
| 18 |
flake8==7.2.0
|
| 19 |
-
cryptography==
|
| 20 |
sentence-transformers>=2.2.0
|
| 21 |
scikit-learn
|
| 22 |
redis>=4.0.0 # optional, for faster counters
|
|
|
|
| 1 |
fastapi==0.115.12
|
| 2 |
uvicorn[standard]==0.34.0
|
| 3 |
pydantic>=2.13.2
|
| 4 |
+
agentic-reliability-framework @ git+https://github.com/arf-foundation/agentic-reliability-framework@main
|
| 5 |
arf-pricing-calculator @ git+https://github.com/arf-foundation/ARF-Bayesian-Pricing-Calculator@main
|
| 6 |
+
pytest==8.3.5
|
| 7 |
+
pytest==8.3.5
|
| 8 |
httpx==0.28.1
|
| 9 |
alembic
|
| 10 |
pydantic-settings
|
| 11 |
sqlalchemy
|
| 12 |
psycopg2-binary==2.9.10
|
| 13 |
slowapi==0.1.9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
prometheus-fastapi-instrumentator==7.1.0
|
| 15 |
flake8==7.2.0
|
| 16 |
+
cryptography==47.0.0
|
| 17 |
sentence-transformers>=2.2.0
|
| 18 |
scikit-learn
|
| 19 |
redis>=4.0.0 # optional, for faster counters
|
tests/conftest.py
CHANGED
|
@@ -3,13 +3,11 @@ pytest configuration and fixtures for ARF API tests.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from app.core.usage_tracker import enforce_quota, Tier
|
| 6 |
-
from app.api.deps import get_db
|
| 7 |
from app.database.base import Base
|
| 8 |
-
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
|
| 9 |
from app.main import app as fastapi_app
|
| 10 |
from sqlalchemy.orm import sessionmaker
|
| 11 |
from sqlalchemy import create_engine
|
| 12 |
-
from fastapi import Request
|
| 13 |
from fastapi.testclient import TestClient
|
| 14 |
import app.core.usage_tracker
|
| 15 |
import os
|
|
@@ -18,10 +16,6 @@ import pytest
|
|
| 18 |
# ===== STEP 1: Set environment variables BEFORE any app imports =====
|
| 19 |
os.environ["ARF_USAGE_TRACKING"] = "false"
|
| 20 |
|
| 21 |
-
# UsageTracker now requires a real pepper to hash/verify API keys (H-2 fix);
|
| 22 |
-
# tests never touch a production key, so a fixed test-only value is fine.
|
| 23 |
-
os.environ.setdefault("ARF_KEY_PEPPER", "test-only-pepper-not-for-production-use-32chars")
|
| 24 |
-
|
| 25 |
# Force the correct database URL for tests
|
| 26 |
os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
| 27 |
os.environ["TEST_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
|
@@ -46,9 +40,6 @@ class MockTracker:
|
|
| 46 |
|
| 47 |
return 1000
|
| 48 |
|
| 49 |
-
def get_tenant_id(self, api_key):
|
| 50 |
-
return "test-tenant"
|
| 51 |
-
|
| 52 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 53 |
|
| 54 |
return (True, None)
|
|
@@ -107,19 +98,10 @@ fastapi_app.dependency_overrides[get_db] = override_get_db
|
|
| 107 |
# Override enforce_quota dependency
|
| 108 |
|
| 109 |
|
| 110 |
-
async def mock_enforce_quota(request
|
| 111 |
-
return {"api_key": "test_key", "tier": Tier.PRO, "
|
| 112 |
fastapi_app.dependency_overrides[enforce_quota] = mock_enforce_quota
|
| 113 |
|
| 114 |
-
# Override verify_internal_key: production fails closed when
|
| 115 |
-
# ARF_INTERNAL_API_KEY is unset, but tests exercise routes directly without
|
| 116 |
-
# the gateway-injected X-Internal-Key header.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
async def mock_verify_internal_key():
|
| 120 |
-
return None
|
| 121 |
-
fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key
|
| 122 |
-
|
| 123 |
|
| 124 |
@pytest.fixture(scope="session", autouse=True)
|
| 125 |
def setup_database():
|
|
@@ -137,15 +119,10 @@ def client():
|
|
| 137 |
|
| 138 |
@pytest.fixture(scope="function")
|
| 139 |
def db_session():
|
| 140 |
-
"""Provide a database session for each test.
|
| 141 |
-
|
| 142 |
-
Schema lifecycle is owned entirely by the session-scoped
|
| 143 |
-
`setup_database` fixture. Dropping tables here would blow away the
|
| 144 |
-
shared schema for any test that runs afterwards without itself
|
| 145 |
-
depending on `db_session` (e.g. tests that build their own bare
|
| 146 |
-
TestClient), leaving them with a database that has no tables at all.
|
| 147 |
-
"""
|
| 148 |
session = TestingSessionLocal()
|
| 149 |
yield session
|
| 150 |
session.rollback()
|
| 151 |
session.close()
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from app.core.usage_tracker import enforce_quota, Tier
|
| 6 |
+
from app.api.deps import get_db
|
| 7 |
from app.database.base import Base
|
|
|
|
| 8 |
from app.main import app as fastapi_app
|
| 9 |
from sqlalchemy.orm import sessionmaker
|
| 10 |
from sqlalchemy import create_engine
|
|
|
|
| 11 |
from fastapi.testclient import TestClient
|
| 12 |
import app.core.usage_tracker
|
| 13 |
import os
|
|
|
|
| 16 |
# ===== STEP 1: Set environment variables BEFORE any app imports =====
|
| 17 |
os.environ["ARF_USAGE_TRACKING"] = "false"
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# Force the correct database URL for tests
|
| 20 |
os.environ["DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
| 21 |
os.environ["TEST_DATABASE_URL"] = "postgresql://postgres:postgres@localhost:5432/testdb"
|
|
|
|
| 40 |
|
| 41 |
return 1000
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
def consume_quota_and_log(self, record, idempotency_key=None):
|
| 44 |
|
| 45 |
return (True, None)
|
|
|
|
| 98 |
# Override enforce_quota dependency
|
| 99 |
|
| 100 |
|
| 101 |
+
async def mock_enforce_quota(request, api_key=None):
|
| 102 |
+
return {"api_key": "test_key", "tier": Tier.PRO, "remaining": 1000}
|
| 103 |
fastapi_app.dependency_overrides[enforce_quota] = mock_enforce_quota
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
@pytest.fixture(scope="session", autouse=True)
|
| 107 |
def setup_database():
|
|
|
|
| 119 |
|
| 120 |
@pytest.fixture(scope="function")
|
| 121 |
def db_session():
|
| 122 |
+
"""Provide a clean database session for each test."""
|
| 123 |
+
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
session = TestingSessionLocal()
|
| 125 |
yield session
|
| 126 |
session.rollback()
|
| 127 |
session.close()
|
| 128 |
+
Base.metadata.drop_all(bind=engine)
|
tests/test_deps.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
| 1 |
-
import importlib
|
| 2 |
-
from unittest.mock import MagicMock, patch
|
| 3 |
-
|
| 4 |
import pytest
|
| 5 |
-
from
|
| 6 |
-
|
| 7 |
-
import app.api.deps as deps
|
| 8 |
from app.api.deps import get_db
|
| 9 |
|
| 10 |
|
|
@@ -18,72 +13,3 @@ def test_get_db_closes_session():
|
|
| 18 |
with pytest.raises(Exception):
|
| 19 |
db_gen.throw(Exception("test error"))
|
| 20 |
mock_session.close.assert_called_once()
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# verify_internal_key tests below are called directly, not through
|
| 24 |
-
# TestClient: tests/conftest.py globally overrides verify_internal_key
|
| 25 |
-
# (`fastapi_app.dependency_overrides[verify_internal_key] = mock_verify_internal_key`)
|
| 26 |
-
# so that already-protected routes (routes_governance.py) can be exercised
|
| 27 |
-
# in tests without the gateway-injected X-Internal-Key header. That override
|
| 28 |
-
# makes the real fail-closed behavior untestable through the app for any
|
| 29 |
-
# router that uses it -- this is the only place it's actually verified to
|
| 30 |
-
# reject what it should reject, rather than just trusted to work because
|
| 31 |
-
# it's wired in.
|
| 32 |
-
#
|
| 33 |
-
# Newly relevant as of the auth fix to routes_risk.py, routes_intents.py,
|
| 34 |
-
# routes_history.py, routes_memory.py (see docs/authentication.md) -- those
|
| 35 |
-
# four routers now depend on this function passing correctly.
|
| 36 |
-
|
| 37 |
-
_NEWLY_PROTECTED_ROUTER_MODULES = [
|
| 38 |
-
"app.api.routes_risk",
|
| 39 |
-
"app.api.routes_intents",
|
| 40 |
-
"app.api.routes_history",
|
| 41 |
-
"app.api.routes_memory",
|
| 42 |
-
]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@pytest.mark.asyncio
|
| 46 |
-
async def test_verify_internal_key_rejects_missing_header(monkeypatch):
|
| 47 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 48 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 49 |
-
await deps.verify_internal_key(x_internal_key=None)
|
| 50 |
-
assert exc_info.value.status_code == 401
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
@pytest.mark.asyncio
|
| 54 |
-
async def test_verify_internal_key_rejects_wrong_key(monkeypatch):
|
| 55 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 56 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 57 |
-
await deps.verify_internal_key(x_internal_key="wrong-key")
|
| 58 |
-
assert exc_info.value.status_code == 401
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@pytest.mark.asyncio
|
| 62 |
-
async def test_verify_internal_key_fails_closed_when_unset(monkeypatch):
|
| 63 |
-
"""The env var being unset must reject every request, not let them
|
| 64 |
-
through -- this is the specific property that makes this safe to add
|
| 65 |
-
to a router without also needing to guarantee the env var is always
|
| 66 |
-
set."""
|
| 67 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "")
|
| 68 |
-
with pytest.raises(HTTPException) as exc_info:
|
| 69 |
-
await deps.verify_internal_key(x_internal_key="anything")
|
| 70 |
-
assert exc_info.value.status_code == 401
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@pytest.mark.asyncio
|
| 74 |
-
async def test_verify_internal_key_accepts_correct_key(monkeypatch):
|
| 75 |
-
monkeypatch.setattr(deps, "INTERNAL_API_KEY", "real-secret")
|
| 76 |
-
result = await deps.verify_internal_key(x_internal_key="real-secret")
|
| 77 |
-
assert result is None
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
@pytest.mark.parametrize("router_module_name", _NEWLY_PROTECTED_ROUTER_MODULES)
|
| 81 |
-
def test_router_requires_verify_internal_key(router_module_name):
|
| 82 |
-
"""Structural check, independent of the function-level tests above:
|
| 83 |
-
proves each router actually declares verify_internal_key as a
|
| 84 |
-
router-level dependency, not just that the function itself works in
|
| 85 |
-
isolation. Mirrors the pattern routes_governance.py already uses
|
| 86 |
-
(`APIRouter(dependencies=[Depends(verify_internal_key)])`)."""
|
| 87 |
-
module = importlib.import_module(router_module_name)
|
| 88 |
-
dependency_callables = [d.dependency for d in module.router.dependencies]
|
| 89 |
-
assert deps.verify_internal_key in dependency_callables
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
|
| 3 |
from app.api.deps import get_db
|
| 4 |
|
| 5 |
|
|
|
|
| 13 |
with pytest.raises(Exception):
|
| 14 |
db_gen.throw(Exception("test error"))
|
| 15 |
mock_session.close.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_governance.py
CHANGED
|
@@ -1,21 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Tests for governance endpoints: /api/v1/intents/evaluate
|
| 3 |
"""
|
| 4 |
-
import tempfile
|
| 5 |
-
|
| 6 |
-
import pytest
|
| 7 |
-
import app.core.usage_tracker as usage_tracker_module
|
| 8 |
-
from app.core.usage_tracker import UsageTracker
|
| 9 |
-
from app.database.models_intents import TenantDB
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@pytest.fixture(autouse=True)
|
| 13 |
-
def seed_tenant(db_session):
|
| 14 |
-
"""Ensure the tenant 'test-tenant' exists before each test."""
|
| 15 |
-
tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first()
|
| 16 |
-
if not tenant:
|
| 17 |
-
db_session.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 18 |
-
db_session.commit()
|
| 19 |
|
| 20 |
|
| 21 |
def test_evaluate_provision_intent(client):
|
|
@@ -88,65 +73,3 @@ def test_invalid_intent_type(client):
|
|
| 88 |
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 89 |
headers={"X-Tenant-ID": "test-tenant"})
|
| 90 |
assert response.status_code == 422
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def test_evaluate_with_criticality(client):
|
| 94 |
-
"""v4.3.2: criticality is accepted and a context_hash is generated."""
|
| 95 |
-
payload = {
|
| 96 |
-
"intent_type": "provision_resource",
|
| 97 |
-
"environment": "prod",
|
| 98 |
-
"resource_type": "database",
|
| 99 |
-
"region": "eastus",
|
| 100 |
-
"size": "Standard",
|
| 101 |
-
"estimated_cost": 1200,
|
| 102 |
-
"policy_violations": [],
|
| 103 |
-
"requester": "alice",
|
| 104 |
-
"provenance": {},
|
| 105 |
-
"configuration": {},
|
| 106 |
-
"criticality": 0.85
|
| 107 |
-
}
|
| 108 |
-
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 109 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 110 |
-
assert response.status_code == 200, response.text
|
| 111 |
-
data = response.json()
|
| 112 |
-
assert "risk_score" in data
|
| 113 |
-
# The healing_intent dict should contain the new fields.
|
| 114 |
-
healing = data.get("healing_intent", {})
|
| 115 |
-
# criticality is passed through
|
| 116 |
-
assert healing.get("criticality") == 0.85
|
| 117 |
-
# context_hash is computed by the governance loop (a 64‑char hex string)
|
| 118 |
-
ctx_hash = healing.get("context_hash")
|
| 119 |
-
assert isinstance(ctx_hash, str) and len(ctx_hash) == 64
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def test_evaluate_intent_against_real_tracker_does_not_crash(client, monkeypatch):
|
| 123 |
-
"""Every other test in this file runs against tests/conftest.py's
|
| 124 |
-
MockTracker, whose consume_quota_and_log ignores record.tier entirely
|
| 125 |
-
and always returns (True, None) -- so none of them would have noticed
|
| 126 |
-
that this endpoint hardcoded tier=None instead of using quota["tier"]
|
| 127 |
-
(already resolved by the enforce_quota dependency). Against the real
|
| 128 |
-
UsageTracker, consume_quota_and_log evaluates tier.monthly_evaluation_limit
|
| 129 |
-
unconditionally, so a None tier raised AttributeError on every real
|
| 130 |
-
call, outside any try/except, surfacing as a raw 500. This test swaps
|
| 131 |
-
in a real UsageTracker (a throwaway SQLite file; the same real
|
| 132 |
-
Postgres the CI service provides backs api_keys/monthly_counts) to
|
| 133 |
-
prove the endpoint no longer crashes."""
|
| 134 |
-
with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
|
| 135 |
-
monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name))
|
| 136 |
-
|
| 137 |
-
payload = {
|
| 138 |
-
"intent_type": "provision_resource",
|
| 139 |
-
"environment": "prod",
|
| 140 |
-
"resource_type": "database",
|
| 141 |
-
"region": "eastus",
|
| 142 |
-
"size": "Standard",
|
| 143 |
-
"estimated_cost": 1200,
|
| 144 |
-
"policy_violations": [],
|
| 145 |
-
"requester": "alice",
|
| 146 |
-
"provenance": {},
|
| 147 |
-
"configuration": {}
|
| 148 |
-
}
|
| 149 |
-
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 150 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 151 |
-
assert response.status_code == 200, response.text
|
| 152 |
-
assert "risk_score" in response.json()
|
|
|
|
| 1 |
"""
|
| 2 |
Tests for governance endpoints: /api/v1/intents/evaluate
|
| 3 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def test_evaluate_provision_intent(client):
|
|
|
|
| 73 |
response = client.post("/api/v1/intents/evaluate", json=payload,
|
| 74 |
headers={"X-Tenant-ID": "test-tenant"})
|
| 75 |
assert response.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_healing_endpoint.py
CHANGED
|
@@ -1,9 +1,5 @@
|
|
| 1 |
-
import tempfile
|
| 2 |
-
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
-
import app.core.usage_tracker as usage_tracker_module
|
| 6 |
-
from app.core.usage_tracker import UsageTracker
|
| 7 |
|
| 8 |
client = TestClient(app)
|
| 9 |
|
|
@@ -22,27 +18,3 @@ def test_healing_evaluate_endpoint():
|
|
| 22 |
response = client.post("/api/v1/healing/evaluate", json=payload,
|
| 23 |
headers={"X-Tenant-ID": "test-tenant"})
|
| 24 |
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def test_healing_evaluate_against_real_tracker_does_not_crash(monkeypatch):
|
| 28 |
-
"""Same regression as test_governance.py's equivalent test, for this
|
| 29 |
-
endpoint's own hardcoded tier=None (routes_governance.py's
|
| 30 |
-
/healing/evaluate handler) -- see that test's docstring for the full
|
| 31 |
-
explanation. Swaps in a real UsageTracker to prove
|
| 32 |
-
consume_quota_and_log no longer crashes on a None tier here either."""
|
| 33 |
-
payload = {
|
| 34 |
-
"event": {
|
| 35 |
-
"component": "my-service",
|
| 36 |
-
"latency_p99": 450.0,
|
| 37 |
-
"error_rate": 0.25,
|
| 38 |
-
"service_mesh": "default",
|
| 39 |
-
"cpu_util": 0.85,
|
| 40 |
-
"memory_util": 0.90
|
| 41 |
-
}
|
| 42 |
-
}
|
| 43 |
-
with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
|
| 44 |
-
monkeypatch.setattr(usage_tracker_module, "tracker", UsageTracker(db_path=tmp.name))
|
| 45 |
-
|
| 46 |
-
response = client.post("/api/v1/healing/evaluate", json=payload,
|
| 47 |
-
headers={"X-Tenant-ID": "test-tenant"})
|
| 48 |
-
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi.testclient import TestClient
|
| 2 |
from app.main import app
|
|
|
|
|
|
|
| 3 |
|
| 4 |
client = TestClient(app)
|
| 5 |
|
|
|
|
| 18 |
response = client.post("/api/v1/healing/evaluate", json=payload,
|
| 19 |
headers={"X-Tenant-ID": "test-tenant"})
|
| 20 |
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_history.py
CHANGED
|
@@ -8,5 +8,9 @@ def test_history():
|
|
| 8 |
response = client.get("/api/v1/history")
|
| 9 |
assert response.status_code == 200
|
| 10 |
data = response.json()
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
response = client.get("/api/v1/history")
|
| 9 |
assert response.status_code == 200
|
| 10 |
data = response.json()
|
| 11 |
+
# The endpoint returns a list of risk points, not an object with an
|
| 12 |
+
# "incidents" key
|
| 13 |
+
assert isinstance(data, list)
|
| 14 |
+
if data: # if not empty, verify the structure of the first item
|
| 15 |
+
assert "risk" in data[0]
|
| 16 |
+
assert "time" in data[0]
|
tests/test_integration.py
DELETED
|
@@ -1,305 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
End‑to‑end integration tests for the ARF governance pipeline.
|
| 3 |
-
|
| 4 |
-
These tests exercise the full path from HTTP request to HealingIntent
|
| 5 |
-
response, validating that every layer – API, governance loop, policy
|
| 6 |
-
engine, risk engine, audit log, and optional skill/criticality features –
|
| 7 |
-
behaves correctly under realistic conditions.
|
| 8 |
-
|
| 9 |
-
v4.3.2: Covers basic evaluation, skill context, criticality, and audit
|
| 10 |
-
trace verification.
|
| 11 |
-
"""
|
| 12 |
-
import pytest
|
| 13 |
-
import time
|
| 14 |
-
from app.database.models_intents import TenantDB, DecisionAuditLogDB
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
@pytest.fixture(autouse=True)
|
| 18 |
-
def seed_tenant(db_session):
|
| 19 |
-
"""Ensure the tenant 'test-tenant' exists before each test."""
|
| 20 |
-
tenant = db_session.query(TenantDB).filter_by(id="test-tenant").first()
|
| 21 |
-
if not tenant:
|
| 22 |
-
db_session.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 23 |
-
db_session.commit()
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class TestFullPipeline:
|
| 27 |
-
"""End‑to‑end tests for the /intents/evaluate endpoint."""
|
| 28 |
-
|
| 29 |
-
def test_basic_provision_evaluation(self, client):
|
| 30 |
-
"""A minimal valid request returns 200 and a well‑formed HealingIntent."""
|
| 31 |
-
payload = {
|
| 32 |
-
"intent_type": "provision_resource",
|
| 33 |
-
"environment": "prod",
|
| 34 |
-
"resource_type": "database",
|
| 35 |
-
"region": "eastus",
|
| 36 |
-
"size": "Standard",
|
| 37 |
-
"estimated_cost": 1200,
|
| 38 |
-
"policy_violations": [],
|
| 39 |
-
"requester": "alice",
|
| 40 |
-
"provenance": {},
|
| 41 |
-
"configuration": {}
|
| 42 |
-
}
|
| 43 |
-
response = client.post(
|
| 44 |
-
"/api/v1/intents/evaluate",
|
| 45 |
-
json=payload,
|
| 46 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 47 |
-
)
|
| 48 |
-
assert response.status_code == 200, response.text
|
| 49 |
-
data = response.json()
|
| 50 |
-
# Top‑level fields
|
| 51 |
-
assert "risk_score" in data
|
| 52 |
-
assert "explanation" in data
|
| 53 |
-
assert "deterministic_id" in data
|
| 54 |
-
assert "recommended_action" in data
|
| 55 |
-
assert isinstance(data["risk_score"], float)
|
| 56 |
-
assert 0.0 <= data["risk_score"] <= 1.0
|
| 57 |
-
# HealingIntent contract
|
| 58 |
-
healing = data.get("healing_intent", {})
|
| 59 |
-
assert healing.get("action") is not None
|
| 60 |
-
assert healing.get("component") is not None
|
| 61 |
-
assert "justification" in healing
|
| 62 |
-
assert "confidence" in healing
|
| 63 |
-
assert "version" in healing
|
| 64 |
-
assert healing["version"] == "2.6.0"
|
| 65 |
-
# v4.3.2: context_hash must be present (64 hex chars)
|
| 66 |
-
ctx_hash = healing.get("context_hash")
|
| 67 |
-
assert isinstance(ctx_hash, str) and len(ctx_hash) == 64, (
|
| 68 |
-
f"context_hash missing or invalid: {ctx_hash}"
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
def test_audit_log_written(self, client, db_session):
|
| 72 |
-
"""A successful evaluation writes a row to the decision audit log.
|
| 73 |
-
|
| 74 |
-
deterministic_id is a hash of (action, component, parameters,
|
| 75 |
-
incident_id, oss_edition) only -- and provision_resource requests
|
| 76 |
-
have no field that varies component or parameters -- so a minimal
|
| 77 |
-
provision_resource payload here would collide with the identical
|
| 78 |
-
one in test_basic_provision_evaluation and hit the audit log's
|
| 79 |
-
idempotency skip. Checking for the specific row by the response's
|
| 80 |
-
own deterministic_id (rather than a tenant-wide count delta) tests
|
| 81 |
-
the actual claim -- "this decision got audited" -- without being
|
| 82 |
-
sensitive to what other tests already wrote for the same decision.
|
| 83 |
-
"""
|
| 84 |
-
payload = {
|
| 85 |
-
"intent_type": "provision_resource",
|
| 86 |
-
"environment": "prod",
|
| 87 |
-
"resource_type": "database",
|
| 88 |
-
"region": "eastus",
|
| 89 |
-
"size": "Standard",
|
| 90 |
-
"estimated_cost": 1200,
|
| 91 |
-
"policy_violations": [],
|
| 92 |
-
"requester": "alice",
|
| 93 |
-
"provenance": {},
|
| 94 |
-
"configuration": {}
|
| 95 |
-
}
|
| 96 |
-
response = client.post(
|
| 97 |
-
"/api/v1/intents/evaluate",
|
| 98 |
-
json=payload,
|
| 99 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 100 |
-
)
|
| 101 |
-
assert response.status_code == 200
|
| 102 |
-
deterministic_id = response.json()["deterministic_id"]
|
| 103 |
-
# The write_audit_log runs as a background task; give it a moment.
|
| 104 |
-
time.sleep(0.5)
|
| 105 |
-
entry = (
|
| 106 |
-
db_session.query(DecisionAuditLogDB)
|
| 107 |
-
.filter_by(tenant_id="test-tenant", deterministic_id=deterministic_id)
|
| 108 |
-
.first()
|
| 109 |
-
)
|
| 110 |
-
assert entry is not None, (
|
| 111 |
-
f"Expected an audit log entry for deterministic_id={deterministic_id}"
|
| 112 |
-
)
|
| 113 |
-
|
| 114 |
-
def test_skill_context_injection(self, client):
|
| 115 |
-
"""When skill_id is provided, the response includes skill posterior data."""
|
| 116 |
-
payload = {
|
| 117 |
-
"intent_type": "provision_resource",
|
| 118 |
-
"environment": "prod",
|
| 119 |
-
"resource_type": "database",
|
| 120 |
-
"region": "eastus",
|
| 121 |
-
"size": "Standard",
|
| 122 |
-
"estimated_cost": 1200,
|
| 123 |
-
"policy_violations": [],
|
| 124 |
-
"requester": "alice",
|
| 125 |
-
"provenance": {},
|
| 126 |
-
"configuration": {},
|
| 127 |
-
"skill_id": "pdf-skill",
|
| 128 |
-
}
|
| 129 |
-
response = client.post(
|
| 130 |
-
"/api/v1/intents/evaluate",
|
| 131 |
-
json=payload,
|
| 132 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 133 |
-
)
|
| 134 |
-
assert response.status_code == 200
|
| 135 |
-
healing = response.json().get("healing_intent", {})
|
| 136 |
-
# Skill fields should be present in the HealingIntent
|
| 137 |
-
assert "skill_id" in healing
|
| 138 |
-
assert healing["skill_id"] == "pdf-skill"
|
| 139 |
-
# Because the skill registry is a singleton, the skill may or may not
|
| 140 |
-
# already exist. In either case, the fields are populated with either
|
| 141 |
-
# the posterior or the default prior.
|
| 142 |
-
assert "skill_alpha" in healing
|
| 143 |
-
assert "skill_beta" in healing
|
| 144 |
-
assert "skill_reliability_score" in healing
|
| 145 |
-
assert "skill_version" in healing
|
| 146 |
-
|
| 147 |
-
def test_criticality_parameter(self, client):
|
| 148 |
-
"""The criticality field is accepted and flows into the HealingIntent."""
|
| 149 |
-
payload = {
|
| 150 |
-
"intent_type": "provision_resource",
|
| 151 |
-
"environment": "prod",
|
| 152 |
-
"resource_type": "database",
|
| 153 |
-
"region": "eastus",
|
| 154 |
-
"size": "Standard",
|
| 155 |
-
"estimated_cost": 1200,
|
| 156 |
-
"policy_violations": [],
|
| 157 |
-
"requester": "alice",
|
| 158 |
-
"provenance": {},
|
| 159 |
-
"configuration": {},
|
| 160 |
-
"criticality": 0.85,
|
| 161 |
-
}
|
| 162 |
-
response = client.post(
|
| 163 |
-
"/api/v1/intents/evaluate",
|
| 164 |
-
json=payload,
|
| 165 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 166 |
-
)
|
| 167 |
-
assert response.status_code == 200
|
| 168 |
-
healing = response.json().get("healing_intent", {})
|
| 169 |
-
assert healing.get("criticality") == 0.85, (
|
| 170 |
-
f"criticality should be 0.85, got {healing.get('criticality')}"
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
def test_policy_violation_denial(self, client):
|
| 174 |
-
"""An intent with a policy violation returns DENY."""
|
| 175 |
-
payload = {
|
| 176 |
-
"intent_type": "provision_resource",
|
| 177 |
-
"environment": "prod",
|
| 178 |
-
"resource_type": "database",
|
| 179 |
-
"region": "westus", # not in default allowed set
|
| 180 |
-
"size": "Standard",
|
| 181 |
-
"estimated_cost": 1200,
|
| 182 |
-
"policy_violations": ["Region 'westus' not allowed"], # pre‑computed
|
| 183 |
-
"requester": "alice",
|
| 184 |
-
"provenance": {},
|
| 185 |
-
"configuration": {}
|
| 186 |
-
}
|
| 187 |
-
response = client.post(
|
| 188 |
-
"/api/v1/intents/evaluate",
|
| 189 |
-
json=payload,
|
| 190 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 191 |
-
)
|
| 192 |
-
assert response.status_code == 200
|
| 193 |
-
data = response.json()
|
| 194 |
-
assert data.get("recommended_action") == "deny", (
|
| 195 |
-
f"Expected action=deny, got {data.get('recommended_action')}"
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
class TestHealingPipeline:
|
| 200 |
-
"""End‑to‑end tests for the /healing/evaluate endpoint."""
|
| 201 |
-
|
| 202 |
-
def test_basic_healing_evaluation(self, client):
|
| 203 |
-
"""A reliability event triggers candidate healing actions."""
|
| 204 |
-
payload = {
|
| 205 |
-
"event": {
|
| 206 |
-
"component": "checkout-service",
|
| 207 |
-
"latency_p99": 600.0,
|
| 208 |
-
"error_rate": 0.25,
|
| 209 |
-
"service_mesh": "default",
|
| 210 |
-
"cpu_util": 0.85,
|
| 211 |
-
"memory_util": 0.90,
|
| 212 |
-
}
|
| 213 |
-
}
|
| 214 |
-
response = client.post(
|
| 215 |
-
"/api/v1/healing/evaluate",
|
| 216 |
-
json=payload,
|
| 217 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 218 |
-
)
|
| 219 |
-
assert response.status_code == 200
|
| 220 |
-
data = response.json()
|
| 221 |
-
assert "selected_action" in data
|
| 222 |
-
assert data["selected_action"] != "NO_ACTION", (
|
| 223 |
-
"Expected at least one healing action to be triggered"
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
def test_healing_with_skill_context(self, client):
|
| 227 |
-
"""Skill context biases the healing decision utility."""
|
| 228 |
-
payload = {
|
| 229 |
-
"event": {
|
| 230 |
-
"component": "checkout-service",
|
| 231 |
-
"latency_p99": 600.0,
|
| 232 |
-
"error_rate": 0.25,
|
| 233 |
-
"service_mesh": "default",
|
| 234 |
-
"cpu_util": 0.85,
|
| 235 |
-
"memory_util": 0.90,
|
| 236 |
-
},
|
| 237 |
-
"skill_id": "pdf-skill",
|
| 238 |
-
"skill_version": 1,
|
| 239 |
-
}
|
| 240 |
-
response = client.post(
|
| 241 |
-
"/api/v1/healing/evaluate",
|
| 242 |
-
json=payload,
|
| 243 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 244 |
-
)
|
| 245 |
-
assert response.status_code == 200
|
| 246 |
-
data = response.json()
|
| 247 |
-
# The response should echo the skill context back
|
| 248 |
-
assert data.get("skill_id") == "pdf-skill"
|
| 249 |
-
assert data.get("skill_version") == 1
|
| 250 |
-
assert "selected_action" in data
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
class TestOutcomeRecording:
|
| 254 |
-
"""End‑to‑end tests for the /intents/outcome endpoint."""
|
| 255 |
-
|
| 256 |
-
def test_record_outcome_updates_risk_engine(self, client, db_session):
|
| 257 |
-
"""Recording a successful outcome for a previously evaluated intent
|
| 258 |
-
updates the conjugate posterior and skill registry."""
|
| 259 |
-
# Step 1: evaluate an intent to create a record
|
| 260 |
-
payload = {
|
| 261 |
-
"intent_type": "provision_resource",
|
| 262 |
-
"environment": "prod",
|
| 263 |
-
"resource_type": "database",
|
| 264 |
-
"region": "eastus",
|
| 265 |
-
"size": "Standard",
|
| 266 |
-
"estimated_cost": 1200,
|
| 267 |
-
"policy_violations": [],
|
| 268 |
-
"requester": "alice",
|
| 269 |
-
"provenance": {},
|
| 270 |
-
"configuration": {},
|
| 271 |
-
}
|
| 272 |
-
eval_resp = client.post(
|
| 273 |
-
"/api/v1/intents/evaluate",
|
| 274 |
-
json=payload,
|
| 275 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 276 |
-
)
|
| 277 |
-
assert eval_resp.status_code == 200
|
| 278 |
-
deterministic_id = eval_resp.json()["deterministic_id"]
|
| 279 |
-
|
| 280 |
-
# Step 2: record a successful outcome
|
| 281 |
-
outcome_payload = {
|
| 282 |
-
"deterministic_id": deterministic_id,
|
| 283 |
-
"success": True,
|
| 284 |
-
"recorded_by": "tester",
|
| 285 |
-
"notes": "integration test",
|
| 286 |
-
}
|
| 287 |
-
outcome_resp = client.post(
|
| 288 |
-
"/api/v1/intents/outcome",
|
| 289 |
-
json=outcome_payload,
|
| 290 |
-
headers={"X-Tenant-ID": "test-tenant"},
|
| 291 |
-
)
|
| 292 |
-
assert outcome_resp.status_code == 200
|
| 293 |
-
assert "outcome_id" in outcome_resp.json()
|
| 294 |
-
|
| 295 |
-
# Step 3: verify that the outcome row exists in the database
|
| 296 |
-
from app.database.models_intents import OutcomeDB
|
| 297 |
-
outcome = (
|
| 298 |
-
db_session.query(OutcomeDB)
|
| 299 |
-
.filter_by(idempotency_key=None) # we didn't send one
|
| 300 |
-
.order_by(OutcomeDB.id.desc())
|
| 301 |
-
.first()
|
| 302 |
-
)
|
| 303 |
-
assert outcome is not None
|
| 304 |
-
assert outcome.success is True
|
| 305 |
-
assert outcome.recorded_by == "tester"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_intent_store.py
CHANGED
|
@@ -21,11 +21,11 @@ def test_save_intent(db_session):
|
|
| 21 |
saved = save_evaluated_intent(
|
| 22 |
db=db_session,
|
| 23 |
deterministic_id=det_id,
|
| 24 |
-
tenant_id="test-tenant",
|
| 25 |
intent_type="ProvisionResourceIntent",
|
| 26 |
api_payload={"foo": "bar"},
|
| 27 |
oss_payload={"intent_type": "provision_resource"},
|
| 28 |
environment="prod",
|
|
|
|
| 29 |
risk_score=0.42,
|
| 30 |
)
|
| 31 |
assert saved.deterministic_id == det_id
|
|
@@ -38,9 +38,9 @@ def test_save_intent(db_session):
|
|
| 38 |
|
| 39 |
def test_update_existing_intent(db_session):
|
| 40 |
det_id = "intent_123"
|
| 41 |
-
#
|
| 42 |
-
save_evaluated_intent(db_session, det_id, "
|
| 43 |
-
updated = save_evaluated_intent(db_session, det_id, "
|
| 44 |
assert updated.risk_score == "0.7"
|
| 45 |
count = db_session.query(IntentDB).filter(
|
| 46 |
IntentDB.deterministic_id == det_id).count()
|
|
|
|
| 21 |
saved = save_evaluated_intent(
|
| 22 |
db=db_session,
|
| 23 |
deterministic_id=det_id,
|
|
|
|
| 24 |
intent_type="ProvisionResourceIntent",
|
| 25 |
api_payload={"foo": "bar"},
|
| 26 |
oss_payload={"intent_type": "provision_resource"},
|
| 27 |
environment="prod",
|
| 28 |
+
tenant_id="test-tenant",
|
| 29 |
risk_score=0.42,
|
| 30 |
)
|
| 31 |
assert saved.deterministic_id == det_id
|
|
|
|
| 38 |
|
| 39 |
def test_update_existing_intent(db_session):
|
| 40 |
det_id = "intent_123"
|
| 41 |
+
# tenant_id is the 7th positional argument, risk_score is the 8th
|
| 42 |
+
save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.5)
|
| 43 |
+
updated = save_evaluated_intent(db_session, det_id, "Type", {}, {}, "prod", "test-tenant", 0.7)
|
| 44 |
assert updated.risk_score == "0.7"
|
| 45 |
count = db_session.query(IntentDB).filter(
|
| 46 |
IntentDB.deterministic_id == det_id).count()
|
tests/test_outcome_service.py
CHANGED
|
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
|
|
| 4 |
from sqlalchemy import create_engine
|
| 5 |
from sqlalchemy.orm import sessionmaker
|
| 6 |
from app.database.base import Base
|
| 7 |
-
from app.database.models_intents import IntentDB
|
| 8 |
from app.services.outcome_service import record_outcome, OutcomeConflictError
|
| 9 |
from agentic_reliability_framework.core.governance.intents import (
|
| 10 |
ProvisionResourceIntent,
|
|
@@ -18,10 +18,6 @@ def db_session():
|
|
| 18 |
TestingSessionLocal = sessionmaker(bind=engine, future=True)
|
| 19 |
Base.metadata.create_all(bind=engine)
|
| 20 |
sess = TestingSessionLocal()
|
| 21 |
-
# Ensure a tenant exists for foreign key constraints
|
| 22 |
-
if not sess.query(TenantDB).filter_by(id="test-tenant").first():
|
| 23 |
-
sess.add(TenantDB(id="test-tenant", name="Test Tenant"))
|
| 24 |
-
sess.commit()
|
| 25 |
yield sess
|
| 26 |
sess.close()
|
| 27 |
|
|
@@ -46,7 +42,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 46 |
|
| 47 |
intent = IntentDB(
|
| 48 |
deterministic_id="intent_abc",
|
| 49 |
-
tenant_id="test-tenant", # <-- required
|
| 50 |
intent_type="ProvisionResourceIntent",
|
| 51 |
payload={},
|
| 52 |
oss_payload=oss_payload,
|
|
@@ -58,7 +53,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 58 |
|
| 59 |
outcome = record_outcome(
|
| 60 |
db=db_session,
|
| 61 |
-
tenant_id="test-tenant",
|
| 62 |
deterministic_id="intent_abc",
|
| 63 |
success=True,
|
| 64 |
recorded_by="tester",
|
|
@@ -75,7 +69,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 75 |
# call engine again
|
| 76 |
outcome2 = record_outcome(
|
| 77 |
db=db_session,
|
| 78 |
-
tenant_id="test-tenant",
|
| 79 |
deterministic_id="intent_abc",
|
| 80 |
success=True,
|
| 81 |
recorded_by="tester",
|
|
@@ -90,7 +83,6 @@ def test_record_outcome_creates_row_and_updates_engine(
|
|
| 90 |
def test_conflict_different_result(db_session, mock_risk_engine):
|
| 91 |
intent = IntentDB(
|
| 92 |
deterministic_id="intent_def",
|
| 93 |
-
tenant_id="test-tenant", # <-- required
|
| 94 |
intent_type="ProvisionResourceIntent",
|
| 95 |
payload={},
|
| 96 |
created_at=datetime.datetime.utcnow()
|
|
@@ -100,7 +92,6 @@ def test_conflict_different_result(db_session, mock_risk_engine):
|
|
| 100 |
|
| 101 |
record_outcome(
|
| 102 |
db_session,
|
| 103 |
-
"test-tenant",
|
| 104 |
"intent_def",
|
| 105 |
True,
|
| 106 |
None,
|
|
@@ -109,7 +100,6 @@ def test_conflict_different_result(db_session, mock_risk_engine):
|
|
| 109 |
with pytest.raises(OutcomeConflictError):
|
| 110 |
record_outcome(
|
| 111 |
db_session,
|
| 112 |
-
"test-tenant",
|
| 113 |
"intent_def",
|
| 114 |
False,
|
| 115 |
None,
|
|
@@ -121,7 +111,6 @@ def test_nonexistent_intent(db_session, mock_risk_engine):
|
|
| 121 |
with pytest.raises(ValueError):
|
| 122 |
record_outcome(
|
| 123 |
db_session,
|
| 124 |
-
"test-tenant",
|
| 125 |
"missing",
|
| 126 |
True,
|
| 127 |
None,
|
|
@@ -134,7 +123,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine(
|
|
| 134 |
# Create an intent with invalid oss_payload (missing required fields)
|
| 135 |
intent = IntentDB(
|
| 136 |
deterministic_id="intent_bad",
|
| 137 |
-
tenant_id="test-tenant", # <-- required
|
| 138 |
intent_type="ProvisionResourceIntent",
|
| 139 |
payload={},
|
| 140 |
oss_payload={"intent_type": "provision_resource"}, # missing fields
|
|
@@ -146,7 +134,6 @@ def test_record_outcome_reconstruction_failure_does_not_update_engine(
|
|
| 146 |
# This should NOT call risk_engine.update_outcome (no dummy fallback)
|
| 147 |
outcome = record_outcome(
|
| 148 |
db=db_session,
|
| 149 |
-
tenant_id="test-tenant",
|
| 150 |
deterministic_id="intent_bad",
|
| 151 |
success=True,
|
| 152 |
recorded_by="tester",
|
|
|
|
| 4 |
from sqlalchemy import create_engine
|
| 5 |
from sqlalchemy.orm import sessionmaker
|
| 6 |
from app.database.base import Base
|
| 7 |
+
from app.database.models_intents import IntentDB
|
| 8 |
from app.services.outcome_service import record_outcome, OutcomeConflictError
|
| 9 |
from agentic_reliability_framework.core.governance.intents import (
|
| 10 |
ProvisionResourceIntent,
|
|
|
|
| 18 |
TestingSessionLocal = sessionmaker(bind=engine, future=True)
|
| 19 |
Base.metadata.create_all(bind=engine)
|
| 20 |
sess = TestingSessionLocal()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
yield sess
|
| 22 |
sess.close()
|
| 23 |
|
|
|
|
| 42 |
|
| 43 |
intent = IntentDB(
|
| 44 |
deterministic_id="intent_abc",
|
|
|
|
| 45 |
intent_type="ProvisionResourceIntent",
|
| 46 |
payload={},
|
| 47 |
oss_payload=oss_payload,
|
|
|
|
| 53 |
|
| 54 |
outcome = record_outcome(
|
| 55 |
db=db_session,
|
|
|
|
| 56 |
deterministic_id="intent_abc",
|
| 57 |
success=True,
|
| 58 |
recorded_by="tester",
|
|
|
|
| 69 |
# call engine again
|
| 70 |
outcome2 = record_outcome(
|
| 71 |
db=db_session,
|
|
|
|
| 72 |
deterministic_id="intent_abc",
|
| 73 |
success=True,
|
| 74 |
recorded_by="tester",
|
|
|
|
| 83 |
def test_conflict_different_result(db_session, mock_risk_engine):
|
| 84 |
intent = IntentDB(
|
| 85 |
deterministic_id="intent_def",
|
|
|
|
| 86 |
intent_type="ProvisionResourceIntent",
|
| 87 |
payload={},
|
| 88 |
created_at=datetime.datetime.utcnow()
|
|
|
|
| 92 |
|
| 93 |
record_outcome(
|
| 94 |
db_session,
|
|
|
|
| 95 |
"intent_def",
|
| 96 |
True,
|
| 97 |
None,
|
|
|
|
| 100 |
with pytest.raises(OutcomeConflictError):
|
| 101 |
record_outcome(
|
| 102 |
db_session,
|
|
|
|
| 103 |
"intent_def",
|
| 104 |
False,
|
| 105 |
None,
|
|
|
|
| 111 |
with pytest.raises(ValueError):
|
| 112 |
record_outcome(
|
| 113 |
db_session,
|
|
|
|
| 114 |
"missing",
|
| 115 |
True,
|
| 116 |
None,
|
|
|
|
| 123 |
# Create an intent with invalid oss_payload (missing required fields)
|
| 124 |
intent = IntentDB(
|
| 125 |
deterministic_id="intent_bad",
|
|
|
|
| 126 |
intent_type="ProvisionResourceIntent",
|
| 127 |
payload={},
|
| 128 |
oss_payload={"intent_type": "provision_resource"}, # missing fields
|
|
|
|
| 134 |
# This should NOT call risk_engine.update_outcome (no dummy fallback)
|
| 135 |
outcome = record_outcome(
|
| 136 |
db=db_session,
|
|
|
|
| 137 |
deterministic_id="intent_bad",
|
| 138 |
success=True,
|
| 139 |
recorded_by="tester",
|
tests/test_payments.py
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from unittest.mock import patch, MagicMock
|
| 3 |
from fastapi.testclient import TestClient
|
| 4 |
from app.main import app
|
| 5 |
|
| 6 |
-
# Every Stripe call in this module is mocked -- nothing here reaches the
|
| 7 |
-
# network, and no real credentials are involved. This module used to skip
|
| 8 |
-
# entirely whenever STRIPE_SECRET_KEY was unset (always, in CI), so none of
|
| 9 |
-
# it ran; stripe.api_key is patched below instead of relying on env vars,
|
| 10 |
-
# since routes_payments.py reads it at import time.
|
| 11 |
client = TestClient(app)
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@pytest.fixture
|
|
@@ -23,13 +21,11 @@ def mock_stripe():
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 26 |
-
|
| 27 |
-
# time and checks `stripe.api_key`, so setenv here would be a no-op.
|
| 28 |
-
monkeypatch.setattr("stripe.api_key", None)
|
| 29 |
response = client.post(
|
| 30 |
"/api/v1/payments/create-checkout-session",
|
| 31 |
-
headers={"Authorization": "Bearer test_key"},
|
| 32 |
json={
|
|
|
|
| 33 |
"success_url": "https://example.com/success",
|
| 34 |
"cancel_url": "https://example.com/cancel"})
|
| 35 |
assert response.status_code == 500
|
|
@@ -40,13 +36,12 @@ def test_create_checkout_session_free_key(mock_stripe):
|
|
| 40 |
# Mock tracker.get_tier to return Tier.FREE
|
| 41 |
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 42 |
mock_tracker.get_tier.return_value = "free"
|
| 43 |
-
mock_tracker.get_tenant_id.return_value = "tenant_test_123"
|
| 44 |
mock_stripe.return_value = MagicMock(
|
| 45 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 46 |
response = client.post(
|
| 47 |
"/api/v1/payments/create-checkout-session",
|
| 48 |
-
headers={"Authorization": "Bearer test_key"},
|
| 49 |
json={
|
|
|
|
| 50 |
"success_url": "https://example.com/success",
|
| 51 |
"cancel_url": "https://example.com/cancel"})
|
| 52 |
assert response.status_code == 200
|
|
@@ -60,39 +55,10 @@ def test_create_checkout_session_pro_key():
|
|
| 60 |
mock_tracker.get_tier.return_value = "pro"
|
| 61 |
response = client.post(
|
| 62 |
"/api/v1/payments/create-checkout-session",
|
| 63 |
-
headers={"Authorization": "Bearer test_key"},
|
| 64 |
json={
|
|
|
|
| 65 |
"success_url": "https://example.com/success",
|
| 66 |
"cancel_url": "https://example.com/cancel"})
|
| 67 |
assert response.status_code == 400
|
| 68 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 69 |
"detail"]
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def test_create_checkout_session_requires_authentication(mock_stripe):
|
| 73 |
-
# Regression test: this endpoint used to take `api_key` as a plain JSON
|
| 74 |
-
# body field with no Depends() gate at all, so any caller could request
|
| 75 |
-
# a session for any tenant_id-bearing key string without proving they
|
| 76 |
-
# held it. No Authorization header (and no api_key in the body -- the
|
| 77 |
-
# field no longer exists on CheckoutRequest) must be rejected before
|
| 78 |
-
# ever reaching Stripe.
|
| 79 |
-
response = client.post(
|
| 80 |
-
"/api/v1/payments/create-checkout-session",
|
| 81 |
-
json={
|
| 82 |
-
"success_url": "https://example.com/success",
|
| 83 |
-
"cancel_url": "https://example.com/cancel"})
|
| 84 |
-
assert response.status_code == 401
|
| 85 |
-
mock_stripe.assert_not_called()
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def test_create_checkout_session_rejects_invalid_key(mock_stripe):
|
| 89 |
-
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 90 |
-
mock_tracker.get_tier.return_value = None
|
| 91 |
-
response = client.post(
|
| 92 |
-
"/api/v1/payments/create-checkout-session",
|
| 93 |
-
headers={"Authorization": "Bearer not-a-real-key"},
|
| 94 |
-
json={
|
| 95 |
-
"success_url": "https://example.com/success",
|
| 96 |
-
"cancel_url": "https://example.com/cancel"})
|
| 97 |
-
assert response.status_code == 403
|
| 98 |
-
mock_stripe.assert_not_called()
|
|
|
|
| 1 |
+
import os
|
| 2 |
import pytest
|
| 3 |
from unittest.mock import patch, MagicMock
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
from app.main import app
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
client = TestClient(app)
|
| 8 |
|
| 9 |
+
# Skip all tests in this module if Stripe secret key is not set
|
| 10 |
+
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
| 11 |
+
if not STRIPE_SECRET_KEY:
|
| 12 |
+
pytest.skip(
|
| 13 |
+
"Stripe not configured – skipping payment tests",
|
| 14 |
+
allow_module_level=True)
|
| 15 |
|
| 16 |
|
| 17 |
@pytest.fixture
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def test_create_checkout_session_missing_stripe_key(monkeypatch):
|
| 24 |
+
monkeypatch.setenv("STRIPE_SECRET_KEY", "")
|
|
|
|
|
|
|
| 25 |
response = client.post(
|
| 26 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 27 |
json={
|
| 28 |
+
"api_key": "test_key",
|
| 29 |
"success_url": "https://example.com/success",
|
| 30 |
"cancel_url": "https://example.com/cancel"})
|
| 31 |
assert response.status_code == 500
|
|
|
|
| 36 |
# Mock tracker.get_tier to return Tier.FREE
|
| 37 |
with patch("app.core.usage_tracker.tracker") as mock_tracker:
|
| 38 |
mock_tracker.get_tier.return_value = "free"
|
|
|
|
| 39 |
mock_stripe.return_value = MagicMock(
|
| 40 |
id="cs_test_123", url="https://checkout.stripe.com/pay")
|
| 41 |
response = client.post(
|
| 42 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 43 |
json={
|
| 44 |
+
"api_key": "test_key",
|
| 45 |
"success_url": "https://example.com/success",
|
| 46 |
"cancel_url": "https://example.com/cancel"})
|
| 47 |
assert response.status_code == 200
|
|
|
|
| 55 |
mock_tracker.get_tier.return_value = "pro"
|
| 56 |
response = client.post(
|
| 57 |
"/api/v1/payments/create-checkout-session",
|
|
|
|
| 58 |
json={
|
| 59 |
+
"api_key": "test_key",
|
| 60 |
"success_url": "https://example.com/success",
|
| 61 |
"cancel_url": "https://example.com/cancel"})
|
| 62 |
assert response.status_code == 400
|
| 63 |
assert "Only free tier keys can be upgraded" in response.json()[
|
| 64 |
"detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_performance.py
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Performance benchmarks for the ARF governance pipeline.
|
| 3 |
-
|
| 4 |
-
These tests measure the latency of key operations and assert that
|
| 5 |
-
they remain within the target thresholds for pilot readiness.
|
| 6 |
-
|
| 7 |
-
Targets (v4.3.2):
|
| 8 |
-
- Full governance loop (single intent): p50 < 50 ms, p99 < 100 ms
|
| 9 |
-
- Policy evaluation alone: p50 < 1 ms
|
| 10 |
-
- Conjugate update: p50 < 0.1 ms
|
| 11 |
-
- HealingIntent serialization: p50 < 5 ms
|
| 12 |
-
"""
|
| 13 |
-
import time
|
| 14 |
-
import pytest
|
| 15 |
-
import numpy as np
|
| 16 |
-
|
| 17 |
-
from agentic_reliability_framework.core.governance.governance_loop import GovernanceLoop
|
| 18 |
-
from agentic_reliability_framework.core.governance.intents import (
|
| 19 |
-
ProvisionResourceIntent,
|
| 20 |
-
ResourceType,
|
| 21 |
-
)
|
| 22 |
-
from agentic_reliability_framework.core.governance.policies import PolicyEvaluator, allow_all
|
| 23 |
-
from agentic_reliability_framework.core.governance.cost_estimator import CostEstimator
|
| 24 |
-
from agentic_reliability_framework.core.governance.risk_engine import RiskEngine
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
# Number of warmup iterations and measured iterations
|
| 28 |
-
WARMUP = 10
|
| 29 |
-
MEASURED = 50
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _measure_latency(fn, *args, **kwargs):
|
| 33 |
-
"""Run fn MEASURED times after WARMUP warmups, return (p50, p99, p100) in seconds."""
|
| 34 |
-
times = []
|
| 35 |
-
for _ in range(WARMUP):
|
| 36 |
-
fn(*args, **kwargs)
|
| 37 |
-
for _ in range(MEASURED):
|
| 38 |
-
t0 = time.perf_counter()
|
| 39 |
-
fn(*args, **kwargs)
|
| 40 |
-
times.append(time.perf_counter() - t0)
|
| 41 |
-
arr = np.array(times) * 1000 # convert to milliseconds
|
| 42 |
-
return np.percentile(arr, 50), np.percentile(arr, 99), arr.max()
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
@pytest.fixture(scope="module")
|
| 46 |
-
def sample_intent():
|
| 47 |
-
return ProvisionResourceIntent(
|
| 48 |
-
resource_type=ResourceType.VM,
|
| 49 |
-
region="eastus",
|
| 50 |
-
size="Standard_D2s_v3",
|
| 51 |
-
requester="perf-test",
|
| 52 |
-
environment="dev",
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
@pytest.fixture(scope="module")
|
| 57 |
-
def governance_loop():
|
| 58 |
-
return GovernanceLoop(
|
| 59 |
-
policy_evaluator=PolicyEvaluator(allow_all()),
|
| 60 |
-
cost_estimator=CostEstimator(),
|
| 61 |
-
risk_engine=RiskEngine(),
|
| 62 |
-
enable_epistemic=False,
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class TestGovernanceLoopPerformance:
|
| 67 |
-
"""Latency benchmarks for the full governance loop."""
|
| 68 |
-
|
| 69 |
-
def test_full_loop_latency(self, governance_loop, sample_intent):
|
| 70 |
-
"""The full loop should complete within 100 ms at p99."""
|
| 71 |
-
p50, p99, p100 = _measure_latency(
|
| 72 |
-
governance_loop.run, sample_intent, context={"service_name": "perf-svc"}
|
| 73 |
-
)
|
| 74 |
-
assert p50 < 100, f"p50 latency {p50:.1f} ms exceeds 100 ms target"
|
| 75 |
-
assert p99 < 200, f"p99 latency {p99:.1f} ms exceeds 200 ms target"
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class TestHealingIntentSerialization:
|
| 79 |
-
"""Serialization performance."""
|
| 80 |
-
|
| 81 |
-
def test_to_enterprise_request_latency(self, governance_loop, sample_intent):
|
| 82 |
-
"""Serializing a HealingIntent to the enterprise request dict should be fast."""
|
| 83 |
-
intent = governance_loop.run(sample_intent, context={"service_name": "perf-svc"})
|
| 84 |
-
p50, p99, p100 = _measure_latency(intent.to_enterprise_request)
|
| 85 |
-
assert p50 < 10, f"p50 serialization latency {p50:.1f} ms exceeds 10 ms target"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class TestRiskEnginePerformance:
|
| 89 |
-
"""Conjugate update latency."""
|
| 90 |
-
|
| 91 |
-
def test_risk_calculation_latency(self, governance_loop, sample_intent):
|
| 92 |
-
"""A single risk calculation should be sub‑millisecond."""
|
| 93 |
-
engine = governance_loop.risk_engine
|
| 94 |
-
p50, p99, p100 = _measure_latency(
|
| 95 |
-
engine.calculate_risk,
|
| 96 |
-
intent=sample_intent,
|
| 97 |
-
cost_estimate=None,
|
| 98 |
-
policy_violations=[],
|
| 99 |
-
)
|
| 100 |
-
assert p50 < 10, f"p50 risk calculation latency {p50:.1f} ms exceeds 10 ms target"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_risk.py
CHANGED
|
@@ -31,8 +31,4 @@ def test_get_risk_internal_error(client, monkeypatch):
|
|
| 31 |
"X-API-Key": "test-key"})
|
| 32 |
assert response.status_code == 500
|
| 33 |
data = response.json()
|
| 34 |
-
|
| 35 |
-
# logs the real exception server-side and returns a generic detail
|
| 36 |
-
# instead (information-disclosure fix from this session's audit).
|
| 37 |
-
assert data.get("detail") == "Internal server error"
|
| 38 |
-
assert "test error" not in data.get("detail", "")
|
|
|
|
| 31 |
"X-API-Key": "test-key"})
|
| 32 |
assert response.status_code == 500
|
| 33 |
data = response.json()
|
| 34 |
+
assert "test error" in data.get("detail", "")
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_routes_admin.py
DELETED
|
@@ -1,126 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Route-level tests for the /admin/keys endpoints against the real,
|
| 3 |
-
Postgres-backed UsageTracker -- these routes are normally exercised through
|
| 4 |
-
the module-level `tracker` singleton (conftest.py replaces it globally with
|
| 5 |
-
MockTracker for every other test), so `tracker` is patched on
|
| 6 |
-
`app.core.usage_tracker` and `ADMIN_API_KEY` on `app.api.routes_admin` for
|
| 7 |
-
the duration of this module.
|
| 8 |
-
|
| 9 |
-
`tracker` is patched on the *defining* module, not on routes_admin, because
|
| 10 |
-
routes_admin no longer holds its own binding: it reaches the singleton
|
| 11 |
-
through `usage_tracker.tracker` so that init_tracker()'s rebinding is
|
| 12 |
-
visible to it. That is also what makes one patch here sufficient where five
|
| 13 |
-
would otherwise be needed.
|
| 14 |
-
|
| 15 |
-
This is the main regression coverage for the api_keys-on-Postgres
|
| 16 |
-
migration: it exercises the exact raw SQL routes_admin.py runs against
|
| 17 |
-
`usage_tracker.tracker._get_pg_conn()`.
|
| 18 |
-
"""
|
| 19 |
-
import hashlib
|
| 20 |
-
import hmac
|
| 21 |
-
import os
|
| 22 |
-
|
| 23 |
-
import pytest
|
| 24 |
-
|
| 25 |
-
from app.core import usage_tracker
|
| 26 |
-
from app.core.usage_tracker import UsageTracker
|
| 27 |
-
import app.api.routes_admin as routes_admin
|
| 28 |
-
|
| 29 |
-
TEST_ADMIN_KEY = "test-admin-key-for-routes-admin-tests"
|
| 30 |
-
TEST_PEPPER = os.environ["ARF_KEY_PEPPER"] # set in conftest.py before app import
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _key_id(raw_key: str) -> str:
|
| 34 |
-
"""Reproduce UsageTracker._lookup_hash without a tracker instance, so
|
| 35 |
-
tests can locate the row created for a given raw key deterministically."""
|
| 36 |
-
return hmac.new(TEST_PEPPER.encode(), raw_key.encode(), hashlib.sha256).hexdigest()
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@pytest.fixture(autouse=True)
|
| 40 |
-
def real_tracker(monkeypatch):
|
| 41 |
-
real = UsageTracker(db_path=":memory:")
|
| 42 |
-
monkeypatch.setattr(usage_tracker, "tracker", real)
|
| 43 |
-
monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
|
| 44 |
-
yield real
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_create_list_update_deactivate_key(client):
|
| 48 |
-
create_resp = client.post(
|
| 49 |
-
"/api/v1/admin/keys",
|
| 50 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 51 |
-
json={"tier": "free", "org_name": "Test Org"},
|
| 52 |
-
)
|
| 53 |
-
assert create_resp.status_code == 200
|
| 54 |
-
body = create_resp.json()
|
| 55 |
-
api_key = body["api_key"]
|
| 56 |
-
assert body["tier"] == "free"
|
| 57 |
-
key_id = _key_id(api_key)
|
| 58 |
-
|
| 59 |
-
list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 60 |
-
assert list_resp.status_code == 200
|
| 61 |
-
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 62 |
-
assert key_id in keys_by_id
|
| 63 |
-
assert keys_by_id[key_id]["tier"] == "free"
|
| 64 |
-
assert keys_by_id[key_id]["is_active"] is True
|
| 65 |
-
|
| 66 |
-
patch_resp = client.patch(
|
| 67 |
-
f"/api/v1/admin/keys/{key_id}/tier",
|
| 68 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 69 |
-
json={"tier": "pro"},
|
| 70 |
-
)
|
| 71 |
-
assert patch_resp.status_code == 200
|
| 72 |
-
|
| 73 |
-
list_resp2 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 74 |
-
assert list_resp2.json()["keys"][0] # non-empty, sanity check
|
| 75 |
-
keys_by_id2 = {row["key_id"]: row for row in list_resp2.json()["keys"]}
|
| 76 |
-
assert keys_by_id2[key_id]["tier"] == "pro"
|
| 77 |
-
|
| 78 |
-
delete_resp = client.delete(f"/api/v1/admin/keys/{key_id}", params={"admin_key": TEST_ADMIN_KEY})
|
| 79 |
-
assert delete_resp.status_code == 200
|
| 80 |
-
|
| 81 |
-
list_resp3 = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 82 |
-
keys_by_id3 = {row["key_id"]: row for row in list_resp3.json()["keys"]}
|
| 83 |
-
assert keys_by_id3[key_id]["is_active"] is False
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def test_update_nonexistent_key_returns_404(client):
|
| 87 |
-
resp = client.patch(
|
| 88 |
-
"/api/v1/admin/keys/does-not-exist/tier",
|
| 89 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 90 |
-
json={"tier": "pro"},
|
| 91 |
-
)
|
| 92 |
-
assert resp.status_code == 404
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def test_rotate_key_deactivates_old_and_creates_new_on_same_tenant(client):
|
| 96 |
-
create_resp = client.post(
|
| 97 |
-
"/api/v1/admin/keys",
|
| 98 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 99 |
-
json={"tier": "pro", "org_name": "Rotate Test Org"},
|
| 100 |
-
)
|
| 101 |
-
assert create_resp.status_code == 200
|
| 102 |
-
old_body = create_resp.json()
|
| 103 |
-
old_key_id = _key_id(old_body["api_key"])
|
| 104 |
-
tenant_id = old_body["tenant_id"]
|
| 105 |
-
|
| 106 |
-
rotate_resp = client.post(
|
| 107 |
-
f"/api/v1/admin/keys/{old_key_id}/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 108 |
-
assert rotate_resp.status_code == 200
|
| 109 |
-
rotated = rotate_resp.json()
|
| 110 |
-
assert rotated["tenant_id"] == tenant_id
|
| 111 |
-
assert rotated["tier"] == "pro"
|
| 112 |
-
assert rotated["deactivated_key_id"] == old_key_id
|
| 113 |
-
new_key_id = _key_id(rotated["api_key"])
|
| 114 |
-
assert new_key_id != old_key_id
|
| 115 |
-
|
| 116 |
-
list_resp = client.get("/api/v1/admin/keys", params={"admin_key": TEST_ADMIN_KEY})
|
| 117 |
-
keys_by_id = {row["key_id"]: row for row in list_resp.json()["keys"]}
|
| 118 |
-
assert keys_by_id[old_key_id]["is_active"] is False
|
| 119 |
-
assert keys_by_id[new_key_id]["is_active"] is True
|
| 120 |
-
assert keys_by_id[new_key_id]["tier"] == "pro"
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def test_rotate_nonexistent_key_returns_404(client):
|
| 124 |
-
resp = client.post(
|
| 125 |
-
"/api/v1/admin/keys/does-not-exist/rotate", params={"admin_key": TEST_ADMIN_KEY})
|
| 126 |
-
assert resp.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_routes_governance_execute.py
DELETED
|
@@ -1,352 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Tests for POST /intents/{id}/execute and POST /admin/executions/{id}/resolve.
|
| 3 |
-
|
| 4 |
-
arf_enterprise is not installed in this test environment (by design -- it's
|
| 5 |
-
an optional, proprietary package; see routes_governance.py's
|
| 6 |
-
ENTERPRISE_EXECUTOR_AVAILABLE guard). The 501 "not available"/"not enabled"
|
| 7 |
-
paths are real, unconditional behavior and tested as such. For the
|
| 8 |
-
success/pending/error paths, the enterprise classes referenced in
|
| 9 |
-
routes_governance.py (EnterpriseExecutor, FakeCloudActuator,
|
| 10 |
-
PendingApprovalError, EnterpriseExecutionError, EnterpriseSafetyError) are
|
| 11 |
-
monkeypatched with lightweight stand-ins -- this tests arf-api's own glue
|
| 12 |
-
code (existence/tenant check, exception-to-HTTP-status mapping, response
|
| 13 |
-
shaping), not EnterpriseExecutor's internals, which are already covered by
|
| 14 |
-
the enterprise repo's own test suite.
|
| 15 |
-
"""
|
| 16 |
-
import pytest
|
| 17 |
-
from app.database.models_intents import TenantDB
|
| 18 |
-
import app.api.routes_governance as routes_governance
|
| 19 |
-
import app.api.routes_admin as routes_admin
|
| 20 |
-
|
| 21 |
-
TENANT_ID = "test-tenant"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
@pytest.fixture(autouse=True)
|
| 25 |
-
def seed_tenant(db_session):
|
| 26 |
-
tenant = db_session.query(TenantDB).filter_by(id=TENANT_ID).first()
|
| 27 |
-
if not tenant:
|
| 28 |
-
db_session.add(TenantDB(id=TENANT_ID, name="Test Tenant"))
|
| 29 |
-
db_session.commit()
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _evaluate_intent(client):
|
| 33 |
-
payload = {
|
| 34 |
-
"intent_type": "provision_resource",
|
| 35 |
-
"environment": "prod",
|
| 36 |
-
"resource_type": "database",
|
| 37 |
-
"region": "eastus",
|
| 38 |
-
"size": "Standard",
|
| 39 |
-
"estimated_cost": 1200,
|
| 40 |
-
"policy_violations": [],
|
| 41 |
-
"requester": "alice",
|
| 42 |
-
"provenance": {},
|
| 43 |
-
"configuration": {},
|
| 44 |
-
}
|
| 45 |
-
resp = client.post(
|
| 46 |
-
"/api/v1/intents/evaluate", json=payload, headers={"X-Tenant-ID": TENANT_ID}
|
| 47 |
-
)
|
| 48 |
-
assert resp.status_code == 200, resp.text
|
| 49 |
-
data = resp.json()
|
| 50 |
-
# data["intent_id"] (top level, set by evaluate_intent_endpoint right
|
| 51 |
-
# before returning: result["intent_id"] = deterministic_id) is what
|
| 52 |
-
# save_evaluated_intent actually persisted to IntentDB.deterministic_id.
|
| 53 |
-
# data["healing_intent"]["intent_id"] is a separate, independently
|
| 54 |
-
# generated id belonging to the HealingIntent object itself -- using it
|
| 55 |
-
# here instead was the exact bug that made every test in this file 404.
|
| 56 |
-
return data["intent_id"], data["healing_intent"]
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class _FakePendingApprovalError(Exception):
|
| 60 |
-
def __init__(self, message, level, approval_required, approval_id=None):
|
| 61 |
-
super().__init__(message)
|
| 62 |
-
self.level = level
|
| 63 |
-
self.approval_required = approval_required
|
| 64 |
-
self.approval_id = approval_id
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
class _FakeExecutionError(Exception):
|
| 68 |
-
pass
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
class _FakeSafetyError(Exception):
|
| 72 |
-
pass
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class _FakeConfig:
|
| 76 |
-
"""Stands in for EnterpriseConfig, which is None in the import fallback
|
| 77 |
-
whenever arf_enterprise isn't installed -- as it isn't in CI, since it's
|
| 78 |
-
a private-repo package deliberately kept out of requirements.txt.
|
| 79 |
-
|
| 80 |
-
Records what it was constructed with so a test can assert that
|
| 81 |
-
ARF_TRUSTED_SIGNING_KEYS actually reaches the executor. Without that
|
| 82 |
-
the trust store could silently go back to empty and every signed intent
|
| 83 |
-
would be rejected as "Untrusted signing key" with nothing failing here.
|
| 84 |
-
"""
|
| 85 |
-
last_trusted_signing_keys = None
|
| 86 |
-
|
| 87 |
-
def __init__(self, trusted_signing_keys=None, **kwargs):
|
| 88 |
-
self.trusted_signing_keys = trusted_signing_keys
|
| 89 |
-
type(self).last_trusted_signing_keys = trusted_signing_keys
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
class _FakeExecutor:
|
| 93 |
-
"""Stands in for EnterpriseExecutor. Behavior is selected via a
|
| 94 |
-
class-level `mode` set by each test before the request is made."""
|
| 95 |
-
mode = "success"
|
| 96 |
-
last_config = None
|
| 97 |
-
|
| 98 |
-
def __init__(self, config=None, actuator=None, approval_store=None,
|
| 99 |
-
on_verified_outcome=None):
|
| 100 |
-
self._on_verified_outcome = on_verified_outcome
|
| 101 |
-
type(self).last_config = config
|
| 102 |
-
|
| 103 |
-
async def execute(self, intent, human_approved=False, admin_approved=False):
|
| 104 |
-
if self.mode == "success":
|
| 105 |
-
if self._on_verified_outcome:
|
| 106 |
-
self._on_verified_outcome(intent, True, {"observed": {"status": "running"}})
|
| 107 |
-
return {"status": "success", "verified": {"status": "running"}, "compensating_action": None}
|
| 108 |
-
if self.mode == "pending":
|
| 109 |
-
raise _FakePendingApprovalError(
|
| 110 |
-
"needs human approval", level="HumanInLoop", approval_required="human",
|
| 111 |
-
approval_id="appr_test123",
|
| 112 |
-
)
|
| 113 |
-
if self.mode == "execution_error":
|
| 114 |
-
raise _FakeExecutionError("ladder denied this intent")
|
| 115 |
-
if self.mode == "safety_error":
|
| 116 |
-
raise _FakeSafetyError("blast radius exceeded")
|
| 117 |
-
raise RuntimeError(f"unhandled test mode: {self.mode}")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
@pytest.fixture
|
| 121 |
-
def enterprise_execution_enabled(monkeypatch):
|
| 122 |
-
monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
|
| 123 |
-
monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", True)
|
| 124 |
-
monkeypatch.setattr(routes_governance, "EnterpriseExecutor", _FakeExecutor)
|
| 125 |
-
monkeypatch.setattr(routes_governance, "EnterpriseConfig", _FakeConfig)
|
| 126 |
-
monkeypatch.setattr(routes_governance, "FakeCloudActuator", lambda: None)
|
| 127 |
-
monkeypatch.setattr(routes_governance, "PendingApprovalError", _FakePendingApprovalError)
|
| 128 |
-
monkeypatch.setattr(routes_governance, "EnterpriseExecutionError", _FakeExecutionError)
|
| 129 |
-
monkeypatch.setattr(routes_governance, "EnterpriseSafetyError", _FakeSafetyError)
|
| 130 |
-
_FakeExecutor.mode = "success"
|
| 131 |
-
yield
|
| 132 |
-
_FakeExecutor.mode = "success"
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def test_execute_returns_501_when_enterprise_package_not_available(client):
|
| 136 |
-
resp = client.post(
|
| 137 |
-
"/api/v1/intents/does-not-matter/execute",
|
| 138 |
-
json={"healing_intent": {}},
|
| 139 |
-
)
|
| 140 |
-
assert resp.status_code == 501
|
| 141 |
-
assert "not installed" in resp.json()["detail"]
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
def test_execute_returns_501_when_not_enabled(client, monkeypatch):
|
| 145 |
-
monkeypatch.setattr(routes_governance, "ENTERPRISE_EXECUTOR_AVAILABLE", True)
|
| 146 |
-
monkeypatch.setattr(routes_governance, "ARF_ENABLE_EXECUTION", False)
|
| 147 |
-
resp = client.post(
|
| 148 |
-
"/api/v1/intents/does-not-matter/execute",
|
| 149 |
-
json={"healing_intent": {}},
|
| 150 |
-
)
|
| 151 |
-
assert resp.status_code == 501
|
| 152 |
-
assert "not enabled" in resp.json()["detail"]
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def test_execute_returns_404_for_unknown_intent(client, enterprise_execution_enabled):
|
| 156 |
-
resp = client.post(
|
| 157 |
-
"/api/v1/intents/does-not-exist-at-all/execute",
|
| 158 |
-
json={"healing_intent": {}},
|
| 159 |
-
)
|
| 160 |
-
assert resp.status_code == 404
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
def test_execute_success_path(client, enterprise_execution_enabled):
|
| 164 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 165 |
-
resp = client.post(
|
| 166 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 167 |
-
json={"healing_intent": healing_intent, "human_approved": True},
|
| 168 |
-
)
|
| 169 |
-
assert resp.status_code == 200, resp.text
|
| 170 |
-
assert resp.json()["status"] == "success"
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def test_trusted_signing_keys_reach_the_executor_split_not_raw(
|
| 174 |
-
client, enterprise_execution_enabled, monkeypatch
|
| 175 |
-
):
|
| 176 |
-
"""ARF_TRUSTED_SIGNING_KEYS holds N comma-separated fingerprints.
|
| 177 |
-
Passing the raw string through as a one-element list would register the
|
| 178 |
-
literal "abc,def" as a single key -- trusting neither real one -- and
|
| 179 |
-
would look identical from the outside, since both spellings produce a
|
| 180 |
-
non-empty list and a 200 here."""
|
| 181 |
-
monkeypatch.setenv("ARF_TRUSTED_SIGNING_KEYS", " abc123 , def456 ,, ")
|
| 182 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 183 |
-
resp = client.post(
|
| 184 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 185 |
-
json={"healing_intent": healing_intent, "human_approved": True},
|
| 186 |
-
)
|
| 187 |
-
assert resp.status_code == 200, resp.text
|
| 188 |
-
assert _FakeConfig.last_trusted_signing_keys == ["abc123", "def456"]
|
| 189 |
-
assert _FakeExecutor.last_config is not None
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def test_unset_trusted_signing_keys_trusts_nothing_rather_than_the_empty_string(
|
| 193 |
-
client, enterprise_execution_enabled, monkeypatch
|
| 194 |
-
):
|
| 195 |
-
"""Fail closed. `"".split(",")` is `[""]`, so the naive parse would
|
| 196 |
-
register the empty string as a trusted fingerprint -- trusting a key
|
| 197 |
-
nobody holds is harmless, but it makes "trusts nothing" and
|
| 198 |
-
"misconfigured" indistinguishable in the logs."""
|
| 199 |
-
monkeypatch.delenv("ARF_TRUSTED_SIGNING_KEYS", raising=False)
|
| 200 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 201 |
-
resp = client.post(
|
| 202 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 203 |
-
json={"healing_intent": healing_intent, "human_approved": True},
|
| 204 |
-
)
|
| 205 |
-
assert resp.status_code == 200, resp.text
|
| 206 |
-
assert _FakeConfig.last_trusted_signing_keys == []
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def test_execute_pending_approval_returns_202_with_approval_id(client, enterprise_execution_enabled):
|
| 210 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 211 |
-
_FakeExecutor.mode = "pending"
|
| 212 |
-
resp = client.post(
|
| 213 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 214 |
-
json={"healing_intent": healing_intent},
|
| 215 |
-
)
|
| 216 |
-
assert resp.status_code == 202
|
| 217 |
-
body = resp.json()
|
| 218 |
-
assert body["approval_id"] == "appr_test123"
|
| 219 |
-
assert body["level"] == "HumanInLoop"
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
def test_execute_execution_error_returns_422(client, enterprise_execution_enabled):
|
| 223 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 224 |
-
_FakeExecutor.mode = "execution_error"
|
| 225 |
-
resp = client.post(
|
| 226 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 227 |
-
json={"healing_intent": healing_intent, "human_approved": True},
|
| 228 |
-
)
|
| 229 |
-
assert resp.status_code == 422
|
| 230 |
-
assert "ladder denied" in resp.json()["detail"]
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def test_execute_safety_error_returns_422(client, enterprise_execution_enabled):
|
| 234 |
-
deterministic_id, healing_intent = _evaluate_intent(client)
|
| 235 |
-
_FakeExecutor.mode = "safety_error"
|
| 236 |
-
resp = client.post(
|
| 237 |
-
f"/api/v1/intents/{deterministic_id}/execute",
|
| 238 |
-
json={"healing_intent": healing_intent, "human_approved": True},
|
| 239 |
-
)
|
| 240 |
-
assert resp.status_code == 422
|
| 241 |
-
assert "blast radius" in resp.json()["detail"]
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
def test_execute_belongs_to_different_tenant_returns_404(client, enterprise_execution_enabled, db_session):
|
| 245 |
-
"""A deterministic_id that exists but under a different tenant must not
|
| 246 |
-
be executable by this caller -- same tenant-scoping guarantee
|
| 247 |
-
record_outcome already provides for /intents/outcome."""
|
| 248 |
-
other_tenant = "other-tenant"
|
| 249 |
-
if not db_session.query(TenantDB).filter_by(id=other_tenant).first():
|
| 250 |
-
db_session.add(TenantDB(id=other_tenant, name="Other Tenant"))
|
| 251 |
-
db_session.commit()
|
| 252 |
-
|
| 253 |
-
from app.database.models_intents import IntentDB
|
| 254 |
-
import datetime
|
| 255 |
-
db_session.add(IntentDB(
|
| 256 |
-
deterministic_id="belongs-to-other-tenant",
|
| 257 |
-
tenant_id=other_tenant,
|
| 258 |
-
intent_type="provision_resource",
|
| 259 |
-
payload={},
|
| 260 |
-
oss_payload={},
|
| 261 |
-
environment="prod",
|
| 262 |
-
evaluated_at=datetime.datetime.utcnow(),
|
| 263 |
-
risk_score="0.1",
|
| 264 |
-
))
|
| 265 |
-
db_session.commit()
|
| 266 |
-
|
| 267 |
-
resp = client.post(
|
| 268 |
-
"/api/v1/intents/belongs-to-other-tenant/execute",
|
| 269 |
-
json={"healing_intent": {}},
|
| 270 |
-
)
|
| 271 |
-
assert resp.status_code == 404
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
# ---------------------------------------------------------------------------
|
| 275 |
-
# Admin resolve/list endpoints
|
| 276 |
-
# ---------------------------------------------------------------------------
|
| 277 |
-
|
| 278 |
-
TEST_ADMIN_KEY = "test-admin-key-for-governance-execute-tests"
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
class _FakeApprovalRecord:
|
| 282 |
-
def __init__(self, id, decision_id, intent_id, level, approval_required, requested_at):
|
| 283 |
-
self.id = id
|
| 284 |
-
self.decision_id = decision_id
|
| 285 |
-
self.intent_id = intent_id
|
| 286 |
-
self.level = level
|
| 287 |
-
self.approval_required = approval_required
|
| 288 |
-
self.requested_at = requested_at
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
class _FakeApprovalStore:
|
| 292 |
-
def __init__(self):
|
| 293 |
-
import datetime
|
| 294 |
-
self._pending = {
|
| 295 |
-
"appr_1": _FakeApprovalRecord(
|
| 296 |
-
"appr_1", "dec_1", "intent-1", "HumanInLoop", "human", datetime.datetime.utcnow()
|
| 297 |
-
)
|
| 298 |
-
}
|
| 299 |
-
self.resolved = []
|
| 300 |
-
|
| 301 |
-
def list_pending(self, limit=100, offset=0):
|
| 302 |
-
return list(self._pending.values())[:limit]
|
| 303 |
-
|
| 304 |
-
def resolve(self, approval_id, approved, resolved_by, note=None):
|
| 305 |
-
if approval_id not in self._pending:
|
| 306 |
-
return False
|
| 307 |
-
del self._pending[approval_id]
|
| 308 |
-
self.resolved.append((approval_id, approved, resolved_by, note))
|
| 309 |
-
return True
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
@pytest.fixture
|
| 313 |
-
def admin_with_approval_store(monkeypatch, client):
|
| 314 |
-
monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
|
| 315 |
-
fake_store = _FakeApprovalStore()
|
| 316 |
-
client.app.state.approval_store = fake_store
|
| 317 |
-
yield fake_store
|
| 318 |
-
client.app.state.approval_store = None
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def test_list_pending_executions_returns_501_without_store(client, monkeypatch):
|
| 322 |
-
monkeypatch.setattr(routes_admin, "ADMIN_API_KEY", TEST_ADMIN_KEY)
|
| 323 |
-
client.app.state.approval_store = None
|
| 324 |
-
resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY})
|
| 325 |
-
assert resp.status_code == 501
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
def test_list_pending_executions(client, admin_with_approval_store):
|
| 329 |
-
resp = client.get("/api/v1/admin/executions/pending", params={"admin_key": TEST_ADMIN_KEY})
|
| 330 |
-
assert resp.status_code == 200
|
| 331 |
-
body = resp.json()
|
| 332 |
-
assert body["total"] == 1
|
| 333 |
-
assert body["pending"][0]["approval_id"] == "appr_1"
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
def test_resolve_execution_approval(client, admin_with_approval_store):
|
| 337 |
-
resp = client.post(
|
| 338 |
-
"/api/v1/admin/executions/appr_1/resolve",
|
| 339 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 340 |
-
json={"approved": True, "note": "looks fine"},
|
| 341 |
-
)
|
| 342 |
-
assert resp.status_code == 200
|
| 343 |
-
assert admin_with_approval_store.resolved == [("appr_1", True, "admin", "looks fine")]
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
def test_resolve_unknown_approval_returns_404(client, admin_with_approval_store):
|
| 347 |
-
resp = client.post(
|
| 348 |
-
"/api/v1/admin/executions/does-not-exist/resolve",
|
| 349 |
-
params={"admin_key": TEST_ADMIN_KEY},
|
| 350 |
-
json={"approved": True},
|
| 351 |
-
)
|
| 352 |
-
assert resp.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|