Files
terra-view/backend/routers/operator_users.py
T
serversdown 68161298a4 fix(auth): hide /admin/users when flag off; pass OPTIONS preflight through gate
- operator_users router now depends on _require_auth_enabled, which raises
  404 when OPERATOR_AUTH_ENABLED is false — prevents world-open pre-seeding
  of a superadmin while the flag is off (the default).  Flag is read as a
  live module attribute (operator_auth.OPERATOR_AUTH_ENABLED) so monkeypatching
  in tests and a runtime flip both take effect.
- operator_gate passes OPTIONS requests through immediately before the exempt-
  path check, so CORS preflight reaches CORSMiddleware rather than being
  303/401'd by the gate.
- Two new tests: test_admin_surface_404s_when_flag_off (test_operator_users)
  and test_options_preflight_passes_through_gate (test_operator_gate).
  Full suite: 90 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:27:01 +00:00

116 lines
4.7 KiB
Python

"""Operator account management — superadmin only. Temp passwords are returned in
the JSON response once (shown to the superadmin to hand off); only hashes persist."""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.templates_config import templates
from backend.models import OperatorUser
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
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")
class NewUser(BaseModel):
email: str
name: str
role: str = "admin"
class RoleChange(BaseModel):
role: str
def _serialize(u: OperatorUser) -> dict:
from datetime import datetime
return {
"id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role,
"active": bool(u.active), "must_change_password": bool(u.must_change_password),
"locked": bool(u.locked_until and u.locked_until > datetime.utcnow()),
"last_login_at": format_local_datetime(u.last_login_at, "%Y-%m-%d %H:%M") if u.last_login_at else None,
}
@router.get("/admin/users")
async def users_page(request: Request, _=Depends(_superadmin)):
return templates.TemplateResponse("admin/users.html", {"request": request})
@router.get("/api/admin/users")
async def list_users(_=Depends(_superadmin), db: Session = Depends(get_db)):
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
return {"users": [_serialize(u) for u in users]}
@router.post("/api/admin/users")
async def add_user(body: NewUser, _=Depends(_superadmin), db: Session = Depends(get_db)):
if body.role not in ("admin", "superadmin"):
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
try:
user, raw = create_operator(db, body.email, body.name, body.role)
except ValueError as e:
return JSONResponse(status_code=400, content={"detail": str(e)})
return {"user": _serialize(user), "password": raw}
@router.post("/api/admin/users/{user_id}/reset-password")
async def reset_user_password(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
raw = reset_operator_password(db, user)
return {"password": raw}
@router.post("/api/admin/users/{user_id}/disable")
async def disable_user(user_id: str, acting=Depends(_superadmin), db: Session = Depends(get_db)):
if acting and acting.id == user_id:
return JSONResponse(status_code=400, content={"detail": "Cannot disable your own account"})
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_active(db, user, False)
return {"active": False}
@router.post("/api/admin/users/{user_id}/enable")
async def enable_user(user_id: str, _=Depends(_superadmin), db: Session = Depends(get_db)):
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_active(db, user, True)
return {"active": True}
@router.post("/api/admin/users/{user_id}/role")
async def change_user_role(user_id: str, body: RoleChange,
acting=Depends(_superadmin), db: Session = Depends(get_db)):
if acting and acting.id == user_id:
return JSONResponse(status_code=400, content={"detail": "Cannot change your own role"})
if body.role not in ("admin", "superadmin"):
return JSONResponse(status_code=400, content={"detail": "role must be admin or superadmin"})
user = db.query(OperatorUser).filter_by(id=user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
set_operator_role(db, user, body.role)
return {"role": user.role}