From d426791a53607f7e7ef8366180e49615b276423f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Tue, 16 Jun 2026 22:37:37 -0300 Subject: [PATCH] feat(sprint-4/etapa-5): modelo de privacidad RLS granular + UI de visibilidad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL 010: tablas user_groups y group_memberships con RLS - SQL 011: tabla process_access para acceso explícito a procesos - SQL 012: función is_platform_admin() + políticas granulares (select/insert/update/delete) en processes - types.ts: Process.ownerId (requerido) - process-repo: fromRow mapea owner_id→ownerId; toRow preserva ownerId en updates; setShared() no-op documentado - ImportPage: asigna ownerId: user.id al crear proceso - LibraryPage: toggle Todos/Míos, OwnershipBadge (🔒/👤) en cada tarjeta, opción "Compartir con equipo" en menú de proceso propio con toast de confirmación - Tests: ownerId: 'test-user' agregado a todos los fixtures de Process Co-Authored-By: Claude Sonnet 4.6 --- src/domain/types.ts | 1 + src/features/import/ImportPage.tsx | 1 + src/features/library/LibraryPage.tsx | 154 ++++++++++++++++-- src/persistence/supabase/process-repo.ts | 10 +- .../migrations/010_create_user_groups.sql | 38 +++++ .../migrations/011_create_process_access.sql | 21 +++ .../migrations/012_update_processes_rls.sql | 53 ++++++ tests/features/report/components.test.tsx | 7 +- tests/features/report/roi-components.test.tsx | 2 +- tests/lib/export/csv-export-roi.test.ts | 2 +- tests/lib/export/csv-export.test.ts | 2 +- tests/lib/export/measure-sizes.test.ts | 2 +- tests/lib/export/pdf-export-roi.test.ts | 2 +- tests/lib/export/pdf-export-sprint2.test.ts | 2 +- tests/lib/export/pdf-export.test.ts | 2 +- tests/lib/export/pdf-orientation.test.ts | 2 +- tests/lib/export/pdf-roi-page1.test.ts | 2 +- tests/lib/export/pdf-roi-pages2-5.test.ts | 2 +- tests/lib/export/pdf-scenario-page1.test.ts | 2 +- .../simulation-store-invalidation.test.ts | 2 +- 20 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 supabase/migrations/010_create_user_groups.sql create mode 100644 supabase/migrations/011_create_process_access.sql create mode 100644 supabase/migrations/012_update_processes_rls.sql diff --git a/src/domain/types.ts b/src/domain/types.ts index 927c270..ca47ad5 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -18,6 +18,7 @@ export interface Process { updatedAt: number groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda + ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear) } export type ActivityType = 'task' | 'subprocess' diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index d7493e1..0f7f374 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -80,6 +80,7 @@ export function ImportPage() { updatedAt: now, groupId: targetGroupId, tags: [], + ownerId: user!.id, } const activityElements = extractActivityElements(graph) diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index b713a5f..3680a43 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -3,10 +3,11 @@ import { useNavigate } from '@tanstack/react-router' import { Upload, Search, Plus, Building2, FolderX, GitBranch, Clock, TrendingUp, BarChart2, FileX2, X, Settings, - MoreVertical, Trash2, + MoreVertical, Trash2, Share2, Lock, User, } from 'lucide-react' import { useLibraryStore } from '@/store/library-store' import { useAuth } from '@/auth/AuthContext' +import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { formatCurrency } from '@/lib/format' import type { Process, ProcessGroup, Simulation } from '@/domain/types' import { AppHeader } from '@/components/AppHeader' @@ -43,6 +44,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean { return activeTags.every((t) => p.tags.includes(t)) } +// ─── Ownership Badge ────────────────────────────────────────────────────────── + +function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) { + const isOwn = process.ownerId === currentUserId + return ( +
+ {isOwn + ? + : + } +
+ ) +} + +// ─── Toast ──────────────────────────────────────────────────────────────────── + +function useToast() { + const [message, setMessage] = useState(null) + + function show(msg: string) { + setMessage(msg) + setTimeout(() => setMessage(null), 2800) + } + + return { message, show } +} + +function Toast({ message }: { message: string | null }) { + if (!message) return null + return ( +
+ {message} +
+ ) +} + // ─── KPI Badge ──────────────────────────────────────────────────────────────── function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) { @@ -80,15 +123,18 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul // ─── Process Card ───────────────────────────────────────────────────────────── -function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }: { +function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: { process: Process simulation: Simulation | null | undefined showGroupChip?: boolean groupName?: string onDelete?: () => void + currentUserId: string + onShared?: () => void }) { const navigate = useNavigate() const [menuState, setMenuState] = useState(null) + const isOwn = process.ownerId === currentUserId // Cierra el menú al hacer click fuera de él useEffect(() => { @@ -114,7 +160,10 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete } {/* Info central */}
-

{process.name || 'Sin nombre'}

+
+

{process.name || 'Sin nombre'}

