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
8 changes: 4 additions & 4 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,30 @@ tagged release will snapshot it under a dated heading.
collision guard, previously enforced only when creating a new identity, now also covers the update
path; bound co-owners remain allowed for the designed cross-provider case.

- **`/admin/config` no longer prints credentials carried inside a URL** (#273). Inline proxy
userinfo (`http://user:pass@proxy:3128`) is scrubbed from every value, and a bearer-equivalent
webhook URL is reduced to its origin. Redaction is now deny-by-default: a URL/DSN-shaped field
renders in full only once it is acknowledged as plaintext, and a guard test fails on any that is
in none of the classification sets — on the DB settings rows as well as `Settings`.

### Fixed

- **OIDC outbound HTTP honours the global proxy** (#277). Discovery, JWKS and the token exchange
went direct, so SSO simply timed out in the egress-restricted deployment the proxy feature exists
for. The OAuth registry cache is versioned on the proxy row too, so a `/admin/proxy` change takes
effect without an SSO edit.
- **A bad AI settings row disables the backend instead of 500ing every endpoint** (#275).
`validate_selection` now covers `max_tlp` and `timeout` — the two fields `resolve` overlays past
pydantic's validators — so a row written outside the admin form fails closed as the resolver
promises, rather than raising outside the fail-soft path.
- **`/admin/config` capability tiles report resolved state, not stored flags** (#274). An enabled
MISP push with no URL or no env API key, and an SSO provider missing its client secret or its
locator (tenant id / domain / base URL), now read amber instead of green — the `/admin` hub was
already correct, so the page an operator opens *to debug* was the one disagreeing.
- **A lifecycle transition can no longer race the autosave debounce** (#278). Clicking "Publish &
disseminate" within ~1.2 s of typing dropped the pending save and froze the pre-edit text into the
immutable snapshot, unrecoverably. Transitions now flush the autosave first and refuse to move the
report forward if that flush fails.
- **Report editor: a stale-write conflict is reported and recoverable** (#271). With autosave as the
only save path, an optimistic-lock 409 was indistinguishable from a network blip: the editor
re-posted the same stale version forever, so a second writer's work was silently discarded. The
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Roles: `ADMIN`, `ANALYST`, `REVIEWER`, `STAKEHOLDER` (read-only).
- **Notebook access is role-wide** for any writer (ANALYST/REVIEWER/ADMIN), NOT owner-scoped (#65); `owner_id` is provenance only — the one owner/admin gate is deleting the whole notebook.
- **Inline-embed tokens** `[[diamond|figure|ach:ID]]` + bare `[[attack]]` (single source `embeds.py`); SVG / base64 `data:` URIs are injected **after** nh3 sanitisation; tokens are notebook-scoped, unknown/cross-notebook ids degrade to an "unavailable" notice.
- **TLP** is a display + dissemination-routing marking (never an in-portal read gate) that gates **AI egress** (`ICEBERG_AI_MAX_TLP`), **dissemination** (`ICEBERG_DISSEMINATION_MAX_TLP`), and the **MISP push** (`ICEBERG_MISP_MAX_TLP`).
- **Every outbound HTTP call honours the global proxy** (`proxy.resolve` — RSS/SIEM/MISP/AI/webhook); **external work rides the durable `OutboxJob` outbox**, enqueued in the same transaction as its cause and drained by `iceberg-worker` (lease/retry/backoff).
- **Every outbound HTTP call honours the global proxy** (`proxy.resolve` — RSS/SIEM/MISP/AI/webhook **and OIDC discovery/JWKS/token**); **external work rides the durable `OutboxJob` outbox**, enqueued in the same transaction as its cause and drained by `iceberg-worker` (lease/retry/backoff).
- **CSP-safe Alpine** — strict `script-src 'self'` (no `unsafe-inline`/`unsafe-eval`): the vendored CSP build, every component registered in `static/js/tags.js`, no inline JS / `on*=` handlers / `x-html`. **Middleware order** (outer→inner): `SecurityHeaders → BodySizeLimit → Audit → RateLimit → Session → CSRF` (`BodySizeLimit` rejects an oversized body with 413 before any inner middleware buffers it; `ICEBERG_MAX_BODY_MB`).
- **Hardening** — the one server-side fetcher (RSS + writer TAXII/MISP pull) rides a full **SSRF guard** (http(s)-only, private/loopback rejected, byte/timeout-bounded, redirect hops re-validated); uploads are MIME-whitelisted + size-capped + **magic-byte validated** (`services/upload_validation.py`); the audit `detail` JSON never carries secrets/PII; prod guards (`config._guard_production`) refuse a SQLite URL / weak `ICEBERG_SECRET_KEY` / wildcard `FORWARDED_ALLOW_IPS`.

Expand Down
36 changes: 30 additions & 6 deletions src/iceberg/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from ..config import get_settings
from ..db import get_session
from ..models import AuditAction, AuditCategory, AuditOutcome, Role
from ..services import audit
from ..services import audit, proxy as proxy_service, proxy_settings
from ..services import oidc_settings as oidc_settings_service
from ..services.users import OIDCIdentityError, upsert_user
from ..templating import templates
Expand All @@ -42,15 +42,17 @@
}

# Authlib OAuth registry, built lazily from the enabled providers. The cache is
# **versioned on ``OIDCSettings.updated_at``** so every uvicorn worker rebuilds
# when the admin config changes — not just the worker that handled the POST (a
# process-global reset only clears one worker; the DB timestamp is shared).
# **versioned on the OIDC + proxy rows' ``updated_at``** so every uvicorn worker
# rebuilds when the admin config changes — not just the worker that handled the
# POST (a process-global reset only clears one worker; the DB timestamps are
# shared).
_oauth: OAuth | None = None
_oauth_version: datetime | None = None
_oauth_version: tuple[datetime, datetime] | None = None


def _build_oauth(session: Session) -> OAuth:
oauth = OAuth()
proxy_row = proxy_settings.get(session)
for provider in oidc_settings_service.enabled_providers(session):
oauth.register(
name=provider.name,
Expand All @@ -60,14 +62,36 @@ def _build_oauth(session: Session) -> OAuth:
client_kwargs={
"scope": provider.scopes,
"code_challenge_method": "S256",
# Discovery, JWKS and the token exchange are ordinary outbound
# HTTP and must honour the global proxy like RSS/SIEM/MISP/AI/
# webhook do (#277) — without this, SSO simply times out in the
# egress-restricted deployment the proxy feature exists for.
# Authlib funnels all three through ``client_kwargs`` into
# ``httpx.AsyncClient``, which takes ``proxy``/``trust_env``
# directly. All three talk to the IdP's own host, so the
# discovery URL is the right target for the NO_PROXY decision.
**proxy_service.resolve(proxy_row, provider.metadata_url),
},
)
return oauth


def _config_version(session: Session) -> tuple[datetime, datetime]:
"""The cache key for the built registry — every input that shapes a client.

The proxy row is in here because the clients bake the resolved proxy in at
registration: without it, changing the proxy at ``/admin/proxy`` would not
reach OIDC until someone happened to edit the SSO config.
"""
return (
oidc_settings_service.get(session).updated_at,
proxy_settings.get(session).updated_at,
)


def _get_oauth(session: Session) -> OAuth:
global _oauth, _oauth_version
version = oidc_settings_service.get(session).updated_at
version = _config_version(session)
if _oauth is None or _oauth_version != version:
_oauth = _build_oauth(session)
_oauth_version = version
Expand Down
27 changes: 23 additions & 4 deletions src/iceberg/services/ai_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,18 @@ def validate_selection(row: AISettings) -> list[str]:
"""Return human-readable problems with a provider selection (empty = valid).

Enforces: known provider, a model when enabled, the required env key present,
Bedrock region set, and **base-URL pinning** — the ollama and generic
Bedrock region set, **base-URL pinning** — the ollama and generic
openai-compatible base URLs must each match their operator-approved env value
so a DB edit can't repoint a key. openai/gemini are hard-pinned in
``services/ai.py`` and need no base URL.
so a DB edit can't repoint a key (openai/gemini are hard-pinned in
``services/ai.py`` and need no base URL) — and the two fields ``resolve``
overlays without pydantic validation, ``max_tlp`` and ``timeout``.

This is the whole fail-closed surface: any field a bad row could carry into
``Settings`` has to be checked here, because ``resolve`` disables the backend
on a non-empty result and nothing downstream re-validates.
"""
from ..config import _AI_BACKENDS # local import avoids a load-time cycle
# Local import avoids a load-time cycle.
from ..config import _AI_BACKENDS, _TLP_VALUES

cfg = get_settings()
errors: list[str] = []
Expand All @@ -126,6 +132,19 @@ def validate_selection(row: AISettings) -> list[str]:
)
if backend == "bedrock" and not row.aws_region.strip():
errors.append("An AWS region is required for the Bedrock backend.")
# ``resolve`` overlays these onto Settings with ``model_copy(update=...)``,
# which SKIPS pydantic validation — so the field validators that would
# normally reject them never run. Unchecked, a row written outside the admin
# form turns ``TLP(settings.ai_max_tlp)`` into a ValueError raised OUTSIDE
# every fail-soft try, 500ing every AI endpoint. Validate here so a bad row
# disables the backend instead, which is what the resolver promises (#275).
if row.max_tlp not in _TLP_VALUES:
errors.append(
f"The TLP egress ceiling must be one of {', '.join(sorted(_TLP_VALUES))}; "
f"got {row.max_tlp!r}."
)
if not row.timeout > 0:
errors.append("The provider timeout must be greater than zero seconds.")
if backend in _ENV_PINNED_BACKENDS:
errors.extend(_base_url_pin_errors(row, backend, cfg))
return errors
Expand Down
Loading