Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ Design notes worth keeping. `ExerciseStateChanged` deliberately does **not** car

**Audit retention & token purge (#251)**: `auditevent` and `authtoken` previously grew without bound and nothing ever deleted from either. `app/services/retention_service.py` adds one daily in-process sweep (`retention_task` = `sweep_once` then `asyncio.sleep`, so the "startup pass plus daily timer" the issue asks for is a single construct; created in `main`'s lifespan beside `heartbeat_task`, **not** awaited inline like `rehydrate_schedules` because a first pass over a legacy table is unbounded and would delay readiness). **Audit pruning is opt-in**: `AuditSettings.retention_days` (seeded from `AUDIT_RETENTION_DAYS`, edited at `/admin/audit`, bounded `0..3650` at the router) defaults to **0 = keep forever** — an upgrade must never silently destroy security records, and SIEM forwarding is only an archival path if an operator can enable a forwarder *first*. The knob deliberately lives on `AuditSettings` rather than `GeneralConfig`: its only reader is this async sweep, which already holds a session, so it needs no process-global cache and is **not** projected into `SiemConfig` (that snapshot exists solely for the sync `emit` path). `retention_days <= 0` short-circuits, which also neutralises a hand-edited negative row that would otherwise compute a cutoff in the *future* and delete everything; the cutoff is strict `<`, so a row landing exactly on it survives. Deletes run in `_PURGE_BATCH`-sized transactions capped at `_MAX_BATCHES` per sweep — one unbounded `DELETE` would be a giant transaction, a long lock window and a WAL spike, and the remainder is simply picked up next pass. **Token purging is unconditional** and needs no knob: `authtoken` is not a log but the live state behind an emailed single-use link, so the row is required while the link is live and worthless after — unused rows go 7 days past `expires_at`, used rows 24 hours past `used_at` (module constants), `consume()` already refuses both classes so nothing live races the delete, and the audit trail independently records issuance and acceptance. Migration `b3c4d5e6f7a8` adds the column (with a `server_default` — the singleton is lazily seeded and may already exist) plus `ix_authtoken_expires_at`; `expires_at` also carries `index=True` on the model because the test schema is built from SQLModel metadata, not Alembic. The sweep emits `audit.retention_purge` (severity `warning`) **only when something was deleted**, so a non-pruning deployment emits nothing ever and a pruning one emits at most one per day — destroying security records is itself security-relevant, and without it a purged window is indistinguishable from tampering; the event cannot eat itself because its `created_at` is newer than any cutoff by construction. It records no domain event and dispatches no WS frame. At shutdown both recurring loops are cancelled **before** `background.drain`: they are bare `create_task`s the drain never sees, and cancelling first lets a shutdown-time purge event's persist/forward children be drained rather than abandoned. Caveat to state plainly: once pruning is on, forwarding is the archive, and it is **best-effort with no outbox or retry** — a forwarder outage overlapping a purge window loses those events permanently. The sweep is a **periodic job** on the task queue (#213): procrastinate's periodic defer is unique per (task, timestamp), so exactly one replica purges each night however many are running. `exercisestatetransition`, communications and responses are deliberately out of scope (domain data with real read paths).

**Outbound proxy (#97)**: corporate egress proxying for the three outbound surfaces — the **LLM** API, the **SIEM `http`** sink, and **OIDC** discovery/JWKS. `app/services/proxy.py` is a pure `resolve(cfg, url) -> dict` returning httpx kwargs, with three modes on a `ProxyMode` StrEnum: `SYSTEM` → `{"trust_env": True}` (honour `HTTP(S)_PROXY`/`NO_PROXY`; the **default**, and exactly what httpx did implicitly before this feature, so upgrades are a no-op — a `proxy: None` key here would *override* the env proxy), `NONE` → always direct, `EXPLICIT` → route via `proxy_url` unless the target host matches the no-proxy list (standard `NO_PROXY` semantics: `*`, CIDR, domain+subdomain, exact). The **bypass decision is per target URL** because an httpx client takes a single `proxy` (no per-host `mounts`). **Caller contract**: `resolve_kwargs(url)` returns `{}` when the cache is unloaded, and every call site splats `**kwargs` last — so an unloaded cache is byte-for-byte pre-feature behaviour (each wiring test has a paired "unset" case). Routing lives in the admin-editable **`ProxySettings` singleton** (`app/models/proxy_settings.py`, row id=1, `mode` a plain string column, **no credential column**) managed by `proxy_settings_service.py`; **credentials are env-only** (`PROXY_USERNAME`/`PROXY_PASSWORD`), injected into the proxy URL's userinfo at call time by `_with_credentials()` and never persisted, returned, or logged. Like `SiemConfig`, an in-memory `ProxyConfig` cache is read by the **sync** `audit_service.emit` → SIEM path; loaded at startup by `main._load_proxy_config()`, which **must run before `register_providers()`** (OIDC bakes the resolved proxy into its Authlib `client_kwargs` at registration). A save invalidates both caches that captured the old proxy at construction: `reset_provider_cache()` (LLM adapters hold a long-lived SDK client, so the proxy is resolved once against the provider's base URL — Bedrock against the real `bedrock-runtime.<region>.amazonaws.com`, **not** the Anthropic host) and `oidc_service.reset_registration()` (Authlib's `register()` overwrites its `_registry` but `create_client()` returns the **cached** client, so re-registering alone would silently keep the old proxy — the reset rebinds a fresh `OAuth()`). All three SDKs take `http_client=`, incl. `AsyncAnthropicBedrock` (no botocore special-case). Admin API `app/routers/proxy.py` (`/api/proxy/settings|targets|test`, `require_admin`) backs the **`/admin/proxy`** page. The connectivity test takes a **target label, never a URL** — `egress_targets()` builds the label→URL map server-side from the configured LLM/SIEM/OIDC endpoints, so the route is not an SSRF oracle (CodeQL flagged the earlier free-text-URL form as critical); it returns only `ok: HTTP <status>` or `error: <ExceptionClass>`, with the exception *message* logged server-side after `_scrub()` strips the credentials an httpx error can echo from the proxy URL. The raw-socket `syslog` sink **cannot** be proxied. Seeded from `PROXY_*` env; wired in compose + `k8s/base/configmap.yaml` (routing) + `k8s/base/secrets.yaml` (credentials).
**Outbound proxy (#97)**: corporate egress proxying for the three outbound surfaces — the **LLM** API, the **SIEM `http`** sink, and **OIDC** discovery/JWKS. `app/services/proxy.py` is a pure `resolve(cfg, url) -> dict` returning httpx kwargs, with three modes on a `ProxyMode` StrEnum: `SYSTEM` → `{"trust_env": True}` (honour `HTTP(S)_PROXY`/`NO_PROXY`; the **default**, and exactly what httpx did implicitly before this feature, so upgrades are a no-op — a `proxy: None` key here would *override* the env proxy), `NONE` → always direct, `EXPLICIT` → route via `proxy_url` unless the target host matches the no-proxy list (standard `NO_PROXY` semantics: `*`, CIDR, domain+subdomain, exact). The **bypass decision is per target URL** because an httpx client takes a single `proxy` (no per-host `mounts`). **Caller contract**: `resolve_kwargs(url)` returns `{}` when the cache is unloaded, and every call site splats `**kwargs` last — so an unloaded cache is byte-for-byte pre-feature behaviour (each wiring test has a paired "unset" case). Routing lives in the admin-editable **`ProxySettings` singleton** (`app/models/proxy_settings.py`, row id=1, `mode` a plain string column, **no credential column**) managed by `proxy_settings_service.py`; **credentials are env-only** (`PROXY_USERNAME`/`PROXY_PASSWORD`), injected into the proxy URL's userinfo at call time by `_with_credentials()` and never persisted, returned, or logged. Like `SiemConfig`, an in-memory `ProxyConfig` cache is read by the **sync** `audit_service.emit` → SIEM path; loaded at startup by `main._load_proxy_config()`, which **must run before `register_providers()`** (OIDC bakes the resolved proxy into its Authlib `client_kwargs` at registration). A save invalidates both caches that captured the old proxy at construction: `reset_provider_cache()` (LLM adapters hold a long-lived SDK client, so the proxy is resolved once against the provider's base URL — Bedrock against the real `bedrock-runtime.<region>.amazonaws.com`, **not** the Anthropic host) and `oidc_service.reset_registration()` (Authlib's `register()` overwrites its `_registry` but `create_client()` returns the **cached** client, so re-registering alone would silently keep the old proxy — the reset rebinds a fresh `OAuth()`). All three SDKs take `http_client=`, incl. `AsyncAnthropicBedrock` (no botocore special-case) — but **not the same client class**: anthropic 1.x / openai 3.x are built on **`httpx2`**, so the LLM adapters hand them an `httpx2.AsyncClient`, while OIDC (Authlib) and the SIEM `http` sink stay on `httpx`. `resolve()` is unaffected — `proxy` / `trust_env` are spelled identically in both — so the kwargs contract is shared and only the constructor differs. `httpx2` arrives with the SDK, never as a core dependency, so the adapters import it **lazily inside `_http_client()`** (the adapter modules themselves import eagerly, to register); the `llm-*` extras floor at the first httpx2-era majors (`anthropic>=1.2.0`, `openai>=3.6.0`) because an older SDK would reject an `httpx2` client. Admin API `app/routers/proxy.py` (`/api/proxy/settings|targets|test`, `require_admin`) backs the **`/admin/proxy`** page. The connectivity test takes a **target label, never a URL** — `egress_targets()` builds the label→URL map server-side from the configured LLM/SIEM/OIDC endpoints, so the route is not an SSRF oracle (CodeQL flagged the earlier free-text-URL form as critical); it returns only `ok: HTTP <status>` or `error: <ExceptionClass>`, with the exception *message* logged server-side after `_scrub()` strips the credentials an httpx error can echo from the proxy URL. The raw-socket `syslog` sink **cannot** be proxied. Seeded from `PROXY_*` env; wired in compose + `k8s/base/configmap.yaml` (routing) + `k8s/base/secrets.yaml` (credentials).

**Facilitator ownership scoping (#12)**: facilitator access to **exercises** is scoped per-exercise, not global. `require_exercise_access` (read gate) and `require_exercise_owner` (mutation gate) in `access_control.py` grant access only to: the creator (`Exercise.created_by`), a **co-facilitator** (a facilitator enrolled as an `ExerciseMember` — reuses the existing membership mechanism, no new field), or a **global admin** (`User.is_admin`, assigned out-of-band like the facilitator role — never via registration). Any other facilitator gets `403` + an `authz.denied` audit event. The same gates must run before nested-resource side effects: inject deletion, assessment reads/queueing, and suggested-inject list/approve/reject routes are explicitly covered alongside injects/responses/communications/inject-comments/ws and the mutation/lifecycle/member/export routes in `exercises.py`; `GET /exercises` is filtered to owned-or-member (admins see all). **Scenarios remain a shared library** (any facilitator lists/reads/edits/exports — intentional, they're reusable templates), and `GET /users` stays facilitator-wide (it's the member-enrolment picker). `is_admin` is a real column so it survives role-preview `model_copy` and is unspoofable.

Expand Down
22 changes: 16 additions & 6 deletions app/services/llm/anthropic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@

from typing import TYPE_CHECKING

import httpx

from app.services import proxy
from app.services.llm.base import register_adapter

if TYPE_CHECKING:
import httpx2

from app.config import LLMProviderConfig

ANTHROPIC_API_BASE = "https://api.anthropic.com"
Expand All @@ -50,14 +50,24 @@ def api_base(self) -> str:
return _BEDROCK_HOST.format(region=self.cfg.aws_region)
return ANTHROPIC_API_BASE

def _http_client(self) -> httpx.AsyncClient | None:
"""A proxied httpx client, or None to let the SDK build its own default.
def _http_client(self) -> httpx2.AsyncClient | None:
"""A proxied httpx2 client, or None to let the SDK build its own default.

Resolved once here, against the provider's base URL, because the SDK client
is long-lived; a proxy change invalidates it via ``reset_provider_cache()``.

``httpx2`` (not ``httpx``) because anthropic>=1 is built on it; it ships with
the SDK, so the import is lazy like the SDK's own — core installs without an
``llm-anthropic``/``llm-bedrock`` extra have neither, and this module imports
eagerly. The proxy kwargs (``proxy`` / ``trust_env``) are spelled the same in
both.
"""
proxy_kwargs = proxy.resolve_kwargs(self.api_base())
return httpx.AsyncClient(**proxy_kwargs) if proxy_kwargs else None
if not proxy_kwargs:
return None
import httpx2

return httpx2.AsyncClient(**proxy_kwargs)

def _get_client(self):
if self._client is not None:
Expand Down Expand Up @@ -88,7 +98,7 @@ def _get_client(self):
return self._client

async def aclose(self) -> None:
"""Close the built SDK client and its httpx pool; a no-op when unbuilt (#269)."""
"""Close the built SDK client and its HTTP pool; a no-op when unbuilt (#269)."""
client, self._client = self._client, None
if client is not None:
await client.close()
Expand Down
24 changes: 17 additions & 7 deletions app/services/llm/openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

from typing import TYPE_CHECKING

import httpx

from app.services import proxy
from app.services.llm.base import register_adapter

if TYPE_CHECKING:
import httpx2

from app.config import LLMProviderConfig

OPENAI_API_BASE = "https://api.openai.com"
Expand All @@ -40,11 +40,21 @@ def api_base(self) -> str:
Ollama's local endpoint is covered by the default no-proxy list."""
return self.cfg.base_url or OPENAI_API_BASE

def _http_client(self) -> httpx.AsyncClient | None:
"""A proxied httpx client, or None to let the SDK build its own default.
Resolved once, against the base URL — the SDK client is long-lived."""
def _http_client(self) -> httpx2.AsyncClient | None:
"""A proxied httpx2 client, or None to let the SDK build its own default.
Resolved once, against the base URL — the SDK client is long-lived.

``httpx2`` (not ``httpx``) because openai>=3 is built on it; it ships with
the SDK, so the import is lazy like the SDK's own — core installs without
the ``llm-openai`` extra have neither, and this module imports eagerly.
The proxy kwargs (``proxy`` / ``trust_env``) are spelled the same in both.
"""
proxy_kwargs = proxy.resolve_kwargs(self.api_base())
return httpx.AsyncClient(**proxy_kwargs) if proxy_kwargs else None
if not proxy_kwargs:
return None
import httpx2

return httpx2.AsyncClient(**proxy_kwargs)

def _get_client(self):
if self._client is not None:
Expand All @@ -65,7 +75,7 @@ def _get_client(self):
return self._client

async def aclose(self) -> None:
"""Close the built SDK client and its httpx pool; a no-op when unbuilt (#269)."""
"""Close the built SDK client and its HTTP pool; a no-op when unbuilt (#269)."""
client, self._client = self._client, None
if client is not None:
await client.close()
Expand Down
15 changes: 9 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ dependencies = [
# your LLM_PROVIDER: llm-anthropic (direct Anthropic), llm-bedrock (Anthropic on
# AWS — the anthropic SDK's Bedrock client, pulls boto3), or llm-openai (covers
# LLM_PROVIDER=openai, ollama, and gemini via the OpenAI-compatible surface).
llm-anthropic = ["anthropic>=0.120.2"]
llm-bedrock = ["anthropic[bedrock]>=0.120.2"]
llm-openai = ["openai>=2.51.0"]
# The floors are the first majors built on httpx2 (anthropic 1.x, openai 3.x): the
# adapters hand the SDK an ``httpx2.AsyncClient`` for outbound proxying (#97), which
# the httpx-era releases would reject.
llm-anthropic = ["anthropic>=1.2.0"]
llm-bedrock = ["anthropic[bedrock]>=1.2.0"]
llm-openai = ["openai>=3.6.0"]
# Install every provider SDK at once (used by the container image).
llm-all = ["anthropic[bedrock]>=0.120.2", "openai>=2.51.0"]
llm-all = ["anthropic[bedrock]>=1.2.0", "openai>=3.6.0"]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
Expand All @@ -77,8 +80,8 @@ dev = [
"pytest-playwright>=0.7",
# All provider SDKs so a dev env can run/smoke any LLM_PROVIDER (the test suite
# itself mocks providers and needs none of these).
"anthropic[bedrock]>=0.120.2",
"openai>=2.51.0",
"anthropic[bedrock]>=1.2.0",
"openai>=3.6.0",
]

[project.scripts]
Expand Down
Loading
Loading