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:
@@ -89,6 +89,12 @@ async def add_environment_to_context(request: Request, call_next):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
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
|
# Override TemplateResponse to include environment and version in context
|
||||||
original_template_response = templates.TemplateResponse
|
original_template_response = templates.TemplateResponse
|
||||||
def custom_template_response(name, context=None, *args, **kwargs):
|
def custom_template_response(name, context=None, *args, **kwargs):
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import os
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
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.models import OperatorUser
|
||||||
from backend.auth_passwords import hash_password, verify_password, generate_password
|
from backend.auth_passwords import hash_password, verify_password, generate_password
|
||||||
from backend.auth_cookies import sign, read, COOKIE_SECURE
|
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
|
# 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.
|
# 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
|
user.role = role
|
||||||
db.commit()
|
db.commit()
|
||||||
return user
|
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
|
||||||
|
|||||||
@@ -62,3 +62,15 @@ def make_project(db_session, name=None, **kwargs):
|
|||||||
db_session.add(p)
|
db_session.add(p)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return p
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user