Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111)
This commit is contained in:
+4
-4
@@ -73,8 +73,8 @@ FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
|||||||
# Accès gratuit temporaire
|
# Accès gratuit temporaire
|
||||||
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
|
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
|
||||||
|
|
||||||
# Serveur MCP (issue #111). Laisser à false tant que l'authentification
|
# Active le serveur MCP (/_mcp) ET le connecteur d'abonné (scope B, #111).
|
||||||
# (OAuth + gate abonnement, scope B) n'est pas en place : sinon le serveur MCP
|
# À true, l'accès à /_mcp exige un jeton MCP (généré dans /compte/mcp) lié à un
|
||||||
# est ouvert sans contrôle d'accès. Prérequis Matomo pour le tracking des appels :
|
# abonnement actif. Laisser false tant que le connecteur n'est pas déployé.
|
||||||
# créer un Custom Dimension slot 1 (scope Action).
|
# Déploiement recommandé : activer d'abord sur test.colibre.fr (branche dev).
|
||||||
DASH_MCP_ENABLED=false
|
DASH_MCP_ENABLED=false
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95))
|
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau, réservée aux abonné·es ([#95](https://github.com/ColinMaudry/colibre/issues/95))
|
||||||
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
|
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
|
||||||
- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
|
- Accès aux données de la commande publique via un serveur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
|
||||||
|
- Connecteur MCP : les abonnés génèrent un jeton dans « Mon compte › Connecteur MCP » pour connecter leur agent IA (Claude, Gemini, Mistral) au serveur MCP colibre ; l'accès est conditionné à un abonnement actif ([#111](https://github.com/ColinMaudry/colibre/issues/111))
|
||||||
|
|
||||||
**Autres améliorations**
|
**Autres améliorations**
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
|||||||
|
# Scope B — Connecteur MCP : accès abonné par jeton Bearer
|
||||||
|
|
||||||
|
**Issue :** #111 (2ᵉ partie, « le point dur »)
|
||||||
|
**Date :** 2026-07-10
|
||||||
|
**Statut :** approuvé (design)
|
||||||
|
**Prérequis :** scope A livré et fusionné dans `dev` (serveur MCP `/_mcp`, `src/mcp/`).
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Conditionner l'accès au serveur MCP colibre (`/_mcp`, livré en scope A) à un
|
||||||
|
**abonnement colibre actif**, via un **jeton Bearer statique dédié** que l'abonné
|
||||||
|
génère lui-même depuis son espace compte et colle dans la configuration de son
|
||||||
|
agent IA.
|
||||||
|
|
||||||
|
Ce scope **n'implémente pas** de serveur OAuth. Le flux OAuth 2.1 complet
|
||||||
|
(bouton « Connecter », enregistrement dynamique de client, PKCE) est explicitement
|
||||||
|
reporté à un **scope B2** ultérieur, si l'usage le justifie.
|
||||||
|
|
||||||
|
## Décisions de conception (arbitrées)
|
||||||
|
|
||||||
|
1. **Jeton statique**, pas de serveur OAuth. Réutilise l'infrastructure
|
||||||
|
`api_tokens` (table SQLite, jetons `colibre_…` hachés) et
|
||||||
|
`has_active_subscription(user_id)` existantes.
|
||||||
|
2. **Jetons dédiés MCP** : une colonne `kind` distingue les jetons. Un jeton MCP
|
||||||
|
ne fonctionne que sur `/_mcp` ; un jeton API (`kind='api'`, tous les jetons
|
||||||
|
CLI actuels) ne fonctionne que sur `/api/v1`.
|
||||||
|
3. **Garde d'abonnement uniquement sur `/_mcp`**. Le comportement de l'API REST
|
||||||
|
existante est inchangé pour les jetons `kind='api'`.
|
||||||
|
4. **Libellé du menu** dans `/compte` : « Connecteur MCP ».
|
||||||
|
5. **Instructions de connexion** pour 4 clients : Claude, Gemini, Mistral (jeton
|
||||||
|
statique — supporté), ChatGPT (voir §7, caveat).
|
||||||
|
|
||||||
|
## Périmètre du support client (vérifié 2026-07)
|
||||||
|
|
||||||
|
| Client | En-tête Bearer statique | Voie documentée sur la page |
|
||||||
|
| --------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Claude** (Code / Desktop) | ✅ | `claude mcp add colibre --transport http <url>/_mcp --header "Authorization: Bearer …"` |
|
||||||
|
| **Gemini CLI** | ✅ | `gemini mcp add --transport http --header "Authorization: Bearer …"` ou `httpUrl`+`headers` dans `settings.json` |
|
||||||
|
| **Mistral Le Chat** | ✅ | Connecteur MCP → auth « API Token », en-tête `Authorization: Bearer …` |
|
||||||
|
| **ChatGPT** (app) | ❌ | L'app exige OAuth 2.1 + PKCE (pas de clé statique). Documenter la voie **développeur** (OpenAI API / Agents SDK, qui accepte un en-tête statique) + note « app ChatGPT = connecteur OAuth, itération future B2 ». |
|
||||||
|
|
||||||
|
## Architecture & flux
|
||||||
|
|
||||||
|
```
|
||||||
|
Abonné → /compte/mcp (« Connecteur MCP », gardé require_subscription)
|
||||||
|
→ [Générer un jeton] → colibre_xxxx (affiché UNE fois) + snippet client
|
||||||
|
|
||||||
|
Agent IA → POST /_mcp (Authorization: Bearer colibre_xxxx)
|
||||||
|
→ before_request guard (src/mcp/auth.py) :
|
||||||
|
• pas de Bearer / jeton introuvable / révoqué / kind≠'mcp' → 401
|
||||||
|
• jeton MCP valide mais user_id nul ou abonnement inactif → 403
|
||||||
|
• OK → increment_usage(token_id) → Dash traite la requête MCP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
|
||||||
|
### 1. Couche données — `src/api/tokens_db.py`
|
||||||
|
|
||||||
|
- **Schéma** : ajouter `kind TEXT NOT NULL DEFAULT 'api'` à `api_tokens`.
|
||||||
|
- `SCHEMA` (CREATE) inclut la colonne → DB fraîche correcte.
|
||||||
|
- **Migration** dans `src/migrations.py` `_MIGRATIONS` :
|
||||||
|
`("0007_add_kind_to_api_tokens", "ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'")`.
|
||||||
|
L'erreur _duplicate column name_ est déjà tolérée par `apply_pending()`
|
||||||
|
(DB fraîche où `SCHEMA` a déjà créé la colonne).
|
||||||
|
- **Initialisation au démarrage** : `init_api()` appelle
|
||||||
|
`tokens_db.init_schema(USERS_DB_PATH)` (comme `saved_views`/`roadmap`), pour
|
||||||
|
garantir que la table existe **avant** que `apply_pending()` (appelée plus tard
|
||||||
|
dans `init_subscriptions`) ne tente l'`ALTER`. Ordre dans `app.py` :
|
||||||
|
`init_api` (ligne ~112) précède `init_subscriptions` (ligne ~129). ✅
|
||||||
|
- **Fonctions** :
|
||||||
|
- `create_token(db_path, label, user_id=None, kind='api') -> (token, id)` —
|
||||||
|
paramètre `kind` ajouté.
|
||||||
|
- `list_user_tokens(db_path, user_id, kind='mcp') -> list[dict]` — jetons d'un
|
||||||
|
utilisateur d'un type donné, triés par `created_at` décroissant.
|
||||||
|
- `revoke_user_token(db_path, token_id, user_id) -> bool` —
|
||||||
|
`WHERE id=? AND user_id=?` (anti-IDOR). Retourne `True` si une ligne a été
|
||||||
|
révoquée, `False` sinon (jeton inexistant ou d'un autre utilisateur).
|
||||||
|
- `increment_usage` et `get_token_by_plaintext` existants, réutilisés tels quels.
|
||||||
|
|
||||||
|
### 2. Garde `/_mcp` — nouveau `src/mcp/auth.py`
|
||||||
|
|
||||||
|
- `init_mcp_auth(server: Flask) -> None` enregistre un `@server.before_request`
|
||||||
|
qui **ne s'active que** si `request.path == "/_mcp"` ou commence par `/_mcp/`.
|
||||||
|
- Logique :
|
||||||
|
1. Lire l'en-tête `Authorization`. Absent ou pas `Bearer ` → **401**.
|
||||||
|
2. `get_token_by_plaintext` → introuvable → **401** ; `revoked_at` non nul →
|
||||||
|
**401** ; `kind != 'mcp'` → **401** (un jeton API ne donne pas accès au MCP).
|
||||||
|
3. `user_id` nul → **403** ; `has_active_subscription(user_id)` faux (en
|
||||||
|
respectant `TOUS_ABONNES`) → **403**.
|
||||||
|
4. Succès → `increment_usage(db_path, token_id)`, laisser passer (`return None`).
|
||||||
|
- **Codes & en-têtes** :
|
||||||
|
- 401 : corps JSON `{"error": "unauthorized", "message": …}` +
|
||||||
|
`WWW-Authenticate: Bearer realm="colibre-mcp"`.
|
||||||
|
- 403 : corps JSON `{"error": "no_active_subscription", "message": …}`.
|
||||||
|
- Messages en français, sans divulguer si le jeton existe (401 générique).
|
||||||
|
- Enregistré dans le bloc `if _mcp_enabled:` de `app.py`, après
|
||||||
|
`configure_mcp_server(...)`.
|
||||||
|
|
||||||
|
### 3. Exemption CSRF — `src/app.py`
|
||||||
|
|
||||||
|
- La boucle d'exemption CSRF actuelle cible `/_dash*` et `/_reload*`. Ajouter
|
||||||
|
`/_mcp` : `_rule.rule.startswith("/_mcp")`. `/_mcp` reçoit des POST JSON-RPC
|
||||||
|
externes sans jeton CSRF possible. (Le webhook Frisbii est déjà exempté de la
|
||||||
|
même façon.)
|
||||||
|
|
||||||
|
### 4. UI self-service — `src/pages/compte/mcp.py`
|
||||||
|
|
||||||
|
- **Section** ajoutée à `src/pages/_compte_shell.py` `SECTIONS` :
|
||||||
|
`{"key": "mcp", "label": "Connecteur MCP", "href": "/compte/mcp", "require_subscription": True}`.
|
||||||
|
Placée avant « Abonnement ». La garde de section existante redirige vers
|
||||||
|
`/compte/abonnement` si pas d'abonnement actif.
|
||||||
|
- **Page** `@register_page` sur `/compte/mcp`, enveloppée par `account_shell` +
|
||||||
|
`account_guard("/compte/mcp", require_subscription=True)`, suivant le patron des
|
||||||
|
autres pages `compte/`.
|
||||||
|
- **Contenu** :
|
||||||
|
1. Explication courte : ce qu'est le connecteur MCP, qu'il faut un abonnement
|
||||||
|
actif, que le jeton vaut identité (à garder secret).
|
||||||
|
2. **Tableau** des jetons MCP de l'utilisateur : label, créé le, dernière
|
||||||
|
utilisation, statut (actif/révoqué), bouton **Révoquer** par jeton actif.
|
||||||
|
3. **Formulaire de création** : champ « label » (ex. « Claude sur mon portable »)
|
||||||
|
- bouton. À la création, le jeton en clair est **affiché une seule fois**
|
||||||
|
(jamais re-stocké en clair), avec bouton copier.
|
||||||
|
4. **Instructions par client** (accordéon/onglets) : Claude, Gemini, Mistral,
|
||||||
|
ChatGPT — chacune avec le snippet de §7, le jeton fraîchement créé injecté
|
||||||
|
dans le snippet, et l'URL `<APP_BASE_URL>/_mcp`.
|
||||||
|
- **Implémentation** : callbacks Dash + inputs CSRF, comme les autres pages
|
||||||
|
`compte/`. Création via `create_token(..., kind='mcp', user_id=current_user.id)` ;
|
||||||
|
liste via `list_user_tokens(..., current_user.id, 'mcp')` ; révocation via
|
||||||
|
`revoke_user_token(..., token_id, current_user.id)`. Toute action vérifie
|
||||||
|
`current_user.is_authenticated` et l'abonnement côté serveur (pas seulement
|
||||||
|
masquée dans l'UI — cf. points de vigilance sécurité de l'issue).
|
||||||
|
|
||||||
|
### 5. API REST inchangée — `src/api/auth.py`
|
||||||
|
|
||||||
|
- `require_token` : ajouter un filtre pour **refuser** les jetons `kind='mcp'`
|
||||||
|
(401), afin que les jetons dédiés MCP ne fonctionnent pas sur `/api/v1`. Les
|
||||||
|
jetons `kind='api'` (tous les jetons CLI existants) restent acceptés à
|
||||||
|
l'identique → aucun changement de comportement pour l'existant.
|
||||||
|
- Nettoyer le `print(API_AUTH_DISABLED)` de débogage présent ligne 19 (bruit).
|
||||||
|
|
||||||
|
### 6. Activation & configuration
|
||||||
|
|
||||||
|
- Le garde rend `DASH_MCP_ENABLED=true` **sûr en production** (accès
|
||||||
|
systématiquement conditionné à l'abonnement). Le défaut reste **`false`**
|
||||||
|
(tests inchangés, pas de flip automatique dans le code).
|
||||||
|
- Déploiement recommandé : activer d'abord sur `test.colibre.fr` (branche `dev`)
|
||||||
|
via la variable d'environnement, valider, puis `main`.
|
||||||
|
- `.template.env` : documenter que `DASH_MCP_ENABLED=true` requiert le connecteur
|
||||||
|
(scope B) et un abonnement actif côté client.
|
||||||
|
- `APP_BASE_URL` (déjà utilisé pour le callback LinkedIn) sert à construire l'URL
|
||||||
|
`/_mcp` dans les snippets. Si absent, la page affiche l'URL relative + un
|
||||||
|
avertissement (comportement dégradé, non bloquant).
|
||||||
|
|
||||||
|
### 7. Instructions par client (contenu de la page)
|
||||||
|
|
||||||
|
> `<URL>` = `<APP_BASE_URL>/_mcp` (ex. `https://colibre.fr/_mcp`) ;
|
||||||
|
> `<TOKEN>` = jeton fraîchement généré.
|
||||||
|
|
||||||
|
- **Claude (Code / Desktop)** :
|
||||||
|
`claude mcp add colibre --transport http <URL> --header "Authorization: Bearer <TOKEN>"`
|
||||||
|
- **Gemini CLI** :
|
||||||
|
`gemini mcp add --transport http --header "Authorization: Bearer <TOKEN>" colibre <URL>`
|
||||||
|
(ou bloc `settings.json` : `mcpServers.colibre.httpUrl` + `headers.Authorization`).
|
||||||
|
- **Mistral Le Chat** : dans les connecteurs MCP, ajouter un serveur HTTP
|
||||||
|
d'URL `<URL>`, authentification « API Token », en-tête `Authorization` =
|
||||||
|
`Bearer <TOKEN>`.
|
||||||
|
- **ChatGPT** : l'app grand public exige OAuth 2.1 (pas de jeton statique) →
|
||||||
|
documenter la voie **développeur** (OpenAI API / Agents SDK) qui accepte un
|
||||||
|
en-tête `Authorization: Bearer <TOKEN>` sur un serveur MCP distant, et noter
|
||||||
|
que la prise en charge dans l'app ChatGPT nécessitera le connecteur OAuth
|
||||||
|
(**scope B2**, itération future).
|
||||||
|
|
||||||
|
## Stratégie de test
|
||||||
|
|
||||||
|
- **`tests/api/test_tokens_db.py`** (étendre) : colonne `kind` par défaut `'api'` ;
|
||||||
|
`create_token(kind='mcp')` ; `list_user_tokens` filtre par user_id + kind ;
|
||||||
|
`revoke_user_token` respecte la propriété (un user ne peut pas révoquer le jeton
|
||||||
|
d'un autre → retourne `False`, ligne intacte).
|
||||||
|
- **`tests/mcp/test_auth.py`** (nouveau) : garde `/_mcp` —
|
||||||
|
401 (pas d'en-tête / `Bearer` vide / jeton inconnu / jeton révoqué /
|
||||||
|
jeton `kind='api'`) ; 403 (jeton `kind='mcp'` valide mais `user_id` nul ou
|
||||||
|
abonnement inactif) ; passage (jeton MCP + abonnement actif, ou `TOUS_ABONNES`) ;
|
||||||
|
`increment_usage` appelé en cas de succès ; en-tête `WWW-Authenticate` sur 401.
|
||||||
|
- **`tests/api/test_api_auth.py`** (étendre) : `require_token` refuse un jeton
|
||||||
|
`kind='mcp'`, accepte un jeton `kind='api'`.
|
||||||
|
- **Migration** : `apply_pending()` idempotente sur DB existante (ajoute `kind`)
|
||||||
|
et sur DB fraîche (tolère _duplicate column_).
|
||||||
|
- **UI** (`tests/…` selon patron compte) : génération → affichage unique du jeton ;
|
||||||
|
révocation ; redirection `/compte/abonnement` sans abonnement ; anti-IDOR
|
||||||
|
(révocation limitée aux jetons de l'utilisateur courant).
|
||||||
|
|
||||||
|
## Hors périmètre (YAGNI)
|
||||||
|
|
||||||
|
- Serveur d'autorisation OAuth 2.1 / DCR / PKCE / consentement (→ scope B2).
|
||||||
|
- Rate-limiting, quotas par jeton, scopes fins par tool MCP.
|
||||||
|
- Refonte de l'API REST (`/api/v1` inchangée hormis le refus des jetons MCP).
|
||||||
|
- Support natif de l'app ChatGPT (nécessite OAuth → scope B2).
|
||||||
|
- Rotation / expiration automatique des jetons (révocation manuelle suffit pour V1).
|
||||||
|
|
||||||
|
## Points de vigilance sécurité (rappel issue #111)
|
||||||
|
|
||||||
|
- Toute règle d'accès (abonnement, propriété du jeton) est **appliquée
|
||||||
|
explicitement côté serveur**, jamais seulement masquée dans l'UI.
|
||||||
|
- Le jeton en clair n'est affiché qu'une fois ; seul son hachage SHA-256 est stocké.
|
||||||
|
- 401 générique (ne pas révéler si un jeton existe).
|
||||||
|
- Révocation et listing strictement limités au propriétaire (`user_id`).
|
||||||
+8
-4
@@ -5,6 +5,14 @@ from src.api import routes
|
|||||||
|
|
||||||
def init_api(server) -> None:
|
def init_api(server) -> None:
|
||||||
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
|
"""Enregistre le blueprint d'API privée sur le serveur Flask."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.api import tokens_db, tracking
|
||||||
|
|
||||||
|
# Garantit que api_tokens existe avant que apply_pending (init_subscriptions,
|
||||||
|
# plus tard) ne tente l'ALTER de la migration 0007.
|
||||||
|
tokens_db.init_schema(os.environ["USERS_DB_PATH"])
|
||||||
|
|
||||||
server.config.setdefault("API_TITLE", "colibre API")
|
server.config.setdefault("API_TITLE", "colibre API")
|
||||||
server.config.setdefault("API_VERSION", "v1")
|
server.config.setdefault("API_VERSION", "v1")
|
||||||
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
|
server.config.setdefault("OPENAPI_VERSION", "3.0.3")
|
||||||
@@ -27,8 +35,4 @@ def init_api(server) -> None:
|
|||||||
api = Api(server)
|
api = Api(server)
|
||||||
api.register_blueprint(routes.bp)
|
api.register_blueprint(routes.bp)
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from src.api import tracking
|
|
||||||
|
|
||||||
tracking.start_worker(os.environ["USERS_DB_PATH"])
|
tracking.start_worker(os.environ["USERS_DB_PATH"])
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,6 @@ def _abort_401(message: str):
|
|||||||
def require_token(fn):
|
def require_token(fn):
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
print(API_AUTH_DISABLED)
|
|
||||||
if not API_AUTH_DISABLED:
|
if not API_AUTH_DISABLED:
|
||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
if not header.startswith("Bearer "):
|
if not header.startswith("Bearer "):
|
||||||
@@ -30,6 +29,9 @@ def require_token(fn):
|
|||||||
_abort_401("invalid_token")
|
_abort_401("invalid_token")
|
||||||
if row["revoked_at"] is not None:
|
if row["revoked_at"] is not None:
|
||||||
_abort_401("revoked_token")
|
_abort_401("revoked_token")
|
||||||
|
if row["kind"] == "mcp":
|
||||||
|
# jeton dédié MCP : non valable sur l'API REST
|
||||||
|
_abort_401("invalid_token")
|
||||||
g.token_id = row["id"]
|
g.token_id = row["id"]
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
last_used_at TEXT,
|
last_used_at TEXT,
|
||||||
count_total INTEGER NOT NULL DEFAULT 0,
|
count_total INTEGER NOT NULL DEFAULT 0,
|
||||||
revoked_at TEXT
|
revoked_at TEXT,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'api'
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
"""
|
"""
|
||||||
@@ -47,13 +48,15 @@ def init_schema(db_path) -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]:
|
def create_token(
|
||||||
|
db_path, label: str, user_id: int | None = None, kind: str = "api"
|
||||||
|
) -> tuple[str, int]:
|
||||||
token = TOKEN_PREFIX + secrets.token_hex(32)
|
token = TOKEN_PREFIX + secrets.token_hex(32)
|
||||||
with _connect(db_path) as conn:
|
with _connect(db_path) as conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO api_tokens (token_hash, label, user_id, created_at) "
|
"INSERT INTO api_tokens (token_hash, label, user_id, kind, created_at) "
|
||||||
"VALUES (?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
(_hash(token), label, user_id, _utcnow_iso()),
|
(_hash(token), label, user_id, kind, _utcnow_iso()),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return token, cur.lastrowid
|
return token, cur.lastrowid
|
||||||
@@ -92,3 +95,24 @@ def list_tokens(db_path) -> list[dict]:
|
|||||||
with _connect(db_path) as conn:
|
with _connect(db_path) as conn:
|
||||||
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
|
rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_user_tokens(db_path, user_id: int, kind: str = "mcp") -> list[dict]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM api_tokens WHERE user_id = ? AND kind = ? "
|
||||||
|
"ORDER BY created_at DESC, id DESC",
|
||||||
|
(user_id, kind),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_token(db_path, token_id: int, user_id: int) -> bool:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE api_tokens SET revoked_at = ? "
|
||||||
|
"WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
||||||
|
(_utcnow_iso(), token_id, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|||||||
+17
@@ -124,6 +124,19 @@ if _mcp_enabled:
|
|||||||
)
|
)
|
||||||
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
|
import src.mcp.tools # noqa: E402,F401 # l'import enregistre les @mcp_enabled
|
||||||
|
|
||||||
|
# Les routes /_mcp existent maintenant : exempter du CSRF (POST JSON-RPC
|
||||||
|
# externe sans jeton) puis brancher le garde d'abonnement.
|
||||||
|
from src.mcp.auth import init_mcp_auth # noqa: E402
|
||||||
|
|
||||||
|
if _auth_csrf is not None:
|
||||||
|
for _rule in app.server.url_map.iter_rules():
|
||||||
|
if _rule.rule.startswith("/_mcp"):
|
||||||
|
_vf = app.server.view_functions.get(_rule.endpoint)
|
||||||
|
if _vf is not None:
|
||||||
|
_auth_csrf.exempt(_vf)
|
||||||
|
|
||||||
|
init_mcp_auth(app.server)
|
||||||
|
|
||||||
from src.subscriptions.setup import init_subscriptions # noqa: E402
|
from src.subscriptions.setup import init_subscriptions # noqa: E402
|
||||||
|
|
||||||
init_subscriptions(app.server)
|
init_subscriptions(app.server)
|
||||||
@@ -136,6 +149,10 @@ from src.roadmap import db as roadmap_db # noqa: E402
|
|||||||
|
|
||||||
roadmap_db.init_schema()
|
roadmap_db.init_schema()
|
||||||
|
|
||||||
|
from src.mcp.account import mcp_account_bp # noqa: E402
|
||||||
|
|
||||||
|
app.server.register_blueprint(mcp_account_bp)
|
||||||
|
|
||||||
|
|
||||||
# robots.txt
|
# robots.txt
|
||||||
@app.server.route("/robots.txt")
|
@app.server.route("/robots.txt")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, request, session
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
mcp_account_bp = Blueprint("mcp_account", __name__)
|
||||||
|
|
||||||
|
_LABEL_MAX = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _has_active_subscription() -> bool:
|
||||||
|
# Réutilise la vérification canonique (gère TOUS_ABONNES).
|
||||||
|
from src.pages._compte_shell import current_user_has_subscription
|
||||||
|
|
||||||
|
return current_user_has_subscription()
|
||||||
|
|
||||||
|
|
||||||
|
def _guard():
|
||||||
|
"""Renvoie une redirection si l'accès est refusé, sinon None."""
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect("/connexion?next=/compte/mcp")
|
||||||
|
if not _has_active_subscription():
|
||||||
|
return redirect("/compte/abonnement")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_account_bp.route("/compte/mcp/creer", methods=["POST"])
|
||||||
|
def creer():
|
||||||
|
denied = _guard()
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
label = (request.form.get("label") or "").strip()[:_LABEL_MAX] or "Sans nom"
|
||||||
|
token, _ = tokens_db.create_token(
|
||||||
|
os.environ["USERS_DB_PATH"], label, user_id=current_user.id, kind="mcp"
|
||||||
|
)
|
||||||
|
session["mcp_new_token"] = token
|
||||||
|
return redirect("/compte/mcp")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp_account_bp.route("/compte/mcp/revoquer/<int:token_id>", methods=["POST"])
|
||||||
|
def revoquer(token_id):
|
||||||
|
denied = _guard()
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
tokens_db.revoke_user_token(os.environ["USERS_DB_PATH"], token_id, current_user.id)
|
||||||
|
return redirect("/compte/mcp")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.subscriptions.db import has_active_subscription
|
||||||
|
from src.utils import TOUS_ABONNES
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized():
|
||||||
|
resp = jsonify(
|
||||||
|
{"error": "unauthorized", "message": "Jeton MCP absent ou invalide."}
|
||||||
|
)
|
||||||
|
resp.status_code = 401
|
||||||
|
resp.headers["WWW-Authenticate"] = 'Bearer realm="colibre-mcp"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden():
|
||||||
|
resp = jsonify(
|
||||||
|
{
|
||||||
|
"error": "no_active_subscription",
|
||||||
|
"message": "Un abonnement colibre actif est requis pour le connecteur MCP.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp.status_code = 403
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticate_mcp():
|
||||||
|
header = request.headers.get("Authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
return _unauthorized()
|
||||||
|
token = header[len("Bearer ") :].strip()
|
||||||
|
if not token:
|
||||||
|
return _unauthorized()
|
||||||
|
|
||||||
|
db_path = os.environ["USERS_DB_PATH"]
|
||||||
|
row = tokens_db.get_token_by_plaintext(db_path, token)
|
||||||
|
if row is None or row["revoked_at"] is not None or row["kind"] != "mcp":
|
||||||
|
return _unauthorized()
|
||||||
|
|
||||||
|
user_id = row["user_id"]
|
||||||
|
if user_id is None:
|
||||||
|
return _forbidden()
|
||||||
|
if not (TOUS_ABONNES or has_active_subscription(user_id)):
|
||||||
|
return _forbidden()
|
||||||
|
|
||||||
|
tokens_db.increment_usage(db_path, row["id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_mcp_auth(server: Flask) -> None:
|
||||||
|
"""Enregistre le garde d'authentification du serveur MCP (/_mcp)."""
|
||||||
|
|
||||||
|
@server.before_request
|
||||||
|
def _guard_mcp():
|
||||||
|
if request.path == "/_mcp" or request.path.startswith("/_mcp/"):
|
||||||
|
return _authenticate_mcp()
|
||||||
|
return None
|
||||||
+12
-2
@@ -42,6 +42,10 @@ _MIGRATIONS: list[tuple[str, str]] = [
|
|||||||
"details TEXT, "
|
"details TEXT, "
|
||||||
"created_at TEXT NOT NULL)",
|
"created_at TEXT NOT NULL)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"0007_add_kind_to_api_tokens",
|
||||||
|
"ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -62,9 +66,15 @@ def apply_pending() -> None:
|
|||||||
except sqlite3.OperationalError as exc:
|
except sqlite3.OperationalError as exc:
|
||||||
# Sur une DB fraîche (schéma déjà à jour), certaines migrations
|
# Sur une DB fraîche (schéma déjà à jour), certaines migrations
|
||||||
# sont sans effet : ADD COLUMN → "duplicate column name",
|
# sont sans effet : ADD COLUMN → "duplicate column name",
|
||||||
# RENAME COLUMN → "no such column". On les ignore.
|
# RENAME COLUMN → "no such column", et un ALTER sur une table pas
|
||||||
|
# encore créée → "no such table" (elle sera créée plus tard par
|
||||||
|
# init_schema avec la colonne déjà dans SCHEMA).
|
||||||
err = str(exc)
|
err = str(exc)
|
||||||
if "duplicate column name" not in err and "no such column" not in err:
|
if (
|
||||||
|
"duplicate column name" not in err
|
||||||
|
and "no such column" not in err
|
||||||
|
and "no such table" not in err
|
||||||
|
):
|
||||||
raise
|
raise
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
|
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ SECTIONS = [
|
|||||||
"href": "/compte/roadmap",
|
"href": "/compte/roadmap",
|
||||||
"require_subscription": True,
|
"require_subscription": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "mcp",
|
||||||
|
"label": "Connecteur MCP",
|
||||||
|
"href": "/compte/mcp",
|
||||||
|
"require_subscription": True,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "abonnement",
|
"key": "abonnement",
|
||||||
"label": "Abonnement",
|
"label": "Abonnement",
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash import dcc, html, register_page
|
||||||
|
from flask import session
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.pages._compte_shell import account_guard, account_shell
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/compte/mcp",
|
||||||
|
title="Connecteur MCP | colibre",
|
||||||
|
name="Connecteur MCP",
|
||||||
|
description="Connectez votre agent IA aux données colibre via le protocole MCP.",
|
||||||
|
)
|
||||||
|
|
||||||
|
MCP_ENABLED = os.getenv("DASH_MCP_ENABLED") == "true"
|
||||||
|
_TOKEN_PLACEHOLDER = "<VOTRE_JETON>"
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_url() -> str:
|
||||||
|
base = os.getenv("APP_BASE_URL", "").rstrip("/")
|
||||||
|
return f"{base}/_mcp" if base else "/_mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf(index: str):
|
||||||
|
return dcc.Input(
|
||||||
|
type="hidden",
|
||||||
|
id={"type": "csrf-input", "index": index},
|
||||||
|
name="csrf_token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def client_instructions(url: str, token: str):
|
||||||
|
"""Construit les blocs d'instructions par client (fonction pure, testable)."""
|
||||||
|
claude = f'claude mcp add colibre --transport http {url} --header "Authorization: Bearer {token}"'
|
||||||
|
gemini = f'gemini mcp add --transport http --header "Authorization: Bearer {token}" colibre {url}'
|
||||||
|
return dbc.Accordion(
|
||||||
|
start_collapsed=True,
|
||||||
|
always_open=False,
|
||||||
|
children=[
|
||||||
|
dbc.AccordionItem(
|
||||||
|
title="Claude (Code / Desktop)",
|
||||||
|
children=html.Pre(html.Code(claude)),
|
||||||
|
),
|
||||||
|
dbc.AccordionItem(
|
||||||
|
title="Gemini CLI",
|
||||||
|
children=[
|
||||||
|
html.Pre(html.Code(gemini)),
|
||||||
|
html.P(
|
||||||
|
"Ou dans ~/.gemini/settings.json : un serveur mcpServers.colibre "
|
||||||
|
f'avec "httpUrl": "{url}" et "headers": '
|
||||||
|
f'{{"Authorization": "Bearer {token}"}}.'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dbc.AccordionItem(
|
||||||
|
title="Mistral Le Chat",
|
||||||
|
children=html.P(
|
||||||
|
"Dans les connecteurs MCP, ajoutez un serveur HTTP d'URL "
|
||||||
|
f"{url}, authentification « API Token », en-tête "
|
||||||
|
f"« Authorization » = « Bearer {token} »."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbc.AccordionItem(
|
||||||
|
title="ChatGPT",
|
||||||
|
children=[
|
||||||
|
html.P(
|
||||||
|
"L'app ChatGPT grand public exige le flux OAuth 2.1 et "
|
||||||
|
"n'accepte pas de jeton statique. En attendant le connecteur "
|
||||||
|
"OAuth (itération future), utilisez la voie développeur "
|
||||||
|
"(API / Agents SDK OpenAI), qui accepte un serveur MCP distant "
|
||||||
|
f"d'URL {url} avec l'en-tête « Authorization: Bearer {token} »."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_row(row: dict):
|
||||||
|
statut = "Révoqué" if row["revoked_at"] else "Actif"
|
||||||
|
actions = []
|
||||||
|
if not row["revoked_at"]:
|
||||||
|
actions = [
|
||||||
|
html.Form(
|
||||||
|
method="POST",
|
||||||
|
action=f"/compte/mcp/revoquer/{row['id']}",
|
||||||
|
children=[
|
||||||
|
_csrf(f"revoke-{row['id']}"),
|
||||||
|
dbc.Button(
|
||||||
|
"Révoquer",
|
||||||
|
type="submit",
|
||||||
|
color="danger",
|
||||||
|
size="sm",
|
||||||
|
outline=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return html.Tr(
|
||||||
|
[
|
||||||
|
html.Td(row["label"]),
|
||||||
|
html.Td(row["created_at"]),
|
||||||
|
html.Td(row["last_used_at"] or "—"),
|
||||||
|
html.Td(statut),
|
||||||
|
html.Td(actions),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def layout(**_):
|
||||||
|
guard = account_guard("/compte/mcp", require_subscription=True)
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
if not MCP_ENABLED:
|
||||||
|
contenu = html.Div(
|
||||||
|
[
|
||||||
|
html.H2("Connecteur MCP"),
|
||||||
|
dbc.Alert(
|
||||||
|
"Le connecteur MCP sera bientôt disponible. Revenez prochainement.",
|
||||||
|
color="info",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return account_shell("mcp", contenu)
|
||||||
|
|
||||||
|
url = _mcp_url()
|
||||||
|
new_token = session.pop("mcp_new_token", None)
|
||||||
|
|
||||||
|
alerts = []
|
||||||
|
if new_token:
|
||||||
|
alerts.append(
|
||||||
|
dbc.Alert(
|
||||||
|
[
|
||||||
|
html.Strong("Votre nouveau jeton (copiez-le maintenant, "),
|
||||||
|
html.Strong("il ne sera plus affiché) :"),
|
||||||
|
html.Pre(html.Code(new_token)),
|
||||||
|
],
|
||||||
|
color="success",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = tokens_db.list_user_tokens(
|
||||||
|
os.environ["USERS_DB_PATH"], current_user.id, "mcp"
|
||||||
|
)
|
||||||
|
table = (
|
||||||
|
dbc.Table(
|
||||||
|
[
|
||||||
|
html.Thead(
|
||||||
|
html.Tr(
|
||||||
|
[
|
||||||
|
html.Th("Nom"),
|
||||||
|
html.Th("Créé le"),
|
||||||
|
html.Th("Dernière utilisation"),
|
||||||
|
html.Th("Statut"),
|
||||||
|
html.Th(""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
html.Tbody([_token_row(dict(r)) for r in tokens]),
|
||||||
|
],
|
||||||
|
striped=True,
|
||||||
|
bordered=False,
|
||||||
|
hover=True,
|
||||||
|
)
|
||||||
|
if tokens
|
||||||
|
else html.P("Aucun jeton pour le moment.")
|
||||||
|
)
|
||||||
|
|
||||||
|
create_form = html.Form(
|
||||||
|
method="POST",
|
||||||
|
action="/compte/mcp/creer",
|
||||||
|
className="mb-4",
|
||||||
|
children=[
|
||||||
|
_csrf("mcp-create"),
|
||||||
|
dbc.Label("Nom du jeton (ex. « Claude sur mon portable »)"),
|
||||||
|
dbc.Input(type="text", name="label", required=True, className="mb-2"),
|
||||||
|
dbc.Button("Générer un jeton", type="submit", color="primary"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
snippet_token = new_token or _TOKEN_PLACEHOLDER
|
||||||
|
contenu = html.Div(
|
||||||
|
[
|
||||||
|
html.H2("Connecteur MCP"),
|
||||||
|
html.P(
|
||||||
|
"Générez un jeton pour connecter votre agent IA (Claude, Gemini, "
|
||||||
|
"Mistral…) aux données colibre via le protocole MCP. Le jeton vaut "
|
||||||
|
"votre identité : gardez-le secret. Un abonnement actif est requis."
|
||||||
|
),
|
||||||
|
*alerts,
|
||||||
|
html.H4("Générer un jeton", className="mt-3"),
|
||||||
|
create_form,
|
||||||
|
html.H4("Mes jetons", className="mt-4"),
|
||||||
|
table,
|
||||||
|
html.H4("Connecter un client", className="mt-4"),
|
||||||
|
html.P(f"URL du serveur MCP : {url}"),
|
||||||
|
client_instructions(url, snippet_token),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return account_shell("mcp", contenu)
|
||||||
@@ -57,3 +57,22 @@ def test_valid_token_sets_g_and_calls_view(temp_db):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()["token_id"] == token_id
|
assert resp.get_json()["token_id"] == token_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_kind_token_rejected_by_rest_api(temp_db):
|
||||||
|
token, _ = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get(
|
||||||
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["message"] == "invalid_token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_kind_token_accepted_by_rest_api(temp_db):
|
||||||
|
token, _ = tokens_db.create_token(temp_db, "x", kind="api")
|
||||||
|
app = _make_app()
|
||||||
|
resp = app.test_client().get(
|
||||||
|
"/protected", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_api_creates_api_tokens_table(monkeypatch, tmp_path):
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from src.api import init_api, tracking
|
||||||
|
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
server = Flask(__name__)
|
||||||
|
init_api(server)
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(str(db_path)) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type='table' AND name='api_tokens'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
tracking.stop_worker()
|
||||||
|
assert rows == [("api_tokens",)]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
def test_migration_0007_adds_kind_to_legacy_api_tokens(monkeypatch, tmp_path):
|
||||||
|
from src import migrations
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
auth_db.init_schema()
|
||||||
|
sub_db.init_schema()
|
||||||
|
|
||||||
|
conn = auth_db.get_conn()
|
||||||
|
conn.execute("DROP TABLE IF EXISTS api_tokens")
|
||||||
|
# ancienne table SANS colonne kind
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE api_tokens ("
|
||||||
|
"id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, "
|
||||||
|
"label TEXT NOT NULL, user_id INTEGER, created_at TEXT NOT NULL, "
|
||||||
|
"last_used_at TEXT, count_total INTEGER NOT NULL DEFAULT 0, revoked_at TEXT)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
migrations.apply_pending()
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()]
|
||||||
|
assert "kind" in cols
|
||||||
|
|
||||||
|
# idempotent : un second passage ne lève pas
|
||||||
|
migrations.apply_pending()
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_pending_tolerates_missing_api_tokens_table(monkeypatch, tmp_path):
|
||||||
|
from src import migrations
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
auth_db.init_schema()
|
||||||
|
sub_db.init_schema()
|
||||||
|
# api_tokens volontairement NON créée (chemin init_subscriptions sans init_api)
|
||||||
|
migrations.apply_pending() # ne doit pas lever
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
@@ -62,3 +62,42 @@ def test_list_tokens_returns_all(temp_db):
|
|||||||
tokens_db.create_token(temp_db, "b")
|
tokens_db.create_token(temp_db, "b")
|
||||||
rows = tokens_db.list_tokens(temp_db)
|
rows = tokens_db.list_tokens(temp_db)
|
||||||
assert [r["label"] for r in rows] == ["a", "b"]
|
assert [r["label"] for r in rows] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_token_defaults_to_api_kind(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x")
|
||||||
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
||||||
|
assert row["kind"] == "api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_token_with_mcp_kind(temp_db):
|
||||||
|
token, _ = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
|
||||||
|
row = tokens_db.get_token_by_plaintext(temp_db, token)
|
||||||
|
assert row["kind"] == "mcp"
|
||||||
|
assert row["user_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_user_tokens_filters_by_user_and_kind(temp_db):
|
||||||
|
tokens_db.create_token(temp_db, "mcp-u1", user_id=1, kind="mcp")
|
||||||
|
tokens_db.create_token(temp_db, "api-u1", user_id=1, kind="api")
|
||||||
|
tokens_db.create_token(temp_db, "mcp-u2", user_id=2, kind="mcp")
|
||||||
|
rows = tokens_db.list_user_tokens(temp_db, 1, "mcp")
|
||||||
|
assert [r["label"] for r in rows] == ["mcp-u1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_user_token_revokes_own(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||||
|
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
|
||||||
|
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_user_token_refuses_other_owner(temp_db):
|
||||||
|
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||||
|
assert tokens_db.revoke_user_token(temp_db, token_id, 999) is False
|
||||||
|
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_user_token_already_revoked_returns_false(temp_db):
|
||||||
|
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||||
|
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
|
||||||
|
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is False
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
def test_section_connecteur_mcp_present():
|
||||||
|
from src.pages._compte_shell import SECTIONS
|
||||||
|
|
||||||
|
keys = {s["key"]: s for s in SECTIONS}
|
||||||
|
assert "mcp" in keys
|
||||||
|
assert keys["mcp"]["label"] == "Connecteur MCP"
|
||||||
|
assert keys["mcp"]["href"] == "/compte/mcp"
|
||||||
|
assert keys["mcp"]["require_subscription"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_module_registers_and_builds_client_instructions():
|
||||||
|
# importe l'app pour la découverte use_pages en contexte propre
|
||||||
|
from src.app import app # noqa: F401
|
||||||
|
from src.pages.compte import mcp as mcp_page
|
||||||
|
|
||||||
|
# helper pur : construit les 4 blocs d'instructions clients
|
||||||
|
blocks = mcp_page.client_instructions(
|
||||||
|
"https://colibre.fr/_mcp", "colibre_TESTTOKEN"
|
||||||
|
)
|
||||||
|
text = str(blocks)
|
||||||
|
assert "colibre_TESTTOKEN" in text
|
||||||
|
assert "https://colibre.fr/_mcp" in text
|
||||||
|
for client_name in ("Claude", "Gemini", "Mistral", "ChatGPT"):
|
||||||
|
assert client_name in text
|
||||||
|
# caveat ChatGPT (OAuth / itération future)
|
||||||
|
assert "OAuth" in text
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def account_client(monkeypatch, tmp_path):
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.auth.setup import init_auth
|
||||||
|
from src.mcp.account import mcp_account_bp
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||||
|
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["WTF_CSRF_ENABLED"] = False
|
||||||
|
init_auth(app) # login manager + CSRF (désactivé) + schéma auth
|
||||||
|
sub_db.init_schema()
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
tokens_db.init_schema(db_path)
|
||||||
|
app.register_blueprint(mcp_account_bp)
|
||||||
|
|
||||||
|
yield app, db_path
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(app, uid):
|
||||||
|
client = app.test_client()
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["_user_id"] = str(uid)
|
||||||
|
sess["_fresh"] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _subscribed_uid():
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
uid = auth_db.create_user("sub@ex.fr", "hash")
|
||||||
|
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
|
||||||
|
sub_db.set_status(sub_id, "active")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_creer_generates_mcp_token_for_subscriber(account_client):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = account_client
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
client = _login(app, uid)
|
||||||
|
|
||||||
|
resp = client.post("/compte/mcp/creer", data={"label": "Claude portable"})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].endswith("/compte/mcp")
|
||||||
|
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess.get("mcp_new_token", "").startswith("colibre_")
|
||||||
|
|
||||||
|
rows = tokens_db.list_user_tokens(db_path, uid, "mcp")
|
||||||
|
assert [r["label"] for r in rows] == ["Claude portable"]
|
||||||
|
assert rows[0]["kind"] == "mcp"
|
||||||
|
assert rows[0]["user_id"] == uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoquer_own_token(account_client):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = account_client
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
client = _login(app, uid)
|
||||||
|
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
|
|
||||||
|
resp = client.post(f"/compte/mcp/revoquer/{tid}")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert tokens_db.get_token_by_plaintext(db_path, token)["revoked_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoquer_other_users_token_is_noop(account_client):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = account_client
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
client = _login(app, uid)
|
||||||
|
other_token, other_id = tokens_db.create_token(
|
||||||
|
db_path, "y", user_id=99999, kind="mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(f"/compte/mcp/revoquer/{other_id}")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert tokens_db.get_token_by_plaintext(db_path, other_token)["revoked_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_creer_blocked_without_subscription(account_client):
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
|
||||||
|
app, db_path = account_client
|
||||||
|
uid = auth_db.create_user("nosub@ex.fr", "hash") # pas d'abonnement
|
||||||
|
client = _login(app, uid)
|
||||||
|
|
||||||
|
resp = client.post("/compte/mcp/creer", data={"label": "x"})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].endswith("/compte/abonnement")
|
||||||
|
assert tokens_db.list_user_tokens(db_path, uid, "mcp") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_creer_blocked_when_anonymous(account_client):
|
||||||
|
app, db_path = account_client
|
||||||
|
resp = app.test_client().post("/compte/mcp/creer", data={"label": "x"})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/connexion" in resp.headers["Location"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path):
|
||||||
|
# DB éphémère + secrets requis par init_auth/init_subscriptions.
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||||
|
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||||
|
monkeypatch.setenv("DASH_MCP_ENABLED", "true")
|
||||||
|
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
|
||||||
|
import src.app as app_module
|
||||||
|
|
||||||
|
app_module = importlib.reload(app_module)
|
||||||
|
client = app_module.app.server.test_client()
|
||||||
|
|
||||||
|
# Pas de jeton : le garde renvoie 401 (et NON une erreur CSRF 400/403),
|
||||||
|
# ce qui prouve exemption CSRF + garde câblés sur /_mcp.
|
||||||
|
resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.headers.get("WWW-Authenticate") == 'Bearer realm="colibre-mcp"'
|
||||||
|
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mcp_app(monkeypatch, tmp_path):
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.mcp.auth import init_mcp_auth
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
auth_db.init_schema()
|
||||||
|
sub_db.init_schema()
|
||||||
|
tokens_db.init_schema(db_path)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/_mcp", methods=["GET", "POST"])
|
||||||
|
def _mcp():
|
||||||
|
return "ok", 200
|
||||||
|
|
||||||
|
init_mcp_auth(app)
|
||||||
|
yield app, db_path
|
||||||
|
auth_db.reset_conn_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def _subscribed_uid():
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
|
|
||||||
|
uid = auth_db.create_user("mcp@ex.fr", "hash")
|
||||||
|
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
|
||||||
|
sub_db.set_status(sub_id, "active")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_header_401_with_challenge(mcp_app):
|
||||||
|
app, _ = mcp_app
|
||||||
|
resp = app.test_client().post("/_mcp")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.headers["WWW-Authenticate"] == 'Bearer realm="colibre-mcp"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bearer_401(mcp_app):
|
||||||
|
app, _ = mcp_app
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer "})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_401(mcp_app):
|
||||||
|
app, _ = mcp_app
|
||||||
|
resp = app.test_client().post(
|
||||||
|
"/_mcp", headers={"Authorization": "Bearer colibre_unknown"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoked_mcp_token_401(mcp_app):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = mcp_app
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
|
tokens_db.revoke_user_token(db_path, tid, uid)
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_kind_token_rejected_401(mcp_app):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = mcp_app
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="api")
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_token_without_user_403(mcp_app):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = mcp_app
|
||||||
|
token, _ = tokens_db.create_token(db_path, "x", user_id=None, kind="mcp")
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_token_no_active_subscription_403(mcp_app):
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
|
||||||
|
app, db_path = mcp_app
|
||||||
|
uid = auth_db.create_user("nosub@ex.fr", "hash") # aucun abonnement
|
||||||
|
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_token_active_subscription_passes_and_increments(mcp_app):
|
||||||
|
from src.api import tokens_db
|
||||||
|
|
||||||
|
app, db_path = mcp_app
|
||||||
|
uid = _subscribed_uid()
|
||||||
|
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert tokens_db.get_token_by_plaintext(db_path, token)["count_total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tous_abonnes_bypasses_subscription(mcp_app, monkeypatch):
|
||||||
|
from src.api import tokens_db
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", True)
|
||||||
|
app, db_path = mcp_app
|
||||||
|
uid = auth_db.create_user("nosub2@ex.fr", "hash")
|
||||||
|
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
Reference in New Issue
Block a user