Files
triqura-ecd/docs/design/RLS_SECURITY.md
2025-11-23 10:13:00 +01:00

8.4 KiB

🔒 Row Level Security (RLS) Documentation

Project: AI Speedrun - Mini-ECD Prototype Epic: E2 - Database & Auth Story: E2.S2 - RLS policies implementeren Last Updated: 2024-11-15


Overview

This document describes the Row Level Security (RLS) implementation for the EPD core database tables. RLS is PostgreSQL's security feature that restricts which rows users can access in database queries.

Security Model

  • Authentication Required: All data access requires a valid Supabase authentication session
  • Authorization: Checked via auth.uid() function which returns the authenticated user's UUID
  • MVP Level: All authenticated users can access all data (suitable for demo/single-org)
  • Production Path: Ready to extend with org_id filtering for multi-tenancy

Tables & Policies

1. Clients Table

Purpose: Basic client information RLS Enabled: Yes

Policies:

Policy Name Operation Rule
Authenticated users can view clients SELECT auth.uid() IS NOT NULL
Authenticated users can create clients INSERT auth.uid() IS NOT NULL
Authenticated users can update clients UPDATE auth.uid() IS NOT NULL
Authenticated users can delete clients DELETE auth.uid() IS NOT NULL

Production Enhancement:

-- Add organization filtering
CREATE POLICY "Users can view own org clients"
  ON clients FOR SELECT
  USING (
    auth.uid() IS NOT NULL AND
    org_id = (SELECT org_id FROM users WHERE id = auth.uid())
  );

2. Intake Notes Table

Purpose: TipTap/ProseMirror JSON content storage RLS Enabled: Yes

Policies:

Policy Name Operation Rule
Authenticated users can view intake notes SELECT auth.uid() IS NOT NULL
Authenticated users can create intake notes INSERT auth.uid() IS NOT NULL
Authenticated users can update intake notes UPDATE auth.uid() IS NOT NULL
Authenticated users can delete intake notes DELETE auth.uid() IS NOT NULL

Security Features:

  • Full-text search index with Dutch language support
  • Cascade delete when parent client is deleted
  • Automatic updated_at trigger

3. Problem Profiles Table

Purpose: DSM-light categorization with severity scoring RLS Enabled: Yes

Policies:

Policy Name Operation Rule
Authenticated users can view problem profiles SELECT auth.uid() IS NOT NULL
Authenticated users can create problem profiles INSERT auth.uid() IS NOT NULL
Authenticated users can update problem profiles UPDATE auth.uid() IS NOT NULL
Authenticated users can delete problem profiles DELETE auth.uid() IS NOT NULL

Data Constraints:

  • Category: Must be one of 6 DSM-light categories
  • Severity: Must be 'laag', 'middel', or 'hoog'
  • Cascade delete with parent client
  • SET NULL on source note deletion

4. Treatment Plans Table

Purpose: Treatment plans with JSONB structure and versioning RLS Enabled: Yes

Policies:

Policy Name Operation Rule
Authenticated users can view treatment plans SELECT auth.uid() IS NOT NULL
Authenticated users can create treatment plans INSERT auth.uid() IS NOT NULL
Authenticated users can update treatment plans UPDATE auth.uid() IS NOT NULL
Authenticated users can delete treatment plans DELETE auth.uid() IS NOT NULL

Versioning:

  • Each client can have multiple versions (v1, v2, etc.)
  • Status: 'concept' (editable) or 'gepubliceerd' (locked)
  • UNIQUE constraint on (client_id, version)

5. AI Events Table

Purpose: Telemetry and debugging for AI API calls RLS Enabled: Yes Special: Append-only (no UPDATE/DELETE for regular users)

Policies:

Policy Name Operation Rule
Authenticated users can view AI events SELECT auth.uid() IS NOT NULL
Authenticated users can create AI events INSERT auth.uid() IS NOT NULL
UPDATE Not allowed (audit trail)
DELETE Not allowed (audit trail)

