-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: journalisation des accès au carnet et rotation du code porteur #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aac1426
8c18d93
988d91e
2251980
66f1722
9c6867a
505f29d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { useTranslations } from "next-intl"; | ||
| import { format, parseISO } from "date-fns"; | ||
| import { fr } from "date-fns/locale"; | ||
| import { History, RefreshCw, ShieldAlert, Loader2 } from "lucide-react"; | ||
| import { getCarnetImportHistory, regenerateCarnetCode } from "@/actions/patients"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Skeleton } from "@/components/ui/skeleton"; | ||
|
|
||
| interface CarnetAccessPanelProps { | ||
| patientId: string; | ||
| } | ||
|
|
||
| /** | ||
| * Traçabilité du carnet : quelles cliniques ont rattaché ce dossier, et | ||
| * révocation du code porteur. | ||
| * | ||
| * Le code donne accès à l'historique médical inter-cliniques ; le praticien doit | ||
| * pouvoir constater les rattachements et régénérer le code si l'un d'eux est | ||
| * illégitime. | ||
| */ | ||
| export function CarnetAccessPanel({ patientId }: CarnetAccessPanelProps) { | ||
| const t = useTranslations("patients.carnetAccess"); | ||
| const queryClient = useQueryClient(); | ||
| const [isRotating, setIsRotating] = useState(false); | ||
| const [rotationError, setRotationError] = useState<string | null>(null); | ||
|
|
||
| const { data: importEvents, isLoading } = useQuery({ | ||
| queryKey: ["carnet-import-history", patientId], | ||
| queryFn: () => getCarnetImportHistory(patientId), | ||
| }); | ||
|
|
||
| const rotateCode = async () => { | ||
| setIsRotating(true); | ||
| setRotationError(null); | ||
|
|
||
| // `finally` indispensable : si l'action serveur rejette, le bouton resterait | ||
| // désactivé et le spinner tournerait indéfiniment, sans message. | ||
| try { | ||
| const result = await regenerateCarnetCode(patientId); | ||
|
|
||
| if (result.success) { | ||
| // La fiche patient porte l'ancien code : elle doit être rechargée. | ||
| queryClient.invalidateQueries({ queryKey: ["patient", patientId] }); | ||
| queryClient.invalidateQueries({ queryKey: ["patients-with-carnets"] }); | ||
| } else { | ||
| setRotationError(result.error ?? t("rotationError")); | ||
| } | ||
| } catch { | ||
| setRotationError(t("rotationError")); | ||
| } finally { | ||
| setIsRotating(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="card-panel p-5 space-y-4"> | ||
| <div className="flex items-start justify-between gap-4 flex-wrap"> | ||
| <div className="flex items-center gap-2.5"> | ||
| <History className="w-4 h-4 text-primary" /> | ||
| <h3 className="font-semibold text-foreground">{t("title")}</h3> | ||
| </div> | ||
| <Button | ||
| size="sm" | ||
| variant="outline" | ||
| onClick={rotateCode} | ||
| disabled={isRotating} | ||
| className="rounded-xl gap-1.5" | ||
| > | ||
| {isRotating ? ( | ||
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> | ||
| ) : ( | ||
| <RefreshCw className="w-3.5 h-3.5" /> | ||
| )} | ||
| {t("regenerate")} | ||
| </Button> | ||
| </div> | ||
|
|
||
| {rotationError && ( | ||
| <p className="flex items-center gap-2 text-sm text-destructive"> | ||
| <ShieldAlert className="w-4 h-4 shrink-0" /> | ||
| {rotationError} | ||
| </p> | ||
| )} | ||
|
|
||
| {isLoading ? ( | ||
| <Skeleton className="h-16 w-full rounded-xl" /> | ||
| ) : !importEvents || importEvents.length === 0 ? ( | ||
| <p className="text-sm text-muted-foreground">{t("noAccess")}</p> | ||
| ) : ( | ||
| <ul className="space-y-2"> | ||
| {importEvents.map((event) => ( | ||
| <li | ||
| key={event.id} | ||
| className="flex items-center justify-between gap-4 rounded-xl border border-border bg-muted/30 px-4 py-2.5" | ||
| > | ||
| <div> | ||
| <p className="text-sm font-medium text-foreground"> | ||
| {event.clinic?.name ?? t("deletedClinic")} | ||
| </p> | ||
| {event.imported_by?.full_name && ( | ||
| <p className="text-xs text-muted-foreground"> | ||
| {t("by", { name: event.imported_by.full_name })} | ||
| </p> | ||
| )} | ||
| </div> | ||
| <time className="text-xs text-muted-foreground shrink-0"> | ||
| {format(parseISO(event.created_at), "d MMM yyyy 'à' HH:mm", { locale: fr })} | ||
| </time> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
|
|
||
| <p className="text-xs text-muted-foreground border-t border-border pt-3">{t("notice")}</p> | ||
| </div> | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| -- ═══════════════════════════════════════════════════════════════════════════════ | ||
| -- 014 — Journalisation des imports de carnet et rotation du code porteur | ||
| -- (audit signature / carnet / ordonnance 2026-08-02) | ||
| -- | ||
| -- Le code `CAR-XXXXXXXXXXXXXXXX` est un jeton porteur : quiconque le détient peut, | ||
| -- via `importPatientCarnet`, rattacher le patient à sa clinique et lire tout | ||
| -- l'historique médical validé inter-cliniques. Deux manques en découlaient : | ||
| -- | ||
| -- 1. Aucune trace. Impossible de répondre à « quelles cliniques ont accédé à | ||
| -- mon dossier, et quand ? » — question à laquelle un patient a le droit | ||
| -- d'obtenir une réponse (RGPD, droit d'accès aux destinataires). | ||
| -- 2. Aucune révocation. Un code divulgué le restait définitivement, puisque | ||
| -- rien ne permettait d'en générer un nouveau. | ||
| -- | ||
| -- On ajoute donc un journal des imports, lisible par le patient concerné, et la | ||
| -- possibilité de faire tourner le code. | ||
| -- ═══════════════════════════════════════════════════════════════════════════════ | ||
|
|
||
| -- ── Journal des imports ─────────────────────────────────────────────────────── | ||
| -- `carnet_id` et non le code lui-même : journaliser un jeton porteur reviendrait | ||
| -- à le dupliquer dans une table de plus. | ||
| CREATE TABLE IF NOT EXISTS carnet_import_events ( | ||
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | ||
| carnet_id UUID NOT NULL REFERENCES patient_carnets(id) ON DELETE CASCADE, | ||
| clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, | ||
| imported_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, | ||
| patient_id UUID REFERENCES patients(id) ON DELETE SET NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_carnet_import_events_carnet ON carnet_import_events (carnet_id, created_at DESC); | ||
|
|
||
| ALTER TABLE carnet_import_events ENABLE ROW LEVEL SECURITY; | ||
|
|
||
| -- Le patient voit qui a importé son carnet : c'est l'intérêt principal du journal. | ||
| CREATE POLICY "carnet_import_events_select_patient" ON carnet_import_events FOR SELECT | ||
| USING ( | ||
| carnet_id IN ( | ||
| SELECT carnet_id FROM patients WHERE auth_user_id = auth.uid() | ||
| ) | ||
| ); | ||
|
|
||
| -- Une clinique voit ses propres imports, pour justifier de son accès — mais | ||
| -- seulement par ses rôles médicaux. La liste des cabinets ayant consulté un | ||
| -- dossier est elle-même une donnée de santé : l'ouvrir à receptionist/assistant | ||
| -- contournerait le contrôle déjà appliqué par getCarnetImportHistory. | ||
| CREATE POLICY "carnet_import_events_select_clinic" ON carnet_import_events FOR SELECT | ||
| USING ( | ||
| clinic_id IN ( | ||
| SELECT clinic_id FROM users | ||
| WHERE id = auth.uid() AND role IN ('owner', 'super_admin') | ||
| ) | ||
| ); | ||
|
|
||
| -- Aucune policy d'INSERT, d'UPDATE ni de DELETE : l'écriture passe exclusivement | ||
| -- par le client d'administration depuis `importPatientCarnet`. Un journal que son | ||
| -- sujet peut réécrire ou effacer ne vaut rien. | ||
|
|
||
| -- ── Rotation du code porteur ────────────────────────────────────────────────── | ||
| -- Même format et même garantie d'unicité que le trigger de la migration 004. | ||
| -- SECURITY DEFINER : l'appelant n'a aucun droit d'UPDATE sur patient_carnets, et | ||
| -- ne doit pas en obtenir — l'autorisation est vérifiée côté Server Action. | ||
| CREATE OR REPLACE FUNCTION rotate_patient_carnet_code(p_carnet_id UUID) | ||
| RETURNS TEXT AS $$ | ||
| DECLARE | ||
| new_code TEXT; | ||
| success BOOLEAN := FALSE; | ||
| BEGIN | ||
| WHILE NOT success LOOP | ||
| new_code := 'CAR-' || upper(substr(replace(uuid_generate_v4()::text, '-', ''), 1, 16)); | ||
| BEGIN | ||
| UPDATE patient_carnets SET public_code = new_code WHERE id = p_carnet_id; | ||
| -- Un UPDATE sans correspondance ne lève rien : sans ce contrôle, la | ||
| -- fonction renverrait un code et l'appelant annoncerait une rotation | ||
| -- réussie alors que rien n'aurait changé en base. | ||
| IF NOT FOUND THEN | ||
| RAISE EXCEPTION 'Carnet % introuvable', p_carnet_id USING ERRCODE = 'no_data_found'; | ||
| END IF; | ||
| success := TRUE; | ||
| EXCEPTION WHEN unique_violation THEN | ||
| -- collision improbable sur 64 bits : on retente avec un nouvel UUID | ||
| END; | ||
| END LOOP; | ||
|
|
||
| RETURN new_code; | ||
| END; | ||
| $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| -- La fonction n'est jamais appelée directement depuis le navigateur : seul le | ||
| -- rôle de service l'exécute, après contrôle applicatif. | ||
| REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM PUBLIC; | ||
| REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM anon; | ||
| REVOKE ALL ON FUNCTION rotate_patient_carnet_code(UUID) FROM authenticated; | ||
|
Comment on lines
+89
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Chercher un ALTER DEFAULT PRIVILEGES ou GRANT EXECUTE existant pour service_role sur les routines.
rg -n -i 'default privileges|grant.*execute.*service_role|grant all on all routines' supabase/migrationsRepository: Zoubeir23/DocFlowAI Length of output: 667 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== candidate migration files =="
git ls-files 'supabase/migrations/*.sql' | sort -V | sed -n '1,40p'
echo
echo "== rotation function references =="
rg -n 'rotate_patient_carnet_code|REVOKE ALL ON FUNCTION|grant[[:space:]]+execute[[:space:]]+on[[:space:]]+function|alter default privileges' supabase/migrations -i
echo
echo "== target migration header and security lines =="
sed -n '1,120p' supabase/migrations/014_journalisation_et_rotation_carnets.sql | cat -n
echo
echo "== policy/call path =="
fd -a 'patients\.(ts|tsx)$' . | sed 's#^\./##' | while read -r f; do
echo "--- $f"
rg -n 'rotate_patient_carnet_code|rpc\(' "$f"
done
echo
echo "== security hardening relevant lines =="
sed -n '60,105p' supabase/migrations/005_security_hardening.sql | cat -n
echo
echo "== service_role grants across migrations =="
python3 - <<'PY'
from pathlib import Path
import re
for p in sorted(Path("supabase/migrations").glob("*.sql")):
txt=p.read_text(errors="replace")
lines=[(n,l) for n,l in enumerate(txt.splitlines(),1) if re.search(r'grant\s+execute\s+on\s+function\s+[^;\n]+service_role', l.strip(), re.I)]
if lines:
print(f"{p}")
for n,l in lines:
print(f" {n}: {l.strip()}")
PYRepository: Zoubeir23/DocFlowAI Length of output: 10212 Accordez Ce fichier crée la fonction puis révoque 🤖 Prompt for AI Agents |
||
Uh oh!
There was an error while loading. Please reload this page.