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_idfiltering 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_attrigger
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 |
| ❌ | Not allowed (audit trail) | |
| ❌ | 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
- Secure by Default: RLS enabled on all tables
- Authentication Required: All policies check
auth.uid() IS NOT NULL - Separation of Concerns: Separate policies for each operation (SELECT, INSERT, UPDATE, DELETE)
- Audit Trail: AI events are append-only
- Foreign Key Constraints: Automatic cleanup with CASCADE/SET NULL
- Type Safety: Generated TypeScript types prevent runtime errors
🔄 Production Enhancements
When moving to production with multiple organizations:
-
Add Organization Column:
ALTER TABLE clients ADD COLUMN org_id UUID REFERENCES organizations(id); -
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()) ); -
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') ) ); -
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
- Supabase RLS Documentation
- PostgreSQL RLS Documentation
- Technical Design:
docs/specs/to-mini-ecd-v1_2.md§ 2.4 - Build Plan:
docs/specs/bouwplan-ai-speedrun-marketing-first-v1.1.mdEpic 2
Status: ✅ Implemented and Tested Next Steps: E2.S3 - Demo auth flow