From 51947d49e7efe07b3f416b00a8eb6ed8fd1644a2 Mon Sep 17 00:00:00 2001 From: julien vdb Date: Fri, 24 Jul 2026 00:47:04 +0200 Subject: [PATCH] 0.13.12 : sync users-permissions roles at bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/permissions-sync.ts : source de vérité versionnée des permissions (85 authenticated / 9 public, issues de l'audit AUDIT_PERMISSIONS.md) ; synchro additive et idempotente au démarrage, extras logués jamais supprimés - plugin::users-permissions.user.update volontairement non accordé (sécurité) - CLAUDE.md : doc du repo (stack, content-types, règles sécurité) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 116 +++++++++++++++++++++++ package.json | 2 +- src/index.ts | 7 +- src/permissions-sync.ts | 203 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/permissions-sync.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c0f114b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# Backend ChoralSync — Strapi + +> Racine du repo backend. Vue d'ensemble front + back : `../CLAUDE.md`. + +## Stack + +- Strapi **5.8.1** (⚠️ API v5 : `documentId`, réponses aplaties — pas de `attributes` imbriqués comme en v4) +- Node **18 à 22** (`engines` du package.json ; v22 utilisée en dev) — gestionnaire : **yarn** +- TypeScript +- Base de données : sélectionnée par `DATABASE_CLIENT` — **SQLite** par défaut en dev (better-sqlite3), **PostgreSQL** ou MySQL supportés (voir `config/database.ts`) +- Plugins actifs : + - Users & Permissions (rôles `Public`, `Authenticated`) + - Documentation (`@strapi/plugin-documentation`) + - Email : nodemailer via **ZeptoMail** (SMTP) + - Upload : **aws-s3** vers **Cloudflare R2** (bucket `choralsync`, servi par `container.choralsync.com`) + - `strapi-v5-plugin-populate-deep` (defaultDepth 3) +- Autres : Stripe (paiements, content-type `order`), Puppeteer (génération), cron jobs dans `config/cron-tasks.ts` (ex. récupération d'actualités GNews), templates d'emails dans `src/email-templates/` + +## Structure + +``` +src/ +├── api/ # 34 content-types (un dossier chacun) +│ └── / +│ ├── content-types//schema.json +│ ├── controllers/ +│ ├── routes/ +│ └── services/ +├── components/ # composants Strapi partagés +├── email-templates/ # templates des emails transactionnels +├── extensions/ # overrides : documentation, upload, users-permissions +└── admin/ # customisation admin +config/ # server, database, plugins, middlewares, cron-tasks, api +``` + +## Content-types + +Vue par domaine (34 au total, liste exhaustive dans `src/api/`) : + +| Domaine | Content-types | +|---|---| +| Chorale | `choral`, `choral-membership`, `choral-permission`, `permissions-template` | +| Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` | +| Groupes | `group`, `group-membership` | +| Événements | `event`, `event-relationship` | +| Boards (kanban) | `board`, `board-list`, `board-card` | +| Messagerie | `channel`, `chat-conversation`, `chat-conversation-member`, `chat-message`, `conversation`, `direct-message`, `message` | +| Notifications | `notification`, `announcement`, `invite` | +| Paiement | `order` (Stripe — ⚠️ route custom `auth: false` dans `src/api/order/routes/order.ts`, webhook) | +| Contenu / divers | `page`, `legal-page`, `ad`, `contact`, `contact-meta`, `form-template`, `mails` | + + + +## Sécurité — règles NON NÉGOCIABLES + +- Aucune route custom en `auth: false` sans justification écrite en commentaire + ET mention dans le CLAUDE.md parent. État actuel : 1 occurrence + (`src/api/order/routes/order.ts` — webhook Stripe). +- Tout controller custom qui lit/écrit des données utilisateur DOIT filtrer + par `ctx.state.user.id`. Jamais de `entityService.findMany` sans filtre + dans un contexte authentifié multi-utilisateurs. +- Toujours utiliser `sanitizeOutput` / les sanitizers Strapi avant de retourner + une entité depuis un controller custom (ne jamais renvoyer l'objet brut). +- `populate=*` interdit côté serveur comme côté client : populate explicite. + ⚠️ Le plugin populate-deep (defaultDepth 3) est installé — ne pas en abuser, + il peut exposer des relations non prévues. +- Les permissions du plugin Users & Permissions sont versionnées dans + **`src/permissions-sync.ts`** (source de vérité) et synchronisées au + démarrage par le bootstrap (`src/index.ts`) — synchro ADDITIVE et + idempotente : les manquantes sont créées, les permissions en trop sont + seulement loguées (`[permissions-sync]`), jamais supprimées automatiquement. + Toute nouvelle route/action consommée par le front DOIT être ajoutée à ce + fichier (et jamais cochée uniquement dans l'admin, sinon elle sera signalée + comme non déclarée). +- Secrets uniquement en variables d'environnement. `.env` jamais commité ; + `.env.example` maintenu à jour. + ⚠️ **DETTE CRITIQUE : `config/plugins.ts` contient des secrets en dur** + (clé API SMTP ZeptoMail, access/secret keys Cloudflare R2). À migrer vers + `env()` et à faire tourner (rotation des clés). + +## Points d'audit récurrents + +Checklist à dérouler lors d'une revue sécurité : + +1. `grep -rn "auth: false" src/` — chaque occurrence doit être justifiée. +2. Rôle Public dans l'export des permissions : uniquement les find/findOne + réellement nécessaires au site public. +3. Controllers custom : filtrage par user, sanitization, gestion d'erreurs + (pas de stack trace renvoyée au client). +4. Lifecycles (`lifecycles.ts`) : pas de logique qui bypasse les permissions. +5. Champs privés : vérifier `"private": true` dans les schema.json pour les + champs sensibles (ils sont sinon exposés dans les réponses API). +6. Rate limiting / CORS dans `config/middlewares.ts` : origines explicites en prod. +7. API tokens : scope minimal, pas de token full-access utilisé par le front. +8. Webhook Stripe (`order`) : vérification de la signature + (`stripe.webhooks.constructEvent`). + +## Commandes + +```bash +yarn develop # dev + admin (strapi develop --debug) +yarn build +yarn strapi config:dump -f config/sync/permissions.json # export permissions +yarn strapi config:restore -f config/sync/permissions.json # import permissions +``` + +## Règles pour Claude + +- Toute modification de schema.json = migration implicite : signaler l'impact + (données existantes, types front à mettre à jour dans `harmony-web/types/` et + `harmony-web/interfaces/`). +- Après modification d'une route/permission : mettre à jour le tableau des + content-types ici ET dans le CLAUDE.md parent. +- Ne jamais modifier directement la base de données. +- Strapi **5** : utiliser les `documentId` et le format de réponse v5 + (pas de `attributes` imbriqués). Toujours vérifier package.json en cas de doute. diff --git a/package.json b/package.json index 7546284..bfcb459 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harmony-back", - "version": "0.13.11", + "version": "0.13.12", "private": true, "description": "A Strapi application", "scripts": { diff --git a/src/index.ts b/src/index.ts index c68d531..d07bfcb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import type { Core } from "@strapi/strapi"; +import { syncRolePermissions } from "./permissions-sync"; export default { @@ -24,7 +25,11 @@ export default { * This gives you an opportunity to set up your data model, * run jobs, or perform some special logic. */ - bootstrap({ strapi }: { strapi: Core.Strapi }) { + async bootstrap({ strapi }: { strapi: Core.Strapi }) { + // Synchronise les permissions Users & Permissions (source de vérité + // versionnée dans src/permissions-sync.ts — additif, idempotent). + await syncRolePermissions(strapi); + strapi.db.lifecycles.subscribe({ models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions diff --git a/src/permissions-sync.ts b/src/permissions-sync.ts new file mode 100644 index 0000000..d73ff5e --- /dev/null +++ b/src/permissions-sync.ts @@ -0,0 +1,203 @@ +import type { Core } from "@strapi/strapi"; + +/** + * Source de vérité VERSIONNÉE des permissions Users & Permissions. + * + * Synchronisée au démarrage (voir bootstrap dans src/index.ts) : toute + * permission listée ici et absente en base est créée pour le rôle. + * La synchro est ADDITIVE : elle n'en supprime jamais — les permissions en + * trop sont seulement signalées dans les logs, la suppression reste un geste + * manuel (admin ou SQL). + * + * Liste établie par audit du front (voir AUDIT_PERMISSIONS.md à la racine du + * projet parent, 24/07/2026) : chaque action correspond à un appel réel de + * harmony-web. Toute modification ici doit être répercutée dans cet audit et + * dans les CLAUDE.md. + */ +const ROLE_PERMISSIONS: Record = { + authenticated: [ + // --- Marketplace (annonces) — ⚠️ update/delete : ownership à verrouiller côté controller + "api::ad.ad.find", + "api::ad.ad.create", + "api::ad.ad.update", + "api::ad.ad.delete", + + // --- Chorale + "api::choral.choral.find", + "api::choral.choral.create", + "api::choral.choral.update", + "api::choral-membership.choral-membership.find", + "api::choral-membership.choral-membership.create", + "api::choral-membership.choral-membership.update", + "api::announcement.announcement.find", + "api::permissions-template.permissions-template.create", + "api::permissions-template.permissions-template.delete", + "api::form-template.form-template.find", + "api::form-template.form-template.create", + + // --- Boards (kanban) + "api::board.board.find", + "api::board.board.create", + "api::board.board.update", + "api::board.board.delete", + "api::board-list.board-list.find", + "api::board-list.board-list.create", + "api::board-list.board-list.update", + "api::board-list.board-list.delete", + "api::board-card.board-card.create", + "api::board-card.board-card.update", + "api::board-card.board-card.delete", + + // --- Chat de chorale (salons + messages) + "api::channel.channel.create", + "api::channel.channel.update", + "api::channel.channel.delete", + "api::message.message.find", + "api::message.message.create", + "api::message.message.update", + "api::message.message.delete", + "api::message.message.addReaction", + + // --- Messagerie directe (ancien système) + "api::conversation.conversation.find", + "api::conversation.conversation.create", + "api::direct-message.direct-message.find", + "api::direct-message.direct-message.create", + + // --- Messagerie (nouveau système chat-conversation) + "api::chat-conversation.chat-conversation.create", + "api::chat-conversation-member.chat-conversation-member.find", + "api::chat-conversation-member.chat-conversation-member.update", + "api::chat-message.chat-message.create", + + // --- Social (posts) + "api::post.post.find", + "api::post.post.create", + "api::post.post.update", + "api::post.post.delete", + "api::post.post.feed", + "api::post.post.savePost", + "api::post.post.removeSavedPost", + "api::post.post.hidePost", + "api::post.post.removeHiddenPost", + "api::post.post.likePost", + "api::post.post.addComment", + "api::post.post.link", + "api::post-ownership.post-ownership.find", + "api::report.report.create", + + // --- Contacts / amis + "api::contact.contact.find", + "api::contact.contact.create", + "api::contact.contact.update", + "api::contact.contact.delete", + "api::contact.contact.activities", + "api::contact.contact.suggestions", + + // --- Événements + "api::event.event.find", + "api::event.event.create", + "api::event.event.update", + "api::event.event.delete", + "api::event.event.feed", + "api::event.event.applyEvent", + "api::event.event.unapplyEvent", + "api::event-relationship.event-relationship.find", + + // --- Groupes + "api::group.group.find", + "api::group.group.create", + "api::group.group.update", + "api::group.group.delete", + "api::group.group.invite", + "api::group-membership.group-membership.find", + "api::group-membership.group-membership.create", + "api::group-membership.group-membership.update", + "api::group-membership.group-membership.delete", + + // --- Invitations chorale + "api::invite.invite.find", + "api::invite.invite.create", + "api::invite.invite.update", + "api::invite.invite.delete", + + // --- Notifications + "api::notification.notification.find", + "api::notification.notification.update", + "api::notification.notification.delete", + + // --- Contenu / commandes + "api::page.page.find", + "api::order.order.find", + "api::order.order.findOne", + + // --- Plugins + "plugin::upload.content-api.upload", + "plugin::users-permissions.auth.changePassword", + "plugin::users-permissions.user.me", + "plugin::users-permissions.user.find", + "plugin::users-permissions.user.findOne", + "plugin::users-permissions.user.updateMe", + // ⚠️ VOLONTAIREMENT ABSENT : plugin::users-permissions.user.update + // (permettrait à n'importe quel utilisateur de modifier n'importe quel + // compte). Le front doit passer par updateMe — voir AUDIT_PERMISSIONS.md. + ], + + public: [ + "plugin::users-permissions.auth.callback", + "plugin::users-permissions.auth.connect", + "plugin::users-permissions.auth.register", + "plugin::users-permissions.auth.emailConfirmation", + "plugin::users-permissions.auth.sendEmailConfirmation", + "plugin::users-permissions.auth.forgotPassword", + "plugin::users-permissions.auth.resetPassword", + // Pages CMS publiques (landing / pages légales) + "api::page.page.find", + "api::legal-page.legal-page.find", + ], +}; + +/** + * Crée les permissions manquantes pour chaque rôle (idempotent, additif). + * À appeler depuis bootstrap(). + */ +export async function syncRolePermissions(strapi: Core.Strapi): Promise { + for (const [roleType, wantedActions] of Object.entries(ROLE_PERMISSIONS)) { + const role = await strapi.db + .query("plugin::users-permissions.role") + .findOne({ where: { type: roleType } }); + + if (!role) { + strapi.log.warn(`[permissions-sync] Rôle "${roleType}" introuvable, ignoré.`); + continue; + } + + const existing = await strapi.db + .query("plugin::users-permissions.permission") + .findMany({ where: { role: role.id }, select: ["action"] }); + const existingActions = new Set(existing.map((p: { action: string }) => p.action)); + + const missing = wantedActions.filter((a) => !existingActions.has(a)); + for (const action of missing) { + await strapi.db + .query("plugin::users-permissions.permission") + .create({ data: { action, role: role.id } }); + } + + const extra = [...existingActions].filter((a) => !wantedActions.includes(a)); + + if (missing.length > 0) { + strapi.log.info( + `[permissions-sync] ${roleType}: ${missing.length} permission(s) créée(s) → ${missing.join(", ")}`, + ); + } + if (extra.length > 0) { + strapi.log.warn( + `[permissions-sync] ${roleType}: ${extra.length} permission(s) en base non déclarée(s) ici (non supprimées) → ${extra.join(", ")}`, + ); + } + if (missing.length === 0 && extra.length === 0) { + strapi.log.info(`[permissions-sync] ${roleType}: à jour (${wantedActions.length} permissions).`); + } + } +}