+ +
Modificado {relativeDate(process.updatedAt)} @@ -156,9 +205,23 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete } {menuState === 'menu' && (
e.stopPropagation()} > + {isOwn && ( + + )}
+ {/* Toggle Todos / Míos */} +
+ + +
+
+ +
) } diff --git a/src/persistence/supabase/process-repo.ts b/src/persistence/supabase/process-repo.ts index 0534aee..ecdc5d4 100644 --- a/src/persistence/supabase/process-repo.ts +++ b/src/persistence/supabase/process-repo.ts @@ -14,7 +14,8 @@ function toRow(process: Process, userId: string) { automation_investment: process.automationInvestment, group_id: process.groupId, tags: process.tags, - owner_id: userId, + // owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates + owner_id: process.ownerId || userId, created_by: userId, updated_by: userId, updated_at: new Date().toISOString(), @@ -36,6 +37,7 @@ function fromRow(row: Record): Process { tags: Array.isArray(row.tags) ? (row.tags as string[]) : [], createdAt: new Date(row.created_at as string).getTime(), updatedAt: new Date(row.updated_at as string).getTime(), + ownerId: (row.owner_id as string) ?? '', } } @@ -69,4 +71,10 @@ export const supabaseProcessRepo = { const { error } = await supabase.from('processes').delete().eq('id', id) if (error) throw error }, + + // No-op en Sprint 4: la implementación real (insertar en process_access) llega cuando existan guests. + // El método existe para que la UI de "Compartir con equipo" funcione sin escritura a DB. + async setShared(_processId: string, _shared: boolean): Promise { + return + }, } diff --git a/supabase/migrations/010_create_user_groups.sql b/supabase/migrations/010_create_user_groups.sql new file mode 100644 index 0000000..5f7b8b7 --- /dev/null +++ b/supabase/migrations/010_create_user_groups.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS public.user_groups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_by uuid REFERENCES public.users(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.user_groups ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "read_groups" ON public.user_groups + FOR SELECT USING (auth.uid() IS NOT NULL); + +CREATE POLICY "manage_groups" ON public.user_groups + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin') + OR created_by = auth.uid() + ); + +CREATE TABLE IF NOT EXISTS public.group_memberships ( + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + group_id uuid NOT NULL REFERENCES public.user_groups(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member')), + PRIMARY KEY (user_id, group_id) +); + +ALTER TABLE public.group_memberships ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "read_memberships" ON public.group_memberships + FOR SELECT USING (auth.uid() IS NOT NULL); + +CREATE POLICY "manage_memberships" ON public.group_memberships + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin') + OR EXISTS ( + SELECT 1 FROM public.user_groups + WHERE id = group_memberships.group_id AND created_by = auth.uid() + ) + ); diff --git a/supabase/migrations/011_create_process_access.sql b/supabase/migrations/011_create_process_access.sql new file mode 100644 index 0000000..bf96fda --- /dev/null +++ b/supabase/migrations/011_create_process_access.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS public.process_access ( + process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE, + grantee_type text NOT NULL CHECK (grantee_type IN ('user','group')), + grantee_id uuid NOT NULL, + permission text NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')), + PRIMARY KEY (process_id, grantee_type, grantee_id) +); + +ALTER TABLE public.process_access ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "read_process_access" ON public.process_access + FOR SELECT USING (auth.uid() IS NOT NULL); + +CREATE POLICY "manage_process_access" ON public.process_access + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin') + OR EXISTS ( + SELECT 1 FROM public.processes + WHERE id = process_access.process_id AND owner_id = auth.uid() + ) + ); diff --git a/supabase/migrations/012_update_processes_rls.sql b/supabase/migrations/012_update_processes_rls.sql new file mode 100644 index 0000000..f07ce79 --- /dev/null +++ b/supabase/migrations/012_update_processes_rls.sql @@ -0,0 +1,53 @@ +-- Helper: retorna true si el usuario actual es platform_admin +CREATE OR REPLACE FUNCTION public.is_platform_admin() +RETURNS boolean +LANGUAGE sql SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.users + WHERE id = auth.uid() AND platform_role = 'platform_admin' + ); +$$; + +-- Eliminar política amplia de Sprint 4 Etapa 2 +DROP POLICY IF EXISTS "authenticated_all_processes" ON public.processes; + +-- Políticas granulares +CREATE POLICY "select_processes" ON public.processes + FOR SELECT USING ( + public.is_platform_admin() + OR owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.process_access + WHERE process_id = processes.id + AND ( + (grantee_type = 'user' AND grantee_id = auth.uid()) + OR (grantee_type = 'group' AND grantee_id IN ( + SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid() + )) + ) + ) + ); + +CREATE POLICY "insert_processes" ON public.processes + FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); + +CREATE POLICY "update_processes" ON public.processes + FOR UPDATE USING ( + public.is_platform_admin() + OR owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.process_access + WHERE process_id = processes.id + AND permission = 'edit' + AND ( + (grantee_type = 'user' AND grantee_id = auth.uid()) + OR (grantee_type = 'group' AND grantee_id IN ( + SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid() + )) + ) + ) + ); + +CREATE POLICY "delete_processes" ON public.processes + FOR DELETE USING (public.is_platform_admin() OR owner_id = auth.uid()); diff --git a/tests/features/report/components.test.tsx b/tests/features/report/components.test.tsx index 79842ab..22304b1 100644 --- a/tests/features/report/components.test.tsx +++ b/tests/features/report/components.test.tsx @@ -229,7 +229,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proceso Test', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', } expect(() => render()).not.toThrow() }) @@ -239,7 +239,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', } render() const text = document.body.textContent ?? '' @@ -251,7 +251,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', } render() const text = document.body.textContent ?? '' @@ -338,6 +338,7 @@ describe('ReportPage — con datos completos', () => { updatedAt: Date.now() - 1000 * 60 * 30, groupId: null, tags: [], + ownerId: 'test-user', } const mockSimulation = { diff --git a/tests/features/report/roi-components.test.tsx b/tests/features/report/roi-components.test.tsx index e24662b..2972f29 100644 --- a/tests/features/report/roi-components.test.tsx +++ b/tests/features/report/roi-components.test.tsx @@ -590,7 +590,7 @@ const mockProcessBase = { currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockSimulationBase = { diff --git a/tests/lib/export/csv-export-roi.test.ts b/tests/lib/export/csv-export-roi.test.ts index 9998024..b830918 100644 --- a/tests/lib/export/csv-export-roi.test.ts +++ b/tests/lib/export/csv-export-roi.test.ts @@ -14,7 +14,7 @@ function makeProcess(extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras, } } diff --git a/tests/lib/export/csv-export.test.ts b/tests/lib/export/csv-export.test.ts index ddfbd06..08b2f51 100644 --- a/tests/lib/export/csv-export.test.ts +++ b/tests/lib/export/csv-export.test.ts @@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC', bpmnXml: '', currency, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras, } } diff --git a/tests/lib/export/measure-sizes.test.ts b/tests/lib/export/measure-sizes.test.ts index 89a1855..b86061c 100644 --- a/tests/lib/export/measure-sizes.test.ts +++ b/tests/lib/export/measure-sizes.test.ts @@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => { bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const simulation: Simulation = { diff --git a/tests/lib/export/pdf-export-roi.test.ts b/tests/lib/export/pdf-export-roi.test.ts index e262ada..838c175 100644 --- a/tests/lib/export/pdf-export-roi.test.ts +++ b/tests/lib/export/pdf-export-roi.test.ts @@ -51,7 +51,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const perActivityActual = [ diff --git a/tests/lib/export/pdf-export-sprint2.test.ts b/tests/lib/export/pdf-export-sprint2.test.ts index 7055155..2d93d39 100644 --- a/tests/lib/export/pdf-export-sprint2.test.ts +++ b/tests/lib/export/pdf-export-sprint2.test.ts @@ -43,7 +43,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } // Simulación SIN recursos en ninguna actividad diff --git a/tests/lib/export/pdf-export.test.ts b/tests/lib/export/pdf-export.test.ts index a42523f..4e7eaf5 100644 --- a/tests/lib/export/pdf-export.test.ts +++ b/tests/lib/export/pdf-export.test.ts @@ -54,7 +54,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-orientation.test.ts b/tests/lib/export/pdf-orientation.test.ts index 59cc7bb..68321c6 100644 --- a/tests/lib/export/pdf-orientation.test.ts +++ b/tests/lib/export/pdf-orientation.test.ts @@ -59,7 +59,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-roi-page1.test.ts b/tests/lib/export/pdf-roi-page1.test.ts index c2a838e..9aa0665 100644 --- a/tests/lib/export/pdf-roi-page1.test.ts +++ b/tests/lib/export/pdf-roi-page1.test.ts @@ -56,7 +56,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-roi-pages2-5.test.ts b/tests/lib/export/pdf-roi-pages2-5.test.ts index 448dcb4..47e0855 100644 --- a/tests/lib/export/pdf-roi-pages2-5.test.ts +++ b/tests/lib/export/pdf-roi-pages2-5.test.ts @@ -52,7 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockRoi: RoiResult = { diff --git a/tests/lib/export/pdf-scenario-page1.test.ts b/tests/lib/export/pdf-scenario-page1.test.ts index 20cba98..003befc 100644 --- a/tests/lib/export/pdf-scenario-page1.test.ts +++ b/tests/lib/export/pdf-scenario-page1.test.ts @@ -52,7 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } const mockSimulation: Simulation = { diff --git a/tests/store/simulation-store-invalidation.test.ts b/tests/store/simulation-store-invalidation.test.ts index f7b3553..c778183 100644 --- a/tests/store/simulation-store-invalidation.test.ts +++ b/tests/store/simulation-store-invalidation.test.ts @@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () => currency: 'USD', overheadPercentage: 0.2, annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000, createdAt: Date.now(), updatedAt: Date.now(), - groupId: null, tags: [], + groupId: null, tags: [], ownerId: 'test-user', } useProcessStore.setState({ currentProcess: proc })