68161298a4
- 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>
116 lines
4.7 KiB
Python
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}
|