Possibilité d'avoir une clé API Globale par famille

This commit is contained in:
2026-07-20 18:47:58 +02:00
parent 9eb19efd92
commit 441da1975a
12 changed files with 224 additions and 62 deletions
+18
View File
@@ -2279,4 +2279,22 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
// ── Migration : clés API à scope "Famille et entreprises" ───────────────
// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt
// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le
// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la
// contrainte existante : quand scope_all=1, la route de création force
// investisseur_id à pointer vers l'investisseur principal (ancrage FK),
// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un
// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et
// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1
// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI).
{
const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!cols.includes('scope_all')) {
db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0');
console.log('[DB] api_keys: colonne scope_all ajoutée');
}
}
export default db;
+16 -7
View File
@@ -3,9 +3,16 @@ import db from '../db/index.js';
/**
* Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
* frontend (requireAuth). Une clé API est toujours scopée à un seul
* investisseur — pas de notion de "scope=all" ici, contrairement aux routes
* internes. Réservé aux routes /api/v1 (lecture seule, Phase 1).
* frontend (requireAuth). Une clé API est scopée soit à un seul investisseur,
* soit à "Famille et entreprises" (scope_all=1, réservé au profil principal
* — voir routes/apiKeys.js) — en miroir du scope=all des routes internes.
* Réservé aux routes /api/v1 (lecture seule, Phase 1).
*
* Expose sur `req` :
* - req.investisseurId : id de l'investisseur si scope unique, sinon null
* - req.investisseurScopeAll: true si la clé couvre tout le foyer
* - req.userId : user_id du titulaire de la clé (toujours défini,
* utile pour le filtre "tous les investisseurs" quand scope_all)
*/
export function requireApiKey(req, res, next) {
const key = req.header('X-API-Key');
@@ -15,7 +22,7 @@ export function requireApiKey(req, res, next) {
const hash = crypto.createHash('sha256').update(key).digest('hex');
const row = db.prepare(`
SELECT k.id, k.investisseur_id, k.scopes, k.revoked_at
SELECT k.id, k.user_id, k.investisseur_id, k.scope_all, k.scopes, k.revoked_at
FROM api_keys k
WHERE k.key_hash = ?
`).get(hash);
@@ -26,8 +33,10 @@ export function requireApiKey(req, res, next) {
db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
req.apiKeyId = row.id;
req.investisseurId = row.investisseur_id;
req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
req.apiKeyId = row.id;
req.userId = row.user_id;
req.investisseurScopeAll = !!row.scope_all;
req.investisseurId = req.investisseurScopeAll ? null : row.investisseur_id;
req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
next();
}
+16 -8
View File
@@ -17,7 +17,7 @@ function generateKey() {
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id
@@ -27,29 +27,37 @@ router.get('/', (req, res) => {
res.json(rows);
});
/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur) ──── */
/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur, ou
scope_all pour "Famille et entreprises") ──────────────────────────────
scope_all=true n'est autorisé que si investisseur_id désigne le profil
principal — enforcement serveur, indépendant de ce que montre l'UI, pour
qu'un appel direct à l'API ne puisse pas contourner cette règle. */
router.post('/', (req, res, next) => {
try {
const nom = (req.body?.nom || '').trim();
const investisseur_id = Number(req.body?.investisseur_id);
const scope_all = !!req.body?.scope_all;
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis');
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
const inv = db.prepare('SELECT id, is_principal FROM investisseurs WHERE id = ? AND user_id = ?')
.get(investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
if (scope_all && !inv.is_principal) {
throw new HttpError(403, 'Seul le profil principal peut créer une clé « Famille et entreprises »');
}
const { full, hash, prefix } = generateKey();
const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes)
VALUES (?, ?, ?, ?, ?, 'read')
`).run(req.user.id, investisseur_id, nom, prefix, hash);
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all)
VALUES (?, ?, ?, ?, ?, 'read', ?)
`).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
@@ -74,7 +82,7 @@ router.patch('/:id', (req, res, next) => {
db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
+15 -7
View File
@@ -27,9 +27,17 @@ const router = Router();
* 200: { description: Synthèse KPI }
*/
router.get('/', (req, res) => {
const invId = req.investisseurId;
const annee = req.query.annee ? Number(req.query.annee) : null;
// Clé "Famille et entreprises" (scope_all) → agrège tous les investisseurs
// du foyer (req.userId) ; clé mono-investisseur → filtre sur req.investisseurId.
// Dans les deux cas un seul paramètre suffit : soit l'id investisseur, soit
// le user_id pour la sous-requête IN (…) — en miroir de ?scope=all côté JWT.
const invCond = (col) => req.investisseurScopeAll
? `${col} IN (SELECT id FROM investisseurs WHERE user_id = ?)`
: `${col} = ?`;
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
// ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
// en_defaut). "capital_investi" et "capital_en_risque" sont des soldes
@@ -51,11 +59,11 @@ router.get('/', (req, res) => {
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS capital_en_risque,
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
FROM investissements i WHERE i.investisseur_id = ?
`).get(invId);
FROM investissements i WHERE ${invCond('i.investisseur_id')}
`).get(invParam);
const interetsConds = ['i.investisseur_id = ?'];
const interetsParams = [invId];
const interetsConds = [invCond('i.investisseur_id')];
const interetsParams = [invParam];
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
const interets = db.prepare(`
@@ -73,8 +81,8 @@ router.get('/', (req, res) => {
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
FROM depots_retraits WHERE investisseur_id = ?
`).get(invId);
FROM depots_retraits WHERE ${invCond('investisseur_id')}
`).get(invParam);
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
});
+9 -2
View File
@@ -14,14 +14,21 @@ const router = Router();
* 200: { description: Liste des mouvements }
*/
router.get('/', (req, res) => {
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const invCond = req.investisseurScopeAll
? 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'dr.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const rows = db.prepare(`
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
dr.montant, dr.libelle
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
WHERE dr.investisseur_id = ?
WHERE ${invCond}
ORDER BY dr.date_operation DESC
`).all(req.investisseurId);
`).all(invParam);
res.json(rows);
});
+13 -4
View File
@@ -27,8 +27,12 @@ const LIST_COLUMNS = `
*/
router.get('/', (req, res) => {
const { statut } = req.query;
const conds = ['i.investisseur_id = ?'];
const args = [req.investisseurId];
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (statut) { conds.push('i.statut = ?'); args.push(statut); }
const rows = db.prepare(`
@@ -59,12 +63,17 @@ router.get('/', (req, res) => {
*/
router.get('/:id', (req, res, next) => {
try {
const invCond = req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const inv = db.prepare(`
SELECT ${LIST_COLUMNS}, i.notes
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ? AND i.investisseur_id = ?
`).get(req.params.id, req.investisseurId);
WHERE i.id = ? AND ${invCond}
`).get(req.params.id, invParam);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const remboursements = db.prepare(`
+16 -2
View File
@@ -7,16 +7,30 @@ const router = Router();
* @openapi
* /investisseur:
* get:
* summary: Profil de l'investisseur lié à la clé API
* summary: Profil investisseur (ou liste, pour une clé "Famille et entreprises")
* description: >
* Avec une clé scopée à un seul investisseur, renvoie son profil (objet).
* Avec une clé "Famille et entreprises" (scope_all), renvoie la liste des
* investisseurs du foyer (tableau) — il n'y a plus un profil unique à
* renvoyer.
* tags: [Investisseur]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200:
* description: Profil investisseur
* description: Profil investisseur, ou liste de profils si scope_all
* 401:
* description: Clé API invalide ou manquante
*/
router.get('/', (req, res) => {
if (req.investisseurScopeAll) {
const investisseurs = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE user_id = ?
ORDER BY is_principal DESC, id ASC
`).all(req.userId);
return res.json(investisseurs);
}
const inv = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE id = ?
+6 -2
View File
@@ -22,8 +22,12 @@ const router = Router();
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
const conds = ['i.investisseur_id = ?'];
const args = [req.investisseurId];
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }