"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0). ``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification + optional local-login enforcement per email domain. See ``docs/V72_Enterprise_SCIM_2FA.md``. """ from __future__ import annotations import hashlib import secrets from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager from app.db import get_conn from app.services.api_v2_helpers import audit_log router = APIRouter(tags=["scim"]) SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"] # ── auth ─────────────────────────────────────────────────────────────────── def _scim_guard(request: Request) -> dict: auth = request.headers.get("authorization") or request.headers.get("Authorization") or "" if auth.lower().startswith("bearer "): digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest() with get_conn() as conn: row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0", (digest,)).fetchone() if row: return {"scim_token_id": row["id"], "name": row["name"]} raise HTTPException(401, "SCIM token required") def _admin_session(request: Request) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") with get_conn() as conn: row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone() if not row or not row["is_admin"]: raise HTTPException(403, "Admin required") return user def _scim_user(row) -> dict: d = dict(row) return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"], "name": {"formatted": d.get("full_name") or d["login"]}, "emails": [{"value": d.get("email") or "", "primary": True}], "active": bool(d.get("is_active", 1)), "meta": {"resourceType": "User"}} # ── SCIM resources ───────────────────────────────────────────────────────── @router.get("/scim/v2/Users") def scim_list(request: Request): _scim_guard(request) with get_conn() as conn: rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall() items = [_scim_user(r) for r in rows] return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": len(items), "Resources": items} @router.post("/scim/v2/Users") async def scim_create(request: Request): _scim_guard(request) try: body = await request.json() except Exception: body = {} username = (body.get("userName") or "").strip() if not username: raise HTTPException(400, "userName required") email = "" for em in body.get("emails") or []: if isinstance(em, dict) and em.get("value"): email = em["value"] break name = ((body.get("name") or {}).get("formatted") or username)[:200] active = body.get("active", True) with get_conn() as conn: if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone(): raise HTTPException(409, "User already exists") cur = conn.execute( "INSERT INTO users (login, full_name, email, is_active, auth_method)" " VALUES (?,?,?,?,'saml')", (username, name, email, 1 if active else 0)) conn.commit() row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone() return JSONResponse(status_code=201, content=_scim_user(row)) @router.get("/scim/v2/Users/{user_id}") def scim_get(user_id: str, request: Request): _scim_guard(request) with get_conn() as conn: row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() if not row: raise HTTPException(404, "User not found") return _scim_user(row) def _apply_scim_update(conn, user_id: str, body: dict) -> None: row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() if not row: raise HTTPException(404, "User not found") updates: dict = {} if "userName" in body and body["userName"]: updates["login"] = body["userName"].strip() if isinstance(body.get("name"), dict) and body["name"].get("formatted"): updates["full_name"] = body["name"]["formatted"][:200] if isinstance(body.get("emails"), list): for em in body["emails"]: if isinstance(em, dict) and em.get("value"): updates["email"] = em["value"][:200] break if "active" in body: updates["is_active"] = 1 if body["active"] else 0 if updates: sets = ", ".join(f"{k}=?" for k in updates) conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id)) if body.get("active") is False: conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,)) conn.commit() @router.put("/scim/v2/Users/{user_id}") async def scim_replace(user_id: str, request: Request): _scim_guard(request) try: body = await request.json() except Exception: body = {} with get_conn() as conn: _apply_scim_update(conn, user_id, body) row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() return _scim_user(row) @router.patch("/scim/v2/Users/{user_id}") async def scim_patch(user_id: str, request: Request): _scim_guard(request) try: body = await request.json() except Exception: body = {} flat: dict = {} for op in body.get("Operations") or []: path = (op.get("path") or "").lower() if path in ("username", "active"): flat["userName" if path == "username" else "active"] = op.get("value") with get_conn() as conn: _apply_scim_update(conn, user_id, {**body, **flat}) row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() return _scim_user(row) @router.delete("/scim/v2/Users/{user_id}") def scim_delete(user_id: str, request: Request): _scim_guard(request) with get_conn() as conn: row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() if not row: raise HTTPException(404, "User not found") # Deprovision = suspend (keeps content + audit trail). conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,)) conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,)) conn.commit() return JSONResponse(status_code=204, content=None) # ── SCIM token management (admin, session) ───────────────────────────────── @router.post("/api/v2/scim/tokens") async def create_scim_token(request: Request): admin = _admin_session(request) try: body = await request.json() except Exception: body = {} raw = f"scim_{secrets.token_urlsafe(32)}" digest = hashlib.sha256(raw.encode()).hexdigest() with get_conn() as conn: cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)" " VALUES (?,?,?)", (digest, str(body.get("name") or "SCIM")[:120], admin["id"])) conn.commit() audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request) return JSONResponse(status_code=201, content={"id": cur.lastrowid, "token": raw, "warning": "shown once"}) @router.get("/api/v2/scim/tokens") def list_scim_tokens(request: Request): _admin_session(request) with get_conn() as conn: rows = conn.execute("SELECT id, name, created_by, revoked, created_at" " FROM scim_tokens ORDER BY id DESC").fetchall() return {"tokens": [dict(r) for r in rows]} @router.delete("/api/v2/scim/tokens/{token_id}") def revoke_scim_token(token_id: int, request: Request): admin = _admin_session(request) with get_conn() as conn: conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,)) conn.commit() audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request) return {"status": "revoked", "id": token_id} # ── domain claims ────────────────────────────────────────────────────────── @router.get("/api/v2/domain-claims") def list_domains(request: Request): _admin_session(request) with get_conn() as conn: rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall() out = [] for r in rows: d = dict(r) d.pop("txt_token", None) out.append(d) return {"domains": out} @router.post("/api/v2/domain-claims") async def create_domain(request: Request): admin = _admin_session(request) try: body = await request.json() except Exception: body = {} domain = (body.get("domain") or "").strip().lower() if not domain or "." not in domain or "/" in domain: raise HTTPException(400, "valid domain required") token = f"flowdeck-verify={secrets.token_hex(16)}" with get_conn() as conn: try: cur = conn.execute( """INSERT INTO domain_claims (domain, txt_token, auto_join_role, enforce_sso, workspace_id) VALUES (?,?,?,?,?)""", (domain, token, body.get("auto_join_role") or "viewer", 1 if body.get("enforce_sso") else 0, body.get("workspace_id"))) conn.commit() except Exception: raise HTTPException(409, "Domain already claimed") from None did = cur.lastrowid audit_log(admin, "domain.claim", "domain", did, domain, request) return JSONResponse(status_code=201, content={ "id": did, "domain": domain, "verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt", "expected_content": token}) @router.post("/api/v2/domain-claims/{domain_id}/verify") async def verify_domain(domain_id: int, request: Request): admin = _admin_session(request) import httpx with get_conn() as conn: row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone() if not row: raise HTTPException(404, "Domain not found") claim = dict(row) url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt" try: async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: resp = await client.get(url) ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "") except Exception: # noqa: BLE001 — unreachable domain = not verified ok = False if ok: with get_conn() as conn: conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,)) conn.commit() audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request) return {"id": domain_id, "verified": ok} @router.delete("/api/v2/domain-claims/{domain_id}") def delete_domain(domain_id: int, request: Request): admin = _admin_session(request) with get_conn() as conn: conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,)) conn.commit() audit_log(admin, "domain.delete", "domain", domain_id, "", request) return {"status": "deleted", "id": domain_id}