From 2879abb355428286a342928994b51934f8fb45f2 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:22:15 +0000 Subject: [PATCH] feat(auth): deny-by-default gate middleware + require_role Adds operator_gate Starlette HTTP middleware that gates every route except an explicit allow-list. Flag defaults OFF so all existing behaviour and tests are unchanged. wire_operator_auth helper in conftest lets tests monkeypatch the module-global SessionLocal and flag, keeping the gate's own DB session pointed at the test engine. Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 6 ++++ backend/operator_auth.py | 69 +++++++++++++++++++++++++++++++++++++ tests/conftest.py | 12 +++++++ tests/test_operator_gate.py | 69 +++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 tests/test_operator_gate.py diff --git a/backend/main.py b/backend/main.py index 92f2470..6b82727 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,6 +89,12 @@ async def add_environment_to_context(request: Request, call_next): response = await call_next(request) return response +# Operator auth — deny-by-default gate over the whole internal app. Governed by +# OPERATOR_AUTH_ENABLED (default off → behaves exactly as today). See +# docs/superpowers/specs/2026-06-17-operator-auth-design.md. +from backend.operator_auth import operator_gate +app.middleware("http")(operator_gate) + # Override TemplateResponse to include environment and version in context original_template_response = templates.TemplateResponse def custom_template_response(name, context=None, *args, **kwargs): diff --git a/backend/operator_auth.py b/backend/operator_auth.py index 4ec1288..93b5cc6 100644 --- a/backend/operator_auth.py +++ b/backend/operator_auth.py @@ -9,10 +9,15 @@ import os import time import uuid from datetime import datetime, timedelta +from urllib.parse import quote + +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse, RedirectResponse from backend.models import OperatorUser from backend.auth_passwords import hash_password, verify_password, generate_password from backend.auth_cookies import sign, read, COOKIE_SECURE +from backend.database import SessionLocal # Feature flag — OFF by default. When off, the gate and require_role both pass # everything through and the app behaves exactly as it does today. @@ -156,3 +161,67 @@ def set_operator_role(db, user, role: str): user.role = role db.commit() return user + + +# Routes reachable with no login. A new route added next year is gated by default. +_EXEMPT_EXACT = { + "/login", "/logout", "/health", + "/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js", + "/portal", # portal home (its own auth) + # machine endpoints — LAN-only, automated, no human (watchers/heartbeats): + "/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat", +} +_EXEMPT_PREFIX = ("/static/", "/portal/") + + +def _is_exempt(path: str) -> bool: + return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX) + + +async def operator_gate(request: Request, call_next): + """Deny-by-default gate. Flag off → pass through (app as today). Flag on → + exempt paths pass; otherwise require a valid operator session, stash it on + request.state.operator, and force a password change when pending.""" + if not OPERATOR_AUTH_ENABLED: + return await call_next(request) + + path = request.url.path + if _is_exempt(path): + return await call_next(request) + + db = SessionLocal() + try: + user = current_operator(request, db) + if user is not None: + db.expunge(user) # detach a fully-loaded row so we can close now + finally: + db.close() + + if user is None: + if path.startswith("/api/"): + return JSONResponse({"detail": "Not authenticated"}, status_code=401) + return RedirectResponse(f"/login?next={quote(path)}", status_code=303) + + if user.must_change_password and path not in ("/change-password", "/logout"): + if path.startswith("/api/"): + return JSONResponse({"detail": "Password change required"}, status_code=403) + return RedirectResponse("/change-password", status_code=303) + + request.state.operator = user + return await call_next(request) + + +def require_role(minimum: str): + """Dependency factory: require a logged-in operator ranked >= `minimum`. + Respects the flag (off → pass through). When on, the middleware has already + set request.state.operator before this runs.""" + def _dep(request: Request): + if not OPERATOR_AUTH_ENABLED: + return None + user = getattr(request.state, "operator", None) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + if not role_at_least(user.role, minimum): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + return _dep diff --git a/tests/conftest.py b/tests/conftest.py index aa44991..031748c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,3 +62,15 @@ def make_project(db_session, name=None, **kwargs): db_session.add(p) db_session.commit() return p + + +def wire_operator_auth(monkeypatch, db_session, enabled=True): + """Point the gate middleware's SessionLocal at the test engine and flip the + flag. The middleware opens its OWN session (it can't use the get_db override), + so it must read the same engine the test writes to.""" + import backend.operator_auth as oa + from sqlalchemy.orm import sessionmaker + maker = sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False) + monkeypatch.setattr(oa, "SessionLocal", maker, raising=False) + monkeypatch.setattr(oa, "OPERATOR_AUTH_ENABLED", enabled, raising=False) + return oa diff --git a/tests/test_operator_gate.py b/tests/test_operator_gate.py new file mode 100644 index 0000000..bb0697d --- /dev/null +++ b/tests/test_operator_gate.py @@ -0,0 +1,69 @@ +# tests/test_operator_gate.py +import uuid +from tests.conftest import wire_operator_auth +from backend.models import OperatorUser +from backend.operator_auth import make_operator_cookie, COOKIE_NAME +from backend.auth_passwords import hash_password + + +def _make_user(db, role="admin", **kw): + u = OperatorUser(id=str(uuid.uuid4()), email=kw.pop("email", "u@x.com"), + display_name="U", password_hash=hash_password("pw"), role=role, **kw) + db.add(u) + db.commit() + return u + + +def test_flag_off_passes_everything(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=False) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_gated_html_redirects_to_login_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"].startswith("/login?next=") + + +def test_gated_api_returns_401_json_when_unauth(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + r = client.get("/api/status-snapshot", follow_redirects=False) + assert r.status_code == 401 + + +def test_valid_session_passes(client, db_session, monkeypatch): + u = _make_user(db_session) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + assert client.get("/", follow_redirects=False).status_code == 200 + + +def test_must_change_password_user_routed_to_change_password(client, db_session, monkeypatch): + u = _make_user(db_session, must_change_password=True) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + r = client.get("/", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/change-password" + + +def test_exempt_paths_pass_without_cookie(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + assert client.get("/health", follow_redirects=False).status_code == 200 + + +def test_portal_paths_are_exempt(client, db_session, monkeypatch): + wire_operator_auth(monkeypatch, db_session, enabled=True) + # /portal/p/ hits the portal's own gate (403/404), never the operator login. + r = client.get("/portal/p/nope", follow_redirects=False) + assert r.status_code in (403, 404) + + +def test_must_change_user_on_api_gets_403_json_not_redirect(client, db_session, monkeypatch): + u = _make_user(db_session, must_change_password=True) + wire_operator_auth(monkeypatch, db_session, enabled=True) + client.cookies.set(COOKIE_NAME, make_operator_cookie(u.id)) + r = client.get("/api/status-snapshot", follow_redirects=False) + assert r.status_code == 403 + assert r.json()["detail"] == "Password change required"