diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 93b5cc6..1069003 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -185,6 +185,10 @@ async def operator_gate(request: Request, call_next): if not OPERATOR_AUTH_ENABLED: return await call_next(request) + # CORS preflight carries no auth and must reach CORSMiddleware, not the gate. + if request.method == "OPTIONS": + return await call_next(request) + path = request.url.path if _is_exempt(path): return await call_next(request) diff --git a/backend/routers/operator_users.py b/backend/routers/operator_users.py index 4a3531d..6d58b9f 100644 --- a/backend/routers/operator_users.py +++ b/backend/routers/operator_users.py @@ -12,9 +12,21 @@ from backend.operator_auth import ( require_role, create_operator, reset_operator_password, set_operator_active, set_operator_role, ) +import backend.operator_auth as operator_auth from backend.utils.timezone import format_local_datetime -router = APIRouter(tags=["operator-users"]) + +def _require_auth_enabled(): + """The operator-management surface does not exist while operator auth is + disabled — otherwise these net-new endpoints would be world-open with the + flag off (the default), letting anyone pre-seed a superadmin. Read the flag + as a live module attribute so the test monkeypatch and a runtime flip both + take effect.""" + if not operator_auth.OPERATOR_AUTH_ENABLED: + raise HTTPException(status_code=404, detail="Not found") + + +router = APIRouter(tags=["operator-users"], dependencies=[Depends(_require_auth_enabled)]) _superadmin = require_role("superadmin") diff --git a/tests/test_operator_gate.py b/tests/test_operator_gate.py index bb0697d..01494aa 100644 --- a/tests/test_operator_gate.py +++ b/tests/test_operator_gate.py @@ -67,3 +67,10 @@ def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, r = client.get("/api/status-snapshot", follow_redirects=False) assert r.status_code == 403 assert r.json()["detail"] == "Password change required" + + +def test_options_preflight_passes_through_gate(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # CORS preflight has no cookie; the gate must not 303/401 it. + r = client.options("/api/status-snapshot", follow_redirects=False) + assert r.status_code not in (303, 401) diff --git a/tests/test_operator_users.py b/tests/test_operator_users.py index 1d149aa..b8881e0 100644 --- a/tests/test_operator_users.py +++ b/tests/test_operator_users.py @@ -118,3 +118,15 @@ def test_deferred_operator_role_rejected_by_api(client, db_session, monkeypatch) _login_as(client, su) assert client.post("/api/admin/users", json={"email": "op@x.com", "name": "Op", "role": "operator"}).status_code == 400 assert client.post(f"/api/admin/users/{target.id}/role", json={"role": "operator"}).status_code == 400 + + +def test_admin_surface_404s_when_flag_off(client, db_session, monkeypatch): + su, _ = create_operator(db_session, "su@x.com", "Su", "superadmin", password="pw-123456") + wire_operator_auth(monkeypatch, db_session, enabled=False) + _login_as(client, su) + # With operator auth OFF, the management surface must not exist (404), even + # though require_role passes through — otherwise it'd be world-open. + assert client.get("/admin/users").status_code == 404 + assert client.get("/api/admin/users").status_code == 404 + assert client.post("/api/admin/users", + json={"email": "x@x.com", "name": "X", "role": "admin"}).status_code == 404