Files
cx-ui/backend/app/routers/auth_router.py
Parham Monfared 1262690276
Some checks failed
build-and-deploy / test (push) Has been cancelled
build-and-deploy / image (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled
Split into a FastAPI backend and a React frontend, add case state and SSO
The single-file stdlib server became the limit: no way to track what had been
done about an alert, no accounts, and a UI that had to be hand-rolled in
template strings. This restructures it into something deployable.

Backend (FastAPI)
- app/ holds config, database, auth, delivery and the routers; triagelib keeps
  the triage engine unchanged, so the validated screening and runbook logic is
  untouched.
- Cases persist per alert fingerprint with a status workflow (investigating,
  customer contacted, escalated to Infra, waiting, remediated, resolved, won't
  fix, false positive), an assignee, notes and an append-only history. An alert
  that stops and re-fires lands back on the same case and counts as a reopen.
- Suppression rules move from a JSON file into the database.

Auth
- Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for
  Authentik: users are created on first login and admin follows a group claim.
  Local login can be switched off entirely once SSO is live.

Zendesk and Jira
- Delivery is now implemented, behind three gates: the integration must be
  configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo
  instance leaves the last off and cannot mail anyone. Both search before
  creating, so re-diagnosing an alert updates one ticket rather than opening
  several, and a rolling daily cap stops a loop mailing everybody.

Deployment
- Multi-stage Dockerfile builds the bundle and serves it from the API origin.
- docker-compose for local and single-host use; Gitea Actions runs the tests,
  builds the image and renders deploy/k8s with envsubst.

Two fixes found while testing: assigning a case returned a null assignee, and
add_event could leave an already-loaded history collection stale.

Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which
does not work in a pod without the CX-Tools containers alongside it.
docs/DEPLOYMENT.md sets out the three ways to close that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:11:28 +01:00

100 lines
3.7 KiB
Python

"""Sign-in: local accounts and the Authentik OIDC round trip."""
from __future__ import annotations
import datetime as dt
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import auth as auth_mod
from ..config import get_settings
from ..db import get_db
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()
class LoginBody(BaseModel):
email: str
password: str
def _set_cookie(response: Response, user: User) -> None:
response.set_cookie(
auth_mod.SESSION_COOKIE, auth_mod.issue_session(user.id),
max_age=settings.session_hours * 3600, httponly=True, samesite="lax",
secure=settings.base_url.startswith("https://"), path="/",
)
@router.get("/me")
def me(user: User | None = Depends(auth_mod.optional_user)):
return {"user": user.to_json() if user else None, "config": settings.public_flags()}
@router.post("/login")
def login(body: LoginBody, response: Response, db: Session = Depends(get_db)):
if not settings.auth_local_enabled:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Local login is disabled; use SSO.")
user = auth_mod.authenticate_local(db, body.email, body.password)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect email or password")
user.last_login = dt.datetime.now(dt.timezone.utc)
db.commit()
_set_cookie(response, user)
return {"user": user.to_json()}
@router.post("/logout")
def logout(response: Response):
response.delete_cookie(auth_mod.SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/oidc/start")
async def oidc_start(request: Request):
if not settings.oidc_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled")
try:
meta = await auth_mod.oidc_discovery()
except auth_mod.OIDCError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback"
state = auth_mod.oidc_state()
url = (f"{meta['authorization_endpoint']}?response_type=code"
f"&client_id={settings.oidc_client_id}"
f"&redirect_uri={redirect_uri}"
f"&scope={settings.oidc_scopes.replace(' ', '%20')}"
f"&state={state}")
response = RedirectResponse(url, status_code=302)
response.set_cookie("cx_oidc_state", state, max_age=600, httponly=True, samesite="lax", path="/")
return response
@router.get("/oidc/callback")
async def oidc_callback(request: Request, code: str = "", state: str = "",
db: Session = Depends(get_db)):
if not settings.oidc_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSO is not enabled")
# The state must match the cookie we set *and* still verify - one guards
# against a swapped browser, the other against a forged value.
if not code or not state or state != request.cookies.get("cx_oidc_state") \
or not auth_mod.oidc_state_valid(state):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired SSO state")
redirect_uri = f"{settings.base_url.rstrip('/')}/api/auth/oidc/callback"
try:
claims = await auth_mod.oidc_exchange(code, redirect_uri)
user = auth_mod.upsert_oidc_user(db, claims)
except auth_mod.OIDCError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
response = RedirectResponse("/", status_code=302)
_set_cookie(response, user)
response.delete_cookie("cx_oidc_state", path="/")
return response