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>
254 lines
9.2 KiB
Python
254 lines
9.2 KiB
Python
"""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
|