diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 92647cb..d9557e0 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -5,6 +5,25 @@ sono le migration in `supabase/migrations/`: **una tabella nuova va documentata stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md), non in questo file. +## Permessi di scrittura + +Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura** +resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a +nessuna di esse da M4 (DD-011). + +| Tabella | Chi può scrivere | +| ---------------------------------------------------------------- | ------------------------------------------------------------------- | +| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) | +| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin | +| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin | +| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | +| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | +| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) | +| `user_roles` | solo admin | + +L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`. +La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale. + ## Anagrafica e utenti | Tabella | Scopo | Note | @@ -50,11 +69,11 @@ non in questo file. ## Turni e notifiche -| Tabella | Scopo | Note | -| -------------------- | --------------------------------------------- | ---- | +| Tabella | Scopo | Note | +| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | -| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | -| `promemoria_push` | Storico dei promemoria inviati. | | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Storico dei promemoria inviati. | | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index fce01f8..4270f20 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -748,3 +748,63 @@ CrAPP è un'app solo chiara. Il blocco `.dark` è rimosso e `:root` dichiara **Riesame** Se arriva una richiesta reale dalla squadra, o se si gioca in palestre al buio abbastanza spesso da rendere il tema scuro una funzione e non un vezzo. + +### DD-023 — Ogni scrittura è limitata a chi la fa + +**Data:** 6 settembre 2026 +**Stato:** Accettata + +**Contesto** +Le tabelle della v1.0 sono nate con policy `USING (true)` per `anon, authenticated`. M4 +(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo +era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con +un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app` → +201, `DELETE` → 200. Qualsiasi giocatore loggato poteva svuotare il calendario della squadra +o riscrivere il voto pagella di un altro, parlando direttamente con PostgREST — senza +passare dall'interfaccia, che quei pulsanti glieli nasconde. + +Il permesso viveva quindi solo nei componenti (`useIsAdmin()`, `io.id`), cioè nel posto che +un attaccante non usa. + +**Decisione** +Le policy rispecchiano quello che l'interfaccia già fa. Tre gruppi: + +- **solo admin**: `eventi_app`. Nell'app li scrive unicamente la rotta `/eventi`, che è già + riservata agli amministratori. +- **solo la propria riga**: `risposte_presenze`, `cacche_partita` (per `giocatore_id`), + `pagelle_voti`, `mvp_voti`, `badge_social_voti` (per `votante_id`). L'admin resta incluso, + perché DD-017 gli riconosce già il diritto di agire al posto del giocatore. +- **invariate**: `turni_palloni` e le tre tabelle scout. Nell'interfaccia non hanno nessun + gate — il turno palloni se lo passa chiunque, e chiunque può aprire lo Scout Live — + quindi stringerle sarebbe una funzionalità nuova, non una messa in sicurezza. + +L'identità è lo slot di `giocatori_squadra` collegato all'account, espresso con lo stesso +`EXISTS` che usano le policy dei profili — la funzione `mio_giocatore_id()` di M2 è stata +rimossa dalla migration di correzione e non si reintroduce. Regge perché `io.id` non è una +scelta libera: dopo DD-011 e DD-018 il `localStorage` viene forzato sullo slot collegato +all'account (`benvenuto.tsx`), e senza slot non si entra. + +**Alternative scartate** + +- Lasciare tutto aperto e documentarlo → si può difendere per una squadra di venti persone + che si conoscono, ma non regge il primo account che passa di mano o il primo telefono + perso, e rende ogni bug indistinguibile da un dispetto. +- Controllare i permessi nelle route server → CrAPP scrive dal client con supabase-js. Ci + vorrebbe un livello API che oggi non esiste, per ottenere quello che la RLS fa da sola. +- Stringere anche palloni e scout → cambierebbe come funziona la squadra, e nessuno l'ha + chiesto. + +**Conseguenze** + +- Un giocatore che non ha ancora collegato lo slot non scrive più niente: l'`EXISTS` non + trova nessuna riga. È lo stesso muro di `/benvenuto`, ora applicato anche al database. +- La lettura resta aperta a tutti gli autenticati, pagelle comprese: chi interroga PostgREST + può ancora vedere **chi** ha votato cosa. L'anonimato delle pagelle è una scelta di + interfaccia, non una garanzia del database, e questa migration non lo cambia. +- I test in `test/integration/permessi.test.ts` diventano la definizione eseguibile di questa + tabella dei permessi. + +**Riesame** +Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la +chiusura della lettura riga per riga), o se turni e scout acquistano un gate +nell'interfaccia: allora le loro policy devono seguirlo. diff --git a/supabase/migrations/20260906120000_m11_scritture_per_ruolo.sql b/supabase/migrations/20260906120000_m11_scritture_per_ruolo.sql new file mode 100644 index 0000000..2ad368e --- /dev/null +++ b/supabase/migrations/20260906120000_m11_scritture_per_ruolo.sql @@ -0,0 +1,155 @@ +-- M11 — Le scritture seguono i permessi dell'interfaccia (DD-023). +-- +-- Le tabelle della v1.0 avevano policy `USING (true)` per anon e authenticated. M4 ha +-- tolto il GRANT ad anon, ma per gli autenticati non era rimasto nessun limite: un +-- giocatore qualsiasi poteva cancellare tutti gli eventi o riscrivere il voto di un altro +-- parlando direttamente con PostgREST, senza passare dall'interfaccia che quei pulsanti +-- glieli nasconde. +-- +-- Da qui in poi le policy dicono quello che i componenti già fanno: +-- eventi_app -> solo admin (la rotta /eventi è loro) +-- risposte_presenze, cacche_partita -> la propria riga (giocatore_id) +-- pagelle_voti, mvp_voti, badge_social_voti -> il proprio voto (votante_id) +-- turni_palloni, scout_* -> invariate: nell'app non hanno gate +-- +-- La lettura non cambia: resta aperta a tutti gli autenticati. +-- +-- L'identità è lo slot di `giocatori_squadra` collegato all'account, con lo stesso +-- EXISTS usato dalle policy dei profili (migration di correzione, DD-017): la funzione +-- `mio_giocatore_id()` di M2 lì è stata rimossa e non va reintrodotta. Regge perché dopo +-- DD-018 il giocatore selezionato sul dispositivo è sempre lo slot dell'account. + +-- --- eventi: li gestisce chi ha /eventi, cioè un amministratore --------------------- +DROP POLICY "Chiunque puo creare eventi" ON public.eventi_app; +DROP POLICY "Chiunque puo modificare eventi" ON public.eventi_app; +DROP POLICY "Chiunque puo eliminare eventi" ON public.eventi_app; + +CREATE POLICY "Gli admin gestiscono gli eventi" ON public.eventi_app + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- --- presenze: ognuno risponde per sé ---------------------------------------------- +DROP POLICY "Chiunque puo salvare la propria presenza" ON public.risposte_presenze; +DROP POLICY "Chiunque puo aggiornare la propria presenza" ON public.risposte_presenze; +DROP POLICY "Chiunque puo togliere la propria presenza" ON public.risposte_presenze; + +CREATE POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutte le presenze" ON public.risposte_presenze + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- --- cacche: ognuno dichiara le proprie -------------------------------------------- +DROP POLICY "Chiunque puo dichiarare le cacche" ON public.cacche_partita; +DROP POLICY "Chiunque puo aggiornare le cacche" ON public.cacche_partita; +DROP POLICY "Chiunque puo cancellare le cacche" ON public.cacche_partita; + +CREATE POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutte le cacche" ON public.cacche_partita + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- --- pagelle: ognuno firma i propri voti ------------------------------------------- +DROP POLICY "Chiunque puo votare le pagelle" ON public.pagelle_voti; +DROP POLICY "Chiunque puo cambiare il proprio voto pagella" ON public.pagelle_voti; +DROP POLICY "Chiunque puo togliere il proprio voto pagella" ON public.pagelle_voti; + +CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutte le pagelle" ON public.pagelle_voti + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- --- MVP ---------------------------------------------------------------------------- +DROP POLICY "Chiunque puo votare l MVP" ON public.mvp_voti; +DROP POLICY "Chiunque puo cambiare il proprio voto" ON public.mvp_voti; +DROP POLICY "Chiunque puo togliere il proprio voto" ON public.mvp_voti; + +CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutti i voti MVP" ON public.mvp_voti + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); + +-- --- badge social ------------------------------------------------------------------- +DROP POLICY "Chiunque puo votare i badge social" ON public.badge_social_voti; +DROP POLICY "Chiunque puo cambiare il proprio voto social" ON public.badge_social_voti; +DROP POLICY "Chiunque puo togliere il proprio voto social" ON public.badge_social_voti; + +CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti + FOR ALL TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.giocatori_squadra g + WHERE g.id = votante_id AND g.auth_user_id = auth.uid() + ) + ); + +CREATE POLICY "Gli admin gestiscono tutti i voti social" ON public.badge_social_voti + FOR ALL TO authenticated + USING (public.has_role(auth.uid(), 'admin'::public.app_role)) + WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role)); diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 371156b..8ff4d55 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -3,14 +3,16 @@ * * A differenza di `schema-profili`, che si limita a provare l'utente anonimo, qui si * creano utenti veri e si interroga il database *come loro*: è l'unico modo per - * verificare le policy scritte su `auth.uid()` e la funzione `mio_giocatore_id()`. + * verificare policy scritte su `auth.uid()`. È anche la definizione eseguibile della + * tabella dei permessi di DD-023 (migration M11). * * Il test **scrive**, quindi gira solo contro l'istanza locale di `npx supabase start`: * le credenziali le legge da `supabase status`, non da `.env`, così non può puntare per * sbaglio alla produzione. Senza stack locale si salta con il motivo. * - * Stato toccato e ripristinato alla fine: gli utenti creati (cancellati), lo slot - * reclamato in `giocatori_squadra` e il telefono del profilo g1. + * Stato toccato e ripristinato alla fine: gli utenti creati (cancellati), gli slot + * reclamati in `giocatori_squadra`, il telefono del profilo g1 e le righe con il + * prefisso `test-permessi`. */ import assert from "node:assert/strict"; import { statoLocale } from "../helpers/locale"; @@ -76,6 +78,7 @@ if (!locale) { }); const PASSWORD = "prova-permessi-123"; + const PREFISSO = "test-permessi"; const idUtenti: string[] = []; let telefonoOriginale: string | null = null; let tokenAdmin = ""; @@ -222,9 +225,143 @@ if (!locale) { "l'elenco degli amministratori non è pubblico", ); }); + + // --- M11: le scritture seguono i permessi dell'interfaccia (DD-023) ------------- + // Prima di M11 ognuna di queste andava a buon fine: le policy della v1.0 erano + // `USING (true)` per chiunque fosse autenticato. + const EVENTO = `${PREFISSO}-evento`; + + await prova("gli eventi li crea e li cancella solo un amministratore", async () => { + const daGiocatore = await rest("eventi_app", tokenGiocatore, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: `${PREFISSO}-abusivo`, + tipo: "allenamento", + titolo: "Non deve esistere", + data: "2026-01-01", + ora: "20:00", + luogo: "", + }), + }); + assert.ok(!daGiocatore.ok, `creazione da giocatore rifiutata (${daGiocatore.status})`); + + // Controllo positivo: l'admin deve poterlo fare, altrimenti l'app è rotta. + const daAdmin = await rest("eventi_app", tokenAdmin, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: EVENTO, + tipo: "allenamento", + titolo: "Allenamento di prova", + data: "2026-01-01", + ora: "20:00", + luogo: "Palestra", + }), + }); + assert.equal(await righeToccate(daAdmin), 1, "l'admin crea gli eventi"); + + const cancella = await rest(`eventi_app?id=eq.${EVENTO}`, tokenGiocatore, { + method: "DELETE", + headers: { Prefer: "return=representation" }, + }); + assert.equal(await righeToccate(cancella), 0, "il giocatore non svuota il calendario"); + const dopo = await rest(`eventi_app?id=eq.${EVENTO}&select=id`, SERVIZIO); + assert.equal(((await dopo.json()) as unknown[]).length, 1, "l'evento è ancora lì"); + }); + + await prova("ognuno risponde alla convocazione solo per sé", async () => { + const mia = await rest("risposte_presenze", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g1", stato: "presente" }), + }); + assert.equal(await righeToccate(mia), 1, "la propria risposta si salva"); + + const altrui = await rest("risposte_presenze", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", stato: "assente" }), + }); + assert.ok(!altrui.ok, `nessuno risponde al posto di un altro (${altrui.status})`); + }); + + await prova("i voti si firmano con il proprio nome", async () => { + const mio = await rest("pagelle_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO, + votante_id: "g1", + votato_id: "g5", + voto: 7, + }), + }); + assert.equal(await righeToccate(mio), 1, "il proprio voto si registra"); + + const falso = await rest("pagelle_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO, + votante_id: "g5", + votato_id: "g1", + voto: 10, + }), + }); + assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); + + const mvp = await rest("mvp_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO, + votante_id: "g5", + votato_id: "g1", + votato_nome: "Uno", + }), + }); + assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`); + }); + + await prova("le cacche le dichiara il diretto interessato", async () => { + const mie = await rest("cacche_partita", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g1", quantita: 2 }), + }); + assert.equal(await righeToccate(mie), 1, "le proprie si dichiarano"); + + const altrui = await rest("cacche_partita", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", quantita: 9 }), + }); + assert.ok(!altrui.ok, `quelle di un altro no (${altrui.status})`); + }); + + // I turni palloni restano aperti di proposito: nell'interfaccia il turno se lo passa + // chiunque, senza gate. Se un giorno arriva il gate, questo test va cambiato. + await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => { + const res = await rest("turni_palloni", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", aggiornato_da: "g1" }), + }); + assert.equal(await righeToccate(res), 1, "DD-023 lascia questa tabella invariata"); + }); } finally { - // Ripristino: prima gli slot (serve il JWT admin, il trigger rifiuta la service key), - // poi il telefono, infine gli utenti. + // Ripristino: prima le righe create (la service role passa sopra alle policy di M11), + // poi gli slot (serve il JWT admin, il trigger rifiuta la service key), il telefono e + // infine gli utenti. + for (const tabella of ["risposte_presenze", "cacche_partita", "turni_palloni"]) { + await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + } + for (const tabella of ["pagelle_voti", "mvp_voti"]) { + await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + } + await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + for (const id of ["g1", "g2"]) { if (tokenAdmin) { await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {