feat: v6.7.0 — SSO/SAML + OIDC entreprise (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, SSO only, onglet Settings « SSO / Enterprise », API /api/v2/sso/*, help, migration 23, docs + OpenAPI 439 chemins) · 802 tests verts
FlowDeck CI / lint (push) Successful in 1m28s
FlowDeck CI / test (push) Successful in 11m52s
FlowDeck CI / docker (push) Successful in 1m51s

This commit is contained in:
2026-09-24 13:32:17 -04:00
parent 9562f30366
commit d074689b18
25 changed files with 4507 additions and 31 deletions
+68
View File
@@ -98,6 +98,74 @@ class PermissionManager:
).fetchone()
return "owner" if owner else "viewer"
# ── SSO (v6.7.0, design §7.2) ─────────────────────────────────────────
def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool:
"""True when that workspace can only be reached through SSO.
FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 /
§4.2): when it is on, local login is refused for every non-admin, so
every workspace on the instance is effectively SSO-only.
``workspace_id`` is accepted to mirror the design's per-workspace API.
"""
from app.services.sso_provisioning import is_sso_only
return is_sso_only()
def _user_auth_method(self) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT auth_method FROM users WHERE id=?", (self.user_id,)
).fetchone()
return (row["auth_method"] or "local") if row else "local"
def get_sso_roles(
self, user_id: int | None = None, workspace_id: int | None = None
) -> list[str]:
"""Roles granted to that user through SSO group mapping (design §7.2).
SSO grants land in the regular ``workspace_members`` row (the mapping
is re-applied at every SSO login), so the answer is the explicit
membership role of a non-local account — local accounts and users
without an explicit grant (the implicit *viewer* fallback is not an
SSO grant) get ``[]``.
"""
if workspace_id is None:
return []
pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self
if pm._user_auth_method() == "local":
return []
with get_conn() as conn:
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(int(workspace_id), pm.user_id),
).fetchone()
return [row["role"]] if row else []
def sync_sso_permissions(
self,
user_id: int | None,
sso_groups: list[str],
workspace_id: int | None = None,
) -> list[int]:
"""Re-apply the group → workspace role mapping (design §7.2).
Delegates to ``sso_provisioning.sync_sso_groups`` (the single source
of truth used at login and by ``POST /api/v2/sso/sync``). Returns the
touched workspace ids, narrowed to ``workspace_id`` when given.
"""
from app.services.sso_provisioning import get_sso_config, sync_sso_groups
cfg = get_sso_config()
if not cfg:
return []
touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg)
if workspace_id is not None:
touched = [w for w in touched if int(w) == int(workspace_id)]
if touched:
self.invalidate()
return touched
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES