diff --git a/.env.example b/.env.example
index 94e1379004..0f15b2ee1b 100644
--- a/.env.example
+++ b/.env.example
@@ -35,6 +35,28 @@ ENVIRONMENT=development
# API port (default: 8000)
PORT=8000
+# ============================================================================
+# Admin Auth (for /debug/* endpoints)
+# ============================================================================
+
+# GET /debug/status and GET /debug/ping can be authorized either by a valid
+# Cloudflare Access JWT (production browser flow) or by ADMIN_TOKEN.
+# Leave ADMIN_TOKEN unset if you only want Cloudflare Access auth.
+# Set it for local development, CI, or as a break-glass fallback when
+# Cloudflare Access is unavailable. Locally, any non-empty string works
+# because these endpoints are only reachable on localhost.
+# ADMIN_TOKEN=
+
+# Cloudflare Access (Zero Trust) settings — only needed when verifying the
+# Cf-Access-Jwt-Assertion header in production. Leave unset locally.
+# CF_ACCESS_TEAM_DOMAIN=anyplot.cloudflareaccess.com
+# CF_ACCESS_AUD=
+
+# Comma-separated list of allowed Google account email addresses for the
+# Cloudflare Access JWT path. Required for the JWT path to authorize anyone;
+# requests with a valid JWT but an unlisted email return 403.
+# ADMIN_ALLOWED_EMAILS=alice@example.com,bob@example.com
+
# ============================================================================
# AI Services (optional)
# ============================================================================
diff --git a/api/cloudbuild.yaml b/api/cloudbuild.yaml
index fed0bf7bd2..fa0c50f56a 100644
--- a/api/cloudbuild.yaml
+++ b/api/cloudbuild.yaml
@@ -6,6 +6,17 @@ substitutions:
_CPU: "1"
_MIN_INSTANCES: "1"
_MAX_INSTANCES: "3"
+ # Cloudflare Access (Zero Trust) for /debug/* — the team domain is stable;
+ # the AUD must be filled in once the Self-hosted Application is created in
+ # the Cloudflare Zero Trust dashboard. With AUD unset (or with no email
+ # in _ADMIN_ALLOWED_EMAILS), the JWT path in require_admin denies all and
+ # only the X-Admin-Token fallback is active.
+ _CF_ACCESS_TEAM_DOMAIN: "anyplot.cloudflareaccess.com"
+ _CF_ACCESS_AUD: ""
+ # Comma-separated. Browser users with a valid Cloudflare Access JWT but an
+ # email not in this list get 403. Required for the JWT path to authorize
+ # anyone.
+ _ADMIN_ALLOWED_EMAILS: ""
steps:
# Build the container image
@@ -53,11 +64,14 @@ steps:
- "--port=8000"
- "--allow-unauthenticated"
- "--add-cloudsql-instances=anyplot:europe-west4:anyplot-db"
- - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest"
+ - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest"
- "--set-env-vars=ENVIRONMENT=production"
- "--execution-environment=gen2"
- "--set-env-vars=GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
- "--set-env-vars=GCS_BUCKET=anyplot-images"
+ - "--set-env-vars=CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}"
+ - "--set-env-vars=CF_ACCESS_AUD=${_CF_ACCESS_AUD}"
+ - "--set-env-vars=^@^ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}"
- "--cpu-throttling"
- "--concurrency=15"
- "--timeout=600"
diff --git a/api/routers/debug.py b/api/routers/debug.py
index a07cc4418f..6932b6174a 100644
--- a/api/routers/debug.py
+++ b/api/routers/debug.py
@@ -6,7 +6,9 @@
import time
from collections import Counter
from datetime import datetime, timedelta, timezone
+from functools import lru_cache
+import jwt as pyjwt
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import text
@@ -22,14 +24,69 @@
router = APIRouter(prefix="/debug", tags=["debug"])
-def require_admin(x_admin_token: str | None = Header(default=None)) -> None:
- """Gate sensitive /debug/* endpoints behind a shared secret.
+@lru_cache(maxsize=1)
+def _jwks_client() -> pyjwt.PyJWKClient | None:
+ """Lazy, process-wide JWKS client for Cloudflare Access JWT verification.
- Without this gate, /debug/status and /debug/ping reflect quality scores,
- weakness aggregates, and DB latency to the public internet. When
- settings.admin_token is unset the endpoint is disabled (503), so a
- misconfigured prod deploy fails closed instead of fails open.
+ PyJWKClient caches keys per instance; we cache the instance itself so a
+ single Cloud Run worker only fetches the JWKS endpoint once at first
+ /debug request after cold start.
"""
+ if not settings.cf_access_team_domain:
+ return None
+ return pyjwt.PyJWKClient(f"https://{settings.cf_access_team_domain}/cdn-cgi/access/certs")
+
+
+def _verify_cf_access_jwt(token: str) -> str | None:
+ """Verify a Cloudflare Access JWT and return the authenticated email.
+
+ Returns None when the JWT path is unconfigured, the signature is invalid,
+ the token is expired, or the aud/iss claims don't match.
+ """
+ client = _jwks_client()
+ if client is None or not settings.cf_access_aud:
+ return None
+ try:
+ signing_key = client.get_signing_key_from_jwt(token)
+ claims = pyjwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256"],
+ audience=settings.cf_access_aud,
+ issuer=f"https://{settings.cf_access_team_domain}",
+ )
+ except pyjwt.PyJWTError:
+ return None
+ email = claims.get("email")
+ return email if isinstance(email, str) else None
+
+
+def require_admin(
+ x_admin_token: str | None = Header(default=None),
+ cf_access_jwt: str | None = Header(default=None, alias="Cf-Access-Jwt-Assertion"),
+) -> None:
+ """Gate sensitive /debug/* endpoints behind Cloudflare Access OR a shared secret.
+
+ Two paths:
+ 1. Browser path — Cloudflare Access verifies a Google identity at the edge,
+ forwards the request with `Cf-Access-Jwt-Assertion`. We verify the JWT
+ against Cloudflare's JWKS and check the email is on the allow-list.
+ 2. Token path — `X-Admin-Token` against `settings.admin_token`. Used by CI,
+ local dev, and break-glass access via the Cloud Run direct URL (which
+ bypasses Cloudflare).
+
+ Without `settings.admin_token` configured the token path is disabled (503),
+ so a misconfigured prod deploy without Cloudflare Access still fails closed.
+ """
+ if cf_access_jwt:
+ email = _verify_cf_access_jwt(cf_access_jwt)
+ if email and email in settings.admin_allowed_emails:
+ return
+ if email:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User {email} not authorized")
+ # Invalid JWT (signature/aud/iss/expiry) — fall through to token path so
+ # a misconfigured edge never strands the operator without break-glass access.
+
expected = settings.admin_token
if not expected:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Debug endpoints not configured")
diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx
index e25ddb4f0e..5b8262700a 100644
--- a/app/src/pages/DebugPage.test.tsx
+++ b/app/src/pages/DebugPage.test.tsx
@@ -95,7 +95,7 @@ describe('DebugPage', () => {
render();
await waitFor(() => {
- expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument();
expect(screen.getByText(/admin token required/i)).toBeInTheDocument();
@@ -112,6 +112,31 @@ describe('DebugPage', () => {
});
});
+ it('surfaces the server message on 403 (Cloudflare Access denial)', async () => {
+ // Cloudflare Access path: signed-in Google account not on the
+ // admin_allowed_emails allow-list. Backend returns 403 with the email
+ // in the detail; the page should switch to the auth-required screen and
+ // show that message instead of falling through to "failed to load: 403".
+ sessionStorage.clear();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({
+ ok: false,
+ status: 403,
+ json: () => Promise.resolve({ status: 403, message: 'User stranger@example.com not authorized', path: '/debug/status' }),
+ }),
+ ),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/stranger@example\.com not authorized/i)).toBeInTheDocument();
+ });
+ expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
+ });
+
it('submits the token, sends X-Admin-Token, and persists to sessionStorage', async () => {
sessionStorage.clear();
let callIndex = 0;
@@ -136,7 +161,7 @@ describe('DebugPage', () => {
render();
- const input = await screen.findByPlaceholderText('X-Admin-Token');
+ const input = await screen.findByPlaceholderText('Admin token (fallback)');
fireEvent.change(input, { target: { value: 'secret-xyz' } });
fireEvent.click(screen.getByRole('button', { name: /unlock/i }));
@@ -159,7 +184,7 @@ describe('DebugPage', () => {
expect(sessionStorage.getItem('anyplot.adminToken')).toBeNull();
});
// Form is still on screen because fetch still returns 401.
- expect(screen.getByPlaceholderText('X-Admin-Token')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Admin token (fallback)')).toBeInTheDocument();
});
});
diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx
index 59ee549e4f..5a1f408dfd 100644
--- a/app/src/pages/DebugPage.tsx
+++ b/app/src/pages/DebugPage.tsx
@@ -160,10 +160,14 @@ function pingColor(ms: number): string {
return colors.error;
}
-// Admin-token storage. /debug endpoints require X-Admin-Token in prod
-// (require_admin gate, api/routers/debug.py); we keep the token in
-// sessionStorage so it survives reloads of the same tab without persisting
-// across browser sessions.
+// Admin auth for /debug endpoints (require_admin in api/routers/debug.py).
+// Two paths:
+// - Cloudflare Access cookie set on .anyplot.ai → forwarded as Cf-Access-Jwt-Assertion
+// to api.anyplot.ai. credentials: 'include' is required for the cookie to
+// travel cross-origin to the API.
+// - X-Admin-Token header as a fallback (CI, break-glass, local dev). Stored
+// in sessionStorage so it survives reloads of the same tab without
+// persisting across browser sessions.
const ADMIN_TOKEN_KEY = 'anyplot.adminToken';
const readAdminToken = (): string => {
try { return sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? ''; } catch { return ''; }
@@ -176,7 +180,10 @@ const clearAdminToken = (): void => {
};
const adminFetch = (url: string, token: string): Promise =>
- fetch(url, token ? { headers: { 'X-Admin-Token': token } } : undefined);
+ fetch(url, {
+ credentials: 'include',
+ headers: token ? { 'X-Admin-Token': token } : undefined,
+ });
export function DebugPage() {
const [data, setData] = useState(null);
@@ -200,9 +207,17 @@ export function DebugPage() {
setLoading(true);
setError(null);
adminFetch(`${API_URL}/debug/status`, adminToken)
- .then(r => {
- if (r.status === 401 || r.status === 503) {
+ .then(async r => {
+ // 403 is the Cloudflare Access JWT path's denial: a signed-in Google
+ // account that isn't on the admin_allowed_emails allow-list. Surface
+ // it on the auth-required screen with the server's message so the
+ // user knows to sign in with a different account or ask for access.
+ if (r.status === 401 || r.status === 403 || r.status === 503) {
setAuthRequired(true);
+ if (r.status === 403) {
+ const body = await r.json().catch(() => ({}));
+ throw new Error(body?.message || 'this account is not authorized for /debug');
+ }
throw new Error(r.status === 503 ? 'admin auth not configured on server' : 'admin token required');
}
if (!r.ok) throw new Error(`${r.status}`);
@@ -301,14 +316,14 @@ export function DebugPage() {
debug · admin auth} />
- {error || 'admin token required'}
+ {error || 'sign in via your browser session, or paste an admin token as a fallback.'}
) => setTokenInput(e.target.value)}
sx={nativeControlSx}
diff --git a/core/config.py b/core/config.py
index a46a3d57ed..e27e655f99 100644
--- a/core/config.py
+++ b/core/config.py
@@ -5,8 +5,9 @@
defaults, and documentation.
"""
-from typing import Optional
+from typing import Any, Optional
+from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -156,6 +157,39 @@ class Settings(BaseSettings):
weakness aggregates, and DB latency to the public internet. Set via Secret
Manager in Cloud Run; pass via the `X-Admin-Token` request header."""
+ cf_access_team_domain: Optional[str] = None
+ """Cloudflare Access team domain, e.g. `anyplot.cloudflareaccess.com`.
+ Used as the JWT issuer and for fetching JWKS keys at
+ `https://{team_domain}/cdn-cgi/access/certs`. When unset, the JWT path in
+ require_admin is skipped (token-only mode)."""
+
+ cf_access_aud: Optional[str] = None
+ """Cloudflare Access Application AUD tag (UUID from the Zero Trust
+ dashboard). Validated as the JWT `aud` claim."""
+
+ admin_allowed_emails: list[str] = []
+ """Email addresses allowed to authenticate via Cloudflare Access for
+ /debug/* endpoints. Defaults to an empty list — must be set explicitly
+ in production. Accepts either a JSON array (`["a@b.com","c@d.com"]`)
+ or a comma-separated string (`a@b.com,c@d.com`) when set via env var."""
+
+ @field_validator("admin_allowed_emails", mode="before")
+ @classmethod
+ def _parse_admin_allowed_emails(cls, value: Any) -> Any:
+ """Allow `ADMIN_ALLOWED_EMAILS=foo@bar.com,baz@qux.com` in addition to
+ the JSON-array form pydantic-settings expects by default. A bare
+ string with no commas is treated as a single-element list."""
+ import json
+
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped:
+ return []
+ if stripped.startswith("["):
+ return json.loads(stripped)
+ return [item.strip() for item in stripped.split(",") if item.strip()]
+ return value
+
# =============================================================================
# CORS
# =============================================================================
diff --git a/pyproject.toml b/pyproject.toml
index 105106ef7b..0827c14269 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,6 +50,8 @@ dependencies = [
"pandas-stubs>=2.3.0",
# Caching
"cachetools>=7.0.6",
+ # Auth — Cloudflare Access JWT verification on /debug/*
+ "pyjwt[crypto]>=2.10.0",
]
[project.optional-dependencies]
diff --git a/tests/unit/api/test_debug.py b/tests/unit/api/test_debug.py
index f94ccece17..755353dcee 100644
--- a/tests/unit/api/test_debug.py
+++ b/tests/unit/api/test_debug.py
@@ -424,3 +424,209 @@ def test_cache_invalidate_unaffected_by_admin_token(self, auth_client) -> None:
# Without X-Admin-Token, cache invalidate still works given correct X-Cache-Token.
response = auth_client.post("/debug/cache/invalidate", headers={"X-Cache-Token": "cachesecret"})
assert response.status_code == 200
+
+
+class TestRequireAdminCfAccess:
+ """Tests for the Cloudflare Access JWT path of `require_admin`.
+
+ The browser path forwards `Cf-Access-Jwt-Assertion` from the Cloudflare
+ edge. We mock `_verify_cf_access_jwt` so these tests don't need a real
+ RS256 signature or JWKS network round-trip.
+ """
+
+ _ALLOWED = "meakeiok@gmail.com"
+ _DENIED = "stranger@example.com"
+
+ def test_status_200_when_jwt_email_allowed(self, auth_client) -> None:
+ """Valid JWT + email on allow-list → 200, no admin token needed."""
+ mock_repo = MagicMock()
+ mock_repo.get_all = AsyncMock(return_value=[])
+ with (
+ patch.object(settings, "admin_token", None),
+ patch.object(settings, "admin_allowed_emails", [self._ALLOWED]),
+ patch("api.routers.debug._verify_cf_access_jwt", return_value=self._ALLOWED),
+ patch("api.routers.debug.SpecRepository", return_value=mock_repo),
+ ):
+ response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "any.jwt.here"})
+ assert response.status_code == 200
+
+ def test_status_403_when_jwt_email_not_allowed(self, auth_client) -> None:
+ """Valid JWT but email not on allow-list → 403, no fall-through."""
+ with (
+ patch.object(settings, "admin_token", "supersecret"),
+ patch.object(settings, "admin_allowed_emails", [self._ALLOWED]),
+ patch("api.routers.debug._verify_cf_access_jwt", return_value=self._DENIED),
+ ):
+ response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "any.jwt.here"})
+ assert response.status_code == 403
+ assert self._DENIED in response.json()["message"]
+
+ def test_status_503_when_jwt_invalid_and_token_unset(self, auth_client) -> None:
+ """Invalid JWT + admin_token unset → falls through to 503 (fail-closed)."""
+ with (
+ patch.object(settings, "admin_token", None),
+ patch("api.routers.debug._verify_cf_access_jwt", return_value=None),
+ ):
+ response = auth_client.get("/debug/status", headers={"Cf-Access-Jwt-Assertion": "garbage"})
+ assert response.status_code == 503
+
+ def test_status_200_when_jwt_invalid_but_token_correct(self, auth_client) -> None:
+ """Invalid JWT + correct X-Admin-Token → 200 (break-glass fall-through)."""
+ mock_repo = MagicMock()
+ mock_repo.get_all = AsyncMock(return_value=[])
+ with (
+ patch.object(settings, "admin_token", "supersecret"),
+ patch("api.routers.debug._verify_cf_access_jwt", return_value=None),
+ patch("api.routers.debug.SpecRepository", return_value=mock_repo),
+ ):
+ response = auth_client.get(
+ "/debug/status", headers={"Cf-Access-Jwt-Assertion": "garbage", "X-Admin-Token": "supersecret"}
+ )
+ assert response.status_code == 200
+
+
+class TestJwksHelpers:
+ """Direct tests for the JWKS helpers — `_jwks_client` and `_verify_cf_access_jwt`.
+
+ The integration tests above mock `_verify_cf_access_jwt` wholesale, which
+ is the right shape there but leaves the helpers themselves uncovered. These
+ tests stub `pyjwt` so we can exercise every branch without needing a real
+ Cloudflare Access deployment or RS256 key material.
+ """
+
+ @staticmethod
+ def _reset_jwks_cache():
+ from api.routers.debug import _jwks_client
+
+ _jwks_client.cache_clear()
+
+ def test_jwks_client_returns_none_when_team_domain_unset(self) -> None:
+ """No team domain → no client (token-only mode)."""
+ from api.routers.debug import _jwks_client
+
+ self._reset_jwks_cache()
+ with patch.object(settings, "cf_access_team_domain", None):
+ assert _jwks_client() is None
+
+ def test_jwks_client_constructs_with_team_domain(self) -> None:
+ """Team domain set → returns a PyJWKClient pointed at /cdn-cgi/access/certs."""
+ from api.routers.debug import _jwks_client
+
+ self._reset_jwks_cache()
+ sentinel = MagicMock()
+ with (
+ patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"),
+ patch("api.routers.debug.pyjwt.PyJWKClient", return_value=sentinel) as mock_ctor,
+ ):
+ result = _jwks_client()
+ assert result is sentinel
+ mock_ctor.assert_called_once_with("https://anyplot.cloudflareaccess.com/cdn-cgi/access/certs")
+ self._reset_jwks_cache()
+
+ def test_verify_cf_access_jwt_returns_none_when_unconfigured(self) -> None:
+ """No JWKS client (team domain unset) → None, no exception."""
+ from api.routers.debug import _verify_cf_access_jwt
+
+ self._reset_jwks_cache()
+ with patch.object(settings, "cf_access_team_domain", None):
+ assert _verify_cf_access_jwt("some.jwt.token") is None
+
+ def test_verify_cf_access_jwt_returns_none_when_aud_unset(self) -> None:
+ """Team domain set but AUD unset → None (don't verify against missing aud)."""
+ from api.routers.debug import _verify_cf_access_jwt
+
+ self._reset_jwks_cache()
+ with (
+ patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"),
+ patch.object(settings, "cf_access_aud", None),
+ patch("api.routers.debug.pyjwt.PyJWKClient", return_value=MagicMock()),
+ ):
+ assert _verify_cf_access_jwt("some.jwt.token") is None
+ self._reset_jwks_cache()
+
+ def test_verify_cf_access_jwt_returns_email_on_valid_token(self) -> None:
+ """Valid signature + claims → email from `email` claim."""
+ from api.routers.debug import _verify_cf_access_jwt
+
+ self._reset_jwks_cache()
+ mock_client = MagicMock()
+ mock_client.get_signing_key_from_jwt.return_value = MagicMock(key="fake-key")
+ with (
+ patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"),
+ patch.object(settings, "cf_access_aud", "the-aud-uuid"),
+ patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client),
+ patch("api.routers.debug.pyjwt.decode", return_value={"email": "alice@example.com"}) as mock_decode,
+ ):
+ email = _verify_cf_access_jwt("good.jwt.token")
+ assert email == "alice@example.com"
+ # Confirm we passed the right audience and issuer to pyjwt.decode.
+ kwargs = mock_decode.call_args.kwargs
+ assert kwargs["audience"] == "the-aud-uuid"
+ assert kwargs["issuer"] == "https://anyplot.cloudflareaccess.com"
+ assert kwargs["algorithms"] == ["RS256"]
+ self._reset_jwks_cache()
+
+ def test_verify_cf_access_jwt_returns_none_on_pyjwt_error(self) -> None:
+ """pyjwt raises (bad signature, expired, wrong aud, …) → None."""
+ import jwt as pyjwt
+
+ from api.routers.debug import _verify_cf_access_jwt
+
+ self._reset_jwks_cache()
+ mock_client = MagicMock()
+ mock_client.get_signing_key_from_jwt.side_effect = pyjwt.PyJWTError("bad sig")
+ with (
+ patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"),
+ patch.object(settings, "cf_access_aud", "the-aud-uuid"),
+ patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client),
+ ):
+ assert _verify_cf_access_jwt("bad.jwt.token") is None
+ self._reset_jwks_cache()
+
+ def test_verify_cf_access_jwt_returns_none_on_non_string_email_claim(self) -> None:
+ """Defensive: claims with no email or non-str email → None."""
+ from api.routers.debug import _verify_cf_access_jwt
+
+ self._reset_jwks_cache()
+ mock_client = MagicMock()
+ mock_client.get_signing_key_from_jwt.return_value = MagicMock(key="fake-key")
+ with (
+ patch.object(settings, "cf_access_team_domain", "anyplot.cloudflareaccess.com"),
+ patch.object(settings, "cf_access_aud", "the-aud-uuid"),
+ patch("api.routers.debug.pyjwt.PyJWKClient", return_value=mock_client),
+ patch("api.routers.debug.pyjwt.decode", return_value={"email": None}),
+ ):
+ assert _verify_cf_access_jwt("token") is None
+ self._reset_jwks_cache()
+
+
+class TestAdminAllowedEmailsParsing:
+ """The admin_allowed_emails field accepts both comma-separated strings
+ (operator-friendly for `.env` files) and JSON arrays (pydantic-settings
+ default for list fields)."""
+
+ def test_parses_comma_separated_string(self) -> None:
+ from core.config import Settings
+
+ s = Settings(admin_allowed_emails="a@x.com,b@y.com, c@z.com ")
+ assert s.admin_allowed_emails == ["a@x.com", "b@y.com", "c@z.com"]
+
+ def test_parses_json_array(self) -> None:
+ from core.config import Settings
+
+ s = Settings(admin_allowed_emails='["a@x.com","b@y.com"]')
+ assert s.admin_allowed_emails == ["a@x.com", "b@y.com"]
+
+ def test_empty_string_yields_empty_list(self) -> None:
+ from core.config import Settings
+
+ s = Settings(admin_allowed_emails="")
+ assert s.admin_allowed_emails == []
+
+ def test_default_is_empty_list(self) -> None:
+ """Defaulting to an empty list prevents accidental authorization if the
+ operator forgets to configure ADMIN_ALLOWED_EMAILS in production."""
+ from core.config import Settings
+
+ s = Settings()
+ assert s.admin_allowed_emails == []
diff --git a/uv.lock b/uv.lock
index be8bab5731..23be4a6dd9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -235,6 +235,7 @@ dependencies = [
{ name = "pillow" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "scikit-learn" },
@@ -443,6 +444,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.14.0" },
{ name = "pygal", marker = "extra == 'lib-pygal'", specifier = ">=3.0.0" },
{ name = "pygal", marker = "extra == 'plotting'", specifier = ">=3.0.0" },
+ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.0.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.2.1" },