Immutability:

  • Regular users cannot modify or delete AI events
  • Ensures audit trail integrity
  • Service role can bypass RLS for admin cleanup

Testing RLS

Test 1: Verify RLS is Enabled

SELECT tablename, rowsecurity as rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;

Expected Result: All 5 tables should show rls_enabled: true

Test 2: Check Policy Count

SELECT
  tablename,
  COUNT(*) as policy_count,
  STRING_AGG(cmd, ', ' ORDER BY cmd) as commands
FROM pg_policies
WHERE schemaname = 'public'
GROUP BY tablename;

Expected Result:

  • ai_events: 2 policies (INSERT, SELECT)
  • Other tables: 4 policies each (DELETE, INSERT, SELECT, UPDATE)

Test 3: Verify Authentication Check

SELECT tablename, policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
  AND qual NOT LIKE '%auth.uid()%';

Expected Result: Empty (all policies use auth.uid() checks)


TypeScript Integration

TypeScript types are auto-generated and available at lib/database.types.ts:

import type { Database } from '@/lib/database.types'

// Usage with Supabase client
const supabase = createClient<Database>(url, key)

// Type-safe queries
const { data: clients } = await supabase
  .from('clients')
  .select('*')

// Insert with type checking
const { data: newClient } = await supabase
  .from('clients')
  .insert({
    first_name: 'John',
    last_name: 'Doe',
    birth_date: '1990-01-01'
  })

Security Best Practices

Current Implementation

  1. Secure by Default: RLS enabled on all tables
  2. Authentication Required: All policies check auth.uid() IS NOT NULL
  3. Separation of Concerns: Separate policies for each operation (SELECT, INSERT, UPDATE, DELETE)
  4. Audit Trail: AI events are append-only
  5. Foreign Key Constraints: Automatic cleanup with CASCADE/SET NULL
  6. Type Safety: Generated TypeScript types prevent runtime errors

🔄 Production Enhancements

When moving to production with multiple organizations:

  1. Add Organization Column:

    ALTER TABLE clients ADD COLUMN org_id UUID REFERENCES organizations(id);
    
  2. Update Policies with Org Filtering:

    CREATE POLICY "Users can view own org data"
      ON clients FOR SELECT
      USING (
        auth.uid() IS NOT NULL AND
        org_id = (SELECT org_id FROM users WHERE id = auth.uid())
      );
    
  3. Add Role-Based Access:

    CREATE POLICY "Admins can view all"
      ON clients FOR SELECT
      USING (
        auth.uid() IS NOT NULL AND
        EXISTS (
          SELECT 1 FROM users
          WHERE id = auth.uid() AND role IN ('admin', 'superadmin')
        )
      );
    
  4. Implement Row-Level Ownership:

    CREATE POLICY "Users can update own records"
      ON intake_notes FOR UPDATE
      USING (author = auth.uid());
    

Troubleshooting

Issue: "new row violates row-level security policy"

Cause: Trying to insert/update data that doesn't satisfy RLS WITH CHECK Solution: Ensure user is authenticated and data meets policy requirements

Issue: No data returned despite existing rows

Cause: User not authenticated or RLS USING clause filters out all rows Solution: Verify auth.uid() returns a valid UUID

Issue: Service role queries still restricted

Cause: Using anon key instead of service role key Solution: Use SUPABASE_SERVICE_ROLE_KEY for admin operations

// Service role bypasses RLS
const supabase = createClient(url, serviceRoleKey)

Migration History

Migration Date Changes
20241115000002_create_epd_core_tables.sql 2024-11-15 Initial RLS policies (demo-level)
20241115000003_enhance_rls_policies.sql 2024-11-15 Granular policies per operation + ai_events immutability

References


Status: Implemented and Tested Next Steps: E2.S3 - Demo auth flow