"""FlowDeck — v7.2.0 Enterprise: SCIM 2.0, TOTP 2FA, WebAuthn, audit, agent governance. Covers migration 28, SCIM CRUD + suspend/revoke, SCIM token admin, TOTP setup/activate/login gating + backup codes, domain claims with SSO enforcement, passkey routes, unified audit log (+CSV) and agent policies with the human approval gate. """ from __future__ import annotations import json import secrets from conftest import anon from app.db import get_conn # ── helpers ──────────────────────────────────────────────────────────────── def _make_user(login=None, is_admin=0, email=None): login = login or f"v72_{secrets.token_hex(4)}" with get_conn() as conn: conn.execute( "INSERT INTO users (login, full_name, email, is_admin) VALUES (?,?,?,?)", (login, login, email if email is not None else f"{login}@test.com", is_admin)) uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() return uid, login def _session(uid, login): from app.auth.session import SessionManager return SessionManager.create_session({"id": uid, "login": login}) def _admin_client(client): uid, login = _make_user(is_admin=1) return client, {"flowdeck_session": _session(uid, login)} def _mk_scim_token(client, cookies, name="IT"): r = client.post("/api/v2/scim/tokens", json={"name": name}, cookies=cookies) assert r.status_code == 201, r.text return r.json()["token"] # ── migration 28 ─────────────────────────────────────────────────────────── def test_migration_28_tables_exist(client): with get_conn() as conn: names = {r["name"] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} for t in ("scim_tokens", "domain_claims", "webauthn_credentials", "agent_policies", "agent_approvals"): assert t in names, f"missing {t}" def test_migration_28_user_columns(client): with get_conn() as conn: cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()} assert {"totp_secret_enc", "totp_backup_hashes", "is_active"} <= cols # ── SCIM tokens ──────────────────────────────────────────────────────────── def test_scim_token_requires_auth(client): assert client.get("/scim/v2/Users").status_code == 401 def test_scim_token_admin_only(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} assert client.post("/api/v2/scim/tokens", json={"name": "x"}, cookies=c).status_code == 403 def test_scim_token_create_and_list(client): client, c = _admin_client(client) r = client.post("/api/v2/scim/tokens", json={"name": "Okta"}, cookies=c) assert r.status_code == 201, r.text body = r.json() assert body["token"].startswith("scim_") lst = client.get("/api/v2/scim/tokens", cookies=c) assert lst.status_code == 200 assert any(t["name"] == "Okta" for t in lst.json()["tokens"]) # raw token is never stored in clear with get_conn() as conn: row = conn.execute("SELECT token_hash FROM scim_tokens ORDER BY id DESC").fetchone() assert body["token"] not in row["token_hash"] def test_scim_token_revoke(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) r = client.delete("/api/v2/scim/tokens/1", cookies=c) assert r.status_code == 200 assert client.get("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"}).status_code == 401 # ── SCIM /Users ──────────────────────────────────────────────────────────── def test_scim_list_requires_bearer(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) r = client.get("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 assert r.json()["schemas"] == ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] def test_scim_create_user(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) r = client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"}, json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "jane.smith", "name": {"formatted": "Jane Smith"}, "emails": [{"value": "jane@corp.example"}]}) assert r.status_code == 201, r.text body = r.json() assert body["userName"] == "jane.smith" assert body["active"] is True with get_conn() as conn: row = conn.execute("SELECT email, auth_method FROM users WHERE login=?", ("jane.smith",)).fetchone() assert row["email"] == "jane@corp.example" assert row["auth_method"] == "saml" def test_scim_create_duplicate_conflict(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) payload = {"userName": "dup.user", "emails": [{"value": "d@e.example"}]} assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"}, json=payload).status_code == 201 assert client.post("/scim/v2/Users", headers={"Authorization": f"Bearer {token}"}, json=payload).status_code == 409 def test_scim_get_user(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) uid, _ = _make_user(login="scim.get.me") r = client.get(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 assert r.json()["id"] == str(uid) assert client.get("/scim/v2/Users/999999", headers={"Authorization": f"Bearer {token}"}).status_code == 404 def test_scim_patch_deactivate_revokes_sessions(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) uid, login = _make_user(login="scim.suspend.me") with get_conn() as conn: conn.execute("INSERT INTO user_sessions (user_id, ip_address, user_agent)" " VALUES (?, '10.0.0.9', 'pytest')", (uid,)) conn.commit() r = client.patch(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"}, json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{"op": "replace", "path": "active", "value": False}]}) assert r.status_code == 200, r.text assert r.json()["active"] is False with get_conn() as conn: assert conn.execute("SELECT is_active FROM users WHERE id=?", (uid,)).fetchone()[0] == 0 assert conn.execute("SELECT revoked FROM user_sessions WHERE user_id=?", (uid,)).fetchone()["revoked"] == 1 def test_scim_put_updates_fields(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) uid, _ = _make_user(login="scim.put.me", email="old@e.example") r = client.put(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"}, json={"userName": "scim.put.renamed", "name": {"formatted": "Renamed"}, "emails": [{"value": "new@e.example"}], "active": True}) assert r.status_code == 200, r.text with get_conn() as conn: row = conn.execute("SELECT login, email, full_name FROM users WHERE id=?", (uid,)).fetchone() assert row["login"] == "scim.put.renamed" assert row["email"] == "new@e.example" assert row["full_name"] == "Renamed" def test_scim_delete_suspends(client): client, c = _admin_client(client) token = _mk_scim_token(client, c) uid, _ = _make_user(login="scim.del.me") r = client.delete(f"/scim/v2/Users/{uid}", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 204 with get_conn() as conn: assert conn.execute("SELECT is_active FROM users WHERE id=?", (uid,)).fetchone()[0] == 0 # ── TOTP 2FA ─────────────────────────────────────────────────────────────── def test_2fa_status_disabled_by_default(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} r = client.get("/auth/2fa/status", cookies=c) assert r.status_code == 200 assert r.json() == {"enabled": False, "backup_remaining": 0} def test_2fa_setup_returns_secret_and_uri(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} r = client.post("/auth/2fa/setup", cookies=c) assert r.status_code == 200, r.text body = r.json() assert body["secret"] assert body["otpauth_url"].startswith("otpauth://totp/FlowDeck:") def test_2fa_activate_rejects_bad_code(client): from app.services import two_factor as t2f uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"] r = client.post("/auth/2fa/activate", cookies=c, json={"secret": secret, "code": "000000"}) assert r.status_code == 400 assert not t2f.is_enabled(uid) def test_2fa_activate_success_returns_backup_codes(client): import pyotp from app.services import two_factor as t2f uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"] r = client.post("/auth/2fa/activate", cookies=c, json={"secret": secret, "code": pyotp.TOTP(secret).now()}) assert r.status_code == 200, r.text codes = r.json()["backup_codes"] assert len(codes) == 10 and len(set(codes)) == 10 assert t2f.is_enabled(uid) assert t2f.remaining_backup_codes(uid) == 10 def test_2fa_secret_encrypted_at_rest(client): import pyotp from app.services import two_factor as t2f uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"] client.post("/auth/2fa/activate", cookies=c, json={"secret": secret, "code": pyotp.TOTP(secret).now()}) with get_conn() as conn: stored = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?", (uid,)).fetchone()[0] assert secret not in stored assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True def test_2fa_verify_totp_and_backup_code(client): import pyotp from app.services import two_factor as t2f uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"] codes = client.post("/auth/2fa/activate", cookies=c, json={"secret": secret, "code": pyotp.TOTP(secret).now()}).json()["backup_codes"] assert t2f.verify_code(uid, pyotp.TOTP(secret).now()) is True assert t2f.verify_code(uid, codes[0]) is True assert t2f.verify_code(uid, codes[0]) is False # single use assert t2f.remaining_backup_codes(uid) == 9 def test_2fa_verify_rejects_bad_code(client): from app.services import two_factor as t2f uid, _ = _make_user() assert t2f.verify_code(uid, "123456") is False def test_2fa_disable(client): import pyotp from app.services import two_factor as t2f uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} secret = client.post("/auth/2fa/setup", cookies=c).json()["secret"] client.post("/auth/2fa/activate", cookies=c, json={"secret": secret, "code": pyotp.TOTP(secret).now()}) assert client.post("/auth/2fa/disable", cookies=c).status_code == 200 assert t2f.is_enabled(uid) is False def test_2fa_routes_require_session(client): anon(client) assert client.get("/auth/2fa/status").status_code == 401 assert client.post("/auth/2fa/setup").status_code == 401 def test_2fa_pending_token_roundtrip(client): from app.services import two_factor as t2f uid, _ = _make_user() token = t2f.mint_pending(uid) assert t2f.redeem_pending(token) == uid assert t2f.redeem_pending("forged") is None assert t2f.redeem_pending(t2f.mint_pending(uid), max_age=-1) is None # ── domain claims ────────────────────────────────────────────────────────── def test_domain_claim_create_and_list(client): client, c = _admin_client(client) r = client.post("/api/v2/domain-claims", cookies=c, json={"domain": "Corp.Example", "auto_join_role": "viewer", "enforce_sso": True}) assert r.status_code == 201, r.text body = r.json() assert body["domain"] == "corp.example" assert body["expected_content"].startswith("flowdeck-verify=") lst = client.get("/api/v2/domain-claims", cookies=c) assert lst.status_code == 200 # txt token is never leaked by the list endpoint assert "txt_token" not in lst.json()["domains"][0] def test_domain_claim_invalid_domain(client): client, c = _admin_client(client) assert client.post("/api/v2/domain-claims", cookies=c, json={"domain": "not-a-domain"}).status_code == 400 assert client.post("/api/v2/domain-claims", cookies=c, json={"domain": "a/b.example"}).status_code == 400 def test_domain_claim_duplicate(client): client, c = _admin_client(client) assert client.post("/api/v2/domain-claims", cookies=c, json={"domain": "dup.example"}).status_code == 201 assert client.post("/api/v2/domain-claims", cookies=c, json={"domain": "dup.example"}).status_code == 409 def test_domain_claim_delete(client): client, c = _admin_client(client) did = client.post("/api/v2/domain-claims", cookies=c, json={"domain": "gone.example"}).json()["id"] assert client.delete(f"/api/v2/domain-claims/{did}", cookies=c).status_code == 200 assert client.get("/api/v2/domain-claims", cookies=c).json()["domains"] == [] def test_domain_routes_require_admin(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} assert client.get("/api/v2/domain-claims", cookies=c).status_code == 403 # ── WebAuthn / passkeys ──────────────────────────────────────────────────── def test_webauthn_register_begin(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} r = client.post("/auth/webauthn/register/begin", cookies=c) assert r.status_code == 200, r.text body = r.json() assert body["challenge"] and body["rp"]["id"] assert body["user"]["id"] def test_webauthn_register_begin_requires_session(client): anon(client) assert client.post("/auth/webauthn/register/begin").status_code == 401 def test_webauthn_register_finish_rejects_bad_credential(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} client.post("/auth/webauthn/register/begin", cookies=c) r = client.post("/auth/webauthn/register/finish", cookies=c, json={"credential": {"id": "abc", "type": "public-key"}}) assert r.status_code == 400 def test_webauthn_register_finish_expired_challenge(client): from app.routers import webauthn as wa uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} r = client.post("/auth/webauthn/register/finish", cookies=c, json={"credential": {"id": "abc", "type": "public-key"}}) assert r.status_code == 400 assert "Challenge" in r.json()["detail"] wa.reset_challenges() def test_webauthn_login_begin_no_passkeys(client): uid, login = _make_user() r = client.post("/auth/webauthn/login/begin", json={"login": login}) assert r.status_code == 400 assert "passkey" in r.json()["detail"].lower() def test_webauthn_login_begin_unknown_user(client): r = client.post("/auth/webauthn/login/begin", json={"login": "ghost_user_xyz"}) assert r.status_code == 401 def test_webauthn_login_begin_requires_login(client): assert client.post("/auth/webauthn/login/begin", json={}).status_code == 400 def test_webauthn_keys_empty_and_delete_404(client): uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} assert client.get("/auth/webauthn/keys", cookies=c).json() == {"keys": []} assert client.delete("/auth/webauthn/keys/9999", cookies=c).status_code == 404 # ── unified audit log ────────────────────────────────────────────────────── def test_audit_requires_admin(client): anon(client) uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403 assert client.get("/api/v2/audit/logs").status_code == 401 def test_audit_merges_sources(client): client, c = _admin_client(client) aid, alogin = _make_user(is_admin=1) with get_conn() as conn: conn.execute( """INSERT INTO api_audit_log (user_id, action, resource_type, resource_id, detail, ip_address) VALUES (?, 'api.page.create', 'page', '12', 'created', '10.0.0.1')""", (aid,)) conn.execute( """INSERT INTO permission_audit_log (performed_by, action, resource_type, resource_id, target_user_id, old_role, new_role) VALUES (?, 'role.change', 'workspace', '1', 42, 'viewer', 'editor')""", (aid,)) conn.execute( """INSERT INTO sso_login_history (user_id, provider_name, provider_type, success, ip_address, sso_identifier) VALUES (?, 'okta', 'oidc', 1, '10.0.0.2', 'bob@corp.example')""", (aid,)) conn.commit() r = client.get("/api/v2/audit/logs", cookies=c) assert r.status_code == 200, r.text logs = r.json()["logs"] sources = {row["source"] for row in logs} assert {"api", "permissions", "sso"} <= sources assert all({"at", "source", "actor", "action", "resource", "detail"} <= set(row) for row in logs) def test_audit_source_filter(client): client, c = _admin_client(client) aid, _ = _make_user(is_admin=1) with get_conn() as conn: conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id) VALUES (?, 'api.x', 'page', '1')""", (aid,)) conn.commit() r = client.get("/api/v2/audit/logs?source=sso", cookies=c) assert all(row["source"] == "sso" for row in r.json()["logs"]) assert client.get("/api/v2/audit/logs?source=bogus", cookies=c).status_code == 400 def test_audit_actor_and_action_filters(client): client, c = _admin_client(client) uid, _ = _make_user() with get_conn() as conn: conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id) VALUES (?, 'page.create', 'page', '1')""", (uid,)) conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id) VALUES (?, 'page.delete', 'page', '2')""", (uid,)) conn.commit() r = client.get(f"/api/v2/audit/logs?actor={uid}&action=create", cookies=c) assert r.status_code == 200 assert len(r.json()["logs"]) == 1 assert r.json()["logs"][0]["action"] == "page.create" def test_audit_csv_export(client): client, c = _admin_client(client) aid, _ = _make_user(is_admin=1) with get_conn() as conn: conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id) VALUES (?, 'page.create', 'page', '1')""", (aid,)) conn.commit() r = client.get("/api/v2/audit/logs?format=csv", cookies=c) assert r.status_code == 200 assert r.headers["content-type"].startswith("text/csv") assert "attachment" in r.headers["content-disposition"] text = r.text assert text.splitlines()[0].startswith("at,source,actor,action") assert "page.create" in text def test_audit_pagination(client): client, c = _admin_client(client) aid, _ = _make_user(is_admin=1) with get_conn() as conn: for i in range(10): conn.execute("""INSERT INTO api_audit_log (user_id, action, resource_type, resource_id) VALUES (?, 'page.create', 'page', ?)""", (aid, i)) conn.commit() r = client.get("/api/v2/audit/logs?limit=3&offset=0", cookies=c) assert len(r.json()["logs"]) == 3 assert r.json()["limit"] == 3 # ── agent governance ─────────────────────────────────────────────────────── def test_policy_defaults(client): from app.services.agent_policies import get_policy p = get_policy(999999) assert p["allowed_tools"] is None assert p["require_approval"] is False def test_policy_upsert_and_list(client): client, c = _admin_client(client) r = client.post("/api/v2/agent-policies", cookies=c, json={"workspace_id": None, "allowed_tools": ["search", "read_page"], "max_steps": 5, "require_approval": True}) assert r.status_code == 201, r.text body = r.json() assert json.loads(body["allowed_tools_json"]) == ["search", "read_page"] assert body["require_approval"] == 1 lst = client.get("/api/v2/agent-policies", cookies=c) assert len(lst.json()["policies"]) == 1 def test_policy_max_steps_clamped(client): client, c = _admin_client(client) r = client.post("/api/v2/agent-policies", cookies=c, json={"max_steps": 9999, "allowed_tools": None}) assert r.json()["max_steps"] == 50 def test_policy_rejects_bad_tools(client): client, c = _admin_client(client) assert client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": "search"}).status_code == 400 def test_check_tool_denies_out_of_scope(client): from app.services.agent_policies import check_tool, get_policy client, c = _admin_client(client) client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": ["search"], "max_steps": 5}) uid, _ = _make_user() out = check_tool(uid, None, "delete_page", is_write=True, conversation_id=0) assert out["allowed"] is False assert "policy scope" in out["reason"] assert get_policy(None)["max_steps"] == 5 def test_check_tool_allows_reads(client): from app.services.agent_policies import check_tool client, c = _admin_client(client) client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": ["search"], "require_approval": True}) uid, _ = _make_user() assert check_tool(uid, None, "search", is_write=False)["allowed"] is True def test_check_tool_requires_approval_for_writes(client): from app.services.agent_policies import check_tool client, c = _admin_client(client) client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": ["search", "create_page"], "require_approval": True}) uid, _ = _make_user() out = check_tool(uid, None, "create_page", is_write=True, conversation_id=0) assert out["allowed"] is False assert out["approval_id"] with get_conn() as conn: row = conn.execute("SELECT * FROM agent_approvals WHERE id=?", (out["approval_id"],)).fetchone() assert row["status"] == "pending" assert row["tool"] == "create_page" assert row["requester_id"] == uid def test_approval_decision_flow(client): from app.services.agent_policies import check_tool client, c = _admin_client(client) client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": ["create_page"], "require_approval": True}) uid, _ = _make_user() aid = check_tool(uid, None, "create_page", is_write=True)["approval_id"] q = client.get("/api/v2/agent-approvals", cookies=c) assert any(a["id"] == aid for a in q.json()["approvals"]) r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c, json={"approve": True}) assert r.status_code == 200 assert r.json()["status"] == "approved" # a decided approval cannot be decided again assert client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c, json={"approve": False}).status_code == 404 def test_approval_rejection(client): from app.services.agent_policies import check_tool client, c = _admin_client(client) client.post("/api/v2/agent-policies", cookies=c, json={"allowed_tools": ["delete_page"], "require_approval": True}) uid, _ = _make_user() aid = check_tool(uid, None, "delete_page", is_write=True)["approval_id"] r = client.post(f"/api/v2/agent-approvals/{aid}/decide", cookies=c, json={"approve": False}) assert r.json()["status"] == "rejected" def test_governance_routes_require_auth(client): anon(client) assert client.get("/api/v2/agent-policies").status_code == 401 assert client.get("/api/v2/agent-approvals").status_code == 401