Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d074689b18 | ||
|
|
9562f30366 | ||
|
|
6dfd6d718e |
@@ -58,3 +58,27 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=FlowDeck <[email protected]>
|
||||
SMTP_USE_TLS=true
|
||||
APP_BASE_URL=http://localhost:8080
|
||||
|
||||
# ── SSO / Enterprise (v6.7.0) ──
|
||||
# Fallback de démarrage uniquement : dès qu'un admin enregistre une configuration
|
||||
# dans Settings → Admin → SSO / Enterprise, la table `sso_config` prime sur le .env.
|
||||
# Le bouton SSO n'apparaît sur la page de connexion que si une config est active.
|
||||
# SSO_PROVIDER=saml # saml | oidc (vide = SSO désactivé)
|
||||
# SSO_NAME=Company SSO # libellé du bouton
|
||||
# SSO_ONLY=false # true = refuser le login local (les admins gardent le leur)
|
||||
# SSO_AUTO_PROVISION=true # créer le compte au premier login SSO
|
||||
# SAML :
|
||||
# SSO_ENTITY_ID=https://idp.example.com/saml/metadata
|
||||
# SSO_SSO_URL=https://idp.example.com/saml/sso
|
||||
# SSO_SLO_URL=https://idp.example.com/saml/slo
|
||||
# SSO_X509_CERTIFICATE=-----BEGIN CERTIFICATE-----
|
||||
# SSO_SIGN_REQUESTS=false # signer les AuthnRequests / LogoutRequest
|
||||
# OIDC :
|
||||
# SSO_ISSUER_URL=https://auth.example.com/realms/flowdeck
|
||||
# SSO_CLIENT_ID=
|
||||
# SSO_CLIENT_SECRET=
|
||||
# SSO_SCOPE=openid profile email
|
||||
# Mapping (JSON) :
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
@@ -1709,5 +1709,7 @@ docker compose restart flowdeck
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py`
|
||||
- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py`
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+137
@@ -1,5 +1,142 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth)
|
||||
|
||||
> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP
|
||||
> d'entreprise (SAML 2.0 ou OpenID Connect + PKCE), auto-provisioning des
|
||||
> comptes, mapping des groupes de l'IdP vers les rôles workspace, mode
|
||||
> « SSO only » et configuration admin dans Settings. Design :
|
||||
> `docs/V6_SSO_SAML_Enterprise_Auth.md` (checklist §9 cochée).
|
||||
|
||||
### Added
|
||||
|
||||
- **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) :
|
||||
login SP-initié `GET /auth/saml/login` (RelayState = id AuthnRequest + jeton CSRF
|
||||
en DB), ACS `POST /auth/saml/callback` (signature, audience, destination,
|
||||
InResponseTo, anti-replay `sso_requests`), SP metadata `GET /auth/saml/metadata`,
|
||||
SLO `GET|POST /auth/saml/logout` (relais LogoutRequest/Response vers l'IdP) ;
|
||||
`allowSingleLabelDomains` activé pour les hôts homelab/LAN
|
||||
- **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) :
|
||||
`GET /auth/oidc/login` (`state` + `code_verifier` stockés en DB),
|
||||
`GET|POST /auth/oidc/callback` (code → token → userinfo ; ID token vérifié via
|
||||
JWKS : aud/iss/nonce/exp), discovery + JWKS mis en cache 1 h,
|
||||
`GET|POST /auth/oidc/logout` (`end_session_endpoint` si présent)
|
||||
- **Provisioning** — `app/services/sso_provisioning.py` : création automatique de
|
||||
l'utilisateur au 1er login (`auth_method=saml|oidc`, lien via email), rôle par
|
||||
défaut + `default_workspace_id`, mapping groupes IdP → rôles workspace
|
||||
(re-synchronisé à chaque login + `POST /api/v2/sso/sync`), mode **SSO only**
|
||||
(login local refusé sauf administrateurs — design §7.1), secrets chiffrés
|
||||
Fernet dans `sso_config` (champ secret vide = conserver l'existant), détection
|
||||
de rejeu via `sso_requests` (TTL 15 min), `sso_login_history` (succès + échecs),
|
||||
`safe_next_path` (pas de redirection externe)
|
||||
- **Routeur** — `app/routers/sso.py` : parcours navigateur + API
|
||||
`GET|POST|PUT|DELETE /api/v2/sso/config` (admin, header `X-CSRF-Token`,
|
||||
`audit_log`), `GET /api/v2/sso/providers` (public, boutons de la page de login),
|
||||
`GET /api/v2/sso/workspaces`, `POST /api/v2/sso/sync`, `GET /api/v2/sso/history` ;
|
||||
CSRF exclut les préfixes `/auth/saml` et `/auth/oidc` (POST IdP cross-site)
|
||||
- **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
|
||||
- **UI admin** — onglet « SSO / Enterprise » dans Settings : statut, config
|
||||
SAML (Entity ID, SSO/SLO URL, certificat PEM, mapping attributs JSON, signature
|
||||
des AuthnRequests) et OIDC (issuer, client id/secret, scope), provisioning
|
||||
(auto-provision, SSO only, espace par défaut), tableau groupes → rôles,
|
||||
URL des métadonnées SP copiable, bouton « Re-sync des groupes », désactivation ;
|
||||
bouton SSO sur la page de login (nom dynamique, masqué si non configuré)
|
||||
- **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`,
|
||||
`get_sso_roles()`, `sync_sso_permissions()` (les grants SSO sont des lignes
|
||||
`workspace_members` explicites ; le fallback « viewer » implicite n'en est pas un)
|
||||
- **Help** — section « Enterprise SSO » + badge `.help-badge.sso` dans `/help`
|
||||
- **`.env` (fallback bootstrap)** — `SSO_PROVIDER`, `SSO_NAME`, `SSO_ONLY`,
|
||||
`SSO_AUTO_PROVISION`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`,
|
||||
`SSO_X509_CERTIFICATE`, `SSO_SIGN_REQUESTS`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID`,
|
||||
`SSO_CLIENT_SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`,
|
||||
`SSO_DEFAULT_WORKSPACE_ID` (la config admin prime sur l'environnement ;
|
||||
section ajoutée dans `.env.example`)
|
||||
- **Dépendances** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0`
|
||||
|
||||
### Changed
|
||||
|
||||
- `/logout` délègue la déconnexion à l'IdP (SLO) quand la session vient de SAML,
|
||||
puis détruit la session locale ; sinon comportement existant
|
||||
- OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
|
||||
(operation IDs uniques — les routes multi-méthodes sont enregistrées en deux
|
||||
routes mono-méthode)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v67_sso.py` : **38 tests** — API de configuration admin
|
||||
(validation, CSRF, secret jamais renvoyé, secret vide préservé, réinitialisation,
|
||||
DELETE), métadonnées SP, login SAML complet contre un IdP mock signé (y compris
|
||||
SLO), OIDC complet (discovery/token/userinfo/JWKS mockés), cas négatifs
|
||||
(mauvais audience/issuer/destination/nonce/state, rejeu, signature altérée,
|
||||
assertion expirée), auto-provisioning, group mapping (nom → rôle workspace),
|
||||
sso_only, historique, rate limit, endpoint public `providers`, extension
|
||||
`PermissionManager`
|
||||
- `pytest -n auto` → **802 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
|
||||
---
|
||||
|
||||
## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace
|
||||
|
||||
> Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient
|
||||
> pilotable par des intégrations tierces via `/api/v2`, et les skills deviennent
|
||||
> partageables (export/import portable + galerie de presets installables).
|
||||
|
||||
### Added
|
||||
|
||||
- **API publique agent** — `app/routers/api_v2_agent.py` (15 routes, Bearer + scopes `read`/`write`,
|
||||
rate limit par token, idempotence, `api_audit_log`) :
|
||||
- `GET/POST /api/v2/agents`, `GET/PUT/DELETE /api/v2/agents/{id}`
|
||||
- `GET/POST /api/v2/agents/conversations`, `GET/DELETE .../{id}`, `GET .../{id}/actions`
|
||||
- `POST /api/v2/agents/conversations/{id}/run` — **run synchrone JSON** (le flux SSE reste interne) :
|
||||
`{status, final, error, reasoning[], actions[], events[], duration_ms}` (500 si `failed`)
|
||||
- `POST /api/v2/agents/{id}/trigger` — déclenchement externe d'un agent custom
|
||||
- propriété des conversations vérifiée par `user_id` → `404` pour un token tiers
|
||||
- **Marketplace de skills** — `app/services/skill_gallery.py` (source unique pour l'interne et le v2) :
|
||||
- `GET /api/v2/skills/{id}/export` → document portable `{format: "flowdeck-skill", version: 1, skill{…}}`
|
||||
(aucun id/workspace/auteur local) ; `POST /api/v2/skills/import` (`409` sur collision, `overwrite: true` pour écraser)
|
||||
- `GET /api/v2/skills/gallery` + `POST /api/v2/skills/gallery/{slug}/install` — **6 presets** :
|
||||
rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
|
||||
- `GET/POST/DELETE /api/v2/skills`, `GET /api/v2/skills/{id}`, `POST /api/v2/skills/{id}/apply`
|
||||
- routes jumelles session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
|
||||
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
|
||||
(le CRUD skills n'avait **aucune** suppression)
|
||||
- **UI** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage du chip
|
||||
- **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis (seul
|
||||
`agent.run.finished` l'était) via `_fire_agent_webhook()` ; les 3 sont déjà au catalogue → abonnement `agent.*`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Docs** — `docs/API_GUIDE_V6.md` : nouveau §2.4 (tables d'endpoints + règles agent) ; nom d'événement
|
||||
corrigé `agent.run.completed` → `agent.run.finished` ; `ROADMAP.md` : 5 phases agent toutes cochées + section v6.6.0
|
||||
- **OpenAPI** — `docs/openapi-v2.json` régénéré : **427 chemins** (was 402), `info.version = 6.6.0`
|
||||
- **Version** — 6.6.0 (`VERSION` + `app/main.py`)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, idempotence, run synchrone,
|
||||
ownership 404, trigger, export/import/validation, galerie + validité des outils des presets,
|
||||
routes internes, cycle de vie webhooks started→finished et started→failed)
|
||||
- `pytest -n auto` → **764 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
|
||||
## v6.5.1 (2026-09-24) — Tests webhooks_v2 complets + roadmap rattrapée
|
||||
|
||||
> Les 7 tests d'intégration Webhooks v2 (stubs `@pytest.mark.skip` depuis
|
||||
> v5.15.0) sont désormais réellement câblés, et le ROADMAP rattrapé sur ce
|
||||
> qui avait été livré entre-temps.
|
||||
|
||||
### Added
|
||||
|
||||
- **7 tests d'intégration webhooks** (`tests/test_webhooks_v2.py`) : retry échec→succès (journal `retrying→retrying→delivered`), échec après `MAX_ATTEMPTS` (4 POST + `failed` final), `fire_event` ignore les events inconnus, aucun abonné → aucun appel, fan-out wildcard (`page.*` reçoit, `collection.created` non), flow complet signé bout en bout (HMAC vérifié sur le body reçu + journal `delivered`), `retry_due_deliveries` (row `retrying` périmée → re-fire → `delivered`, original `superseded`)
|
||||
- **Fixture `db` isolée** — SQLite temporaire par test (env + singleton `settings` restaurés, safe `-n auto`), tables webhook créées par `init_db()` ; `httpx.MockTransport` injecté via stub module + `RETRY_DELAYS=(0,0,0)` (zéro sleep réel)
|
||||
|
||||
### Changed
|
||||
|
||||
- **ROADMAP** — case « migration `sync.py` vers Bearer » cochée (livrée v6.4.0 : Bearer-first + repli session PWA) ; ligne v5.15.0 mise à jour ; résumé final → « Reste: SSO/SAML » ; **suite 749 verts, 0 skip**
|
||||
|
||||
### Tests
|
||||
|
||||
- `pytest -n auto` → **749 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
|
||||
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
|
||||
|
||||
> Les synced blocks passent en production et existent enfin dans les databases :
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,8 +49,10 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v6.3)
|
||||
### API & Intégrations (v6.3–v6.6)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API agent publique (v6.6)**: `/api/v2/agents/*` — agents, conversations, **run synchrone JSON**, journal d'actions + rollback, `trigger` externe
|
||||
- **Marketplace de skills (v6.6)**: export/import portable + galerie de 6 presets installables (`/api/v2/skills/*`), section « Galerie » dans la palette `/` de l'agent
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
@@ -87,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
python3 -m pytest tests/ -v # 764/764 passent (0 skip)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+72
-8
@@ -404,11 +404,11 @@ app/
|
||||
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
|
||||
|
||||
#### Plan de migration (5 phases)
|
||||
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||
1. **Phase 1 — Core Agent** ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
|
||||
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
|
||||
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
|
||||
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
|
||||
3. **Phase 3 — Outils avancés** ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
|
||||
4. **Phase 4 — Autonomie** ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
|
||||
5. **Phase 5 — Plateforme** ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills
|
||||
|
||||
#### Limitations (v1)
|
||||
- Pas de modification concurrente (lock optimiste DB)
|
||||
@@ -702,7 +702,7 @@ Détails livrés :
|
||||
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
|
||||
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
|
||||
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
|
||||
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration)
|
||||
- [x] **Tests** — 21 dédiés (`tests/test_webhooks_v2.py`) ; **7 tests d'intégration câblés** (retry échec→succès, échec après MAX_ATTEMPTS, event inconnu ignoré, aucun abonné, fan-out wildcard, flow complet signé bout en bout, `retry_due_deliveries` superseded→delivered) — fixture `db` = SQLite isolée par test + `httpx.MockTransport` + `RETRY_DELAYS` neutralisé ; suite complète **749 verts, 0 skip**
|
||||
|
||||
---
|
||||
|
||||
@@ -806,7 +806,7 @@ Détails livrés :
|
||||
|
||||
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
|
||||
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
|
||||
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt)
|
||||
- [x] *(reporté, livré v6.4.0)* : migration de `sync.py` vers Bearer — auth `Authorization: Bearer` (scopes read/write) via `get_bearer_user()` + repli cookie session conservé pour compat PWA offline (`app/routers/sync.py`)
|
||||
|
||||
#### Phase 8 — OpenAPI, tests & docs ✅
|
||||
|
||||
@@ -849,6 +849,70 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.7.0 — SSO / SAML + OIDC entreprise (Enterprise Auth) ✅ (2026-09-24)
|
||||
|
||||
> **Objectif** : dernière feature de la roadmap v6.0.0 — authentification fédérée
|
||||
> via un IdP d'entreprise. **COMPLETED**. Design : [`docs/V6_SSO_SAML_Enterprise_Auth.md`](docs/V6_SSO_SAML_Enterprise_Auth.md).
|
||||
|
||||
- [x] **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : login SP-initié
|
||||
(`/auth/saml/login`), ACS signé (signature/audience/destination/InResponseTo validés),
|
||||
SP metadata public (`/auth/saml/metadata`), SLO (`/auth/saml/logout`) ; `allowSingleLabelDomains`
|
||||
pour les hôts homelab/LAN
|
||||
- [x] **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : `state` +
|
||||
`code_verifier` en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h
|
||||
- [x] **Provisioning** — `app/services/sso_provisioning.py` : auto-provision au 1er login
|
||||
(`auth_method=saml|oidc`, lien par email), rôle par défaut + `default_workspace_id`,
|
||||
mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode **SSO only**
|
||||
(login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay `sso_requests`,
|
||||
historique `sso_login_history`
|
||||
- [x] **Routeur** — `app/routers/sso.py` : parcours navigateur + API admin
|
||||
`GET/POST/DELETE /api/v2/sso/config` (admin + CSRF), `GET /api/v2/sso/workspaces`,
|
||||
`POST /api/v2/sso/sync` ; CSRF exclut `/auth/saml/*` + `/auth/oidc/*`
|
||||
- [x] **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
|
||||
- [x] **UI admin** — onglet « SSO / Enterprise » dans Settings (config SAML/OIDC, provisioning,
|
||||
groupes → rôles, URL métadonnées SP copiable, désactivation) ; bouton SSO sur la page de login
|
||||
- [x] **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, `get_sso_roles()`,
|
||||
`sync_sso_permissions()`
|
||||
- [x] **Help** — section « SSO (Enterprise) » + badge `.help-badge.sso`
|
||||
- [x] **Docs** — CHANGELOG, WORKLOAD, README, ARCHITECTURE, `docs/API_GUIDE_V6.md` §2.5,
|
||||
OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0`
|
||||
- [x] **Tests** — `tests/test_v67_sso.py` : **38 tests** (config admin + CSRF + secret préservé,
|
||||
métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper,
|
||||
group mapping, `sso_only`, SLO, historique, rate limit, extension `PermissionManager`)
|
||||
- [x] **Version** — 6.7.0 · `ruff check app tests` OK · **suite 802 verts, 0 skip**
|
||||
|
||||
---
|
||||
|
||||
## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24)
|
||||
> **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » :
|
||||
> exposer l'agent en API publique pour les intégrations tierces et rendre les skills
|
||||
> partageables (marketplace). **COMPLETED**. Voir [`docs/API_GUIDE_V6.md` §2.4](docs/API_GUIDE_V6.md).
|
||||
|
||||
- [x] **API publique agent** — `app/routers/api_v2_agent.py` (15 routes `/api/v2/agents/*`,
|
||||
Bearer + scopes `read`/`write`, rate limit par token, idempotence `Idempotency-Key`, `api_audit_log`) :
|
||||
- agents CRUD, conversations + messages, **run synchrone JSON** (le flux SSE reste réservé à l'UI),
|
||||
journal `agent_actions` + `POST /agents/actions/{id}/undo`, `POST /agents/{id}/trigger`
|
||||
- propriété des conversations vérifiée par `user_id` : un token tiers reçoit `404` (pas de fuite)
|
||||
- réponse de run : `{status, final, error, reasoning[], actions[], events[], duration_ms}`
|
||||
- [x] **Marketplace skills** — `app/services/skill_gallery.py` (source unique interne + v2) :
|
||||
- **export portable** `{format: "flowdeck-skill", version: 1, skill{name, description, prompt_template, allowed_tools}}`
|
||||
(aucun id/workspace/auteur local ne fuit) + **import** avec contrôle de collision (`409`, `overwrite: true` pour écraser)
|
||||
- **galerie de 6 presets** installables : rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
|
||||
(`GET /api/v2/skills/gallery`, `POST /api/v2/skills/gallery/{slug}/install`)
|
||||
- routes jumelles côté session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
|
||||
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
|
||||
(comble le trou CRUD : pas de suppression de skill avant)
|
||||
- **UI branchée** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage automatique du chip
|
||||
- [x] **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis pour la 1re fois
|
||||
(seul `agent.run.finished` l'était) ; les 3 sont dans le catalogue `webhook_outbound` → abonnement `agent.*`
|
||||
- [x] **Docs** — `docs/API_GUIDE_V6.md` (§2.4 + correction du nom d'événement `agent.run.completed` → `agent.run.finished`),
|
||||
OpenAPI régénéré : `docs/openapi-v2.json` → **427 chemins**, `info.version = 6.6.0`
|
||||
- [x] **Tests** — `tests/test_v66_agent_api.py` : **15 tests** (auth/scopes, CRUD agents, run synchrone,
|
||||
ownership 404, trigger, export/import/validation, galerie + outils valides, routes internes, cycle de vie webhooks)
|
||||
- [x] **Version** — 6.6.0 · `ruff check app tests` OK · `eslint static/js` 0 problème · **suite 764 verts, 0 skip**
|
||||
|
||||
---
|
||||
|
||||
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
|
||||
> **Objectif** : passer les synced blocks en « production » (résolution +
|
||||
> propagation fiables partout) et les faire vivre dans les databases/vues —
|
||||
@@ -884,7 +948,7 @@ Détails livrés :
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **SSO/SAML** — enterprise authentication ✅ (livré v6.7.0) — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
|
||||
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
|
||||
|
||||
@@ -932,4 +996,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.*
|
||||
|
||||
+6
-3
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
> **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0)
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
|
||||
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
|
||||
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -61,5 +64,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 764+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
|
||||
|
||||
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
|
||||
ID token signature is verified against the issuer JWKS via authlib's JOSE
|
||||
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
|
||||
explicitly so the rules are visible and unit-testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
|
||||
DEFAULT_OIDC_MAPPING: dict[str, str] = {
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
_DISCOVERY_TTL = 3600.0
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""OIDC processing failure — ``message`` is user-facing."""
|
||||
|
||||
|
||||
def pkce_pair() -> tuple[str, str]:
|
||||
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
||||
|
||||
|
||||
async def discover(issuer_url: str) -> dict:
|
||||
"""Fetch (and cache) the issuer's OIDC discovery document."""
|
||||
issuer = issuer_url.rstrip("/")
|
||||
url = f"{issuer}/.well-known/openid-configuration"
|
||||
now = time.time()
|
||||
hit = _discovery_cache.get(issuer)
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
|
||||
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
|
||||
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
|
||||
_discovery_cache[issuer] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
doc: dict,
|
||||
*,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
code_challenge: str,
|
||||
) -> str:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": scope or "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
|
||||
return doc["authorization_endpoint"] + sep + urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(
|
||||
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
|
||||
) -> dict:
|
||||
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
auth = None
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
if r.status_code != 200:
|
||||
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
|
||||
try:
|
||||
tokens = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
|
||||
if "error" in tokens:
|
||||
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
|
||||
return tokens
|
||||
|
||||
|
||||
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Best-effort userinfo fetch (groups often only live there)."""
|
||||
endpoint = doc.get("userinfo_endpoint")
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
data = r.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as err: # userinfo is optional enrichment
|
||||
logger.debug("userinfo fetch failed: %s", err)
|
||||
return {}
|
||||
|
||||
|
||||
def validate_id_token(
|
||||
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
|
||||
) -> dict:
|
||||
"""Verify the ID token signature and claims. Returns the claims dict."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from authlib.jose import JsonWebKey
|
||||
from authlib.jose import jwt as jose_jwt
|
||||
|
||||
if isinstance(id_token, bytes):
|
||||
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
|
||||
# str — accept both instead of crashing on ``bytes.count(".")``.
|
||||
id_token = id_token.decode()
|
||||
if not id_token or id_token.count(".") != 2:
|
||||
raise OIDCError("Missing or malformed ID token")
|
||||
|
||||
try:
|
||||
keyset = JsonWebKey.import_key_set(jwks)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
|
||||
|
||||
try:
|
||||
# Pick the key matching the token header (kid) when several are offered.
|
||||
header = json.loads(_b64url_decode(id_token.split(".")[0]))
|
||||
kid = header.get("kid")
|
||||
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
|
||||
token_obj = jose_jwt.decode(id_token, key or keyset)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"ID token signature verification failed: {err}") from err
|
||||
|
||||
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
|
||||
now = int(time.time())
|
||||
|
||||
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
|
||||
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
|
||||
aud = claims.get("aud")
|
||||
aud_list = aud if isinstance(aud, list) else [aud]
|
||||
if client_id not in aud_list:
|
||||
raise OIDCError("ID token audience does not include this client")
|
||||
exp = claims.get("exp")
|
||||
if not isinstance(exp, int) or exp < now:
|
||||
raise OIDCError("ID token expired")
|
||||
iat = claims.get("iat")
|
||||
if isinstance(iat, int) and iat > now + 300:
|
||||
raise OIDCError("ID token issued in the future")
|
||||
if nonce and claims.get("nonce") != nonce:
|
||||
raise OIDCError("ID token nonce mismatch")
|
||||
if not claims.get("sub"):
|
||||
raise OIDCError("ID token has no subject")
|
||||
return claims
|
||||
|
||||
|
||||
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
|
||||
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
|
||||
mapping = mapping or DEFAULT_OIDC_MAPPING
|
||||
identity: dict = {"_raw": claims}
|
||||
for field in ("login", "email", "full_name", "avatar_url"):
|
||||
source = mapping.get(field) or field
|
||||
value = claims.get(source, "")
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else ""
|
||||
identity[field] = str(value or "").strip()
|
||||
groups = claims.get(mapping.get("groups", "groups"), [])
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
identity["groups"] = [str(g) for g in groups if g]
|
||||
if not identity["email"]:
|
||||
identity["email"] = claims.get("email", "") or ""
|
||||
if not identity["full_name"]:
|
||||
identity["full_name"] = claims.get("name", "") or identity["email"]
|
||||
return identity
|
||||
@@ -0,0 +1,279 @@
|
||||
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
|
||||
|
||||
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
|
||||
dict and builds the SP settings from the ``sso_config`` row.
|
||||
|
||||
What the toolkit validates in strict mode (all covered by tests):
|
||||
XML schema, signature of the assertion and/or the message against the IdP
|
||||
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
|
||||
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
|
||||
against the AuthnRequest id we pass to ``process_response()`` — combined
|
||||
with the single-use ``sso_requests`` store that makes replay impossible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
|
||||
#: NameID; every other value is matched against attribute Name / FriendlyName
|
||||
#: / URI local part (so ``email`` finds both ``email`` and
|
||||
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
|
||||
DEFAULT_SAML_MAPPING: dict[str, str] = {
|
||||
"login": "nameid",
|
||||
"email": "nameid",
|
||||
"full_name": "displayName",
|
||||
"avatar_url": "avatar",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class SAMLError(Exception):
|
||||
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLIdentity:
|
||||
"""What a validated assertion tells us about the user."""
|
||||
|
||||
name_id: str
|
||||
name_id_format: str = ""
|
||||
session_index: str = ""
|
||||
attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def resolve(self, source: str) -> str:
|
||||
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
|
||||
if not source or source == "nameid":
|
||||
return self.name_id or ""
|
||||
if source in self.attributes and self.attributes[source]:
|
||||
return (self.attributes[source][0] or "").strip()
|
||||
# FriendlyName match (case-insensitive)
|
||||
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
|
||||
if source.lower() in lower and lower[source.lower()]:
|
||||
return (lower[source.lower()][0] or "").strip()
|
||||
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
|
||||
# a mapping of "email" must still find ".../claims/emailaddress".
|
||||
want = source.lower().lstrip("./")
|
||||
for name, values in self.attributes.items():
|
||||
if not values:
|
||||
continue
|
||||
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
|
||||
if local == want or local.endswith(want) or want.endswith(local):
|
||||
return (values[0] or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def external_base_url(request: Request) -> str:
|
||||
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
|
||||
proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def saml_endpoints(request: Request) -> dict[str, str]:
|
||||
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
|
||||
base = external_base_url(request)
|
||||
return {
|
||||
"entity_id": f"{base}/auth/saml/metadata",
|
||||
"acs": f"{base}/auth/saml/callback",
|
||||
"slo": f"{base}/auth/saml/logout",
|
||||
"metadata": f"{base}/auth/saml/metadata",
|
||||
}
|
||||
|
||||
|
||||
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
|
||||
"""python3-saml settings dict built from a ``sso_config`` row."""
|
||||
sign_requests = bool(cfg.get("sign_requests"))
|
||||
sp: dict = {
|
||||
"entityId": endpoints["entity_id"],
|
||||
"assertionConsumerService": {
|
||||
"url": endpoints["acs"],
|
||||
"binding": BINDING_HTTP_POST,
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": endpoints["slo"],
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"NameIDFormat": NAMEID_FORMAT_EMAIL,
|
||||
}
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
sp["privateKey"] = cfg["sp_private_key"]
|
||||
sp["x509cert"] = cfg["sp_certificate"]
|
||||
|
||||
idp: dict = {
|
||||
"entityId": cfg.get("entity_id") or "",
|
||||
"singleSignOnService": {
|
||||
"url": cfg.get("sso_url") or "",
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"x509cert": cfg.get("x509_certificate") or "",
|
||||
}
|
||||
if cfg.get("slo_url"):
|
||||
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
|
||||
|
||||
return {
|
||||
"strict": True,
|
||||
"debug": False,
|
||||
"sp": sp,
|
||||
"idp": idp,
|
||||
"security": {
|
||||
"authnRequestsSigned": sign_requests,
|
||||
"logoutRequestSigned": sign_requests,
|
||||
"logoutResponseSigned": False,
|
||||
"wantMessagesSigned": False,
|
||||
"wantAssertionsSigned": True,
|
||||
"wantNameIdEncrypted": False,
|
||||
"wantAssertionsEncrypted": False,
|
||||
"wantXmlValidation": True,
|
||||
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
||||
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
|
||||
"rejectDeprecatedAlgorithm": True,
|
||||
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
|
||||
# SP through single-label hosts (http://flowdeck/, docker service
|
||||
# names). python3-saml rejects those URLs unless this is on.
|
||||
"allowSingleLabelDomains": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
|
||||
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
return {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": script_name,
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": post_data or {},
|
||||
}
|
||||
|
||||
|
||||
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
try:
|
||||
return OneLogin_Saml2_Auth(
|
||||
_request_data(request, script_name, post_data=post_data), old_settings=settings
|
||||
)
|
||||
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
|
||||
raise SAMLError(f"Invalid SAML configuration: {err}") from err
|
||||
|
||||
|
||||
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
|
||||
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
|
||||
auth = _auth(request, cfg, "/auth/saml/login")
|
||||
try:
|
||||
url = auth.login(return_to=relay_state)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
|
||||
request_id = auth.get_last_request_id() or ""
|
||||
if not request_id:
|
||||
raise SAMLError("AuthnRequest was built without an id")
|
||||
return url, request_id
|
||||
|
||||
|
||||
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
|
||||
"""Validate the IdP's SAMLResponse and extract the identity.
|
||||
|
||||
``request_id`` is the id of the AuthnRequest we issued (from the
|
||||
single-use ``sso_requests`` row): the toolkit rejects any response whose
|
||||
``InResponseTo`` does not match it.
|
||||
"""
|
||||
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
|
||||
try:
|
||||
auth.process_response(request_id=request_id or None)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML response could not be processed: {err}") from err
|
||||
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
|
||||
if not auth.is_authenticated():
|
||||
raise SAMLError("SAML response did not authenticate the user")
|
||||
|
||||
name_id = auth.get_nameid() or ""
|
||||
if not name_id:
|
||||
raise SAMLError("SAML assertion carries no NameID")
|
||||
return SAMLIdentity(
|
||||
name_id=name_id,
|
||||
name_id_format=auth.get_nameid_format() or "",
|
||||
session_index=auth.get_session_index() or "",
|
||||
attributes=auth.get_attributes() or {},
|
||||
friendly_attributes=auth.get_friendlyname_attributes() or {},
|
||||
)
|
||||
|
||||
|
||||
def metadata_xml(request: Request, cfg: dict) -> str:
|
||||
"""SP metadata XML (for the IdP configuration screen)."""
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
settings = OneLogin_Saml2_Settings(
|
||||
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
|
||||
)
|
||||
try:
|
||||
xml = settings.get_sp_metadata()
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SP metadata: {err}") from err
|
||||
if isinstance(xml, bytes):
|
||||
xml = xml.decode("utf-8")
|
||||
return xml
|
||||
|
||||
|
||||
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
|
||||
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
|
||||
auth = _auth(request, cfg, "/auth/saml/logout")
|
||||
if not cfg.get("slo_url"):
|
||||
raise SAMLError("The IdP has no Single Logout URL configured")
|
||||
try:
|
||||
return auth.logout(
|
||||
return_to=return_to,
|
||||
name_id=name_id or None,
|
||||
session_index=session_index or None,
|
||||
)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
|
||||
|
||||
|
||||
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
|
||||
"""Process a LogoutRequest / LogoutResponse received from the IdP.
|
||||
|
||||
``form`` holds the POSTed fields, ``query`` the GET parameters (the
|
||||
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
|
||||
Returns ``(redirect_url, errors)``.
|
||||
"""
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
|
||||
if not post_data:
|
||||
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
|
||||
req_data = {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": "/auth/saml/logout",
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(query),
|
||||
"post_data": post_data,
|
||||
}
|
||||
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
|
||||
try:
|
||||
url = auth.process_slo(keep_local_session=True)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML logout could not be processed: {err}") from err
|
||||
return url, auth.get_errors()
|
||||
@@ -82,6 +82,26 @@ class Settings(BaseSettings):
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin
|
||||
# saves a configuration in Settings → Admin → SSO / Enterprise, the
|
||||
# `sso_config` table wins (see app/services/sso_provisioning.py).
|
||||
sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled)
|
||||
sso_name: str = "Company SSO" # button label on the login page
|
||||
sso_entity_id: str = "" # SAML: IdP entity id
|
||||
sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect)
|
||||
sso_slo_url: str = "" # SAML: IdP Single Logout URL
|
||||
sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM)
|
||||
sso_issuer_url: str = "" # OIDC: issuer identifier
|
||||
sso_client_id: str = "" # OIDC: client id
|
||||
sso_client_secret: str = "" # OIDC: client secret (env only)
|
||||
sso_scope: str = "openid profile email"
|
||||
sso_attribute_mapping: str = "" # JSON, defaults per provider
|
||||
sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}]
|
||||
sso_auto_provision: bool = True
|
||||
sso_only: bool = False # refuse local login when true
|
||||
sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest
|
||||
sso_default_workspace_id: int = 0
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
|
||||
+5
-1
@@ -39,6 +39,7 @@ from app.routers import (
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.api_v2_agent import router as api_v2_agent_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
@@ -49,6 +50,7 @@ from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.sso import router as sso_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
@@ -122,7 +124,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.5.0",
|
||||
version="6.7.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -135,6 +137,7 @@ app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sso_router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
@@ -169,6 +172,7 @@ app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
app.include_router(api_v2_agent_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/saml", "/auth/oidc", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -983,3 +983,87 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(23, "v6.7.0: SSO/SAML enterprise auth")
|
||||
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
|
||||
|
||||
``sso_config`` — single active SSO provider (SAML or OIDC), managed
|
||||
from Settings → Admin → SSO / Enterprise. Secrets
|
||||
(``client_secret``, SP private key) are encrypted at
|
||||
rest by ``app.services.sso_provisioning``.
|
||||
``sso_login_history`` — audit trail of every SSO login attempt (successes
|
||||
AND rejections — signature failure, replay, no
|
||||
local account…).
|
||||
``sso_requests`` — single-use anti-replay store: AuthnRequest ids and
|
||||
OIDC states, CSRF relay tokens, PKCE verifiers and
|
||||
the post-login redirect target. One row is consumed
|
||||
by exactly one callback.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'saml',
|
||||
name TEXT NOT NULL DEFAULT 'Company SSO',
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
sso_url TEXT NOT NULL DEFAULT '',
|
||||
slo_url TEXT DEFAULT '',
|
||||
x509_certificate TEXT NOT NULL DEFAULT '',
|
||||
issuer_url TEXT DEFAULT '',
|
||||
client_id TEXT DEFAULT '',
|
||||
client_secret TEXT DEFAULT '',
|
||||
scope TEXT DEFAULT 'openid profile email',
|
||||
attribute_mapping TEXT NOT NULL DEFAULT '{}',
|
||||
groups_mapping TEXT NOT NULL DEFAULT '[]',
|
||||
auto_provision INTEGER NOT NULL DEFAULT 1,
|
||||
sso_only INTEGER NOT NULL DEFAULT 0,
|
||||
sign_requests INTEGER NOT NULL DEFAULT 0,
|
||||
default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
sp_private_key TEXT DEFAULT '',
|
||||
sp_certificate TEXT DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL DEFAULT 'SSO',
|
||||
sso_identifier TEXT,
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_history_user "
|
||||
"ON sso_login_history(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
relay_state TEXT NOT NULL DEFAULT '',
|
||||
code_verifier TEXT NOT NULL DEFAULT '',
|
||||
next_path TEXT NOT NULL DEFAULT '/workspaces',
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.responses import StreamingResponse
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -524,6 +525,81 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
|
||||
|
||||
|
||||
# ── Skill marketplace (v6.6.0, Agent phase 5) ──
|
||||
# Galerie de presets + export/import portable — même implémentation que
|
||||
# l'API publique (/api/v2/skills/*), via app.services.skill_gallery.
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""FlowDeck — Public API v2 : Agent & Skill marketplace (v6.6.0, phase 5).
|
||||
|
||||
Thin Bearer+scopes wrappers over the existing agent logic (AgentEngine,
|
||||
`agent_skills`, the gallery service) so third-party integrations can drive
|
||||
FlowDeck Agent without a browser session:
|
||||
|
||||
* ``/api/v2/agents`` — agents CRUD, conversations, synchronous runs (JSON,
|
||||
the SSE stream stays an internal/UI concern), audit journal & rollback.
|
||||
* ``/api/v2/skills`` — the skill marketplace: CRUD, portable export/import and
|
||||
the built-in gallery of installable presets.
|
||||
|
||||
Rules honoured (see docs/API_GUIDE_V6.md): one code path (the engine and the
|
||||
gallery service are reused, never re-implemented), JSON only, no secrets or
|
||||
internal columns, rate limit + audit + idempotency on every mutation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.agent import _default_agent
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
)
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2-agent"])
|
||||
|
||||
|
||||
# ── Shared guards ──────────────────────────────────────────────────────────
|
||||
|
||||
def _guard(request: Request, authorization: str | None, *, write: bool = False) -> dict:
|
||||
"""Bearer auth + per-token rate limit (+ write scope when required)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_v2_rate_limit(user.get("_token_hash"), ip):
|
||||
raise HTTPException(429, "Rate limit exceeded: 300 req/min per token")
|
||||
if write and not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
|
||||
|
||||
async def _json_body(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _workspace_of(request: Request, body: dict | None = None) -> int | None:
|
||||
"""Workspace resolution mirrors the internal agent router: explicit param
|
||||
wins, then the token's own workspace, else NULL (shared/global scope)."""
|
||||
body = body or {}
|
||||
raw = body.get("workspace_id") or request.query_params.get("workspace_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _owned_conversation(conn, conversation_id: int, user_id: int):
|
||||
"""Conversation visible to this token's user (ownership is enforced here,
|
||||
unlike the session router where the browser is already authenticated)."""
|
||||
return conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
(conversation_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) -> AgentEngine:
|
||||
engine = AgentEngine(user_id, workspace_id=workspace_id)
|
||||
if provider:
|
||||
user_key = get_user_llm_key(user_id, provider)
|
||||
if user_key and user_key.get("api_key"):
|
||||
engine.llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=user_key["api_key"],
|
||||
api_base=user_key.get("api_base") or None,
|
||||
)
|
||||
else:
|
||||
engine.llm = LLMClient(provider=provider)
|
||||
return engine
|
||||
|
||||
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user["id"])
|
||||
clause = "WHERE workspace_id IS ? OR workspace_id=?"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM agents {clause}", (ws, ws)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM agents {clause} ORDER BY agent_type, name LIMIT ? OFFSET ?",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"agents": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents")
|
||||
async def create_agent_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agents (workspace_id, name, icon, agent_type, description,
|
||||
system_instructions, model, scope_json, trigger_json, approval_mode, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"),
|
||||
body.get("description", ""), body.get("system_instructions", ""),
|
||||
body.get("model", "gpt-4o"),
|
||||
json.dumps(body.get("scope", {})), json.dumps(body.get("trigger", {})),
|
||||
body.get("approval_mode", "auto"), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
agent_id = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(409, f"Cannot create agent: {exc}") from exc
|
||||
audit_log(user, "agent.create", "agent", agent_id, name, request)
|
||||
data = {"id": agent_id, "name": name, "status": "created", "agent": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
sets, params = [], []
|
||||
for col in ("name", "icon", "description", "system_instructions", "model",
|
||||
"approval_mode", "is_active"):
|
||||
if col in body:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(body[col])
|
||||
if "scope" in body:
|
||||
sets.append("scope_json=?")
|
||||
params.append(json.dumps(body["scope"]))
|
||||
if "trigger" in body:
|
||||
sets.append("trigger_json=?")
|
||||
params.append(json.dumps(body["trigger"]))
|
||||
if sets:
|
||||
params.append(agent_id)
|
||||
conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "agent.update", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
raise HTTPException(404, "Agent not found")
|
||||
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.delete", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_conversations WHERE user_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_conversations WHERE user_id=?
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?""",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"conversations": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents/conversations")
|
||||
async def create_conversation_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
agent_id = body.get("agent_id")
|
||||
with get_conn() as conn:
|
||||
if agent_id is not None:
|
||||
agent = conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
agent_id = agent["id"]
|
||||
else:
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(agent_id, user["id"], body.get("title") or "New conversation",
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider") or "", body.get("model") or ""),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conv_id,)).fetchone()
|
||||
audit_log(user, "agent.conversation.create", "agent_conversation", conv_id, "", request)
|
||||
data = {"id": conv_id, "status": "created", "conversation": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
messages = conn.execute(
|
||||
"SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"conversation": row_to_dict(conv), "messages": [row_to_dict(m) for m in messages]}
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.conversation.delete", "agent_conversation", conversation_id, "", request)
|
||||
return {"id": conversation_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
async def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"actions": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
async def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT a.id FROM agent_actions a
|
||||
JOIN agent_conversations c ON c.id = a.conversation_id
|
||||
WHERE a.id=? AND c.user_id=?""",
|
||||
(action_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Action not found")
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(500, f"Rollback failed: {exc}") from exc
|
||||
audit_log(user, "agent.action.undo", "agent_action", action_id, "", request)
|
||||
return {"id": action_id, "status": "reverted"}
|
||||
|
||||
|
||||
# ── Runs (JSON — the SSE stream stays internal) ────────────────────────────
|
||||
|
||||
def _collect_run_events(events: list[dict]) -> dict:
|
||||
"""Aggregate an engine event stream into a JSON run result.
|
||||
|
||||
Engine events are flat (``{"type": "final", "content": ...}``), the same
|
||||
shape the SSE panel consumes.
|
||||
"""
|
||||
final = None
|
||||
reasoning = []
|
||||
actions = []
|
||||
error = None
|
||||
for ev in events:
|
||||
etype = ev.get("type")
|
||||
if etype == "final":
|
||||
final = ev.get("content") or final
|
||||
elif etype == "reasoning":
|
||||
reasoning.append(ev.get("content") or "")
|
||||
elif etype == "action":
|
||||
actions.append({k: v for k, v in ev.items() if k != "type"})
|
||||
elif etype == "error":
|
||||
error = ev.get("message") or "run failed"
|
||||
return {
|
||||
"status": "failed" if error else "completed",
|
||||
"final": final,
|
||||
"error": error,
|
||||
"reasoning": reasoning,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agents/conversations/{conversation_id}/run")
|
||||
async def run_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronous agent run: buffers the engine stream and returns JSON.
|
||||
|
||||
Third parties get one HTTP round-trip instead of an SSE subscription; the
|
||||
same AgentEngine, permissions, journal and webhooks are used as the UI.
|
||||
"""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
objective = (body.get("message") or body.get("objective") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(400, "message is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
eff_provider = body.get("provider") or conv["provider"] or None
|
||||
eff_model = body.get("model") or conv["model"] or None
|
||||
if body.get("provider") is not None or body.get("model") is not None:
|
||||
conn.execute(
|
||||
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body.get("provider", conv["provider"] or ""),
|
||||
body.get("model", conv["model"] or ""), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
conv_context = {}
|
||||
try:
|
||||
conv_context = json.loads(conv["context_json"] or "{}") or {}
|
||||
except (TypeError, ValueError):
|
||||
conv_context = {}
|
||||
|
||||
ws = _workspace_of(request, body)
|
||||
if ws is None:
|
||||
ws = conv_context.get("workspace_id")
|
||||
|
||||
engine = _engine_for(user["id"], ws, eff_provider)
|
||||
started = time.time()
|
||||
events = [
|
||||
ev async for ev in engine.run(
|
||||
conversation_id, objective,
|
||||
model=eff_model,
|
||||
mentions=body.get("mentions"),
|
||||
files=body.get("files"),
|
||||
skill_id=body.get("skill_id"),
|
||||
skill_ids=body.get("skill_ids"),
|
||||
extra_context=body.get("context"),
|
||||
)
|
||||
]
|
||||
result = _collect_run_events(events)
|
||||
with get_conn() as conn:
|
||||
actions = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
payload = {
|
||||
"conversation_id": conversation_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": [row_to_dict(a) for a in actions],
|
||||
"events": events,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.run", "agent_conversation", conversation_id, objective[:200], request)
|
||||
status_code = 200 if result["status"] == "completed" else 500
|
||||
data = payload
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, status_code)
|
||||
return JSONResponse(content=data, status_code=status_code)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/trigger")
|
||||
async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Fire a custom agent from an external integration (JSON, synchronous)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], f"Run: {agent['name']}", json.dumps({"workspace_id": ws})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »."
|
||||
if body.get("message"):
|
||||
objective = f"{objective}\n\n{body['message']}"
|
||||
engine = _engine_for(user["id"], ws, agent["model"] or None)
|
||||
started = time.time()
|
||||
events = [ev async for ev in engine.run(conv_id, objective, model=agent["model"])]
|
||||
result = _collect_run_events(events)
|
||||
payload = {
|
||||
"conversation_id": conv_id,
|
||||
"agent_id": agent_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": result["actions"],
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.trigger", "agent", agent_id, objective[:200], request)
|
||||
return JSONResponse(content=payload, status_code=200 if result["status"] == "completed" else 500)
|
||||
|
||||
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?",
|
||||
(ws, ws),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?
|
||||
ORDER BY name LIMIT ? OFFSET ?""",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"skills": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(
|
||||
{k: body[k] for k in ("name", "description", "prompt_template", "allowed_tools")
|
||||
if k in body} | {"format": skill_gallery.EXPORT_FORMAT}
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.create", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "created" if created else "updated", "skill": row_to_dict(row) if row else {}}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/v2/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill_v2(slug: str, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(404, f"Unknown gallery skill: {slug}")
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.gallery.install", "skill", row.get("id"), slug, request)
|
||||
data = {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row_to_dict(row)}
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Import a portable skill document (from another FlowDeck instance)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.import", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "skill.delete", "skill", skill_id, row["name"] or "", request)
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Open a conversation pre-loaded with the skill (ready to run)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], skill["name"],
|
||||
json.dumps({"workspace_id": ws if ws is not None else skill["workspace_id"],
|
||||
"skill_id": skill_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "skill.apply", "skill", skill_id, skill["name"], request)
|
||||
return JSONResponse(content={"conversation_id": conv_id, "skill": skill["name"],
|
||||
"status": "ready"}, status_code=201)
|
||||
+74
-5
@@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
.sso-btn{border-color:rgba(35,131,226,.5);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<div class="oauth-section" id="oauth-section">
|
||||
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||
</div>
|
||||
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
|
||||
<div class="oauth-section" id="sso-section" style="display:none">
|
||||
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<div id="sso-buttons"></div>
|
||||
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Show session expired banner if ?expired=1 in URL
|
||||
@@ -101,6 +108,38 @@ let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
||||
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
|
||||
(async function loadSsoProviders(){
|
||||
try{
|
||||
const r = await fetch('/api/v2/sso/providers');
|
||||
if(!r.ok) return;
|
||||
const d = await r.json();
|
||||
const providers = d.providers || [];
|
||||
if(!providers.length) return;
|
||||
const wrap = document.getElementById('sso-buttons');
|
||||
providers.forEach(function(p){
|
||||
const b = document.createElement('button');
|
||||
b.className = 'oauth-btn sso-btn';
|
||||
b.style.marginBottom = '8px';
|
||||
b.title = 'Sign in with ' + (p.name || 'SSO');
|
||||
b.onclick = function(){ window.location = p.login_url; };
|
||||
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
|
||||
b.appendChild(icon); b.appendChild(label);
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
document.getElementById('sso-section').style.display = 'block';
|
||||
if(d.sso_only){
|
||||
// Local auth is refused server-side too — don't show a dead form.
|
||||
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
|
||||
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
|
||||
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
|
||||
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
|
||||
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -191,6 +230,16 @@ async def register(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
# SSO-only instance (v6.7.0): local registration is refused — accounts are
|
||||
# auto-provisioned by the IdP instead (admins still come from Settings).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only():
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"error": "Registration is disabled — sign in with your organization SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
@@ -260,7 +309,15 @@ async def local_login(request: Request):
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
# Successful local login — SSO-only instances keep a way in for admins
|
||||
# only (every other account must use the IdP, design §7.1).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only() and not ud.get("is_admin"):
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled on this instance — sign in with SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
@@ -373,9 +430,21 @@ async def callback(
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
async def logout(request: Request):
|
||||
"""Clear session and redirect to login page.
|
||||
|
||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||
is configured (the actual cookie clearing happens on the SLO route).
|
||||
"""
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
local_target = "/auth/login?provider=local"
|
||||
|
||||
if user and user.get("_sso_name_id"):
|
||||
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
|
||||
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
|
||||
|
||||
response = RedirectResponse(url=local_target, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
|
||||
@@ -489,6 +489,7 @@ async def help_page(request: Request):
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
@@ -590,6 +591,16 @@ FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
|
||||
|
||||
Two families of routes:
|
||||
|
||||
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
|
||||
ACS callback, SP metadata, Single Logout). The callback endpoints are
|
||||
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
|
||||
single-use ``sso_requests`` relay token + full assertion validation.
|
||||
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
|
||||
with write scope), consumed by Settings → Admin → SSO / Enterprise.
|
||||
|
||||
Every attempt — success or rejection — lands in ``sso_login_history``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
DEFAULT_NEXT = "/workspaces"
|
||||
|
||||
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
|
||||
_RATE_WINDOW = 60.0
|
||||
_RATE_MAX = 5
|
||||
_rate_store: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
|
||||
from app.config import settings
|
||||
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = f"{bucket}:{ip}"
|
||||
now = time.time()
|
||||
window, count = _rate_store.get(key, (0.0, 0))
|
||||
if now - window > _RATE_WINDOW:
|
||||
_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= _RATE_MAX:
|
||||
return False
|
||||
_rate_store[key] = (window, count + 1)
|
||||
return True
|
||||
|
||||
|
||||
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
|
||||
"""Small standalone error/info page (same styling as the login page)."""
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>FlowDeck — {title}</title><style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
|
||||
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
|
||||
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
|
||||
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
def _sso_config_or_error() -> dict | None:
|
||||
cfg = sso.get_sso_config()
|
||||
return sso.normalize_config(cfg) if cfg else None
|
||||
|
||||
|
||||
def _session_cookie(user_data: dict, request: Request):
|
||||
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
|
||||
return SessionManager.create_session(user_data, request)
|
||||
|
||||
|
||||
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
|
||||
provider_type = (cfg or {}).get("provider_type", "saml")
|
||||
sso.log_sso_login(
|
||||
user_id=None,
|
||||
provider_type=provider_type,
|
||||
provider_name=(cfg or {}).get("name") or "SSO",
|
||||
identifier=identifier,
|
||||
request=request,
|
||||
success=False,
|
||||
error=message,
|
||||
)
|
||||
logger.warning("SSO login rejected: %s", message)
|
||||
safe = (
|
||||
message.replace("&", "&").replace("<", "<").replace(">", ">")[:400]
|
||||
)
|
||||
return _page(
|
||||
"SSO sign-in failed",
|
||||
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page(
|
||||
"SAML not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
|
||||
csrf_token = secrets.token_hex(16)
|
||||
# The id is only known after building the request, so build it first with a
|
||||
# placeholder relay state, then re-issue with the real one? python3-saml
|
||||
# builds the AuthnRequest inside login(); we instead create the row right
|
||||
# after login() returns the URL — but the RelayState is already embedded.
|
||||
# So: generate the request id ourselves is not possible → build the URL,
|
||||
# then patch the RelayState by rebuilding with the known id.
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
|
||||
authn_id = provisional[1]
|
||||
relay = f"{authn_id}.{csrf_token}"
|
||||
sso.create_request(
|
||||
"saml_authn",
|
||||
request_id=authn_id,
|
||||
relay_state=csrf_token,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
# Replace the placeholder RelayState with the real token (same SAMLRequest).
|
||||
parsed = urlparse(provisional[0])
|
||||
params = parse_qs(parsed.query)
|
||||
params["RelayState"] = [relay]
|
||||
flat = [(k, v) for k, values in params.items() for v in values]
|
||||
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/auth/saml/callback")
|
||||
async def saml_callback(request: Request):
|
||||
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
|
||||
if not _rate_ok(request, "saml-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
form = await request.form()
|
||||
saml_response = str(form.get("SAMLResponse") or "")
|
||||
relay_state = str(form.get("RelayState") or "")
|
||||
if not saml_response:
|
||||
return _login_error("Missing SAMLResponse", cfg=None, request=request)
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _login_error("SAML is not configured", cfg=None, request=request)
|
||||
|
||||
authn_id, _, csrf_token = relay_state.partition(".")
|
||||
pending = sso.peek_request("saml_authn", authn_id)
|
||||
if not pending and sso.was_consumed("saml_authn", authn_id):
|
||||
# Same assertion twice: the single-use row is already spent.
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
if not pending or not csrf_token or not secrets.compare_digest(
|
||||
pending.get("relay_state", ""), csrf_token
|
||||
):
|
||||
return _login_error(
|
||||
"Unknown or expired login request (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
try:
|
||||
identity = saml_provider.process_response(
|
||||
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
|
||||
)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
|
||||
|
||||
# Single-use: the same AuthnRequest id can never authenticate twice.
|
||||
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
|
||||
if not consumed:
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
|
||||
claims = sso.identity_from_saml(identity, cfg)
|
||||
identifier = sso.sso_identifier_field(claims)
|
||||
try:
|
||||
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="saml",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
user_data = dict(user)
|
||||
user_data["_sso_name_id"] = identity.name_id
|
||||
user_data["_sso_session_index"] = identity.session_index
|
||||
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(user_data, request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
async def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
try:
|
||||
xml = saml_provider.metadata_xml(request, cfg)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _page("Metadata error", f"<p>{err}</p>", status=500)
|
||||
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
|
||||
|
||||
|
||||
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
|
||||
|
||||
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
|
||||
local session);
|
||||
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
|
||||
of our own SLO, or a LogoutRequest issued by the IdP).
|
||||
"""
|
||||
form = dict(await request.form()) if request.method == "POST" else {}
|
||||
query = dict(request.query_params)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
|
||||
if payload:
|
||||
try:
|
||||
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SLO processing failed: %s", err)
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
if errors:
|
||||
return _login_error(
|
||||
"; ".join(errors)[:300], cfg=cfg, request=request
|
||||
)
|
||||
response = RedirectResponse(url or next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
# SP-initiated
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
|
||||
try:
|
||||
logout_url = saml_provider.build_logout_url(
|
||||
request, cfg,
|
||||
return_to=sso.safe_next_path(next),
|
||||
name_id=user.get("_sso_name_id", ""),
|
||||
session_index=user.get("_sso_session_index", ""),
|
||||
)
|
||||
# Keep the cookie-clearing headers built above: hand the browser
|
||||
# to the IdP with our local session already dead.
|
||||
response = RedirectResponse(logout_url, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SP-initiated SLO failed: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/saml/logout")
|
||||
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/saml/logout")
|
||||
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
# ═════════════════════════════════ OIDC ═══════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/oidc/login")
|
||||
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""Redirect to the OIDC provider (authorization code + PKCE)."""
|
||||
if not _rate_ok(request, "oidc"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _page(
|
||||
"OIDC not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
nonce = secrets.token_hex(16)
|
||||
verifier, challenge = oidc_provider.pkce_pair()
|
||||
sso.create_request(
|
||||
"oidc",
|
||||
request_id=state,
|
||||
relay_state=nonce,
|
||||
code_verifier=verifier,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
url = oidc_provider.build_authorize_url(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
scope=cfg.get("scope") or "openid profile email",
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge=challenge,
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request):
|
||||
"""OIDC callback: exchange the code, validate the ID token, open a session."""
|
||||
if not _rate_ok(request, "oidc-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
params = dict(request.query_params)
|
||||
if request.method == "POST":
|
||||
params.update({k: str(v) for k, v in (await request.form()).items()})
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _login_error("OIDC is not configured", cfg=None, request=request)
|
||||
|
||||
if params.get("error"):
|
||||
return _login_error(
|
||||
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
code, state = params.get("code", ""), params.get("state", "")
|
||||
pending = sso.consume_request("oidc", state)
|
||||
if not code or not pending:
|
||||
return _login_error(
|
||||
"Unknown or expired OIDC state (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
tokens = await oidc_provider.exchange_code(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
client_secret=sso.client_secret_value(cfg),
|
||||
code=code,
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
code_verifier=pending.get("code_verifier", ""),
|
||||
)
|
||||
jwks = await _fetch_jwks(doc)
|
||||
claims = oidc_provider.validate_id_token(
|
||||
tokens.get("id_token", ""),
|
||||
issuer=cfg["issuer_url"],
|
||||
client_id=cfg["client_id"],
|
||||
nonce=pending.get("relay_state", ""),
|
||||
jwks=jwks,
|
||||
)
|
||||
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
|
||||
|
||||
merged = {**claims, **userinfo}
|
||||
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
|
||||
identifier = sso.sso_identifier_field(identity)
|
||||
try:
|
||||
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="oidc",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(dict(user), request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
async def oidc_callback(request: Request):
|
||||
"""OIDC callback (GET, authorization code in the query string)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/callback")
|
||||
async def oidc_callback_post(request: Request):
|
||||
"""OIDC callback (POST, form_post response mode)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as err:
|
||||
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
|
||||
if not isinstance(data, dict) or not data.get("keys"):
|
||||
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
|
||||
return data
|
||||
|
||||
|
||||
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Local logout + RP-initiated logout at the provider when supported."""
|
||||
cfg = _sso_config_or_error()
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if cfg and cfg["provider_type"] == "oidc":
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
end_session = doc.get("end_session_endpoint")
|
||||
if end_session:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
qs = urlencode({
|
||||
"client_id": cfg["client_id"],
|
||||
"post_logout_redirect_uri": external_base_url(request) + next,
|
||||
})
|
||||
sep = "&" if "?" in end_session else "?"
|
||||
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
|
||||
except oidc_provider.OIDCError as err:
|
||||
logger.debug("RP-initiated logout skipped: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/logout")
|
||||
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/logout")
|
||||
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (POST)."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
exempted in the middleware, so the check lives here for this router.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth_header[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
scopes = user.get("_token_scopes") or ""
|
||||
need = "write" if write else "read"
|
||||
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
if not cookie or not header or not secrets.compare_digest(cookie, header):
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
async def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
return {"providers": [], "sso_only": False}
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
base = external_base_url(request)
|
||||
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
|
||||
return {
|
||||
"providers": [{
|
||||
"type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"icon": "🏢",
|
||||
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
|
||||
}],
|
||||
"sso_only": bool(cfg.get("sso_only")),
|
||||
"base_url": base,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
|
||||
f"provider={saved.get('provider_type')}", request)
|
||||
return sso.public_config_view(saved)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
|
||||
return {"status": "ok", "disabled": removed}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
|
||||
).fetchall()
|
||||
cfg = _sso_config_or_error()
|
||||
return {
|
||||
"workspaces": [dict(r) for r in rows],
|
||||
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
|
||||
"sso_only": bool((cfg or {}).get("sso_only")),
|
||||
"provisioned_users": sso.provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
|
||||
h.sso_identifier, h.ip_address, h.success, h.error_message,
|
||||
h.created_at
|
||||
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
|
||||
ORDER BY h.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ident = d.get("sso_identifier") or ""
|
||||
if "|" in ident: # drop the stored group list from the UI payload
|
||||
d["sso_identifier"] = ident.split("|", 1)[0]
|
||||
d["success"] = bool(d["success"])
|
||||
out.append(d)
|
||||
return {"history": out}
|
||||
@@ -27,6 +27,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
|
||||
async def _fire_agent_webhook(event: str, payload: dict) -> None:
|
||||
"""Dispatch an outbound agent lifecycle event (never raises).
|
||||
|
||||
Lifecycle: ``agent.run.started`` → ``agent.run.finished`` | ``agent.run.failed``.
|
||||
All three are in the webhook_outbound catalogue, so integrations can subscribe
|
||||
to `agent.*` and drive FlowDeck Agent from outside (Agent phase 5).
|
||||
"""
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("%s webhook dispatch failed", event)
|
||||
|
||||
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
@@ -151,6 +165,11 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
await _fire_agent_webhook("agent.run.started", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": model or "",
|
||||
})
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
@@ -260,19 +279,20 @@ class AgentEngine:
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
await _fire_agent_webhook("agent.run.failed", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"error": str(exc)[:500],
|
||||
})
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »).
|
||||
|
||||
Single source of truth for shareable agent skills:
|
||||
|
||||
* **Gallery** — built-in presets shipped with FlowDeck, installable into any
|
||||
workspace with one call (the self-hosted equivalent of a skill marketplace).
|
||||
* **Portable payloads** — a skill exports to a versioned JSON document that any
|
||||
other FlowDeck instance can re-import unchanged.
|
||||
|
||||
Both the internal router (``/api/agent/skills/*``, session cookie) and the
|
||||
public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so
|
||||
there is exactly one implementation of export/import/install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
EXPORT_FORMAT = "flowdeck-skill"
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
# ── Gallery presets ────────────────────────────────────────────────────────
|
||||
# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set),
|
||||
# so an installed preset can never expose a tool that does not exist.
|
||||
|
||||
GALLERY: dict[str, dict] = {
|
||||
"rapport-hebdo": {
|
||||
"name": "Rapport hebdo",
|
||||
"icon": "📊",
|
||||
"description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.",
|
||||
"prompt_template": (
|
||||
"Rédige le rapport hebdomadaire de l'équipe.\n"
|
||||
"1. Repère les pages et documents modifiés cette semaine (search_workspace).\n"
|
||||
"2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n"
|
||||
"3. Crée un document « Rapport hebdo — <date> » (create_document) structuré ainsi : "
|
||||
"Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n"
|
||||
"Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"compte-rendu-reunion": {
|
||||
"name": "Compte rendu de réunion",
|
||||
"icon": "📝",
|
||||
"description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.",
|
||||
"prompt_template": (
|
||||
"À partir de la note de réunion fournie (ou demandée) :\n"
|
||||
"1. Crée un document « CR — <titre> » (create_document).\n"
|
||||
"2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n"
|
||||
"3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"base-crm": {
|
||||
"name": "Base CRM",
|
||||
"icon": "🗂️",
|
||||
"description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.",
|
||||
"prompt_template": (
|
||||
"Crée une base CRM complète :\n"
|
||||
"1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), "
|
||||
"Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n"
|
||||
"2. Vue board groupée sur Statut (create_view).\n"
|
||||
"3. Trois lignes d'exemple réalistes (create_page).\n"
|
||||
"Termine par le lien/identifiant de la collection créée."
|
||||
),
|
||||
"allowed_tools": ["create_collection", "add_property", "create_view", "create_page"],
|
||||
},
|
||||
"okr": {
|
||||
"name": "Objectifs OKR",
|
||||
"icon": "🎯",
|
||||
"description": "Génère une base d'OKR avec objectifs, résultats clés et progression.",
|
||||
"prompt_template": (
|
||||
"Crée une base « OKR » :\n"
|
||||
"1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), "
|
||||
"Progression (nombre 0-100).\n"
|
||||
"2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n"
|
||||
"3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n"
|
||||
"4. Vue board par période (create_view)."
|
||||
),
|
||||
"allowed_tools": [
|
||||
"create_collection", "add_property", "add_relation",
|
||||
"create_view", "create_page", "create_sub_item",
|
||||
],
|
||||
},
|
||||
"analyse-repo": {
|
||||
"name": "Analyse repo Gitea",
|
||||
"icon": "🛠️",
|
||||
"description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.",
|
||||
"prompt_template": (
|
||||
"Analyse le dépôt Gitea du workspace :\n"
|
||||
"1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n"
|
||||
"2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n"
|
||||
"3. Crée un document « Suivi repo — <dépôt> » (create_document) avec un tableau des issues "
|
||||
"et 3 recommandations de priorisation."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"],
|
||||
},
|
||||
"resume-document": {
|
||||
"name": "Résumé de document",
|
||||
"icon": "📄",
|
||||
"description": "Résume un document long en une page : points clés, chiffres, décisions.",
|
||||
"prompt_template": (
|
||||
"Résume le document fourni (ou demandé) :\n"
|
||||
"1. Lis-le intégralement (read_document).\n"
|
||||
"2. Crée un document « Résumé — <titre> » (create_document) : 5 points clés, chiffres marquants, "
|
||||
"décisions/engagements, questions restées ouvertes.\n"
|
||||
"3. Maximum une page, phrases courtes, aucune reformulation qui change le sens."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Gallery access ─────────────────────────────────────────────────────────
|
||||
|
||||
def list_gallery() -> list[dict]:
|
||||
"""Return every preset with its slug, ready for API responses."""
|
||||
return [{"slug": slug, **preset} for slug, preset in GALLERY.items()]
|
||||
|
||||
|
||||
def get_gallery(slug: str) -> dict | None:
|
||||
preset = GALLERY.get(str(slug or "").strip().lower())
|
||||
return {"slug": slug, **preset} if preset else None
|
||||
|
||||
|
||||
# ── Portable payload (export / import) ─────────────────────────────────────
|
||||
|
||||
def export_skill(row: Any) -> dict:
|
||||
"""Build the portable JSON document for a stored skill row.
|
||||
|
||||
Only shareable fields are included: ids, workspace and author stay local so
|
||||
an import never leaks (nor depends on) the source instance's internals.
|
||||
"""
|
||||
skill = row if isinstance(row, dict) else dict(row)
|
||||
tools = skill.get("allowed_tools_json") or "[]"
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError):
|
||||
tools = []
|
||||
return {
|
||||
"format": EXPORT_FORMAT,
|
||||
"version": EXPORT_VERSION,
|
||||
"skill": {
|
||||
"name": skill.get("name") or "",
|
||||
"description": skill.get("description") or "",
|
||||
"prompt_template": skill.get("prompt_template") or "",
|
||||
"allowed_tools": list(tools) if isinstance(tools, list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_payload(payload: Any) -> dict:
|
||||
"""Validate an import payload → normalized skill fields.
|
||||
|
||||
Accepts a full exported document (``{format, version, skill}``) or a bare
|
||||
skill object (``{name, prompt_template, ...}``) for hand-written imports.
|
||||
Raises ``ValueError`` with a human-readable message on invalid input.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Payload JSON attendu (objet)")
|
||||
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
|
||||
if payload.get("format") and payload.get("format") != EXPORT_FORMAT:
|
||||
raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})")
|
||||
version = payload.get("version")
|
||||
if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION):
|
||||
raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})")
|
||||
|
||||
name = str(skill.get("name") or "").strip()
|
||||
prompt = str(skill.get("prompt_template") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if not prompt:
|
||||
raise ValueError("prompt_template est requis")
|
||||
tools = skill.get("allowed_tools") or []
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc
|
||||
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils")
|
||||
|
||||
return {
|
||||
"name": name[:120],
|
||||
"description": str(skill.get("description") or "")[:500],
|
||||
"prompt_template": prompt,
|
||||
"allowed_tools": tools,
|
||||
}
|
||||
|
||||
|
||||
def upsert_skill(
|
||||
fields: dict,
|
||||
*,
|
||||
workspace_id: int | None,
|
||||
created_by: int | None,
|
||||
overwrite: bool = False,
|
||||
) -> tuple[dict, bool]:
|
||||
"""Insert (or replace) a skill in the current workspace.
|
||||
|
||||
Returns ``(row, created)``. Raises ``ValueError`` when the name already
|
||||
exists and ``overwrite`` is False — callers translate that to HTTP 409.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, fields["name"]),
|
||||
).fetchone()
|
||||
if existing and not overwrite:
|
||||
raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}")
|
||||
tools_json = json.dumps(fields["allowed_tools"])
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?",
|
||||
(fields["description"], fields["prompt_template"], tools_json, existing["id"]),
|
||||
)
|
||||
skill_id = existing["id"]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_skills
|
||||
(workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(workspace_id, fields["name"], fields["description"],
|
||||
fields["prompt_template"], tools_json, created_by),
|
||||
)
|
||||
skill_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
data = dict(row) if row else {"id": skill_id}
|
||||
return data, existing is None
|
||||
@@ -0,0 +1,783 @@
|
||||
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
|
||||
|
||||
Single source of truth for the SSO configuration (``sso_config`` table, with
|
||||
an ``SSO_*`` environment fallback for bootstrap installs) and for what
|
||||
happens when an IdP says "this is [email protected]":
|
||||
|
||||
1. resolve the local account (by email → merge, else by login),
|
||||
2. create it when ``auto_provision`` is on, else reject with an audit row,
|
||||
3. sync attributes + map SSO groups to workspace roles,
|
||||
4. hand back the user dict so the caller can mint a session.
|
||||
|
||||
Secrets at rest: ``client_secret`` and the generated SP private key are
|
||||
encrypted with a Fernet key derived from ``app_secret_key``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROVIDER_TYPES = ("saml", "oidc")
|
||||
|
||||
|
||||
class SSOConfigError(Exception):
|
||||
"""Invalid SSO configuration payload (message shown to the admin)."""
|
||||
|
||||
|
||||
class SSOProvisioningError(Exception):
|
||||
"""A login was rejected (no local account, missing attributes…)."""
|
||||
|
||||
|
||||
# ── Secrets at rest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fernet():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
||||
import base64
|
||||
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(value.encode()).decode()
|
||||
except Exception:
|
||||
return "" # key rotated / not ours — treat as unset
|
||||
|
||||
|
||||
# ── Config store ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _default_mapping(provider_type: str) -> dict:
|
||||
if provider_type == "oidc":
|
||||
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
|
||||
|
||||
return dict(DEFAULT_OIDC_MAPPING)
|
||||
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
|
||||
|
||||
return dict(DEFAULT_SAML_MAPPING)
|
||||
|
||||
|
||||
def _env_config() -> dict | None:
|
||||
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
|
||||
|
||||
Only used when the table holds no active row — the Settings UI always
|
||||
wins once an admin saved a configuration.
|
||||
"""
|
||||
provider_type = (settings.sso_provider or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
return None
|
||||
cfg = {
|
||||
"id": 0,
|
||||
"provider_type": provider_type,
|
||||
"name": settings.sso_name or "Company SSO",
|
||||
"entity_id": settings.sso_entity_id,
|
||||
"sso_url": settings.sso_sso_url,
|
||||
"slo_url": settings.sso_slo_url,
|
||||
"x509_certificate": settings.sso_x509_certificate,
|
||||
"issuer_url": settings.sso_issuer_url,
|
||||
"client_id": settings.sso_client_id,
|
||||
"client_secret": settings.sso_client_secret,
|
||||
"scope": settings.sso_scope,
|
||||
"attribute_mapping": settings.sso_attribute_mapping,
|
||||
"groups_mapping": settings.sso_groups_mapping,
|
||||
"auto_provision": int(settings.sso_auto_provision),
|
||||
"sso_only": int(settings.sso_only),
|
||||
"sign_requests": int(settings.sso_sign_requests),
|
||||
"default_workspace_id": settings.sso_default_workspace_id,
|
||||
"sp_private_key": "",
|
||||
"sp_certificate": "",
|
||||
"workspace_id": None,
|
||||
"active": 1,
|
||||
"_source": "env",
|
||||
}
|
||||
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
|
||||
return None
|
||||
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def get_sso_config(require_active: bool = True) -> dict | None:
|
||||
"""Active SSO config as a dict (DB row, else env fallback)."""
|
||||
row = _raw_row(require_active=require_active)
|
||||
if row:
|
||||
cfg = dict(row)
|
||||
cfg["_source"] = "db"
|
||||
return cfg
|
||||
if not require_active:
|
||||
return _env_config()
|
||||
return _env_config()
|
||||
|
||||
|
||||
def _raw_row(require_active: bool = True) -> dict | None:
|
||||
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
where = "WHERE active=1" if require_active else ""
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception: # table missing (very old install) → env only
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def client_secret_value(cfg: dict) -> str:
|
||||
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
|
||||
raw = (cfg or {}).get("client_secret") or ""
|
||||
if not raw:
|
||||
return ""
|
||||
if (cfg or {}).get("_source") == "env":
|
||||
return raw
|
||||
return decrypt_secret(raw)
|
||||
|
||||
|
||||
def _json_field(value, fallback):
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value or "")
|
||||
return parsed if isinstance(parsed, type(fallback)) else fallback
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _strict_json(value, expected, field: str):
|
||||
"""Parse a payload field and reject wrong shapes (before normalize,
|
||||
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
|
||||
if value is None or value == "":
|
||||
return expected()
|
||||
if isinstance(value, (dict, list)):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise SSOConfigError(f"{field} must be valid JSON") from exc
|
||||
if not isinstance(parsed, expected):
|
||||
kind = "object" if expected is dict else "array"
|
||||
raise SSOConfigError(f"{field} must be a JSON {kind}")
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_config(cfg: dict) -> dict:
|
||||
"""Parse JSON columns + fill defaults (single place for every consumer)."""
|
||||
out = dict(cfg)
|
||||
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
|
||||
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
|
||||
if not out["attribute_mapping"]:
|
||||
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
|
||||
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
|
||||
"client_id", "client_secret", "scope", "name"):
|
||||
out[key] = (out.get(key) or "").strip()
|
||||
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
|
||||
out[key] = int(out.get(key) or 0)
|
||||
return out
|
||||
|
||||
|
||||
def validate_config_payload(payload: dict) -> dict:
|
||||
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
|
||||
provider_type = str(payload.get("provider_type") or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
|
||||
|
||||
payload = dict(payload)
|
||||
payload["attribute_mapping"] = _strict_json(
|
||||
payload.get("attribute_mapping"), dict, "attribute_mapping"
|
||||
)
|
||||
payload["groups_mapping"] = _strict_json(
|
||||
payload.get("groups_mapping"), list, "groups_mapping"
|
||||
)
|
||||
cfg = normalize_config({**payload, "provider_type": provider_type})
|
||||
|
||||
if provider_type == "saml":
|
||||
for field in ("entity_id", "sso_url"):
|
||||
if not cfg[field]:
|
||||
raise SSOConfigError(f"SAML requires '{field}'")
|
||||
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise SSOConfigError(f"'{field}' must be an http(s) URL")
|
||||
cert = cfg["x509_certificate"].strip()
|
||||
if cert and "BEGIN CERTIFICATE" not in cert:
|
||||
raise SSOConfigError("x509_certificate must be a PEM certificate")
|
||||
if not cert:
|
||||
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
|
||||
cfg["x509_certificate"] = cert
|
||||
else:
|
||||
if not cfg["issuer_url"] or not cfg["client_id"]:
|
||||
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
|
||||
if not cfg["issuer_url"].startswith(("http://", "https://")):
|
||||
raise SSOConfigError("'issuer_url' must be an http(s) URL")
|
||||
|
||||
if not isinstance(cfg["attribute_mapping"], dict):
|
||||
raise SSOConfigError("attribute_mapping must be a JSON object")
|
||||
if not isinstance(cfg["groups_mapping"], list):
|
||||
raise SSOConfigError("groups_mapping must be a JSON array")
|
||||
for entry in cfg["groups_mapping"]:
|
||||
if not isinstance(entry, dict) or "sso_group" not in entry:
|
||||
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
|
||||
|
||||
ws = cfg.get("default_workspace_id")
|
||||
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
|
||||
return cfg
|
||||
|
||||
|
||||
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
|
||||
"""Create or replace the single SSO configuration (idempotent)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = validate_config_payload(payload)
|
||||
columns = {
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
|
||||
"groups_mapping": json.dumps(cfg["groups_mapping"]),
|
||||
"auto_provision": cfg["auto_provision"],
|
||||
"sso_only": cfg["sso_only"],
|
||||
"sign_requests": cfg["sign_requests"],
|
||||
"default_workspace_id": cfg["default_workspace_id"],
|
||||
"active": 1,
|
||||
"updated_at": str(int(time.time())),
|
||||
}
|
||||
|
||||
# Secret handling: a blank incoming secret keeps the stored one (the raw
|
||||
# row still holds the Fernet blob — never re-encrypt a decrypted value).
|
||||
existing = _raw_row() or {}
|
||||
if "client_secret" in cfg:
|
||||
incoming = str(cfg.get("client_secret") or "").strip()
|
||||
if incoming:
|
||||
columns["client_secret"] = encrypt_secret(incoming)
|
||||
else:
|
||||
columns["client_secret"] = existing.get("client_secret") or ""
|
||||
# SP keypair: keep an existing one, generate one for SAML if missing.
|
||||
sp_key = existing.get("sp_private_key") or ""
|
||||
sp_cert = existing.get("sp_certificate") or ""
|
||||
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
|
||||
sp_key, sp_cert = generate_sp_keypair()
|
||||
columns["sp_private_key"] = sp_key
|
||||
columns["sp_certificate"] = sp_cert
|
||||
if created_by:
|
||||
columns["created_by"] = created_by
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
sets = ", ".join(f"{k}=?" for k in columns)
|
||||
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
|
||||
cfg_id = row["id"]
|
||||
else:
|
||||
keys = ", ".join(columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
|
||||
)
|
||||
cfg_id = cur.lastrowid
|
||||
conn.commit()
|
||||
saved = get_sso_config(require_active=False)
|
||||
saved["id"] = cfg_id
|
||||
return saved
|
||||
|
||||
|
||||
def delete_sso_config() -> bool:
|
||||
"""Disable SSO entirely (local logins keep working)."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def public_config_view(cfg: dict | None) -> dict:
|
||||
"""Config for the admin UI — secrets never leave the server."""
|
||||
if not cfg:
|
||||
return {"configured": False}
|
||||
cfg = normalize_config(cfg)
|
||||
return {
|
||||
"configured": True,
|
||||
"id": cfg.get("id"),
|
||||
"source": cfg.get("_source", "db"),
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"client_secret_set": bool(client_secret_value(cfg)),
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": cfg["attribute_mapping"],
|
||||
"groups_mapping": cfg["groups_mapping"],
|
||||
"auto_provision": bool(cfg["auto_provision"]),
|
||||
"sso_only": bool(cfg["sso_only"]),
|
||||
"sign_requests": bool(cfg["sign_requests"]),
|
||||
"default_workspace_id": cfg.get("default_workspace_id"),
|
||||
"sp_certificate": cfg.get("sp_certificate") or "",
|
||||
"active": bool(cfg.get("active", 1)),
|
||||
"provisioned_users": provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
def is_sso_only(cfg: dict | None = None) -> bool:
|
||||
"""True when local login must be refused (design §7.1 / §4.2)."""
|
||||
cfg = cfg if cfg is not None else get_sso_config()
|
||||
return bool(cfg and normalize_config(cfg).get("sso_only"))
|
||||
|
||||
|
||||
def provisioned_count() -> int:
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchone()
|
||||
return int(row["n"] if row is not None else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def generate_sp_keypair() -> tuple[str, str]:
|
||||
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
|
||||
import datetime
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
|
||||
])
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
priv = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return priv, public
|
||||
|
||||
|
||||
def ensure_sp_keypair(cfg: dict) -> dict:
|
||||
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
|
||||
if cfg.get("provider_type") != "saml":
|
||||
return cfg
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
return cfg
|
||||
from app.db import get_conn
|
||||
|
||||
priv, cert = generate_sp_keypair()
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
|
||||
(priv, cert, cfg.get("id")),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # env-sourced config has no row to update
|
||||
logger.debug("SP keypair not persisted: %s", err)
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
|
||||
|
||||
# ── Audit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def log_sso_login(
|
||||
*,
|
||||
user_id: int | None,
|
||||
provider_type: str,
|
||||
provider_name: str,
|
||||
identifier: str,
|
||||
request,
|
||||
success: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
|
||||
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO sso_login_history
|
||||
(user_id, provider_type, provider_name, sso_identifier,
|
||||
ip_address, user_agent, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
|
||||
1 if success else 0, (error or "")[:500]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # audit must never break the login path
|
||||
logger.warning("sso_login_history write failed: %s", err)
|
||||
|
||||
|
||||
# ── Group mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
|
||||
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
mappings = cfg.get("groups_mapping") or []
|
||||
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
|
||||
touched: list[int] = []
|
||||
|
||||
with get_conn() as conn:
|
||||
for entry in mappings:
|
||||
group_name = str(entry.get("sso_group") or "").strip().lower()
|
||||
if not group_name or group_name not in wanted:
|
||||
continue
|
||||
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
|
||||
if not ws_id:
|
||||
continue
|
||||
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
role = "editor"
|
||||
conn.execute(
|
||||
"""INSERT INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(int(ws_id), user_id, role),
|
||||
)
|
||||
touched.append(int(ws_id))
|
||||
|
||||
# Default workspace: every SSO user lands there as a plain member.
|
||||
default_ws = cfg.get("default_workspace_id")
|
||||
if default_ws:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, 'editor')""",
|
||||
(int(default_ws), user_id),
|
||||
)
|
||||
if int(default_ws) not in touched:
|
||||
touched.append(int(default_ws))
|
||||
conn.commit()
|
||||
return touched
|
||||
|
||||
|
||||
def force_sync_all_groups() -> dict:
|
||||
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
raise SSOProvisioningError("No SSO configuration")
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
groups_source = mapping.get("groups", "groups")
|
||||
updated = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
groups = _stored_groups(row["id"], groups_source, cfg)
|
||||
if sync_sso_groups(row["id"], groups, cfg):
|
||||
updated += 1
|
||||
return {"users": len(rows), "updated": updated}
|
||||
|
||||
|
||||
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
|
||||
"""Groups seen at the last login of that user (stored in attribute sync)."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sso_identifier FROM sso_login_history "
|
||||
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row or not row["sso_identifier"]:
|
||||
return []
|
||||
raw = row["sso_identifier"]
|
||||
if "|" in raw:
|
||||
ident, _, groups_json = raw.partition("|")
|
||||
groups = json.loads(groups_json or "[]")
|
||||
return [str(g) for g in groups] if isinstance(groups, list) else []
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Auto-provisioning ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _unique_login(conn, base: str) -> str:
|
||||
candidate = base
|
||||
n = 1
|
||||
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
|
||||
n += 1
|
||||
candidate = f"{base}_{n}"
|
||||
return candidate
|
||||
|
||||
|
||||
def identity_from_saml(identity, cfg: dict) -> dict:
|
||||
"""Apply the SAML attribute mapping to a validated assertion."""
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
out = {
|
||||
"login": identity.resolve(mapping.get("login", "nameid")),
|
||||
"email": identity.resolve(mapping.get("email", "nameid")),
|
||||
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
|
||||
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
|
||||
"name_id": identity.name_id,
|
||||
}
|
||||
groups = identity.resolve(mapping.get("groups", "groups"))
|
||||
if groups:
|
||||
# Multi-valued SAML attribute: take every value of the resolved source.
|
||||
source = mapping.get("groups", "groups")
|
||||
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
|
||||
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
|
||||
else:
|
||||
out["groups"] = []
|
||||
out["email"] = (out["email"] or "").strip().lower()
|
||||
if "@" not in out["email"]:
|
||||
# NameID may be a persistent opaque id — fall back to login when it is
|
||||
# an email, otherwise leave empty (login will carry the identity).
|
||||
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
|
||||
if not out["full_name"]:
|
||||
out["full_name"] = out["email"] or out["login"]
|
||||
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
|
||||
return out
|
||||
|
||||
|
||||
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
|
||||
"""Resolve/create the local user for an SSO identity. Returns the user dict.
|
||||
|
||||
Raises ``SSOProvisioningError`` when the login must be refused (the
|
||||
caller writes the audit row).
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
login_hint = (identity.get("login") or "").strip()
|
||||
if not email and not login_hint:
|
||||
raise SSOProvisioningError(
|
||||
"SSO assertion carries no usable email/login — check the attribute mapping"
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = None
|
||||
if email:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if not user and login_hint:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
|
||||
|
||||
if user:
|
||||
# §7.1 — email match → merge: the existing account is reused and
|
||||
# tagged with the SSO method (no duplicate account).
|
||||
updates, params = [], []
|
||||
if identity.get("full_name"):
|
||||
updates.append("full_name=?")
|
||||
params.append(identity["full_name"])
|
||||
if email:
|
||||
updates.append("email=?")
|
||||
params.append(email)
|
||||
if identity.get("avatar_url"):
|
||||
updates.append("avatar_url=?")
|
||||
params.append(identity["avatar_url"])
|
||||
updates.append("auth_method=?")
|
||||
params.append(provider_type)
|
||||
updates.append("last_login=?")
|
||||
params.append(str(time.time()))
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
else:
|
||||
if not cfg.get("auto_provision"):
|
||||
raise SSOProvisioningError(
|
||||
"No local account for this SSO identity and auto-provisioning is disabled"
|
||||
)
|
||||
base_login = login_hint or email
|
||||
login = _unique_login(conn, base_login)
|
||||
conn.execute(
|
||||
"""INSERT INTO users
|
||||
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)""",
|
||||
(
|
||||
login,
|
||||
identity.get("full_name") or email or login,
|
||||
email,
|
||||
identity.get("avatar_url") or "",
|
||||
provider_type,
|
||||
str(time.time()),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise SSOProvisioningError("Could not create or load the SSO user")
|
||||
user_dict = dict(row)
|
||||
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
|
||||
return user_dict
|
||||
|
||||
|
||||
def sso_identifier_field(identity: dict) -> str:
|
||||
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
|
||||
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
|
||||
groups = identity.get("groups") or []
|
||||
if groups:
|
||||
return f"{ident}|{json.dumps(groups)}"
|
||||
return ident
|
||||
|
||||
|
||||
def safe_next_path(candidate: str | None) -> str:
|
||||
"""Sanitize the post-login redirect target (open-redirect guard)."""
|
||||
if not candidate:
|
||||
return "/workspaces"
|
||||
candidate = str(candidate)
|
||||
if not candidate.startswith("/") or candidate.startswith("//"):
|
||||
return "/workspaces"
|
||||
parsed = urllib.parse.urlsplit(candidate)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return "/workspaces"
|
||||
return candidate
|
||||
|
||||
|
||||
# ── Anti-replay request store ──────────────────────────────────────────────
|
||||
|
||||
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
|
||||
|
||||
|
||||
def create_request(kind: str, *, request_id: str, relay_state: str = "",
|
||||
code_verifier: str = "", next_path: str = "/workspaces") -> None:
|
||||
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
|
||||
from app.db import get_conn
|
||||
|
||||
purge_stale_requests()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO sso_requests
|
||||
(id, kind, relay_state, code_verifier, next_path, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
|
||||
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
|
||||
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if relay_state and row["relay_state"] and not secrets.compare_digest(
|
||||
row["relay_state"], relay_state
|
||||
):
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def peek_request(kind: str, request_id: str) -> dict | None:
|
||||
"""Read a request without consuming it (CSRF check before heavy validation)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def was_consumed(kind: str, request_id: str) -> bool:
|
||||
"""True when this single-use request id was already spent (replay)."""
|
||||
if not request_id:
|
||||
return False
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def purge_stale_requests() -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -500,7 +500,7 @@
|
||||
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
|
||||
llmModel: '', llmKeys: [], defaultProvider: 'offline',
|
||||
activeContext: null, ctxPinnedKey: null,
|
||||
contextChips: [], skills: [],
|
||||
contextChips: [], skills: [], gallery: [],
|
||||
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
@@ -811,6 +811,42 @@
|
||||
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
|
||||
self.skills = d.skills || [];
|
||||
}).catch(function(){ self.skills = []; });
|
||||
// Galerie de skills (marketplace, v6.6.0) — presets installables en 1 clic.
|
||||
fetch('/api/agent/skills/gallery').then(function(r){return r.json()}).then(function(d){
|
||||
var installed = {};
|
||||
(self.skills || []).forEach(function(s){ installed[String(s.name).toLowerCase()] = true; });
|
||||
self.gallery = (d.gallery || []).filter(function(g){
|
||||
return !installed[String(g.name).toLowerCase()];
|
||||
});
|
||||
}).catch(function(){ self.gallery = []; });
|
||||
},
|
||||
// Installe un preset de galerie puis l'épingle au contexte (phase 5).
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
if(!res.ok){
|
||||
self.toast((res.d && res.d.detail) || 'Installation impossible.', true);
|
||||
return;
|
||||
}
|
||||
self.toast('Skill « ' + (res.d.name || name || slug) + ' » installée.');
|
||||
self.fetchSkills();
|
||||
self._pinDbSkill(res.d.id, res.d.name || name || slug, res.d.icon || icon || '📦');
|
||||
}).catch(function(){ self.toast('Installation impossible (réseau).', true); });
|
||||
},
|
||||
// Épingle un skill enregistré (id DB) dans les chips de contexte.
|
||||
_pinDbSkill(id, name, icon){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === id; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); return; }
|
||||
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
|
||||
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
loadConversations(){
|
||||
var self = this;
|
||||
@@ -1254,7 +1290,11 @@
|
||||
items.forEach(function(o){ o._i = seq++; out.push(o); });
|
||||
}
|
||||
var skillsPresent = false, admPresent = false;
|
||||
var built = [], dbs = [], adm = [];
|
||||
var built = [], dbs = [], adm = [], gal = [];
|
||||
var installedNames = {};
|
||||
for(var j=0;j<(this.skills||[]).length;j++){
|
||||
installedNames[String(this.skills[j].name).toLowerCase()] = true;
|
||||
}
|
||||
for(var i=0;i<FD_SKILLS.length;i++){
|
||||
if(qmatch(FD_SKILLS[i].cmd)){
|
||||
var s = FD_SKILLS[i];
|
||||
@@ -1267,6 +1307,15 @@
|
||||
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
|
||||
}
|
||||
}
|
||||
// Galerie (marketplace) — presets pas encore installés.
|
||||
for(i=0;i<(this.gallery||[]).length;i++){
|
||||
var g = this.gallery[i];
|
||||
if(installedNames[String(g.name).toLowerCase()]) continue;
|
||||
if(qmatch(g.slug)){
|
||||
gal.push({key:'gal-'+g.slug, cmd:'/'+g.slug, desc:(g.description || 'Skill galerie'),
|
||||
icon:(g.icon || '📦'), _type:'gallery', slug:g.slug, skillName:g.name});
|
||||
}
|
||||
}
|
||||
for(i=0;i<FD_ADMIN_CMDS.length;i++){
|
||||
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
|
||||
}
|
||||
@@ -1276,7 +1325,11 @@
|
||||
out.push({_sep:'Skills', key:'sep-skills'});
|
||||
pushItems(built.concat(dbs));
|
||||
}
|
||||
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(gal.length){
|
||||
out.push({_sep:'Galerie', key:'sep-gal'});
|
||||
pushItems(gal);
|
||||
}
|
||||
if((skillsPresent || gal.length) && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(admPresent) pushItems(adm);
|
||||
this._slashSeq = seq;
|
||||
return out;
|
||||
@@ -1302,17 +1355,14 @@
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'gallery'){
|
||||
this.installGallerySkill(c.slug, c.icon, c.skillName);
|
||||
this.cmdFocus = -1; this.slashDismiss = false;
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'db'){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
|
||||
else {
|
||||
var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
|
||||
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
}
|
||||
this._pinDbSkill(c.skillId, c.cmd, c.icon);
|
||||
} else {
|
||||
var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; });
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -870,6 +871,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: SSO / Enterprise (v6.7.0) -->
|
||||
<div x-show="activeSection==='admin-sso'">
|
||||
<h2>SSO / Enterprise</h2>
|
||||
<p class="section-desc">Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
|
||||
<div class="llm-summary-icon">🔐</div>
|
||||
<div class="llm-summary-body">
|
||||
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
|
||||
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
|
||||
</div>
|
||||
<div class="llm-summary-side">
|
||||
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider -->
|
||||
<div class="setting-group">
|
||||
<h3>Fournisseur</h3>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
|
||||
<option value="saml">SAML 2.0</option>
|
||||
<option value="oidc">OpenID Connect</option>
|
||||
</select>
|
||||
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SAML -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
|
||||
<h3>Configuration SAML</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
|
||||
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
|
||||
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
|
||||
</label>
|
||||
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||
<span>SP metadata à donner à l'IdP :</span>
|
||||
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
|
||||
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC -->
|
||||
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
|
||||
<h3>Configuration OpenID Connect</h3>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
|
||||
<div class="pw-wrapper" style="max-width:560px;">
|
||||
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
|
||||
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
|
||||
style="padding-right:36px;">
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
|
||||
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
|
||||
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning -->
|
||||
<div class="setting-group">
|
||||
<h3>Provisioning & accès</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
|
||||
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
|
||||
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
|
||||
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
|
||||
</label>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
|
||||
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
|
||||
<option value="">— Aucun —</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group mapping -->
|
||||
<div class="setting-group">
|
||||
<h3>Groupes IdP → rôles workspace</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(g, i) in ssoGroups" :key="i">
|
||||
<tr>
|
||||
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
|
||||
<option value="">Espace par défaut</option>
|
||||
<template x-for="w in ssoWorkspaces" :key="w.id">
|
||||
<option :value="w.id" x-text="w.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</td>
|
||||
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
|
||||
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
|
||||
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
|
||||
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
|
||||
</div>
|
||||
|
||||
<!-- Agent & IA: per-user provider keys + admin global default -->
|
||||
<div x-show="activeSection==='llm'">
|
||||
<h2>Agent & IA</h2>
|
||||
@@ -1115,6 +1251,17 @@ function settingsInit() {
|
||||
backupMsg: '',
|
||||
backupOk: false,
|
||||
|
||||
// v6.7.0 SSO / Enterprise (admin)
|
||||
ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
|
||||
ssoAttrJson: '{}',
|
||||
ssoGroups: [],
|
||||
ssoWorkspaces: [],
|
||||
ssoSource: 'db',
|
||||
ssoProvisioned: 0,
|
||||
ssoSaving: false,
|
||||
ssoMsg: '',
|
||||
ssoOk: false,
|
||||
|
||||
async init() {
|
||||
await this.loadTags();
|
||||
await this.loadGiteaStatus();
|
||||
@@ -1935,6 +2082,113 @@ function settingsInit() {
|
||||
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
|
||||
finally { this.clipTesting = false; }
|
||||
},
|
||||
// ── v6.7.0 SSO / Enterprise (admin) ──
|
||||
ssoStatusTitle() {
|
||||
if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
|
||||
if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
|
||||
return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
|
||||
},
|
||||
ssoStatusDesc() {
|
||||
var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
|
||||
return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
|
||||
},
|
||||
ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
|
||||
async copySsoMetadata() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.ssoMetadataUrl());
|
||||
if (typeof toast === 'function') toast('SP metadata URL copiée');
|
||||
} catch(e) {}
|
||||
},
|
||||
async loadSsoConfig() {
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.ssoSource = d.source || 'db';
|
||||
this.ssoProvisioned = d.provisioned_users || 0;
|
||||
if (d.configured === false) {
|
||||
this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
|
||||
} else {
|
||||
this.ssoCfg = {
|
||||
id: d.id || null,
|
||||
provider_type: d.provider_type || 'saml',
|
||||
name: d.name || '',
|
||||
entity_id: d.entity_id || '',
|
||||
sso_url: d.sso_url || '',
|
||||
slo_url: d.slo_url || '',
|
||||
x509_certificate: d.x509_certificate || '',
|
||||
issuer_url: d.issuer_url || '',
|
||||
client_id: d.client_id || '',
|
||||
client_secret: '',
|
||||
client_secret_set: !!d.client_secret_set,
|
||||
scope: d.scope || 'openid profile email',
|
||||
attribute_mapping: d.attribute_mapping || {},
|
||||
auto_provision: !!d.auto_provision,
|
||||
sso_only: !!d.sso_only,
|
||||
sign_requests: !!d.sign_requests,
|
||||
default_workspace_id: d.default_workspace_id || ''
|
||||
};
|
||||
this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
|
||||
this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
|
||||
if (w.ok) {
|
||||
var wd = await w.json();
|
||||
this.ssoWorkspaces = wd.workspaces || [];
|
||||
this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
async saveSsoConfig() {
|
||||
this.ssoMsg = '';
|
||||
var amap = {};
|
||||
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
|
||||
catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
|
||||
if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
|
||||
this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
|
||||
}
|
||||
this.ssoSaving = true;
|
||||
var payload = Object.assign({}, this.ssoCfg, {
|
||||
attribute_mapping: amap,
|
||||
groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
|
||||
auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
|
||||
sso_only: this.ssoCfg.sso_only ? 1 : 0,
|
||||
sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
|
||||
default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
|
||||
});
|
||||
delete payload.id;
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||||
var d = null; try { d = await r.json(); } catch(e) {}
|
||||
if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
|
||||
else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
finally { this.ssoSaving = false; }
|
||||
},
|
||||
async disableSso() {
|
||||
if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
|
||||
if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
},
|
||||
async syncSsoGroups() {
|
||||
this.ssoMsg = '';
|
||||
try {
|
||||
var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
|
||||
var d = null; try { d = await r.json(); } catch(e) {}
|
||||
if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
|
||||
else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
|
||||
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
|
||||
},
|
||||
addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
|
||||
removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
|
||||
// ── v5.2.0 Backups (admin) ──
|
||||
async loadBackups() {
|
||||
try {
|
||||
|
||||
+92
-5
@@ -2,9 +2,17 @@
|
||||
|
||||
> **Statut** : ✅ **IMPLÉMENTÉ (v6.3.0, 2026-09-21)** — l'API publique `/api/v2` est livrée :
|
||||
> routeur `app/routers/api_v2.py`, helpers `app/services/api_v2_helpers.py`, migration 20,
|
||||
> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json` — 402 chemins), 24 tests dédiés.
|
||||
> OpenAPI généré (`/docs`, `/redoc`, `docs/openapi-v2.json`), 24 tests dédiés.
|
||||
> **v6.6.0 (2026-09-24)** — **Agent phase 5** : wrappers agent (`/api/v2/agents/*`) +
|
||||
> marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique
|
||||
> partagée `app/services/skill_gallery.py`, OpenAPI régénéré (**427 chemins**), 15 tests dédiés
|
||||
> (`tests/test_v66_agent_api.py`). Voir §2.4.
|
||||
> **v6.7.0 (2026-09-24)** — **SSO / SAML + OIDC entreprise** : parcours navigateur
|
||||
> `/auth/saml/*` + `/auth/oidc/*` (hors scope `/api/v2`), API admin de configuration
|
||||
> `/api/v2/sso/*` (session + admin + CSRF), logique partagée `app/services/sso_provisioning.py`,
|
||||
> OpenAPI régénéré, 38 tests dédiés (`tests/test_v67_sso.py`). Voir §2.5.
|
||||
> Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception.
|
||||
> **Dernière mise à jour** : 2026-09-21
|
||||
> **Dernière mise à jour** : 2026-09-24
|
||||
> **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation.
|
||||
>
|
||||
> **Reste reporté (v6.4)** : webhooks HMAC `X-FlowDeck-Signature` + retry 2s/10s/60s + événements étendus ;
|
||||
@@ -208,9 +216,88 @@ POST /api/move, /col-mapping (GET/DELETE/POST), /board-config/{owner}/{repo} (GE
|
||||
- Pas de pagination, filtres, tri.
|
||||
- Pas de documentation OpenAPI en production (`docs_url` n'est actif qu'en DEBUG).
|
||||
|
||||
---
|
||||
### 2.4 API publique v2 — Agent & Skill marketplace (`app/routers/api_v2_agent.py`, v6.6.0)
|
||||
|
||||
## 3. Conventions cibles pour l'API v2
|
||||
> **Agent phase 5 « Plateforme »** : permettre à une intégration tierce de piloter
|
||||
> FlowDeck Agent et d'installer/partager des skills, sans session navigateur.
|
||||
|
||||
**Agents, conversations & runs**
|
||||
| Méthode | Route | Scope | Description |
|
||||
|---------|-------|-------|-------------|
|
||||
| GET | `/api/v2/agents` | read | Liste les agents (pagination `limit`/`offset`) |
|
||||
| POST | `/api/v2/agents` | write | Crée un agent (`name`, `system_instructions`, `model`, `scope`, `trigger`) |
|
||||
| GET/PUT/DELETE | `/api/v2/agents/{id}` | read / write | Détail, mise à jour, suppression |
|
||||
| GET | `/api/v2/agents/conversations` | read | Conversations **de l'utilisateur du token** |
|
||||
| POST | `/api/v2/agents/conversations` | write | Crée une conversation (`agent_id` optionnel) |
|
||||
| GET/DELETE | `/api/v2/agents/conversations/{id}` | read / write | Conversation + messages / suppression |
|
||||
| POST | `/api/v2/agents/conversations/{id}/run` | write | **Run synchrone JSON** (le flux SSE reste interne) |
|
||||
| GET | `/api/v2/agents/conversations/{id}/actions` | read | Journal d'audit (`agent_actions`, snapshots) |
|
||||
| POST | `/api/v2/agents/actions/{id}/undo` | write | Rollback d'une action |
|
||||
| POST | `/api/v2/agents/{id}/trigger` | write | Déclenche un agent custom (JSON, synchrone) |
|
||||
|
||||
Réponse de `run` : `{conversation_id, status: completed|failed, final, error, reasoning[], actions[], events[], duration_ms}` —
|
||||
HTTP 500 quand `status = failed`. Les événements `events[]` sont ceux du flux SSE
|
||||
(`reasoning`, `action`, `final`, `notice`, `error`), à plat : `{"type": "final", "content": ...}`.
|
||||
|
||||
**Skill marketplace**
|
||||
| Méthode | Route | Scope | Description |
|
||||
|---------|-------|-------|-------------|
|
||||
| GET/POST | `/api/v2/skills` | read / write | Liste / création (`409` si nom déjà présent) |
|
||||
| GET/DELETE | `/api/v2/skills/{id}` | read / write | Détail / suppression |
|
||||
| GET | `/api/v2/skills/{id}/export` | read | **Document portable** `{format, version, skill{...}}` |
|
||||
| POST | `/api/v2/skills/import` | write | Import (identique → `409`, `overwrite: true` → maj) |
|
||||
| GET | `/api/v2/skills/gallery` | read | Presets embarqués (6) |
|
||||
| POST | `/api/v2/skills/gallery/{slug}/install` | write | Installe un preset dans le workspace |
|
||||
| POST | `/api/v2/skills/{id}/apply` | write | Ouvre une conversation préchargée du skill |
|
||||
|
||||
Mêmes endpoints (session cookie) côté interne : `/api/agent/skills/gallery`,
|
||||
`/api/agent/skills/{id}/export`, `/api/agent/skills/import`, `DELETE /api/agent/skills/{id}` —
|
||||
**même implémentation** via `app/services/skill_gallery.py`.
|
||||
|
||||
**Règles spécifiques agent**
|
||||
1. La **propriété** des conversations est vérifiée (`user_id` du token) : un token ne voit
|
||||
jamais les conversations d'un autre utilisateur (`404`, pas `403`, pour ne pas fuiter).
|
||||
2. Le run est **synchrone** : le moteur (`AgentEngine`), l'ACL (`PermissionManager`), le journal
|
||||
`agent_actions` et les webhooks sont exactement ceux de l'UI — aucun second chemin.
|
||||
3. Cycle de vie webhook : `agent.run.started` → `agent.run.finished` | `agent.run.failed`
|
||||
(catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible).
|
||||
4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …).
|
||||
|
||||
### 2.5 SSO / Enterprise auth (`app/routers/sso.py`, v6.7.0)
|
||||
|
||||
> **Authentification fédérée** : un IdP d'entreprise (SAML 2.0 ou OpenID Connect + PKCE)
|
||||
> ouvre une session FlowDeck ; les comptes sont créés au premier login et les groupes de
|
||||
> l'IdP deviennent des rôles workspace. Design : `docs/V6_SSO_SAML_Enterprise_Auth.md`.
|
||||
|
||||
**Parcours navigateur (session cookie, CSRF exclu — POST IdP cross-site)**
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| GET | `/auth/saml/login` | Redirection SP-initiée vers l'IdP (`RelayState` = next sûr) |
|
||||
| POST | `/auth/saml/callback` | Assertion Consumer Service — signature/aud/dest/InResponseTo validés |
|
||||
| GET | `/auth/saml/metadata` | Métadonnées SP XML (EntityID, ACS, SLO, certificat) |
|
||||
| GET | `/auth/saml/logout` | SLO — relaye `SAMLRequest` à l'IdP puis détruit la session (POST accepté pour l'IdP) |
|
||||
| GET | `/auth/oidc/login` | Redirection authorize (`state` + `code_verifier` en DB) |
|
||||
| GET | `/auth/oidc/callback` | Code → token → userinfo ; ID token vérifié (JWKS, aud/iss/nonce/exp) |
|
||||
| GET/POST | `/auth/oidc/logout` | Déconnexion OIDC (relaye à l'IdP si `end_session_endpoint`) |
|
||||
|
||||
**API admin (session + rôle admin + header `X-CSRF-Token`)**
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| GET | `/api/v2/sso/providers` | **Public** (page de login) : `{providers[{type,name,icon,login_url}], sso_only, base_url}` |
|
||||
| GET | `/api/v2/sso/config` | Config publique (`{configured, source: db\|env, client_secret_set, provisioned_users}` — **jamais** le secret) |
|
||||
| POST/PUT | `/api/v2/sso/config` | Crée/met à jour (`{configured:false}` = actif) ; champ secret vide = conserver |
|
||||
| DELETE | `/api/v2/sso/config` | Désactive le SSO (les comptes SSO existants restent) |
|
||||
| GET | `/api/v2/sso/workspaces` | Épingles pour le mapping (workspaces + `provisioned_users`) |
|
||||
| POST | `/api/v2/sso/sync` | Re-synchronise les groupes de tous les utilisateurs SSO |
|
||||
| GET | `/api/v2/sso/history` | Journal d'audit des tentatives (`limit` ≤ 200) |
|
||||
|
||||
Règles : secrets chiffrés Fernet (`sso_config`), anti-replay `sso_requests` (TTL 15 min),
|
||||
historique `sso_login_history` (succès/échecs), mode SSO only (login local refusé sauf admins),
|
||||
bootstrap possible par variables `SSO_*` du `.env` (la config admin prime).
|
||||
|
||||
---
|
||||
|
||||
### 3.1 Auth & tokens
|
||||
|
||||
@@ -606,7 +693,7 @@ EVENTS = [
|
||||
"mention.created", "notification.created",
|
||||
"sprint.created", "sprint.updated", "sprint.completed",
|
||||
"share.created", "share.revoked", "page.published", "page.unpublished",
|
||||
"agent.run.completed",
|
||||
"agent.run.started", "agent.run.finished", "agent.run.failed",
|
||||
```
|
||||
|
||||
**Payload type** :
|
||||
|
||||
@@ -663,7 +663,16 @@ Phase 5 — Custom Agents
|
||||
1. Scope + trigger_json
|
||||
2. Scheduler (lifespan FastAPI)
|
||||
3. Déclencheurs webhook (via webhooks.py)
|
||||
4. Mode approbation (auto/confirm)
|
||||
4. Mode approbation (auto/confirm) ✅ (v4.10.0)
|
||||
|
||||
> **Note numérotation** — ce document suit l'ordre historique d'implémentation.
|
||||
> Le plan officiel à 5 phases de `ROADMAP.md` se lit : phase 3 « Outils avancés » ✅ v4.10.0 ·
|
||||
> phase 4 « Autonomie » (agents, skills, déclencheurs) ✅ v4.10.0 ·
|
||||
> **phase 5 « Plateforme » ✅ v6.6.0** — API publique `/api/v2/agents/*` (run synchrone JSON,
|
||||
> journal + rollback, `trigger` externe), marketplace de skills (export/import portable,
|
||||
> galerie de 6 presets, section « Galerie » dans la palette `/` du panneau) et webhooks de cycle de vie
|
||||
> `agent.run.started` → `agent.run.finished` | `agent.run.failed`.
|
||||
> Référence : `docs/API_GUIDE_V6.md` §2.4 · tests : `tests/test_v66_agent_api.py`.
|
||||
|
||||
|
||||
-------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# V6.0.0 — SSO / SAML : Enterprise Authentication
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Statut** : ✅ **Livré en v6.7.0** (2026-09-24) — conception historique ci-dessous
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-sso-saml` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant)
|
||||
@@ -181,7 +181,7 @@ SSO_PROVIDER=saml
|
||||
SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/
|
||||
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
|
||||
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout
|
||||
SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_X509_CERTIFICATE="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
|
||||
SSO_AUTO_PROVISION=true
|
||||
SSO_DEFAULT_WORKSPACE_ID=1
|
||||
@@ -432,18 +432,20 @@ ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
|
||||
|
||||
## 9. Checklist d'implémentation
|
||||
|
||||
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2
|
||||
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
|
||||
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC
|
||||
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users`
|
||||
5. **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. **Extension `settings.html`** — UI admin SSO
|
||||
7. **Extension `login.html`** — boutons SSO
|
||||
8. **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. **Security** — validation assertions, rate limiting, audit log
|
||||
10. **Tests** — tous les scénarios SSO
|
||||
11. **Documentation utilisateur** — `/help` section SSO setup
|
||||
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt
|
||||
> ✅ **Tout est livré en v6.7.0** (`tests/test_v67_sso.py`, 38 tests) :
|
||||
|
||||
1. ✅ **`auth/providers/saml_provider.py`** — wrapper python3-saml (`app/auth/providers/saml_provider.py`)
|
||||
2. ✅ **`auth/providers/oidc_provider.py`** — wrapper OIDC authlib (`app/auth/providers/oidc_provider.py`)
|
||||
3. ✅ **`routers/sso.py`** — endpoints SSO/OIDC (`app/routers/sso.py` + API admin `/api/v2/sso/*`)
|
||||
4. ✅ **Migration 23 `sso_config`** + `sso_login_history` + `sso_requests` + colonne `auth_method` sur `users` (déjà présente)
|
||||
5. ✅ **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. ✅ **Extension `settings.html`** — UI admin SSO (onglet « SSO / Enterprise »)
|
||||
7. ✅ **Extension page de login** — boutons SSO (nom dynamique)
|
||||
8. ✅ **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. ✅ **Security** — validation assertions (sign/aud/dest/InResponseTo), anti-replay `sso_requests`, rate limit login, historique
|
||||
10. ✅ **Tests** — `tests/test_v67_sso.py` : 38 scénarios (flots SAML/OIDC, négatifs, groupes, sso_only, SLO)
|
||||
11. ✅ **Documentation utilisateur** — `/help` section « SSO (Enterprise) »
|
||||
12. ✅ **Dépendance** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0` dans requirements.txt
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2106
-3
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,6 @@ beautifulsoup4==4.*
|
||||
PyYAML==6.*
|
||||
python-docx>=1.1
|
||||
pypdf==6.*
|
||||
python3-saml==1.16.0
|
||||
authlib==1.8.0
|
||||
cryptography>=42.0
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""FlowDeck — v6.6.0 : Agent phase 5 (API publique agent + skill marketplace).
|
||||
|
||||
Covers the Bearer+scopes wrappers under ``/api/v2/agents`` and
|
||||
``/api/v2/skills``, the portable skill export/import + gallery install, the
|
||||
internal (session) marketplace routes, ownership checks, and the agent run
|
||||
lifecycle webhooks (``agent.run.started`` / ``finished`` / ``failed``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import skill_gallery
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Fresh SQLite DB + offline (mock) LLM — no network, no shared state."""
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn, init_db
|
||||
from app.main import app
|
||||
from app.password_utils import hash_password
|
||||
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.llm_provider = "offline"
|
||||
settings.agent_max_iterations = 12
|
||||
settings.agent_max_tokens_budget = 500_000
|
||||
settings.agent_run_timeout_seconds = 30
|
||||
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
"VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password("test"),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
yield TestClient(app)
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _token(client, login: str, scopes: str = "read,write") -> dict:
|
||||
"""Register an account (opens a session) → legacy token → scoped v2 token."""
|
||||
r = client.post("/auth/register", json={
|
||||
"email": f"{login}@test.dev", "password": "secret123", "name": login,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
legacy = client.post("/api/v1/token").json()["token"]
|
||||
r = client.post("/api/v2/tokens", json={"name": "phase5", "scopes": scopes},
|
||||
headers={"Authorization": f"Bearer {legacy}"})
|
||||
assert r.status_code == 200, r.text
|
||||
return {"Authorization": f"Bearer {r.json()['token']}"}
|
||||
|
||||
|
||||
# ── Auth & scopes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_agents_requires_bearer(client):
|
||||
r = client.get("/api/v2/agents")
|
||||
assert r.status_code == 401
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert r.json()["status"] == 401
|
||||
|
||||
|
||||
def test_v2_skills_rejects_bad_token(client):
|
||||
r = client.get("/api/v2/skills", headers={"Authorization": "Bearer nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_v2_agent_write_requires_write_scope(client):
|
||||
headers = _token(client, "readonly", scopes="read")
|
||||
assert client.get("/api/v2/agents", headers=headers).status_code == 200
|
||||
r = client.post("/api/v2/agents", json={"name": "Nope"}, headers=headers)
|
||||
assert r.status_code == 403
|
||||
assert "write" in r.json()["detail"]
|
||||
|
||||
|
||||
# ── Agents CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_agents_crud(client):
|
||||
headers = _token(client, "agentcrud")
|
||||
|
||||
r = client.post("/api/v2/agents", json={
|
||||
"name": "Analyste", "description": "Synthèses", "model": "gpt-4o",
|
||||
"system_instructions": "Sois concis.", "scope": {"tools": ["search_workspace"]},
|
||||
}, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
agent_id = r.json()["id"]
|
||||
scope = r.json()["agent"]["scope_json"]
|
||||
assert scope == {"tools": ["search_workspace"]} or scope == '{"tools": ["search_workspace"]}'
|
||||
|
||||
listed = client.get("/api/v2/agents", headers=headers).json()
|
||||
assert any(a["id"] == agent_id for a in listed["agents"])
|
||||
assert listed["total"] >= 1
|
||||
|
||||
got = client.get(f"/api/v2/agents/{agent_id}", headers=headers)
|
||||
assert got.status_code == 200
|
||||
assert got.json()["name"] == "Analyste"
|
||||
assert "password_hash" not in got.text
|
||||
|
||||
upd = client.put(f"/api/v2/agents/{agent_id}",
|
||||
json={"description": "V2", "approval_mode": "confirm"},
|
||||
headers=headers)
|
||||
assert upd.status_code == 200
|
||||
assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).json()["description"] == "V2"
|
||||
|
||||
assert client.get("/api/v2/agents/999999", headers=headers).status_code == 404
|
||||
assert client.delete(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_v2_agent_idempotency(client):
|
||||
headers = _token(client, "agentidem")
|
||||
idem = {"Idempotency-Key": "agent-create-1"}
|
||||
r1 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem})
|
||||
r2 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem})
|
||||
assert r1.status_code == 201
|
||||
assert r2.status_code == r1.status_code
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
with_id = [a for a in client.get("/api/v2/agents", headers=headers).json()["agents"]
|
||||
if a["name"] == "Idem"]
|
||||
assert len(with_id) == 1, "idempotent replay must not create a second agent"
|
||||
|
||||
|
||||
# ── Conversations, run & audit ──────────────────────────────────────────────
|
||||
|
||||
def test_v2_conversation_and_sync_run(client):
|
||||
headers = _token(client, "runner")
|
||||
|
||||
r = client.post("/api/v2/agents/conversations", json={"title": "Run test"},
|
||||
headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
conv_id = r.json()["id"]
|
||||
|
||||
bad = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={}, headers=headers)
|
||||
assert bad.status_code == 400
|
||||
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "Crée une collection CRM"}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
run = r.json()
|
||||
assert run["conversation_id"] == conv_id
|
||||
assert run["status"] == "completed"
|
||||
assert run["final"], "offline mock must yield a final answer"
|
||||
assert isinstance(run["events"], list) and run["events"]
|
||||
|
||||
detail = client.get(f"/api/v2/agents/conversations/{conv_id}", headers=headers).json()
|
||||
roles = [m["role"] for m in detail["messages"]]
|
||||
assert "user" in roles and "assistant" in roles
|
||||
|
||||
# Audit journal + rollback surface exposed to integrations
|
||||
actions = client.get(f"/api/v2/agents/conversations/{conv_id}/actions",
|
||||
headers=headers)
|
||||
assert actions.status_code == 200
|
||||
assert isinstance(actions.json()["actions"], list)
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT action FROM api_audit_log WHERE resource_id=? ORDER BY id",
|
||||
(str(conv_id),),
|
||||
).fetchall()
|
||||
assert "agent.run" in [r_[0] for r_ in rows]
|
||||
|
||||
|
||||
def test_v2_conversation_ownership(client):
|
||||
alice = _token(client, "alice")
|
||||
bob = _token(client, "bob")
|
||||
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Privé"},
|
||||
headers=alice).json()["id"]
|
||||
|
||||
# Bob sees nothing of Alice's conversation (and gets 404, not 403 leakage).
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=bob).status_code == 404
|
||||
assert client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "hack"}, headers=bob).status_code == 404
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}/actions",
|
||||
headers=bob).status_code == 404
|
||||
assert client.delete(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=bob).status_code == 404
|
||||
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=alice).status_code == 200
|
||||
|
||||
|
||||
def test_v2_trigger_agent(client):
|
||||
headers = _token(client, "trigger")
|
||||
agent_id = client.post("/api/v2/agents", json={
|
||||
"name": "Déclenché", "system_instructions": "Crée un document de statut.",
|
||||
}, headers=headers).json()["id"]
|
||||
|
||||
r = client.post(f"/api/v2/agents/{agent_id}/trigger", json={}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload["agent_id"] == agent_id
|
||||
assert payload["conversation_id"] > 0
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["final"]
|
||||
|
||||
|
||||
def test_v2_conversation_delete(client):
|
||||
headers = _token(client, "deleter")
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Bye"},
|
||||
headers=headers).json()["id"]
|
||||
assert client.delete(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=headers).status_code == 404
|
||||
|
||||
|
||||
# ── Skill marketplace ───────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_skills_crud_and_scopes(client):
|
||||
headers = _token(client, "skillcrud")
|
||||
|
||||
r = client.post("/api/v2/skills", json={
|
||||
"name": "Veille", "description": "Surveille un sujet",
|
||||
"prompt_template": "Cherche les infos sur le sujet et résume.",
|
||||
"allowed_tools": ["search_workspace"],
|
||||
}, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
skill_id = r.json()["id"]
|
||||
|
||||
listed = client.get("/api/v2/skills", headers=headers).json()
|
||||
assert any(s["id"] == skill_id for s in listed["skills"])
|
||||
|
||||
got = client.get(f"/api/v2/skills/{skill_id}", headers=headers)
|
||||
assert got.status_code == 200
|
||||
tools = got.json()["allowed_tools_json"]
|
||||
assert tools == ["search_workspace"] or tools == '["search_workspace"]'
|
||||
|
||||
dup = client.post("/api/v2/skills", json={
|
||||
"name": "Veille", "prompt_template": "autre",
|
||||
}, headers=headers)
|
||||
assert dup.status_code == 409
|
||||
|
||||
assert client.delete(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_v2_skill_export_import_roundtrip(client):
|
||||
headers = _token(client, "porter")
|
||||
|
||||
created = client.post("/api/v2/skills", json={
|
||||
"name": "Rapport CRM", "description": "d", "prompt_template": "fais le rapport",
|
||||
"allowed_tools": ["read_document", "create_document"],
|
||||
}, headers=headers).json()
|
||||
|
||||
export = client.get(f"/api/v2/skills/{created['id']}/export", headers=headers)
|
||||
assert export.status_code == 200
|
||||
doc = export.json()
|
||||
assert doc["format"] == skill_gallery.EXPORT_FORMAT
|
||||
assert doc["version"] == skill_gallery.EXPORT_VERSION
|
||||
assert doc["skill"]["name"] == "Rapport CRM"
|
||||
assert doc["skill"]["allowed_tools"] == ["read_document", "create_document"]
|
||||
# Portable = no instance internals leak
|
||||
assert "id" not in doc["skill"] and "workspace_id" not in doc["skill"]
|
||||
assert "created_by" not in doc["skill"]
|
||||
|
||||
# Same instance, different name → import as-is would clash → 409 first
|
||||
clash = client.post("/api/v2/skills/import", json=doc, headers=headers)
|
||||
assert clash.status_code == 409
|
||||
|
||||
# Renamed import succeeds, overwrite updates the existing one
|
||||
doc["skill"]["name"] = "Rapport CRM (copie)"
|
||||
imp = client.post("/api/v2/skills/import", json=doc, headers=headers)
|
||||
assert imp.status_code == 201, imp.text
|
||||
assert imp.json()["skill"]["prompt_template"] == "fais le rapport"
|
||||
|
||||
doc["skill"]["prompt_template"] = "version 2"
|
||||
upd = client.post("/api/v2/skills/import", json={**doc, "overwrite": True},
|
||||
headers=headers)
|
||||
assert upd.status_code in (200, 201), upd.text
|
||||
reimported = client.get(f"/api/v2/skills/{imp.json()['id']}", headers=headers).json()
|
||||
assert reimported["prompt_template"] == "version 2"
|
||||
|
||||
|
||||
def test_v2_skill_import_validation(client):
|
||||
headers = _token(client, "validator")
|
||||
|
||||
assert client.post("/api/v2/skills/import", json={"nope": True},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"format": "not-flowdeck", "skill": {"name": "x",
|
||||
"prompt_template": "y"}},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"format": skill_gallery.EXPORT_FORMAT, "version": 99,
|
||||
"skill": {"name": "x", "prompt_template": "y"}},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"name": "sans outils", "prompt_template": "",
|
||||
"allowed_tools": "not-a-list"},
|
||||
headers=headers).status_code == 400
|
||||
|
||||
|
||||
def test_v2_gallery_install(client):
|
||||
headers = _token(client, "galery")
|
||||
|
||||
gallery = client.get("/api/v2/skills/gallery", headers=headers)
|
||||
assert gallery.status_code == 200
|
||||
presets = gallery.json()["gallery"]
|
||||
assert len(presets) >= 6
|
||||
slugs = [p["slug"] for p in presets]
|
||||
assert "rapport-hebdo" in slugs and "base-crm" in slugs
|
||||
for p in presets:
|
||||
assert p["prompt_template"], f"preset {p['slug']} has no prompt"
|
||||
for tool in p["allowed_tools"]:
|
||||
assert tool in _known_tools(), f"preset {p['slug']} uses unknown tool {tool}"
|
||||
|
||||
r = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={},
|
||||
headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["status"] == "installed"
|
||||
skill_id = r.json()["id"]
|
||||
|
||||
# Installed preset shows up in the list, and can be applied
|
||||
names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]]
|
||||
assert "Rapport hebdo" in names
|
||||
applied = client.post(f"/api/v2/skills/{skill_id}/apply", json={}, headers=headers)
|
||||
assert applied.status_code == 201
|
||||
conv = client.get(f"/api/v2/agents/conversations/{applied.json()['conversation_id']}",
|
||||
headers=headers)
|
||||
assert conv.status_code == 200
|
||||
|
||||
# Re-install updates instead of duplicating
|
||||
again = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={},
|
||||
headers=headers)
|
||||
assert again.status_code in (200, 201)
|
||||
names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]]
|
||||
assert names.count("Rapport hebdo") == 1
|
||||
|
||||
assert client.post("/api/v2/skills/gallery/does-not-exist/install", json={},
|
||||
headers=headers).status_code == 404
|
||||
|
||||
|
||||
def _known_tools() -> set[str]:
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
return set(ToolRegistry().tools.keys())
|
||||
|
||||
|
||||
# ── Internal (session) marketplace routes ───────────────────────────────────
|
||||
|
||||
def test_internal_gallery_and_skill_lifecycle(client):
|
||||
gallery = client.get("/api/agent/skills/gallery")
|
||||
assert gallery.status_code == 200
|
||||
assert gallery.json()["total"] >= 6
|
||||
|
||||
installed = client.post("/api/agent/skills/gallery/base-crm/install", json={})
|
||||
assert installed.status_code == 200, installed.text
|
||||
skill_id = installed.json()["id"]
|
||||
|
||||
export = client.get(f"/api/agent/skills/{skill_id}/export")
|
||||
assert export.status_code == 200
|
||||
assert export.json()["format"] == skill_gallery.EXPORT_FORMAT
|
||||
|
||||
doc = export.json()
|
||||
doc["skill"]["name"] = "Base CRM (import)"
|
||||
imp = client.post("/api/agent/skills/import", json=doc)
|
||||
assert imp.status_code == 200, imp.text
|
||||
assert imp.json()["name"] == "Base CRM (import)"
|
||||
|
||||
assert client.post("/api/agent/skills/gallery/nope/install",
|
||||
json={}).status_code == 404
|
||||
assert client.delete(f"/api/agent/skills/{skill_id}").status_code == 200
|
||||
assert client.get(f"/api/agent/skills/{skill_id}/export").status_code == 404
|
||||
|
||||
|
||||
# ── Agent run lifecycle webhooks ────────────────────────────────────────────
|
||||
|
||||
def test_agent_run_lifecycle_webhooks(client, monkeypatch):
|
||||
"""started → finished on success, started → failed on LLM error."""
|
||||
from app.db import get_conn
|
||||
from app.services import webhook_outbound
|
||||
|
||||
fired: list[tuple[str, dict]] = []
|
||||
|
||||
async def record(event, payload):
|
||||
fired.append((event, dict(payload)))
|
||||
|
||||
monkeypatch.setattr(webhook_outbound, "fire_event", record)
|
||||
|
||||
headers = _token(client, "hooks")
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Hooks"},
|
||||
headers=headers).json()["id"]
|
||||
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "Crée une page de notes"}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
events = [e for e, _ in fired]
|
||||
assert "agent.run.started" in events
|
||||
assert "agent.run.finished" in events
|
||||
assert events.index("agent.run.started") < events.index("agent.run.finished")
|
||||
for event in events:
|
||||
assert event in EVENTS, f"{event} must be in the webhook catalogue"
|
||||
|
||||
# Failure path: the LLM blows up → started + failed, never finished.
|
||||
fired.clear()
|
||||
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
from app.services.llm_client import LLMClient
|
||||
monkeypatch.setattr(LLMClient, "complete", boom)
|
||||
|
||||
conv2 = client.post("/api/v2/agents/conversations", json={"title": "KO"},
|
||||
headers=headers).json()["id"]
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv2}/run",
|
||||
json={"message": "ça va planter"}, headers=headers)
|
||||
assert r.status_code == 500
|
||||
assert r.json()["status"] == "failed"
|
||||
events = [e for e, _ in fired]
|
||||
assert "agent.run.started" in events
|
||||
assert "agent.run.failed" in events
|
||||
assert "agent.run.finished" not in events
|
||||
failed_payload = next(p for e, p in fired if e == "agent.run.failed")
|
||||
assert failed_payload["conversation_id"] == conv2
|
||||
assert "llm down" in failed_payload["error"]
|
||||
|
||||
# Conversation status must be released (finally block) for both paths.
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status FROM agent_conversations WHERE id IN (?, ?)", (conv_id, conv2)
|
||||
).fetchall()
|
||||
assert {r_["status"] for r_ in rows} == {"idle"}
|
||||
File diff suppressed because it is too large
Load Diff
+221
-33
@@ -1,18 +1,26 @@
|
||||
"""FlowDeck — v6.4.0 Webhooks v2 tests (HMAC, retries, +20 events)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.db import get_conn, init_db
|
||||
from app.services import webhook_outbound
|
||||
from app.services.webhook_outbound import (
|
||||
EVENTS,
|
||||
MAX_ATTEMPTS,
|
||||
RETRY_DELAYS,
|
||||
_deliver_once,
|
||||
_event_matches,
|
||||
init_webhook_tables,
|
||||
deliver_to_sub,
|
||||
fire_event,
|
||||
retry_due_deliveries,
|
||||
sign_payload,
|
||||
verify_signature,
|
||||
)
|
||||
@@ -21,9 +29,38 @@ from app.services.webhook_outbound import (
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""Ensure webhook tables exist."""
|
||||
init_webhook_tables()
|
||||
return True
|
||||
"""Fresh isolated SQLite database with the full FlowDeck schema.
|
||||
|
||||
Creates ``webhook_subscriptions`` + ``webhook_deliveries`` (versioned
|
||||
migrations) so delivery journal rows can be asserted, then restores the
|
||||
previous settings — safe under ``pytest -n auto``.
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = tmp.name
|
||||
tmp.close()
|
||||
|
||||
prev_env = os.environ.get("DATABASE_URL")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
|
||||
import app.config
|
||||
settings_obj = app.config.settings
|
||||
prev_url = settings_obj.database_url
|
||||
settings_obj.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
init_db()
|
||||
|
||||
yield
|
||||
|
||||
settings_obj.database_url = prev_url
|
||||
if prev_env is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = prev_env
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.unlink(db_path + suffix)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -280,53 +317,204 @@ async def test_deliver_once_request_error():
|
||||
assert "Request error" in error
|
||||
|
||||
|
||||
# ── Integration Tests (Skipped if dependencies are missing) ────────────────────
|
||||
# ── Integration Tests (isolated DB + mocked HTTP) ───────────────────────────
|
||||
|
||||
|
||||
def _add_sub(url: str = "https://receiver.example/hook", event: str = "*",
|
||||
secret: str = "test_secret") -> int:
|
||||
"""Insert an active subscription and return its id."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret, active) VALUES (?, ?, ?, 1)",
|
||||
(url, event, secret),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _journal() -> list[dict]:
|
||||
"""All delivery-journal rows, oldest first."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries ORDER BY id"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _patch_async_client(monkeypatch, handler) -> None:
|
||||
"""Route every ``httpx.AsyncClient`` built by webhook_outbound to a MockTransport."""
|
||||
real_client = httpx.AsyncClient
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
kwargs.pop("transport", None)
|
||||
return real_client(transport=transport, **kwargs)
|
||||
|
||||
from types import SimpleNamespace
|
||||
stub = SimpleNamespace(
|
||||
AsyncClient=factory,
|
||||
TimeoutException=httpx.TimeoutException,
|
||||
RequestError=httpx.RequestError,
|
||||
)
|
||||
monkeypatch.setattr(webhook_outbound, "httpx", stub)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_to_sub_retries_on_failure():
|
||||
"""Test that deliver_to_sub retries on failure."""
|
||||
pass
|
||||
async def test_deliver_to_sub_retries_on_failure(db, monkeypatch):
|
||||
"""Two failures then success → 3 attempts, journal retrying→retrying→delivered."""
|
||||
monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0)) # no real sleeps
|
||||
sub_id = _add_sub(url="https://receiver.example/retry", event="page.created")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.side_effect = [
|
||||
MagicMock(status_code=500),
|
||||
MagicMock(status_code=500),
|
||||
MagicMock(status_code=200),
|
||||
]
|
||||
|
||||
ok = await deliver_to_sub(
|
||||
sub_id, "https://receiver.example/retry", "page.created",
|
||||
{"page_id": 1}, "test_secret", _client=mock_client,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert mock_client.post.call_count == 3
|
||||
assert [r["status"] for r in _journal()] == ["retrying", "retrying", "delivered"]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_to_sub_fails_after_max_attempts():
|
||||
"""Test that deliver_to_sub fails after MAX_ATTEMPTS."""
|
||||
pass
|
||||
async def test_deliver_to_sub_fails_after_max_attempts(db, monkeypatch):
|
||||
"""Persistent 500 → exactly MAX_ATTEMPTS attempts, final journal row 'failed'."""
|
||||
monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
|
||||
sub_id = _add_sub(url="https://receiver.example/down")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = MagicMock(status_code=500)
|
||||
|
||||
ok = await deliver_to_sub(
|
||||
sub_id, "https://receiver.example/down", "page.created",
|
||||
{}, "test_secret", _client=mock_client,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert mock_client.post.call_count == MAX_ATTEMPTS
|
||||
journal = _journal()
|
||||
assert len(journal) == MAX_ATTEMPTS # 3 'retrying' + 1 'failed'
|
||||
assert journal[-1]["status"] == "failed"
|
||||
assert journal[-1]["http_code"] == 500
|
||||
assert journal[-1]["attempt"] == MAX_ATTEMPTS - 1
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_ignores_unknown_events():
|
||||
"""Test that fire_event ignores unknown events."""
|
||||
pass
|
||||
async def test_fire_event_ignores_unknown_events(db, monkeypatch):
|
||||
"""fire_event returns before touching subscribers/HTTP for unknown events."""
|
||||
delivered = AsyncMock()
|
||||
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
|
||||
_add_sub(url="https://receiver.example/any", event="*")
|
||||
|
||||
await fire_event("nope.not_an_event", {"x": 1})
|
||||
|
||||
delivered.assert_not_awaited()
|
||||
assert _journal() == []
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_no_subscribers():
|
||||
"""Test that fire_event does nothing if no subscribers."""
|
||||
pass
|
||||
async def test_fire_event_no_subscribers(db, monkeypatch):
|
||||
"""Valid event but empty webhook_subscriptions → no delivery attempt."""
|
||||
delivered = AsyncMock()
|
||||
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
|
||||
|
||||
await fire_event("page.created", {"page_id": 1})
|
||||
|
||||
delivered.assert_not_awaited()
|
||||
assert _journal() == []
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_event_delivers_to_subscribers():
|
||||
"""Test that fire_event delivers to matching subscribers."""
|
||||
pass
|
||||
async def test_fire_event_delivers_to_subscribers(db, monkeypatch):
|
||||
"""Wildcard fan-out: only the subscription matching page.* receives the event."""
|
||||
matching = _add_sub(url="https://receiver.example/pages", event="page.*")
|
||||
_add_sub(url="https://receiver.example/collections", event="collection.created")
|
||||
delivered = AsyncMock()
|
||||
monkeypatch.setattr(webhook_outbound, "deliver_to_sub", delivered)
|
||||
|
||||
await fire_event("page.created", {"page_id": 42})
|
||||
|
||||
delivered.assert_awaited_once()
|
||||
args = delivered.await_args.args
|
||||
assert args[0] == matching
|
||||
assert args[1] == "https://receiver.example/pages"
|
||||
assert args[2] == "page.created"
|
||||
assert args[3] == {"page_id": 42}
|
||||
assert args[4] == "test_secret"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_webhook_flow():
|
||||
"""Test a full webhook flow: event fired → delivered to subscriber."""
|
||||
pass
|
||||
async def test_full_webhook_flow(db, monkeypatch):
|
||||
"""End-to-end: subscription → fire_event → signed HTTP POST → 'delivered' journal."""
|
||||
secret = "flow_secret_abc"
|
||||
sub_id = _add_sub(url="https://receiver.example/hook", event="page.*", secret=secret)
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["headers"] = dict(request.headers)
|
||||
seen["body"] = bytes(request.content)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
|
||||
await fire_event("page.created", {"page_id": 7, "title": "Hello"})
|
||||
|
||||
# HTTP assertions: right URL, event header, verifiable HMAC over the raw body
|
||||
assert seen["url"] == "https://receiver.example/hook"
|
||||
assert seen["headers"]["x-flowdeck-event"] == "page.created"
|
||||
assert verify_signature(secret, seen["body"],
|
||||
seen["headers"]["x-flowdeck-signature"]) is True
|
||||
payload = json.loads(seen["body"])
|
||||
assert payload["event"] == "page.created"
|
||||
assert payload["page_id"] == 7
|
||||
assert payload["title"] == "Hello"
|
||||
|
||||
# Journal assertions
|
||||
journal = _journal()
|
||||
assert len(journal) == 1
|
||||
assert journal[0]["webhook_id"] == sub_id
|
||||
assert journal[0]["status"] == "delivered"
|
||||
assert journal[0]["http_code"] == 200
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Requires full FlowDeck setup with DB and config")
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_due_deliveries():
|
||||
"""Test retry_due_deliveries retries failed deliveries."""
|
||||
pass
|
||||
async def test_retry_due_deliveries(db, monkeypatch):
|
||||
"""A 'retrying' row past next_retry_at is re-fired, delivered, then superseded."""
|
||||
monkeypatch.setattr(webhook_outbound, "RETRY_DELAYS", (0, 0, 0))
|
||||
sub_id = _add_sub(url="https://receiver.example/retry-due", event="page.*")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO webhook_deliveries
|
||||
(webhook_id, event, status, http_code, error, duration_ms,
|
||||
attempt, payload, next_retry_at)
|
||||
VALUES (?, 'page.created', 'retrying', 500, 'HTTP 500', 12, 0, ?, ?)""",
|
||||
(sub_id, json.dumps({"page_id": 9}), time.time() - 1),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
hits: list = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
hits.append(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
|
||||
retried = await retry_due_deliveries(now=time.time())
|
||||
|
||||
assert retried == 1
|
||||
assert len(hits) == 1
|
||||
assert hits[0].headers["x-flowdeck-event"] == "page.created"
|
||||
journal = _journal()
|
||||
assert len(journal) == 2
|
||||
assert journal[0]["status"] == "superseded" # original due row
|
||||
assert journal[1]["status"] == "delivered" # new attempt
|
||||
assert journal[1]["http_code"] == 200
|
||||
|
||||
Reference in New Issue
Block a user