From 1d48dbc3ad8d76297d3694db9ba9f53ce1817102 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 12:58:55 +0000 Subject: [PATCH] Backlog: publish/autosave race, /admin/config truthfulness, OIDC proxy, AI fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more from the Fable review backlog, weighted toward the core invariants: one that can destroy an analyst's work, two on a page whose whole job is telling operators the truth, one that breaks a documented invariant, and one fail-closed gap. **#278 — transition races the autosave debounce.** The lifecycle buttons were plain form POSTs that navigated immediately, dropping a pending 1.2s debounce. Type a fix to a key judgement, click "Publish & disseminate" within ~1.2s, and the snapshot freezes the PRE-EDIT text — permanently, because published output is immutable and `report_save`'s not-published guard blocks any correction. The race predates #267, but removing the manual "Save draft" button removed the flush users had. Transitions now `await saveNow()` before submitting (the pattern the AI panel already used) and refuse to move the report forward if that flush fails, naming a conflict specifically. Skipped when `canEdit` is false, so a reviewer approving someone else's report doesn't attempt an author-only save. **#273 — /admin/config printed credentials carried inside a URL (security).** The page promises "secrets are shown only as set/not-set — never their value", but inline proxy userinfo (`http://user:pass@proxy`, the standard form, which `services/proxy.py` passes through untouched) and Slack incoming-webhook URLs (bearer-equivalent by design) rendered in full — twice each, since `data-search` carries a second copy. Userinfo is now scrubbed from *every* value; the webhook URL is reduced to its origin. Root cause was allow-by-default, so that is inverted too: a URL/DSN-shaped field renders in full only once listed in `PLAINTEXT_URL_FIELDS`, and a guard test — extended over the DB settings rows, which previously had no equivalent — fails on any that is in none of the three sets. A future `sentry_dsn` is redacted *and* flagged. **#274 — /admin/config tiles reported stored flags, not resolved state.** MISP and Webhook hard-coded `ok: True` off the stored `enabled` flag, so an enabled MISP with no URL (or no env API key, read only at send time) showed green on the page an operator opens *to debug* while the /admin hub correctly said NOT CONFIGURED. Both now use the hub's off → not-configured → enabled tri-state. The SSO readiness check gained the missing provider-locator test via a new `oidc_settings.validate_provider` mirroring `ai_settings.validate_selection`: Auth0 with a client id and secret but no domain yields `https:///.well-known/…` and fails at the first login, so it can no longer read green on either surface. **#277 — OIDC egress bypassed the global proxy.** Discovery, JWKS and the token exchange went direct, contradicting "every outbound HTTP call honours the global proxy" — in the egress-restricted deployment that feature exists for, SSO just timed out. `proxy.resolve` is now applied per provider at registration and threaded through Authlib's `client_kwargs`, which is exactly where httpx takes `proxy`/`trust_env`. The registry cache is versioned on the proxy row as well as the OIDC row, so a /admin/proxy change reaches SSO without an SSO edit. **#275 — AI resolve() 500'd instead of failing closed.** `resolve` overlays the row with `model_copy(update=...)`, which skips pydantic validation, but `validate_selection` never checked `max_tlp` or `timeout`. A row carrying `max_tlp="PURPLE"` therefore stayed "valid", and `TLP(settings.ai_max_tlp)` raised inside `should_send_report` — outside every fail-soft try — 500ing every AI endpoint. Both fields are validated now, so a bad row disables the backend, which is what the resolver's docstring promises. Tests: the 409-on-stale contract for transitions plus the client-side flush ordering; proxy-password and Slack-path redaction end to end through the template including `data-search`, the deny-by-default classifier, and the extended URL-shaped audit; MISP/Webhook/SSO tile tri-states; OIDC proxy kwargs asserted through Authlib's own `extract_client_kwargs` (with the NO_PROXY bypass, SYSTEM mode, and cache invalidation on a proxy change); and bogus `max_tlp`/`timeout` resolving to a disabled backend with a fail-soft assist response. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQpYwRbt1YhpTXPb46MR5v --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 22 +++ CLAUDE.md | 2 +- src/iceberg/auth/routes.py | 36 +++- src/iceberg/services/ai_settings.py | 27 ++- src/iceberg/services/effective_config.py | 233 ++++++++++++++++++++--- src/iceberg/services/oidc_settings.py | 52 +++++ src/iceberg/static/css/iceberg.css | 3 + src/iceberg/static/js/tags.js | 28 ++- src/iceberg/templates/admin_config.html | 6 +- src/iceberg/templates/report_edit.html | 9 +- tests/test_admin_home.py | 27 ++- tests/test_ai_settings.py | 57 ++++++ tests/test_effective_config.py | 212 ++++++++++++++++++++- tests/test_proxy.py | 116 +++++++++++ tests/test_report_editor_conflict.py | 61 ++++++ 16 files changed, 855 insertions(+), 44 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aeb5afc..d267da2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ _The lean CLAUDE.md keeps a compressed auth summary and a one-line-per-invariant list; the full paragraphs live here._ -Authentication is **multi-provider OIDC** (Microsoft Entra, Authentik, Auth0, Okta — simultaneously; Authorization Code flow with PKCE), admin-configured on the `OIDCSettings` singleton row (`/admin/oidc`, seeded from the legacy `ICEBERG_OIDC_*` env on first read). One generic Authlib registration per **enabled** provider (`services/oidc_settings.enabled_providers`) plus a per-IdP **adapter** (`auth/oidc/`: a `StandardOIDCAdapter` base + thin `entra`/`authentik`/`auth0`/`okta` claim-extraction subclasses in a self-registering registry) drives a single parametrised route pair `/auth/oidc/{provider}/{login,callback}`; the legacy `/auth/entra/login` + `/auth/callback` remain Entra back-compat aliases. A per-provider `role_map` (`"group=ROLE,…"`) maps the IdP's group/role claim to an Iceberg role (unmapped/absent → read-only `STAKEHOLDER`; Entra's >200-group "overage" fails closed). A claim value that merely *spells* a role is honoured only on an **app-roles** claim (`roles` — values the operator defines for this app) with **no** `role_map` set: that is the legacy single-Entra flow. A directory `groups` claim (the Authentik/Okta default) is an uncurated org-wide namespace, so a pre-existing "Admin" group there provisions a `STAKEHOLDER`, never an admin, and writing a `role_map` disables the name-is-role fallback entirely (#269). A missing **or explicitly unverified** email is rejected, and provisioning is keyed on the immutable `(auth_provider, issuer, sub)` triple (email is non-identifying — the same person may exist under two IdPs — and a cross-provider `(issuer, sub)` collision is refused). An email owned by an **unbound** legacy/dev row is refused on both the creation *and* the update path, so an IdP-side email change can't shadow an account an administrator must link explicitly (#276); *bound* co-owners stay allowed, which is the designed cross-provider case. Each provider's client secret is **env-only** (`ICEBERG_OIDC__CLIENT_SECRET`). Optional Entra profile claims (`department`, `jobTitle`, `companyName`, `officeLocation` by default) are persisted on the user for stakeholder categorisation. After login Iceberg mints its own short-lived JWT with a per-user `token_version` claim — sent as a Bearer header by API clients, or stored in a signed session cookie by the portal — so logout can invalidate existing Iceberg tokens and the "all endpoints JWT-authenticated" rule holds uniformly. A **dev-login bypass** (`ICEBERG_DEV_AUTH=true`, disabled when `ICEBERG_ENVIRONMENT=prod`) issues a JWT for a chosen role without an IdP, for local development and tests. The JWT and the OIDC session cookie are signed with **purpose-separated keys** derived from `ICEBERG_SECRET_KEY` via HMAC-SHA256 (`auth/signing.py`, `jwt` vs `session` contexts), so a token from one signing context can never validate in the other. A prod instance with no usable login path (dev auth hard-off, OIDC unset) logs a startup warning rather than failing boot (`main._warn_if_no_login_path`). +Authentication is **multi-provider OIDC** (Microsoft Entra, Authentik, Auth0, Okta — simultaneously; Authorization Code flow with PKCE), admin-configured on the `OIDCSettings` singleton row (`/admin/oidc`, seeded from the legacy `ICEBERG_OIDC_*` env on first read). One generic Authlib registration per **enabled** provider (`services/oidc_settings.enabled_providers`) plus a per-IdP **adapter** (`auth/oidc/`: a `StandardOIDCAdapter` base + thin `entra`/`authentik`/`auth0`/`okta` claim-extraction subclasses in a self-registering registry) drives a single parametrised route pair `/auth/oidc/{provider}/{login,callback}`; the legacy `/auth/entra/login` + `/auth/callback` remain Entra back-compat aliases. A per-provider `role_map` (`"group=ROLE,…"`) maps the IdP's group/role claim to an Iceberg role (unmapped/absent → read-only `STAKEHOLDER`; Entra's >200-group "overage" fails closed). A claim value that merely *spells* a role is honoured only on an **app-roles** claim (`roles` — values the operator defines for this app) with **no** `role_map` set: that is the legacy single-Entra flow. A directory `groups` claim (the Authentik/Okta default) is an uncurated org-wide namespace, so a pre-existing "Admin" group there provisions a `STAKEHOLDER`, never an admin, and writing a `role_map` disables the name-is-role fallback entirely (#269). A missing **or explicitly unverified** email is rejected, and provisioning is keyed on the immutable `(auth_provider, issuer, sub)` triple (email is non-identifying — the same person may exist under two IdPs — and a cross-provider `(issuer, sub)` collision is refused). An email owned by an **unbound** legacy/dev row is refused on both the creation *and* the update path, so an IdP-side email change can't shadow an account an administrator must link explicitly (#276); *bound* co-owners stay allowed, which is the designed cross-provider case. Each provider's client secret is **env-only** (`ICEBERG_OIDC__CLIENT_SECRET`). All OIDC outbound HTTP — discovery metadata, JWKS, the token exchange — routes through the **global proxy** like every other outbound subsystem (#277): `proxy.resolve` is applied per provider at registration and threaded into Authlib's `client_kwargs`, which is where httpx picks up `proxy`/`trust_env`; the registry cache is versioned on the OIDC **and** proxy rows so a `/admin/proxy` change reaches SSO without an SSO edit. Optional Entra profile claims (`department`, `jobTitle`, `companyName`, `officeLocation` by default) are persisted on the user for stakeholder categorisation. After login Iceberg mints its own short-lived JWT with a per-user `token_version` claim — sent as a Bearer header by API clients, or stored in a signed session cookie by the portal — so logout can invalidate existing Iceberg tokens and the "all endpoints JWT-authenticated" rule holds uniformly. A **dev-login bypass** (`ICEBERG_DEV_AUTH=true`, disabled when `ICEBERG_ENVIRONMENT=prod`) issues a JWT for a chosen role without an IdP, for local development and tests. The JWT and the OIDC session cookie are signed with **purpose-separated keys** derived from `ICEBERG_SECRET_KEY` via HMAC-SHA256 (`auth/signing.py`, `jwt` vs `session` contexts), so a token from one signing context can never validate in the other. A prod instance with no usable login path (dev auth hard-off, OIDC unset) logs a startup warning rather than failing boot (`main._warn_if_no_login_path`). The session cookie is `SameSite=Lax` + `HttpOnly` (and `Secure` in prod); as defence-in-depth a **same-origin CSRF middleware** (`auth/csrf.py`) rejects any cookie-authenticated state-changing request whose `Origin`/`Referer` doesn't match the host (Bearer API clients and anonymous requests are exempt — token auth isn't browser-CSRF-prone). Logout is **POST-only** so it can't be triggered cross-site. @@ -96,7 +96,7 @@ Audience groups are admin-managed via both the JSON API and `/admin/audience`. T `GET /api/reports/{id}/stix` exports a visible report as a STIX 2.1 bundle (`application/stix+json`) without turning Iceberg into an IOC store: the report becomes a STIX `report` SDO and controlled taxonomy tags become threat-actor, malware, campaign, attack-pattern or sector identity objects. A read-only TAXII-shaped API rooted at `GET /api/taxii2/` serves one `published-reports` collection: collection metadata, manifest entries for served STIX object ids with report-title metadata, and flattened STIX objects from published reports visible to the authenticated user (stakeholders keep audience-group scoping; drafts are excluded for everyone). Manifest/object pulls support pragmatic TAXII query params for incremental clients: `added_after`, `limit`, `next`, `match[type]`, and `match[id]`. The report view exposes the direct STIX download. `GET /api/reports/{id}/related` uses a local, rebuildable `ReportEmbedding` table for access-scoped related products; embeddings are upserted on publish, shown in a report-view side panel when available, and can be rebuilt with `iceberg-rebuild-related`. ### Governed AI assist (`src/iceberg/services/ai.py`, `api/ai.py`) -AI assist is **off by default** (`ICEBERG_AI_BACKEND=none`). The provider is **admin-editable** at `/admin/ai` — the `ICEBERG_AI_*` env values seed a single `AISettings` row (`services/ai_settings.py`, the `MISPSettings`/`ProxySettings` DB-singleton pattern) on first read, then the row is the source of truth. `ai_settings.resolve(session)` overlays that row onto the process `Settings` (the secret `ICEBERG_AI_API_KEY` is **never** overridden — it stays env-sourced), and `api/ai.py` threads the resolved config into every `ai_service` call via an `AIConfig` dependency, so provider/model/timeout/TLP-ceiling changes take effect without a restart while `services/ai.py` keeps operating on a `Settings` object. The writer-only API offers advisory endpoints for key judgements, source summaries, tag suggestions, Diamond/ACH starts, analytic challenge notes, and **IOC extraction from a source** (`POST /api/ai/extract-iocs`, the `ioc_extract` task — FR #95). Backends selectable via `ICEBERG_AI_BACKEND` / the row (validated at config load + by `ai_settings.validate_selection`): `none`, **`openai`** and **`gemini`** (first-party APIs on OpenAI-compatible endpoints with **hard-pinned base URLs** so a DB edit can't redirect the key), **`ollama`** (a local server whose base URL must match the operator-approved `ICEBERG_AI_OLLAMA_BASE_URL`), **`openai-compatible`** (a generic `/chat/completions` endpoint, pinned the same way to `ICEBERG_AI_OPENAI_COMPATIBLE_BASE_URL` — the env value is the trust anchor and the row may only match it, an unset pin refuses the backend, and the check is re-run **inside the backend at call time**, not just on the admin form, so nothing reaches an unapproved host even if the row is written directly; #270), **`claude`** (Anthropic's first-party API via the official `anthropic` SDK — optional `anthropic` extra; default model `claude-opus-4-8`; key env-only `ICEBERG_AI_API_KEY`), and **`bedrock`** (Amazon Bedrock via the SDK's `AnthropicBedrockMantle` client — optional `bedrock` extra; default model `anthropic.claude-opus-4-8`; `ICEBERG_AI_AWS_REGION` + the standard AWS credential chain, no API key). Admin saves + the connectivity probe are audited (`AI_SETTINGS_UPDATED`/`AI_TEST`); the rail link `/admin/ai` is admin-only and the API key surfaces only as a set/not-set pill. Backends are **`AIBackend` ABC adapters** in a `_BACKENDS` registry (FR #123): the base `run()` is the template method that owns the cross-cutting plumbing (fail-soft wrapping, the `BackendUnavailable`→`disabled` mapping, the `AISuggestion`+provenance envelope), and each subclass implements only `_complete()` (the provider call + response→dict map). `ClaudeBackend`/`BedrockBackend` extend a shared `_AnthropicBackend` (they differ only in client construction + default model) and deliberately omit `temperature`/`thinking` (Opus 4.x rejects `temperature`); the SDK's `anthropic` import is **lazy** inside `_client()` (a backend enabled without its extra installed fails soft). Adding a provider is a subclass + one registry entry (keep `config._AI_BACKENDS` in sync — config can't import the registry without a layering cycle). All calls pass through `services/ai.py` regardless of backend: report content is TLP-gated by `ICEBERG_AI_MAX_TLP`, failures are fail-soft, prompts/responses are not logged, `AuditAction.AI_ASSIST` records metadata only, and every backend is **proxy-aware** (the global outbound proxy is threaded into the SDK's custom httpx client). The **Diamond/ACH** tasks build their payload from *all* of a notebook's reports, so the gate is applied **per included report** (`ai.sendable_reports` filters every over-ceiling report out of the payload, not just gating on the first — #97); if no report clears the ceiling the task fails closed with no egress. Accepting a suggestion is explicit and stamps `ai_provenance` on the report/source field. **IOC extraction** reads a notebook `Source`'s text (no server-side fetch) and the suggested candidates are refanged + constrained to the curated `IOCType` set by `iocs.normalise_candidates` before the analyst promotes a subset (each accepted row reuses the ordinary `POST /api/notebooks/{id}/iocs` create with the originating source as provenance). Source-content egress (`summarise-source` + `ioc_extract`) is **TLP-gated by the source's own `tlp`** against `ICEBERG_AI_MAX_TLP` (`ai.should_send_source`, mirroring `should_send_report`): an over-ceiling source returns a fail-soft "Source TLP exceeds the configured AI egress ceiling" with no egress. The notebook Indicators section shows the review UI when AI is enabled; the feed-reader surface is a fast-follow (a feed item becomes a `Source` on *send to notebook*, then uses this same flow). +AI assist is **off by default** (`ICEBERG_AI_BACKEND=none`). The provider is **admin-editable** at `/admin/ai` — the `ICEBERG_AI_*` env values seed a single `AISettings` row (`services/ai_settings.py`, the `MISPSettings`/`ProxySettings` DB-singleton pattern) on first read, then the row is the source of truth. `ai_settings.resolve(session)` overlays that row onto the process `Settings` (the secret `ICEBERG_AI_API_KEY` is **never** overridden — it stays env-sourced), and `api/ai.py` threads the resolved config into every `ai_service` call via an `AIConfig` dependency, so provider/model/timeout/TLP-ceiling changes take effect without a restart while `services/ai.py` keeps operating on a `Settings` object. The writer-only API offers advisory endpoints for key judgements, source summaries, tag suggestions, Diamond/ACH starts, analytic challenge notes, and **IOC extraction from a source** (`POST /api/ai/extract-iocs`, the `ioc_extract` task — FR #95). Backends selectable via `ICEBERG_AI_BACKEND` / the row (validated at config load + by `ai_settings.validate_selection`, which is the *whole* fail-closed surface — it also checks `max_tlp` and `timeout`, the two fields `resolve` overlays via `model_copy(update=...)` and so past pydantic's validators; unchecked, a bad `max_tlp` reached `TLP(settings.ai_max_tlp)` outside every fail-soft `try` and 500'd every AI endpoint, #275): `none`, **`openai`** and **`gemini`** (first-party APIs on OpenAI-compatible endpoints with **hard-pinned base URLs** so a DB edit can't redirect the key), **`ollama`** (a local server whose base URL must match the operator-approved `ICEBERG_AI_OLLAMA_BASE_URL`), **`openai-compatible`** (a generic `/chat/completions` endpoint, pinned the same way to `ICEBERG_AI_OPENAI_COMPATIBLE_BASE_URL` — the env value is the trust anchor and the row may only match it, an unset pin refuses the backend, and the check is re-run **inside the backend at call time**, not just on the admin form, so nothing reaches an unapproved host even if the row is written directly; #270), **`claude`** (Anthropic's first-party API via the official `anthropic` SDK — optional `anthropic` extra; default model `claude-opus-4-8`; key env-only `ICEBERG_AI_API_KEY`), and **`bedrock`** (Amazon Bedrock via the SDK's `AnthropicBedrockMantle` client — optional `bedrock` extra; default model `anthropic.claude-opus-4-8`; `ICEBERG_AI_AWS_REGION` + the standard AWS credential chain, no API key). Admin saves + the connectivity probe are audited (`AI_SETTINGS_UPDATED`/`AI_TEST`); the rail link `/admin/ai` is admin-only and the API key surfaces only as a set/not-set pill. Backends are **`AIBackend` ABC adapters** in a `_BACKENDS` registry (FR #123): the base `run()` is the template method that owns the cross-cutting plumbing (fail-soft wrapping, the `BackendUnavailable`→`disabled` mapping, the `AISuggestion`+provenance envelope), and each subclass implements only `_complete()` (the provider call + response→dict map). `ClaudeBackend`/`BedrockBackend` extend a shared `_AnthropicBackend` (they differ only in client construction + default model) and deliberately omit `temperature`/`thinking` (Opus 4.x rejects `temperature`); the SDK's `anthropic` import is **lazy** inside `_client()` (a backend enabled without its extra installed fails soft). Adding a provider is a subclass + one registry entry (keep `config._AI_BACKENDS` in sync — config can't import the registry without a layering cycle). All calls pass through `services/ai.py` regardless of backend: report content is TLP-gated by `ICEBERG_AI_MAX_TLP`, failures are fail-soft, prompts/responses are not logged, `AuditAction.AI_ASSIST` records metadata only, and every backend is **proxy-aware** (the global outbound proxy is threaded into the SDK's custom httpx client). The **Diamond/ACH** tasks build their payload from *all* of a notebook's reports, so the gate is applied **per included report** (`ai.sendable_reports` filters every over-ceiling report out of the payload, not just gating on the first — #97); if no report clears the ceiling the task fails closed with no egress. Accepting a suggestion is explicit and stamps `ai_provenance` on the report/source field. **IOC extraction** reads a notebook `Source`'s text (no server-side fetch) and the suggested candidates are refanged + constrained to the curated `IOCType` set by `iocs.normalise_candidates` before the analyst promotes a subset (each accepted row reuses the ordinary `POST /api/notebooks/{id}/iocs` create with the originating source as provenance). Source-content egress (`summarise-source` + `ioc_extract`) is **TLP-gated by the source's own `tlp`** against `ICEBERG_AI_MAX_TLP` (`ai.should_send_source`, mirroring `should_send_report`): an over-ceiling source returns a fail-soft "Source TLP exceeds the configured AI egress ceiling" with no egress. The notebook Indicators section shows the review UI when AI is enabled; the feed-reader surface is a fast-follow (a feed item becomes a `Source` on *send to notebook*, then uses this same flow). ### Inbound collection (RSS ingestion) (`src/iceberg/services/feeds.py`, `web/admin_feeds.py`, `web/feeds.py`) The **inbound collection** channel (FR #50, roadmap backlog I): external RSS/Atom feeds, **admin-configured** at `/admin/feeds` (CRUD + "Fetch all now"), whose articles populate a **writer-only analyst feed reader** at `/feeds` where an analyst **"sends an article to a notebook"** (existing or new) — creating an auto-graded `Source` via `services/notebooks.py` (`add_source`/`create_notebook`) and stamping `ingested_at`. **One service** (`services/feeds.py`) owns admin CRUD, fetching and ingestion. `fetch_feed` does **bounded, per-feed-isolated outbound HTTP** (`httpx.stream` with `ICEBERG_RSS_FETCH_TIMEOUT` and `ICEBERG_RSS_MAX_RESPONSE_BYTES`, manual redirect handling with every hop re-validated and redirect bodies left unread), parses with **`feedparser`** only after the byte cap is enforced (tolerating `parsed.bozo`), **nh3-sanitises** each item's summary/content (the same boundary as `rendering/markdown.py`), and upserts items deduped on `(feed_id, guid)`; **every failure is logged + recorded on the feed (`fetch_error`), never raised** (mirrors the dissemination per-recipient and `siem._safe` patterns). Fetching is driven through the **durable job outbox**: both the **opt-in scheduled poller** — a lightweight `asyncio` loop in `main.py`'s `lifespan` (gated by `ICEBERG_RSS_POLL_ENABLED`, interval `ICEBERG_RSS_POLL_INTERVAL_MINUTES`, **off by default** so tests/dev never reach the network) — and the admin "Fetch all now" button **enqueue an `RSS_POLL` `OutboxJob`** (committed before any network I/O, so a crash leaves an inspectable row for `iceberg-worker`) and then opportunistically process due jobs in a worker thread (`anyio.to_thread.run_sync` with its own `Session`). Per-feed item counts are capped by `ICEBERG_RSS_MAX_ITEMS_PER_FEED`, and the poller's job idempotency key serialises concurrent ticks so two app processes can't double-fetch the same cycle. @@ -118,7 +118,7 @@ Security-relevant events are **persisted locally** (the `AuditEvent` table — t **Application logging (`logging_config.py`).** Ordinary `iceberg.*` app logs are configured once at app startup from env: `ICEBERG_LOG_LEVEL` and `ICEBERG_LOG_FORMAT` (`auto` = text outside prod, JSON in prod). `AuditMiddleware` also seeds a contextvar with the request `correlation_id`, so app logs emitted during a request line up with the audit trail; non-request logs use `correlation_id="-"`. The `iceberg.audit` stdout SIEM payload is compatibility-sensitive and passes through as the raw OWASP JSON line rather than being double-encoded into the app-log JSON envelope. Uvicorn's own loggers remain server-owned. -**Effective configuration (`services/effective_config.py`, `web/admin_config.py`, read-only `/admin/config`).** An admin-only viewer that answers "what config is this process using, where did each value come from, and which optional features are available?" without shell access. `snapshot(session)` introspects the pydantic `Settings` **and** the six admin-editable DB settings rows (Audit/Proxy/MISP/Webhook/AI/OIDC), emitting one row per operationally-meaningful field with a **provenance** — `database` (a settings row is authoritative), `environment` (`settings.model_fields_set`), or `built-in default`. **Secrets never cross the boundary**: a field in the explicit secret set is coerced to a plain `set`/`not set` string server-side (no value or prefix serialised). It also re-runs the prod boot-guards (`config._guard_production`'s checks) **non-fatally** to list every issue, surfaces startup advisories (no-login-path, console-email-in-prod, in-memory-rate-limit-in-prod), and renders feature-capability tiles (environment, AI backend, enabled SSO providers, dev-login, rate limiting, email, RSS poll, MISP/webhook, Typst-available). The page is server-rendered with a CSP-safe Alpine text filter (`configFilter` in `tags.js`, rows carry a `data-search` attribute — no JSON island needed). Read-only: no model, no save path. +**Effective configuration (`services/effective_config.py`, `web/admin_config.py`, read-only `/admin/config`).** An admin-only viewer that answers "what config is this process using, where did each value come from, and which optional features are available?" without shell access. `snapshot(session)` introspects the pydantic `Settings` **and** the six admin-editable DB settings rows (Audit/Proxy/MISP/Webhook/AI/OIDC), emitting one row per operationally-meaningful field with a **provenance** — `database` (a settings row is authoritative), `environment` (`settings.model_fields_set`), or `built-in default`. **Secrets never cross the boundary**: a field in the explicit secret set is coerced to a plain `set`/`not set` string server-side (no value or prefix serialised), and that promise extends to credentials carried *inside* an ordinary value — inline URL userinfo (`http://user:pass@proxy:3128`, the standard proxy form, which `services/proxy.py` passes through untouched) is scrubbed from **every** value, and a bearer-equivalent webhook URL is reduced to its origin. Redaction is **deny-by-default**: a URL/DSN-shaped field renders in full only once it is listed in `PLAINTEXT_URL_FIELDS`, and a guard test fails on any URL-shaped field (on `Settings` *or* a DB row) that is in none of the three classification sets — so a future `sentry_dsn` is redacted *and* flagged rather than printed (#273). The capability tiles report **resolved** state, not stored flags: MISP/Webhook use the same off → not-configured → enabled tri-state as the `/admin` hub (an enabled MISP with no URL or no env API key cannot send, so it reads amber, not a green "on"), and the SSO tile checks each enabled provider's client secret **and** its locator via `oidc_settings.validate_provider` (tenant id / domain / base URL + slug), since a blank locator still yields a syntactically valid discovery URL that fails only at the first login (#274). It also re-runs the prod boot-guards (`config._guard_production`'s checks) **non-fatally** to list every issue, surfaces startup advisories (no-login-path, console-email-in-prod, in-memory-rate-limit-in-prod), and renders feature-capability tiles (environment, AI backend, enabled SSO providers, dev-login, rate limiting, email, RSS poll, MISP/webhook, Typst-available). The page is server-rendered with a CSP-safe Alpine text filter (`configFilter` in `tags.js`, rows carry a `data-search` attribute — no JSON island needed). Read-only: no model, no save path. **Settings & integrations hub (`web/admin_home.py`, `templates/admin_home.html`, `/admin`).** The map on top of the deep config pages: one tile per admin-configurable subsystem (AI, MISP, proxy, RSS, webhook, SSO under *Outbound integrations*; audit/SIEM and effective config under *Governance*), each with a status pill and a link in, plus the two curation screens (taxonomy, audience groups). Status comes from `effective_config.admin_hub_tiles(session)`, which reads the *same* settings singletons the config pages themselves edit, so the hub can never disagree with the page it links to — it owns no state and has no save path. The pill distinguishes **OFF** (opt-in integration disabled) from **NOT CONFIGURED** (enabled but missing an endpoint or its env-only secret), which is the state an operator most needs flagged: it looks on but cannot deliver. Secrets are only ever consulted as set/not-set, never rendered. @@ -135,7 +135,7 @@ The portal is a **"command-center" design** — a light, print-like, authoritati **⌘K command palette.** An additive Alpine overlay in `base.html` (jump-to over the role's nav items, built as a Jinja `jump_items` list); ⌘K/Ctrl-K or the topbar trigger opens it, arrow keys + Enter navigate, Esc closes. It follows the combobox/listbox dialog pattern: focus stays in the palette while open, the active result is exposed with `aria-activedescendant`, and focus returns to the invoking control on close. No backend dependency (live `/api/search` results are a noted future hook). -**Report editor (`report_edit.html`).** A full-height 3-pane workspace (`.editor-shell`): a command header (inline title + **marking chips** + lifecycle flow + save-state + Submit/Approve/Publish), then a `1fr 1fr 340px` grid of **markdown ∣ live preview ∣ docked tabbed panel**, verb-labelled and kept to four — **Cite · Classify · Link · Publish** (plus **Assist** only when the AI backend is available to an editor). *Link* merges the requirement and audience forms; *Publish* holds the lifecycle stepper, the rendered-PDF list, and — pinned in the dock footer under every tab — the one lifecycle transition available to this user, replacing the separate subhead stepper + submit row. There is **one save model**: the editor autosaves and the header `save-state` is the only report of it (the old "Save draft" button is gone; a `