- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
298 lines
12 KiB
Python
298 lines
12 KiB
Python
"""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")
|
|
async 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}")
|
|
async 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}")
|
|
async 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")
|
|
async 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}")
|
|
async 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")
|
|
async 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}")
|
|
async 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}
|