0.13.12 : sync users-permissions roles at bootstrap
Build release Docker image / Build Docker Images (push) Failing after 2m2s
Build release Docker image / Build Docker Images (push) Failing after 2m2s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
│ └── <name>/
|
||||||
|
│ ├── content-types/<name>/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` |
|
||||||
|
|
||||||
|
<!-- Tableau détaillé (champs sensibles, accès par rôle) à compléter après export des permissions. -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harmony-back",
|
"name": "harmony-back",
|
||||||
"version": "0.13.11",
|
"version": "0.13.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A Strapi application",
|
"description": "A Strapi application",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+6
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Core } from "@strapi/strapi";
|
import type { Core } from "@strapi/strapi";
|
||||||
|
import { syncRolePermissions } from "./permissions-sync";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@ export default {
|
|||||||
* This gives you an opportunity to set up your data model,
|
* This gives you an opportunity to set up your data model,
|
||||||
* run jobs, or perform some special logic.
|
* 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({
|
strapi.db.lifecycles.subscribe({
|
||||||
models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions
|
models: ["plugin::users-permissions.user"], // Applies only to users in users-permissions
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string[]> = {
|
||||||
|
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<void> {
|
||||||
|
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).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user