refactor(settings): validate cross-field rules in the model

The "AUTH_TYPE=oidc requires OIDC_ISSUER, OIDC_CLIENT_ID and
OIDC_FRONTEND_URL" check lived in app.py, so it only ran when the Flask
app was imported; a worker or script with the same misconfiguration
started fine. It is now a model validator on the auth group and runs
wherever Settings is loaded, with the same message.

DEPLOYMENT_TYPE, which app.py read straight from the environment to
decide whether a missing JWT_SECRET_KEY is fatal, is a documented
setting on the server group now, so it shows up in the reference like
every other variable the app reads.
This commit is contained in:
arc53-machine committed 2026-09-17 11:09:09 +01:00
1 parent 95d0799494
commit a8dab8864d
5 files changed
+43 -12

No files matched your search

@@ -1111,6 +1111,12 @@ Public callback URL for MCP OAuth; unset derives it from CONNECTOR_REDIRECT_BASE
Serving the UI, public URLs, and process-level knobs of the API server.
### `DEPLOYMENT_TYPE`
Type `str`, default unset.
Deployment class, e.g. cloud or production. A production class refuses to run without a configured JWT_SECRET_KEY instead of generating a local one on disk.
### `SERVE_UI`
Type `bool`, default `true`.
+1 -10
View File
@@ -1,5 +1,4 @@
import logging
import os
import platform
import uuid
@@ -170,16 +169,8 @@ def enforce_document_upload_request_size_limit():
# only local development may use the atomic filesystem fallback.
settings.JWT_SECRET_KEY = resolve_jwt_secret_key(
settings.JWT_SECRET_KEY,
os.getenv("DEPLOYMENT_TYPE"),
settings.DEPLOYMENT_TYPE,
)
if settings.AUTH_TYPE == "oidc":
_missing_oidc = [
name
for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
if not getattr(settings, name)
]
if _missing_oidc:
raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}")
SIMPLE_JWT_TOKEN = None
if settings.AUTH_TYPE == "simple_jwt":
payload = {"sub": "local"}
+13 -1
View File
@@ -4,11 +4,15 @@ from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from pydantic import Field, field_validator, model_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice, normalize_secret
#: Settings an OIDC deployment cannot run without; checked when AUTH_TYPE=oidc.
OIDC_REQUIRED = ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
class AuthSettings(SettingsGroup):
"""How users authenticate: none, a shared token, per-session JWTs, or OIDC SSO."""
@@ -91,3 +95,11 @@ class AuthSettings(SettingsGroup):
def _normalize_auth_type(cls, v):
# ``AUTH_TYPE=None`` and ``AUTH_TYPE=`` in .env both mean "no authentication".
return normalize_choice(normalize_secret(v))
@model_validator(mode="after")
def _require_oidc_settings(self):
if self.AUTH_TYPE == "oidc":
missing = [name for name in OIDC_REQUIRED if not getattr(self, name)]
if missing:
raise ValueError(f"AUTH_TYPE=oidc requires settings: {', '.join(missing)}")
return self
+7
View File
@@ -12,6 +12,13 @@ from docsgpt.core.settings._shared import SettingsGroup
class ServerSettings(SettingsGroup):
"""Serving the UI, public URLs, and process-level knobs of the API server."""
DEPLOYMENT_TYPE: Optional[str] = Field(
default=None,
description=(
"Deployment class, e.g. cloud or production. A production class refuses to run without a "
"configured JWT_SECRET_KEY instead of generating a local one on disk."
),
)
SERVE_UI: bool = Field(
default=True, description="Serve the web UI shipped in the package (docsgpt/static) from the API process."
)
+16 -1
View File
@@ -110,6 +110,21 @@ class TestReference:
)
@pytest.mark.unit
class TestCrossFieldRules:
OIDC = {"OIDC_ISSUER": "https://idp.example/", "OIDC_CLIENT_ID": "docsgpt", "OIDC_FRONTEND_URL": "http://app"}
def test_oidc_requires_issuer_client_and_frontend(self):
with pytest.raises(ValidationError, match="AUTH_TYPE=oidc requires settings: OIDC_CLIENT_ID, OIDC_FRONTEND_URL"):
Settings.model_validate({"AUTH_TYPE": "oidc", "OIDC_ISSUER": self.OIDC["OIDC_ISSUER"]})
def test_oidc_with_required_settings_loads(self):
assert Settings.model_validate({"AUTH_TYPE": "OIDC", **self.OIDC}).AUTH_TYPE == "oidc"
def test_oidc_settings_are_not_required_for_other_modes(self):
assert Settings.model_validate({"AUTH_TYPE": "session_jwt"}).OIDC_ISSUER is None
@pytest.mark.unit
class TestClosedChoices:
"""Enum-like settings are Literal types: a typo fails at startup instead of falling through."""
@@ -121,7 +136,7 @@ class TestClosedChoices:
@pytest.mark.parametrize(
("name", "raw", "expected"),
[
("AUTH_TYPE", " OIDC ", "oidc"),
("AUTH_TYPE", " Session_JWT ", "session_jwt"),
("VECTOR_STORE", "PGVector", "pgvector"),
("STORAGE_TYPE", "S3", "s3"),
("URL_STRATEGY", "Backend", "backend"),