Split into a FastAPI backend and a React frontend, add case state and SSO
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

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>
This commit is contained in:
2026-08-06 07:11:28 +01:00
parent a039e0b5fd
commit 1262690276
68 changed files with 3839 additions and 2223 deletions

253
backend/app/auth.py Normal file
View File

@@ -0,0 +1,253 @@
"""Authentication: signed session cookies, local accounts, and OIDC.
Local accounts exist so the tool runs on a laptop and in a cluster that has not
been wired to SSO yet. When CX_OIDC_ENABLED is set, Authentik becomes the source
of truth: users are created on first login, and admin rights follow a group
claim rather than being set here.
The session itself is the same either way - a signed, expiring cookie - so the
rest of the app never has to care which provider a user came from.
"""
from __future__ import annotations
import base64
import datetime as dt
import hashlib
import hmac
import json
import secrets
import time
from typing import Any, Optional
import httpx
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from .config import get_settings
from .db import get_db
from .models import AuthProvider, User
SESSION_COOKIE = "cx_session"
settings = get_settings()
# --- password hashing -------------------------------------------------------
# PBKDF2 from the standard library: no native build step in the image, and
# strong enough for a small internal user table.
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 240_000)
return f"pbkdf2_sha256$240000${base64.b64encode(salt).decode()}${base64.b64encode(digest).decode()}"
def verify_password(password: str, stored: str) -> bool:
try:
algo, rounds, salt_b64, digest_b64 = stored.split("$")
if algo != "pbkdf2_sha256":
return False
expected = base64.b64decode(digest_b64)
actual = hashlib.pbkdf2_hmac("sha256", password.encode(),
base64.b64decode(salt_b64), int(rounds))
return hmac.compare_digest(expected, actual)
except (ValueError, TypeError):
return False
# --- session cookie ---------------------------------------------------------
def _sign(payload: bytes) -> str:
mac = hmac.new(settings.secret_key.encode(), payload, hashlib.sha256).digest()
return f"{base64.urlsafe_b64encode(payload).decode()}.{base64.urlsafe_b64encode(mac).decode()}"
def issue_session(user_id: int) -> str:
payload = json.dumps({
"uid": user_id,
"exp": int(time.time()) + settings.session_hours * 3600,
}).encode()
return _sign(payload)
def read_session(token: str) -> Optional[int]:
try:
body_b64, mac_b64 = token.split(".")
payload = base64.urlsafe_b64decode(body_b64)
expected = hmac.new(settings.secret_key.encode(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(expected, base64.urlsafe_b64decode(mac_b64)):
return None
data = json.loads(payload)
if int(data.get("exp", 0)) < time.time():
return None
return int(data["uid"])
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None
# --- dependencies -----------------------------------------------------------
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
token = request.cookies.get(SESSION_COOKIE, "")
uid = read_session(token) if token else None
if not uid:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not signed in")
user = db.get(User, uid)
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Account is not active")
return user
def require_admin(user: User = Depends(current_user)) -> User:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Administrator access required")
return user
def optional_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
token = request.cookies.get(SESSION_COOKIE, "")
uid = read_session(token) if token else None
return db.get(User, uid) if uid else None
# --- local accounts ---------------------------------------------------------
def authenticate_local(db: Session, email: str, password: str) -> Optional[User]:
if not settings.auth_local_enabled:
return None
user = db.query(User).filter(User.email == email.strip().lower()).one_or_none()
if not user or not user.is_active or user.provider != AuthProvider.LOCAL:
return None
if not user.password_hash or not verify_password(password, user.password_hash):
return None
return user
def ensure_bootstrap_admin(db: Session) -> Optional[str]:
"""Create the first admin so a fresh deployment is reachable.
Only ever runs when the user table is empty, and only with a password
supplied through the environment - it never invents one.
"""
if db.query(User).count():
return None
if not settings.bootstrap_admin_password:
return ("No users exist and CX_BOOTSTRAP_ADMIN_PASSWORD is unset. "
"Set it and restart, or sign in through SSO.")
db.add(User(
email=settings.bootstrap_admin_email.strip().lower(),
name="Bootstrap admin",
is_admin=True,
provider=AuthProvider.LOCAL,
password_hash=hash_password(settings.bootstrap_admin_password),
))
db.commit()
return f"Created bootstrap admin {settings.bootstrap_admin_email}"
# --- OIDC / Authentik -------------------------------------------------------
class OIDCError(RuntimeError):
pass
_discovery_cache: dict[str, Any] = {}
async def oidc_discovery() -> dict[str, Any]:
"""Fetch and cache the provider metadata."""
if not settings.oidc_issuer:
raise OIDCError("CX_OIDC_ISSUER is not set.")
if _discovery_cache.get("_issuer") == settings.oidc_issuer:
return _discovery_cache
url = settings.oidc_issuer.rstrip("/") + "/.well-known/openid-configuration"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url)
if resp.status_code != 200:
raise OIDCError(f"OIDC discovery failed ({resp.status_code}) at {url}")
data = resp.json()
data["_issuer"] = settings.oidc_issuer
_discovery_cache.clear()
_discovery_cache.update(data)
return data
def oidc_state() -> str:
"""A signed, short-lived value tying the callback to this browser."""
payload = json.dumps({"n": secrets.token_urlsafe(16), "exp": int(time.time()) + 600}).encode()
return _sign(payload)
def oidc_state_valid(state: str) -> bool:
try:
body_b64, mac_b64 = state.split(".")
payload = base64.urlsafe_b64decode(body_b64)
expected = hmac.new(settings.secret_key.encode(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(expected, base64.urlsafe_b64decode(mac_b64)):
return False
return int(json.loads(payload).get("exp", 0)) >= time.time()
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return False
async def oidc_exchange(code: str, redirect_uri: str) -> dict[str, Any]:
meta = await oidc_discovery()
async with httpx.AsyncClient(timeout=15) as client:
token_resp = await client.post(meta["token_endpoint"], data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
}, headers={"Accept": "application/json"})
if token_resp.status_code != 200:
raise OIDCError(f"Token exchange failed ({token_resp.status_code}): {token_resp.text[:200]}")
access = token_resp.json().get("access_token")
if not access:
raise OIDCError("Token response contained no access_token.")
info_resp = await client.get(meta["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access}"})
if info_resp.status_code != 200:
raise OIDCError(f"userinfo failed ({info_resp.status_code}): {info_resp.text[:200]}")
return info_resp.json()
def upsert_oidc_user(db: Session, claims: dict[str, Any]) -> User:
"""Find or create the local record for an SSO identity.
Matching is on `sub` first so a changed email still lands on the same user;
an existing local account with the same address is adopted rather than
duplicated.
"""
sub = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not sub and not email:
raise OIDCError("SSO returned neither sub nor email.")
groups = claims.get(settings.oidc_groups_claim) or []
if isinstance(groups, str):
groups = [groups]
is_admin = settings.oidc_admin_group in {str(g) for g in groups}
user = None
if sub:
user = db.query(User).filter(User.oidc_sub == sub).one_or_none()
if user is None and email:
user = db.query(User).filter(User.email == email).one_or_none()
if user is None:
user = User(email=email or f"{sub}@sso.local", provider=AuthProvider.OIDC)
db.add(user)
user.oidc_sub = sub or user.oidc_sub
user.provider = AuthProvider.OIDC
user.name = str(claims.get("name") or claims.get("preferred_username") or user.name or email)
if email:
user.email = email
user.is_admin = is_admin
user.is_active = True
user.last_login = dt.datetime.now(dt.timezone.utc)
if not user.signoff_name:
user.signoff_name = user.name
db.commit()
return user