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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:22:15 +00:00
parent e8fe4845aa
commit 2879abb355
4 changed files with 156 additions and 0 deletions
+6
View File
@@ -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):
+69
View File
@@ -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
+12
View File
@@ -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
+69
View File
@@ -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/<bad> 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"