Coverage for node / src / stigmem_node / settings.py: 95%
160 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1from datetime import datetime
2from typing import Annotated
4from pydantic import field_validator, model_validator
5from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
8class Settings(BaseSettings):
9 model_config = SettingsConfigDict(
10 env_prefix="STIGMEM_",
11 env_file=".env",
12 env_file_encoding="utf-8",
13 extra="ignore",
14 )
16 db_path: str = "stigmem.db"
17 host: str = "0.0.0.0" # noqa: S104 # nosec B104 — overridable via STIGMEM_HOST
18 port: int = 8765
19 # Optional stable node identity for deterministic local/CI federation fixtures.
20 # When unset, the node creates and persists a stigmem:node:<uuid> identity.
21 node_id: str = ""
22 node_url: str = "http://localhost:8765"
23 # The node's own org identity URI (Phase 2a). Empty → derived from node_url.
24 entity_uri: str = ""
25 log_level: str = "info"
26 cors_allowed_origins: Annotated[list[str], NoDecode] = []
27 cors_allowed_origin_regex: str | None = None
28 cors_allow_credentials: bool = True
29 cors_dev_localhost: bool = False
31 # When True (default), every request must carry a valid Bearer token.
32 # Set to False only for local development / single-operator installs.
33 auth_required: bool = True
34 # /metrics (Prometheus) leaks per-principal entity_uri/tenant/peer labels and
35 # enables unbounded-cardinality DoS, so it requires the admin capability by
36 # default (M11 / F-AVAIL-1). Set False only where /metrics is otherwise
37 # access-controlled, e.g. behind a private scrape interface or a sidecar proxy.
38 metrics_require_auth: bool = True
39 # Static API-key lifecycle controls. 0 disables max-age enforcement.
40 api_key_max_age_days: int = 90
41 api_key_expiring_soon_days: int = 30
42 legacy_sha256_accept_until: datetime | None = None
44 @field_validator("cors_allowed_origins", mode="before")
45 @classmethod
46 def _parse_cors_allowed_origins(cls, v: object) -> list[str]:
47 if v is None or v == "": 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 return []
49 if isinstance(v, str):
50 return [origin.strip() for origin in v.split(",") if origin.strip()]
51 if isinstance(v, list): 51 ↛ 53line 51 didn't jump to line 53 because the condition on line 51 was always true
52 return [str(origin).strip() for origin in v if str(origin).strip()]
53 return v # type: ignore[return-value]
55 # Federation — Phase 3 (spec §6)
56 federation_enabled: bool = False
57 # Base64url Ed25519 keypair. If both are empty, auto-generated and stored in node_meta.
58 federation_pubkey: str = ""
59 federation_privkey: str = ""
60 # Pull replication interval in seconds (spec §6.3); advisory pull_interval_s
61 # from peer overrides this.
62 federation_pull_interval_s: int = 30
63 federation_push_enabled: bool = False
64 # Explicit dev/test escape hatch for federation without mTLS. Production
65 # federation should leave this false and configure STIGMEM_TLS_* instead.
66 federation_insecure: bool = False
67 # Additional acknowledgement for local Docker/dev networks whose service DNS
68 # names are not loopback. Never set in production.
69 local_dev_allow_insecure_non_loopback: bool = False
70 # Nonce window: how long (seconds) a nonce is kept to detect replays
71 # (spec §6.6, default 5 min).
72 federation_nonce_window_s: int = 300
73 # Clock-skew leeway for peer-token exp/iat/nbf claim checks.
74 peer_token_leeway_s: int = 30
75 # Maximum accepted remote HLC skew for federated fact ingest. Future skew is
76 # strict by default because it can advance local logical time; past skew is a
77 # wider archival bound and may be set to 0 for one-off historical backfills.
78 federation_hlc_max_future_skew_s: int = 300
79 federation_hlc_max_past_skew_s: int = 2_592_000
80 # Dual-trust grace window (hours) for an origin's PRIOR (retired) signing key.
81 # After a key rotation, the retiring key is accepted alongside the current key
82 # only while now - rotation_event.rotated_at <= this window; past it the prior
83 # key is DROPPED (a stale/compromised retired key can no longer forge origin
84 # signatures, direct or relayed). Generous 7-day default so legitimate
85 # in-progress rotations are never disrupted; the current key is always accepted.
86 federation_key_rotation_grace_hours: int = 168
87 # Allow team-scoped facts to cross federation boundaries
88 # (must be explicitly enabled; audit-logged).
89 federation_allow_team: bool = False
90 # Relay re-federates inbound facts only when ON, subject to origin
91 # scope/tenant propagation (Phase 2c). Default OFF = today's behaviour.
92 federation_relay_enabled: bool = False
94 # Phase 3 (DNSSEC-rooted origin key first-trust, Rev 6 §10). All
95 # default-safe/inert: the master gate is OFF, so the first-trust ladder is
96 # unreachable on a default node (proven by reachability + lazy-import CI
97 # guards). Schema (migrations 053-055) lands empty; ENFORCEMENT (ladder,
98 # epoch pins, quarantine cap/TTL) arrives in later 3b tasks. The recency
99 # re-check floor/cap settings belong to 3c and are NOT defined here.
100 #
101 # Master gate for the DNSSEC first-trust ladder (operator-pin -> DNSSEC ->
102 # operator-confirm -> fail-closed). Default OFF; only meaningful when
103 # federation_relay_enabled is also ON.
104 federation_dnssec_trust_enabled: bool = False
105 # Per-origin-overridable ceiling on RRSIG age. An RRSIG older than this
106 # falls through to operator-confirm on a never-fresh host (and hard-rejects
107 # on a previously-fresh sticky-signed host, I4). Default 7 days (in seconds)
108 # — generous relative to a typical zone re-sign interval so a slow-resigning
109 # zone is not mistaken for an attack.
110 federation_dnssec_max_rrsig_age: int = 7 * 24 * 60 * 60
111 # Per-relay-peer cap on pending_first_trust inserts (I9 queue bound): an
112 # untrusted relay cannot flood the operator-confirm queue. Default 100.
113 federation_dnssec_pending_confirm_cap: int = 100
114 # TTL (seconds) for unconfirmed pending_first_trust quarantine rows; rows
115 # older than this are evicted (I9 queue bound). Default 7 days.
116 federation_dnssec_pending_confirm_ttl: int = 7 * 24 * 60 * 60
118 # Phase 3 build-phase 3c — relay-path recency/revocation re-check cadence
119 # (Rev 6 I5 / §7, NF-R5C-5). The effective re-check interval is
120 # clamp(record_DNS_TTL, floor, cap): the origin's DNS TTL drives the cadence
121 # (its freshness signal), the admin sets the bounds. Within the effective
122 # interval a pinned binding is honored WITHOUT a fresh DNS re-resolution
123 # (re-checks are cached per-origin, not per-fact).
124 #
125 # Anti-storm floor (seconds): a pinned binding is never re-resolved more
126 # often than this even when the record's DNS TTL is shorter. Default 300s.
127 federation_dnssec_recheck_floor_seconds: int = 300
128 # Cap (seconds): a pinned binding is re-resolved at least this often even
129 # when the record's DNS TTL is longer (admin-raisable for lower DNS load).
130 # Default 3600s (1h).
131 federation_dnssec_recheck_cap_seconds: int = 3600
132 # Unreachable/suppression grace (Rev 6 I5): when a relay-path re-check gets
133 # NO validatable answer (transport SERVFAIL/timeout, UNVALIDATABLE, BOGUS,
134 # INSECURE, ABSENT_AUTHENTICATED) on an already-pinned binding, the pinned
135 # key is honored only up to min(this, ttl_multiple x record_DNS_TTL)
136 # measured from the pin's last_validated_at, then relayed facts FAIL CLOSED
137 # (unreachable, NEVER treated as a positive revocation). Default 24h cap.
138 federation_dnssec_unreachable_grace_seconds: int = 86400
139 # Multiple of the record's DNS TTL that bounds the unreachable grace from
140 # below (the grace is the MIN of the absolute cap above and this multiple of
141 # the TTL). Default 4.
142 federation_dnssec_unreachable_ttl_multiple: int = 4
144 # Decay sweeper (Phase 6, spec §decay)
145 # 0 = disabled; positive = decay non-expiring facts older than N seconds
146 # when sweep runs without explicit ttl_seconds
147 decay_ttl_seconds: int = 0
148 # 0.0 = disabled; positive = decay facts below this confidence when sweep
149 # runs without explicit min_confidence
150 decay_min_confidence: float = 0.0
152 # Track C / C1: require Ed25519 attestation on all fact assertions.
153 # When True, POST /v1/facts must include a valid attestation token.
154 # Defaults to False for backward compatibility.
155 attestation_required: bool = False
157 # OIDC bridge (Track B / B3): human identity → scoped API keys.
158 # Set oidc_enabled=true and configure the remaining fields to activate.
159 oidc_enabled: bool = False
160 # IdP issuer URL; discovery doc fetched from {issuer_url}/.well-known/openid-configuration
161 oidc_issuer_url: str = ""
162 # client_id expected in the id_token's "aud" claim
163 oidc_audience: str = ""
164 # lifetime of issued API keys in hours (default 8 h working-day session)
165 oidc_token_ttl_hours: int = 8
166 # Accepted OIDC id_token signing algorithms. Operators can narrow this list.
167 oidc_id_token_algorithms: list[str] = ["RS256", "ES256", "PS256", "EdDSA"]
168 # comma-separated list of allowed email domains; empty = allow any
169 oidc_allowed_domains: str = ""
171 # Async job threshold (spec §14.5 / §15.4): scopes with more facts than this
172 # trigger the async 202 path. Override in tests to force async path at small scale.
173 async_job_threshold: int = 100_000
175 # Source attestation mode (legacy compatibility field).
176 # Source-attestation runtime behavior is gated by the experimental
177 # stigmem-plugin-source-attestation package. Default installs keep this off.
178 source_attestation_mode: str = "off"
180 # P-INJ-1: source ↔ identity binding, graduated to core. The `attested` flag
181 # is always evaluated (a fact's source is attested when it matches the writing
182 # principal). When this is true, an unattested source is REJECTED at write;
183 # default false flags it (attested=False) but allows the write. Enforce belongs
184 # in the hardened profile — it breaks writing facts sourced from elsewhere.
185 source_attestation_enforce: bool = False
187 # F-CONF-1: garden ACL recall filtering, graduated to core and ON by default.
188 # Restricts tenant-wide recall/query/graph/subscription results to gardens the
189 # caller is a member of. Facts with garden_id=NULL (single-tenant installs) are
190 # unaffected. Set false only to intentionally accept tenant-wide garden visibility.
191 memory_garden_acl_recall_filter: bool = True
193 # Garden-membership-derived OIDC permission ceiling, graduated to core.
194 # Off by default: enabling it caps OIDC-issued permissions to what the user's
195 # garden memberships grant, which downgrades non-member sessions — a hardened
196 # least-privilege posture, not a safe universal default. Belongs in the
197 # hardened profile.
198 oidc_permission_ceiling: bool = False
200 # Rate limiting for hosted offering (per API key, sliding 1-hour window).
201 # 0 = disabled.
202 rate_limit_write_per_hour: int = 1000
203 rate_limit_read_per_hour: int = 5000
204 rate_limit_disabled_ack: bool = False
206 # F-AVAIL-1: ingest size caps (DoS protection). 0 disables the cap.
207 max_request_body_bytes: int = 1_048_576 # 1 MiB hard cap on any request body
208 max_fact_value_bytes: int = 262_144 # 256 KiB cap on a single fact value
210 # Storage backend (Phase 8 / 11).
211 # "sqlite" (default) — local SQLite file at db_path.
212 # "libsql" — libSQL / Turso; uses db_path as the local replica
213 # file; set libsql_url + libsql_auth_token for
214 # embedded-replica sync with Turso.
215 # "postgres" — PostgreSQL; set pg_dsn to a libpq connection string.
216 storage_backend: str = "sqlite"
217 # Turso database endpoint, e.g. "libsql://my-db.turso.io"
218 libsql_url: str = ""
219 # Turso auth token (from `turso db tokens create`)
220 libsql_auth_token: str = ""
221 # PostgreSQL connection string, e.g. "postgresql://user:pw@localhost/stigmem"
222 pg_dsn: str = ""
223 # DATABASE_URL alias (Heroku / PaaS convention); also read from bare DATABASE_URL env var.
224 database_url: str = ""
225 # PostgreSQL schema for all tables (default: "public"). Use a unique
226 # per-test schema to achieve row-level isolation without separate databases.
227 pg_schema: str = "public"
228 # Connection pool bounds for the Postgres backend.
229 postgres_pool_min: int = 2
230 postgres_pool_max: int = 10
232 # Encryption at rest (Phase 8).
233 # "off" (default) — no encryption; plaintext DB (dev-friendly default).
234 # "on" — SQLCipher for SQLite backend; native encryption for libSQL.
235 # When "on", exactly one of at_rest_key_passphrase_env / at_rest_key_kms_uri
236 # must be set — the node refuses to start otherwise.
237 at_rest_encryption: str = "off"
238 # Name of the env var whose value is the passphrase (not the passphrase itself).
239 # e.g. STIGMEM_AT_REST_KEY_PASSPHRASE_ENV=MY_DB_PASSPHRASE
240 at_rest_key_passphrase_env: str = ""
241 # KMS URI for raw 32-byte key material. "env://VAR" reads a hex-encoded key
242 # from env var VAR. Future schemes: "aws-kms://...", "gcp-kms://...".
243 at_rest_key_kms_uri: str = ""
245 @field_validator("at_rest_encryption")
246 @classmethod
247 def _validate_encryption_mode(cls, v: str) -> str:
248 if v not in ("on", "off"):
249 raise ValueError(f"at_rest_encryption must be 'on' or 'off'; got {v!r}")
250 return v
252 # Federation Trust — Phase 8 (spec §19)
253 # trust_mode controls source-trust scoring and quarantine routing:
254 # "strict" — trust is computed for all inbound facts; t < 0.2 → quarantine.
255 # "relaxed" — trust is computed but quarantine is not auto-triggered (default).
256 # "off" — trust not computed; source_trust is null on all facts.
257 trust_mode: str = "relaxed"
259 # Sanitizer mode (§19.7) applied at recall time:
260 # "block" — fact excluded, placeholder returned.
261 # "quarantine"— fact moved to quarantine garden.
262 # "warn" — fact returned with sanitizer_warnings (default).
263 # "off" — no check (implied by trust_mode=off).
264 sanitizer_mode: str = "warn"
266 # UUID of the node's designated quarantine garden.
267 # Required in strict mode; facts below threshold are rejected with 403 if unset.
268 quarantine_garden_id: str = ""
270 # Source-trust score weights (§19.4.2). Must sum to 1.0; deviations are not
271 # validated at startup — set incorrectly and t will be out of [0,1] range.
272 trust_weight_identity: float = 0.35
273 trust_weight_peer_history: float = 0.30
274 trust_weight_scope_authority: float = 0.25
275 trust_weight_attestation_mode: float = 0.10
277 # Path to a newline-delimited file of extra sanitizer regex patterns (§19.7.2).
278 sanitizer_extra_patterns_file: str = ""
280 # Path to YAML file defining operator auto-trust rules (always_trust / never_trust).
281 trust_rules_file: str = ""
283 # Plugin signing gate (ADR-011 / PR 4-INF.3).
284 # When true, installed entry-point plugins must pass signing verification
285 # before registration. Set false only for local development; unsigned plugin
286 # loading remains warning- and audit-visible.
287 plugin_signing_required: bool = True
288 # Required literal acknowledgement before unsigned-plugin loading is allowed.
289 plugin_unsigned_ack: str = ""
290 # Disable installed entry-point plugin discovery for test and smoke
291 # environments that intentionally exercise the default no-plugin node.
292 plugin_auto_discovery_enabled: bool = True
293 # Comma-separated Sigstore signing identities accepted for production plugin
294 # registration when plugin_signing_required=true.
295 plugin_trusted_publishers: str = ""
296 # Comma-separated signing identities accepted through explicit operator
297 # override. Overrides remain audit-visible and are not a substitute for the
298 # trusted-publisher allowlist.
299 plugin_trust_override_publishers: str = ""
301 # Transparency log backend (§19.2.3):
302 # "local" — append-only JSONL file with hash chain (default, no external deps).
303 # "rekor" — Sigstore Rekor REST API.
304 # "off" — no TL submission; inclusion proofs are never verified.
305 tl_backend: str = "local"
306 tl_local_path: str = "stigmem_tl.jsonl"
307 tl_rekor_url: str = "https://rekor.sigstore.dev"
308 fact_chain_checkpoint_interval: int = 1000
309 fact_chain_checkpoint_max_age_s: int = 60
310 fact_chain_checkpoint_retry_s: int = 60
312 # Capability token signing — spec §19.3.2 (C-SEC-1).
313 # Base64url-encoded raw 32-byte Ed25519 seed used to sign capability tokens and
314 # revocation events. If empty, token signing is skipped and verify_token() will
315 # reject all tokens (dev/test nodes that don't participate in trust federation).
316 node_private_key: str = ""
318 @field_validator("node_private_key")
319 @classmethod
320 def _validate_node_private_key(cls, v: str) -> str:
321 if not v:
322 return v
323 import base64
325 padded = v + "=" * (-len(v) % 4)
326 try:
327 raw = base64.urlsafe_b64decode(padded)
328 except Exception as exc:
329 raise ValueError(f"node_private_key is not valid base64url: {exc}") from exc
330 if len(raw) != 32: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 raise ValueError(f"node_private_key must decode to exactly 32 bytes; got {len(raw)}")
332 return v
334 # -------------------------------------------------------------------------
335 # Embeddings — Phase 9 (spec §20 / design memo §2)
336 # -------------------------------------------------------------------------
337 # Set embed_enabled=true to activate sqlite-vec integration.
338 # When false (default), no extension is loaded and no embeddings are stored.
339 embed_enabled: bool = False
341 # "local" — Ollama HTTP API (default); requires a running Ollama instance.
342 # "openai" — OpenAI embeddings API; requires OPENAI_API_KEY (or the env var
343 # named by embed_openai_api_key_env).
344 # "stub" — deterministic test stub; no external dependencies.
345 embed_model_provider: str = "local"
347 # Model identifier passed to the provider.
348 # Local default: "nomic-embed-text-v1.5" (768-dim, Apache-2.0, runs on laptop).
349 # OpenAI default: "text-embedding-3-small" (1536-dim).
350 embed_model_id: str = "nomic-embed-text-v1.5"
352 # Output dimensionality. MUST match the model; changing this after the first
353 # embedding requires running `stigmem embed reindex` (migration tool).
354 embed_dimension: int = 768
356 # Ollama base URL (local provider only).
357 embed_ollama_url: str = "http://localhost:11434"
359 # Name of the env var holding the OpenAI API key (openai provider only).
360 embed_openai_api_key_env: str = "OPENAI_API_KEY"
362 # Facts with confidence below this threshold have their vec_facts entry
363 # deleted during the decay sweep (design memo §2 "Decay interaction").
364 embed_tombstone_threshold: float = 0.1
366 # Subscription primitive (Phase 9, spec §20)
367 # How long (seconds) the replay window extends back from now (default 24 h).
368 subscription_replay_s: int = 86400
369 # How often (seconds) the background sweep retries pending/failed delivery.
370 subscription_delivery_sweep_s: int = 30
371 # Consecutive delivery failures before the circuit breaker opens on a subscription.
372 subscription_circuit_threshold: int = 10
373 # How long (seconds) an event may remain in 'delivering' state before the next
374 # ``deliver_pending`` invocation reverts it to 'pending' for redelivery.
375 # Guards against crashed workers stranding events. Must be larger than the
376 # worst-case webhook timeout (10 s) by a comfortable margin.
377 subscription_claim_timeout_s: int = 300
378 # F-AVAIL-3: cap on active subscriptions per (subscriber_identity, tenant).
379 # 0 disables the cap. Default is generous; raise for high-fan-out operators.
380 max_subscriptions_per_principal: int = 1000
381 # M12 / F-AVAIL-2: terminal (delivered/failed) subscription_events older than
382 # this are pruned by the delivery sweep so the table cannot grow unbounded.
383 # Clamped to >= subscription_replay_s at prune time so the replay window is
384 # never truncated. 0 disables pruning entirely.
385 subscription_event_retention_s: int = 604800 # 7 days
386 # GHSA-5p3m-vhh6-9236: webhook delivery_address must be https by default.
387 # When False (default), a webhook delivery_address is required to be https at
388 # both creation and delivery time. Set True ONLY for local/dev where http
389 # webhooks are needed — this is the advisory's explicit, operator-controlled
390 # opt-in for the insecure http scheme. https-only blocks plaintext exfil and
391 # narrows the SSRF surface.
392 webhook_allow_insecure_http: bool = False
394 @property
395 def webhook_allowed_schemes(self) -> frozenset[str]:
396 """Allowed schemes for webhook delivery_address (https-only by default)."""
397 if self.webhook_allow_insecure_http:
398 return frozenset({"https", "http"})
399 return frozenset({"https"})
401 # -------------------------------------------------------------------------
402 # mTLS Federation Transport — Phase 12 (spec §22.1)
403 # -------------------------------------------------------------------------
404 # Path to the node's PEM-encoded X.509 certificate for mTLS federation.
405 # When tls_cert_path + tls_key_path are both set, mTLS is activated:
406 # the uvicorn server requires client certs and the pull client presents this
407 # cert to peers. Opt-out is only permitted for localhost deployments
408 # (set host to "localhost" / "127.0.0.1" / "::1" and leave paths empty).
409 tls_cert_path: str = ""
410 # Path to the node's PEM-encoded private key corresponding to tls_cert_path.
411 tls_key_path: str = ""
412 # Path to a PEM CA bundle used to verify peer certificates.
413 # Required when tls_cert_path + tls_key_path are configured.
414 tls_ca_bundle: str = ""
416 @model_validator(mode="after")
417 def _require_ca_bundle_for_mtls(self) -> "Settings":
418 if self.tls_cert_path and self.tls_key_path and not self.tls_ca_bundle:
419 raise ValueError(
420 "STIGMEM_TLS_CA_BUNDLE is required when mTLS is enabled "
421 "(STIGMEM_TLS_CERT_PATH + STIGMEM_TLS_KEY_PATH are set). "
422 "Without it, peer certs fall back to the system CA store instead "
423 "of the closed federation trust bundle (spec §22.1.2.2)."
424 )
425 return self
427 @property
428 def mtls_enabled(self) -> bool:
429 """True when mTLS cert + key are configured (non-localhost deployments)."""
430 return bool(self.tls_cert_path and self.tls_key_path)
432 # -------------------------------------------------------------------------
433 # Observability — Phase 13 (spec §23)
434 # -------------------------------------------------------------------------
435 # Set otel_enabled=true to activate OpenTelemetry tracing.
436 # Requires stigmem-node[observability] (opentelemetry-sdk + OTLP exporter).
437 otel_enabled: bool = False
439 # Service name reported in OTel resource attributes.
440 otel_service_name: str = "stigmem-node"
442 # OTLP collector base URL (HTTP protocol).
443 # e.g. "http://localhost:4318" for a local OpenTelemetry Collector or Tempo.
444 # Leave empty to disable OTLP export (spans collected locally only).
445 otel_exporter_otlp_endpoint: str = ""
447 # -------------------------------------------------------------------------
448 # Time-travel / as_of — Phase 13 (spec §24.2.2)
449 # -------------------------------------------------------------------------
450 # Minimum allowed as_of timestamp (ISO 8601 UTC). Queries before this floor
451 # return 400 as_of_before_retention_floor. Empty string = no floor enforced.
452 as_of_retention_floor: str = ""
455settings = Settings()