feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- 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)
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user