aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,204 lines (967 loc) • 33.7 kB
YAML
---
template_name: "Supabase RLS Policies"
template_version: "1.0.0"
output_format: "markdown"
destination: "rls-policies.md"
description: "Row Level Security policies for Supabase tables"
---
sections:
- id: overview
title: "RLS Overview"
instruction: |
Document the Row Level Security strategy:
## Purpose
Explain the overall security model and why RLS is being used.
## Authentication Context
- How users are authenticated (Supabase Auth, JWT, etc)
- Available auth context variables:
- `auth.uid()` - Current user ID
- `auth.jwt()` - JWT claims
- `auth.email()` - User email
- Custom claims in JWT
## Security Model
- Role-based access control (RBAC)
- Multi-tenancy approach (if applicable)
- Public vs authenticated vs specific role access
## Performance Considerations
- RLS policy performance impact
- Indexing strategy to support policies
- Caching considerations
## Testing Strategy
- How policies will be tested
- Test users and scenarios
elicit: true
- id: policy-patterns
title: "Common Policy Patterns"
instruction: |
Document reusable policy patterns used across tables:
## Pattern 1: Owner-Only Access
```sql
-- Users can only access their own records
(auth.uid() = user_id)
```
## Pattern 2: Tenant-Based Access
```sql
-- Users can access records in their organization
(auth.uid() IN (
SELECT user_id FROM org_members
WHERE org_id = table.org_id
))
```
## Pattern 3: Role-Based Access
```sql
-- Only admins can access
((auth.jwt() ->> 'role')::text = 'admin')
```
## Pattern 4: Public Read, Authenticated Write
```sql
-- SELECT: true (public read)
-- INSERT/UPDATE/DELETE: auth.uid() IS NOT NULL
```
## Pattern 5: Hierarchical Permissions
```sql
-- Access based on organizational hierarchy
```
Document any other patterns specific to your application.
elicit: true
- id: table-policies
title: "Table-by-Table Policies"
instruction: |
For each table requiring RLS, document comprehensive policies:
# Table: `table_name`
## Enable RLS
```sql
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
```
## SELECT Policies
### Policy: `policy_name_select`
**Purpose**: Describe who can read what
**Policy Expression**:
```sql
CREATE POLICY "policy_name_select"
ON table_name
FOR SELECT
TO authenticated -- or public, anon, etc
USING (
-- Policy expression
auth.uid() = user_id
);
```
**Rationale**: Explain the business rule
**Performance**: Any indexes needed to support this policy
## INSERT Policies
### Policy: `policy_name_insert`
**Purpose**: Describe who can create records
**Policy Expression**:
```sql
CREATE POLICY "policy_name_insert"
ON table_name
FOR INSERT
TO authenticated
WITH CHECK (
-- Policy expression
auth.uid() = user_id
);
```
**Validation**: What validations this ensures
## UPDATE Policies
### Policy: `policy_name_update`
**Purpose**: Describe who can modify records
**Policy Expression**:
```sql
CREATE POLICY "policy_name_update"
ON table_name
FOR UPDATE
TO authenticated
USING (
-- Who can see the record to update it
auth.uid() = user_id
)
WITH CHECK (
-- What values they can set
auth.uid() = user_id
);
```
**Notes**: USING checks old values, WITH CHECK validates new values
## DELETE Policies
### Policy: `policy_name_delete`
**Purpose**: Describe who can delete records
**Policy Expression**:
```sql
CREATE POLICY "policy_name_delete"
ON table_name
FOR DELETE
TO authenticated
USING (
-- Who can delete
auth.uid() = user_id OR
(auth.jwt() ->> 'role')::text = 'admin'
);
```
## ALL Policies (if using combined policy)
Sometimes a single policy for all operations is clearer:
```sql
CREATE POLICY "policy_name_all"
ON table_name
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
```
---
Repeat this section for each table.
elicit: true
- id: public-tables
title: "Public Tables"
instruction: |
Document tables with public read access:
# Table: `public_table_name`
## Public Read Policy
```sql
ALTER TABLE public_table_name ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_policy"
ON public_table_name
FOR SELECT
TO anon, authenticated
USING (true);
```
## Restricted Write Policy
```sql
CREATE POLICY "authenticated_write_policy"
ON public_table_name
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
```
**Rationale**: Why this table is public
**Security Considerations**: What data is safe to expose
elicit: false
- id: service-role
title: "Service Role Bypass"
instruction: |
Document scenarios where service role (bypass RLS) is used:
## Service Role Usage
### Backend Operations
Operations that need to bypass RLS:
- Scheduled jobs (cron, edge functions with service key)
- Admin operations
- Data migration
- Analytics aggregation
### Safety Measures
- How service key is secured
- Where service role operations are logged
- Who has access to service key
### Alternatives
When possible, prefer:
- Security definer functions with RLS
- Elevated permission policies
- Temporary privilege escalation
elicit: false
- id: helper-functions
title: "Security Helper Functions"
instruction: |
Document PostgreSQL functions that support RLS:
## Function: `check_user_permission`
```sql
CREATE OR REPLACE FUNCTION check_user_permission(
user_id uuid,
resource_id uuid,
permission_type text
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
-- Permission checking logic
RETURN EXISTS (
SELECT 1 FROM permissions
WHERE user_id = $1
AND resource_id = $2
AND permission = $3
);
END;
$$;
```
**Usage in Policies**:
```sql
USING (check_user_permission(auth.uid(), id, 'read'))
```
## Function: `get_user_org_id`
```sql
CREATE OR REPLACE FUNCTION get_user_org_id()
RETURNS uuid
LANGUAGE sql
STABLE
AS $$
SELECT org_id FROM user_profiles
WHERE user_id = auth.uid();
$$;
```
**Usage in Policies**:
```sql
USING (org_id = get_user_org_id())
```
Document all helper functions used in RLS policies.
elicit: false
- id: multi-tenancy
title: "Multi-Tenancy Implementation"
condition: "multi_tenant"
instruction: |
If implementing multi-tenancy with RLS:
## Tenant Isolation Strategy
### Tenant Identification
- How tenants are identified (org_id, team_id, etc)
- Where tenant ID is stored (JWT claim, database lookup)
### Tenant-Scoped Tables
For each tenant-scoped table:
```sql
-- Example: projects table
CREATE POLICY "tenant_isolation_policy"
ON projects
FOR ALL
TO authenticated
USING (
org_id = (auth.jwt() ->> 'org_id')::uuid
)
WITH CHECK (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
```
### Cross-Tenant Scenarios
- Shared resources across tenants
- Super admin access
- Tenant-to-tenant relationships
### Performance
- Indexes on tenant_id columns
- Query patterns that leverage tenant isolation
elicit: true
- id: storage-policies
title: "Supabase Storage Policies"
instruction: |
RLS policies for Supabase Storage buckets (storage.objects table).
## Overview
Supabase Storage uses RLS on the `storage.objects` system table to control file access.
By default, Storage requires explicit RLS policies - no uploads allowed without policies.
## Pattern 1: User-Specific Uploads
Users can only upload files to their own folder:
```sql
CREATE POLICY "Users upload own avatars"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars' AND
(select auth.uid())::text = (storage.foldername(name))[1]
);
```
**Folder Structure**: `avatars/{user_id}/filename.jpg`
**How it works**:
- `storage.foldername(name)` splits path by `/` returning array
- `[1]` gets first folder (user_id)
- Compares with authenticated user's ID
## Pattern 2: Public Read, Authenticated Write
Anyone can view files, only authenticated users can upload:
```sql
-- Public read
CREATE POLICY "Public avatars readable"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'avatars');
-- Authenticated write
CREATE POLICY "Authenticated upload avatars"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'avatars');
```
**Use case**: Public profile pictures, logos, marketing assets.
## Pattern 3: Tenant-Scoped Files
Users can only access files from their organization:
```sql
CREATE POLICY "Tenant file isolation"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'documents' AND
(storage.foldername(name))[1] = ((select auth.jwt()) ->> 'org_id')
);
CREATE POLICY "Tenant file uploads"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'documents' AND
(storage.foldername(name))[1] = ((select auth.jwt()) ->> 'org_id')
);
```
**Folder Structure**: `documents/{org_id}/{file_id}.pdf`
## Pattern 4: Delete Own Files
Users can delete their own files:
```sql
CREATE POLICY "Users delete own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars' AND
(select auth.uid())::text = (storage.foldername(name))[1]
);
```
## Pattern 5: File Overwriting (upsert)
For file overwriting via `upsert` option, grant SELECT + UPDATE:
```sql
-- Allow reading (required for upsert check)
CREATE POLICY "Users read own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars' AND
(select auth.uid())::text = (storage.foldername(name))[1]
);
-- Allow updating (for upsert)
CREATE POLICY "Users update own files"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars' AND
(select auth.uid())::text = (storage.foldername(name))[1]
)
WITH CHECK (
bucket_id = 'avatars' AND
(select auth.uid())::text = (storage.foldername(name))[1]
);
```
## Bucket Configuration
Create buckets with appropriate public/private settings:
```sql
-- Create private bucket (requires policies)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', false);
-- Create public bucket (files publicly accessible by URL)
INSERT INTO storage.buckets (id, name, public)
VALUES ('public-images', 'public-images', true);
```
**Note**: Even public buckets respect RLS for uploads/deletes.
## Helper Functions
```sql
-- Extract user folder from path
CREATE OR REPLACE FUNCTION storage.user_owns_file(file_path text)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT (select auth.uid())::text = (storage.foldername(file_path))[1];
$$;
-- Usage in policy
CREATE POLICY "Users access own files"
ON storage.objects
FOR ALL
TO authenticated
USING (
bucket_id = 'private' AND
storage.user_owns_file(name)
);
```
## Security Considerations
1. **Always validate bucket_id** in policies (prevent cross-bucket access)
2. **Use folder structure** for user/tenant isolation
3. **Set appropriate bucket public/private** settings
4. **Monitor storage size** per user/tenant (implement quotas)
5. **Validate file types** server-side (policies can't check content)
## Testing
Test storage policies with Supabase client:
```typescript
// Test upload as user A
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.id}/avatar.jpg`, file);
// Should succeed for own folder
expect(error).toBeNull();
// Test read as user B (should fail for user A's folder)
const { data: files, error: listError } = await supabase.storage
.from('avatars')
.list(`${otherUserId}/`);
// Should return empty or error
expect(files).toHaveLength(0);
```
## Performance
Storage policies are evaluated on every file operation.
**Optimize**:
- Use simple folder path checks (fast)
- Avoid complex JOINs in storage policies
- Cache JWT claims in helper functions
- Use indexes on bucket_id (already indexed by Supabase)
## Common Pitfalls
- **Forgetting SELECT policy** for upsert operations
- **Not validating bucket_id** (allows cross-bucket access)
- **Complex policies** (slow file operations)
- **Mixing public/private** bucket settings incorrectly
elicit: false
- id: performance-optimization
title: "RLS Performance Optimization"
instruction: |
Critical performance optimizations for RLS policies validated by Supabase documentation.
## 🚀 Optimization 1: Wrap Auth Functions with SELECT (94.97% faster)
**Critical Discovery** from Supabase docs: Wrapping auth functions enables query caching.
### ❌ SLOW (no caching):
```sql
CREATE POLICY "users_select"
ON users
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
```
### ✅ FAST (cached, 94.97% improvement):
```sql
CREATE POLICY "users_select"
ON users
FOR SELECT
TO authenticated
USING ((select auth.uid()) = user_id);
```
**Why**: PostgreSQL caches the result of `(select auth.uid())` for the duration of the transaction,
avoiding repeated function calls.
**Impact**: **19x faster queries** in high-traffic scenarios.
**Apply to all auth functions**:
- `(select auth.uid())`
- `(select auth.jwt())`
- `(select auth.email())`
## 🚀 Optimization 2: Index Policy Columns (99.94% improvement)
**Always index columns used in policy expressions.**
```sql
-- Policy uses user_id
CREATE POLICY "user_policy" ON posts
USING ((select auth.uid()) = user_id);
-- Index user_id (99.94% faster)
CREATE INDEX idx_posts_user_id ON posts(user_id);
```
**Critical indexes**:
```sql
-- Tenant isolation
CREATE INDEX idx_table_org_id ON table_name(org_id);
-- Owner-based policies
CREATE INDEX idx_table_user_id ON table_name(user_id);
-- Time-based policies
CREATE INDEX idx_table_scheduling
ON table_name(publish_at, expire_at)
WHERE publish_at IS NOT NULL OR expire_at IS NOT NULL;
```
## 🚀 Optimization 3: Filter Client-Side Explicitly
**Even with RLS policies, explicitly filter in client queries.**
```typescript
// ❌ Relies only on RLS
const { data } = await supabase
.from('users')
.select('*'); // Returns only own data due to RLS, but query planner doesn't know
// ✅ Explicit filter (helps query planner)
const { data } = await supabase
.from('users')
.select('*')
.eq('user_id', userId); // Query planner can use index
```
**Why**: Explicit filters help PostgreSQL query planner choose optimal index scan.
## 🚀 Optimization 4: Specify Roles Explicitly
```sql
-- ❌ Applies to all roles (unnecessary checks)
CREATE POLICY "policy" ON table USING (...);
-- ✅ Specific role (fewer checks)
CREATE POLICY "policy" ON table
TO authenticated -- Only authenticated users
USING (...);
```
**Common roles**:
- `TO authenticated` - Logged-in users
- `TO anon` - Anonymous users
- `TO public` - Both authenticated and anon
## 🚀 Optimization 5: Use Security Definer Functions
**Bypass RLS on join tables** with security definer functions.
```sql
-- ❌ Policy with JOIN (slow, 99.99% slower)
CREATE POLICY "team_access" ON documents
USING (
team_id IN (
SELECT team_id FROM user_teams
WHERE user_id = (select auth.uid())
)
);
-- ✅ Security definer function (99.99% improvement)
CREATE OR REPLACE FUNCTION user_team_ids()
RETURNS TABLE(team_id UUID)
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT team_id FROM user_teams
WHERE user_id = auth.uid();
$$;
CREATE POLICY "team_access" ON documents
USING (team_id IN (SELECT user_team_ids()));
```
**Why**: Security definer functions run with elevated privileges, bypassing RLS on join tables.
## 🚀 Optimization 6: Minimize Joins
**Avoid joining source and target tables in policies.**
```sql
-- ❌ JOIN in policy (slow)
CREATE POLICY "org_access" ON documents
USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = (select auth.uid())
AND users.org_id = documents.org_id
)
);
-- ✅ Use JWT claims (no JOIN)
CREATE POLICY "org_access" ON documents
USING (
org_id = ((select auth.jwt()) ->> 'org_id')::uuid
);
```
**Strategy**: Store necessary claims in JWT (org_id, role, tenant_id).
## Performance Checklist
Apply to every RLS policy:
- [ ] Wrap `auth.uid()` with `(select auth.uid())`
- [ ] Index all columns used in USING/WITH CHECK
- [ ] Specify role explicitly (TO authenticated vs TO public)
- [ ] Use JWT claims instead of JOINs where possible
- [ ] Create security definer functions for complex permission checks
- [ ] Test query plans with EXPLAIN ANALYZE
- [ ] Filter client-side explicitly
## Measuring Performance
```sql
-- Check query plan (look for Sequential Scan vs Index Scan)
EXPLAIN ANALYZE
SELECT * FROM users WHERE user_id = 'xxx';
-- Monitor slow queries
SELECT
query,
calls,
mean_exec_time,
max_exec_time
FROM pg_stat_statements
WHERE query LIKE '%users%'
ORDER BY mean_exec_time DESC
LIMIT 10;
```
## Real-World Impact
**Before optimization**:
- Query time: 250ms
- Database CPU: 80%
- Queries/sec: 40
**After optimization** (wrapped functions + indexes):
- Query time: 12ms (95% improvement)
- Database CPU: 15%
- Queries/sec: 800 (20x increase)
elicit: false
- id: advanced-patterns
title: "Advanced RLS Patterns"
instruction: |
Advanced RLS patterns beyond basic owner-only and tenant isolation.
## Pattern 6: Time-Based Access (Scheduled Content)
**Use case**: Blog posts with scheduled publishing, promotions with expiration, time-limited content.
```sql
CREATE POLICY "scheduled_content"
ON posts
FOR SELECT
TO authenticated
USING (
(publish_at IS NULL OR publish_at <= NOW()) AND
(expire_at IS NULL OR expire_at > NOW())
);
```
**How it works**:
- `publish_at IS NULL` - No schedule, always visible
- `publish_at <= NOW()` - Past publish date, visible
- `expire_at IS NULL` - No expiration, always visible
- `expire_at > NOW()` - Not expired, visible
**Performance**:
```sql
-- Index for time-based queries
CREATE INDEX idx_posts_scheduling
ON posts(publish_at, expire_at)
WHERE publish_at IS NOT NULL OR expire_at IS NOT NULL;
```
**Client-side filtering** (helps query planner):
```typescript
const { data } = await supabase
.from('posts')
.select('*')
.lte('publish_at', new Date().toISOString())
.or('publish_at.is.null')
.gte('expire_at', new Date().toISOString())
.or('expire_at.is.null');
```
## Pattern 7: Hierarchical Organizations (Detailed)
**Use case**: Org > Team > User hierarchy with different access levels.
### Simple Hierarchy (Org-Level):
```sql
CREATE POLICY "org_hierarchy"
ON resources
FOR SELECT
TO authenticated
USING (
org_id IN (
SELECT org_id
FROM user_org_memberships
WHERE user_id = (select auth.uid())
)
);
```
**User sees resources from ALL orgs they belong to.**
### Complex Hierarchy (Org + Team):
```sql
-- Option 1: User sees resources from their teams
CREATE POLICY "team_hierarchy"
ON resources
FOR SELECT
TO authenticated
USING (
team_id IN (
SELECT team_id
FROM user_team_memberships
WHERE user_id = (select auth.uid())
)
);
-- Option 2: Combined (org admin sees all, team member sees team only)
CREATE POLICY "combined_hierarchy"
ON resources
FOR SELECT
TO authenticated
USING (
-- Org admin sees all resources in org
(
org_id IN (
SELECT org_id
FROM user_org_memberships
WHERE user_id = (select auth.uid()) AND role = 'admin'
)
)
OR
-- Team member sees only team resources
(
team_id IN (
SELECT team_id
FROM user_team_memberships
WHERE user_id = (select auth.uid())
)
)
);
```
**Performance**:
```sql
-- Indexes for hierarchy lookups
CREATE INDEX idx_user_org_memberships_user
ON user_org_memberships(user_id, org_id);
CREATE INDEX idx_user_team_memberships_user
ON user_team_memberships(user_id, team_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_resources_team_id ON resources(team_id);
```
**Optimization** (security definer function):
```sql
CREATE OR REPLACE FUNCTION user_accessible_orgs()
RETURNS TABLE(org_id UUID)
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT org_id FROM user_org_memberships
WHERE user_id = auth.uid();
$$;
CREATE POLICY "org_hierarchy_optimized"
ON resources
FOR SELECT
TO authenticated
USING (org_id IN (SELECT user_accessible_orgs()));
```
## Pattern 8: Role-Based with Custom Claims (Advanced)
**Use case**: Different permissions per role (admin, manager, analyst, user).
### Setup: Add role to JWT
```sql
CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb AS $$
DECLARE
claims jsonb;
user_role text;
user_org_id uuid;
BEGIN
-- Get user role and org from profiles table
SELECT role, org_id INTO user_role, user_org_id
FROM public.user_profiles
WHERE user_id = (event->>'user_id')::uuid;
-- Add to JWT claims
claims := event->'claims';
claims := jsonb_set(claims, '{role}', to_jsonb(user_role));
claims := jsonb_set(claims, '{org_id}', to_jsonb(user_org_id));
RETURN jsonb_set(event, '{claims}', claims);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
**Configure in Supabase Dashboard**: Authentication > Hooks > Custom Access Token
### Policy: Role-Based Access
```sql
-- Admin sees all
CREATE POLICY "admin_full_access"
ON sensitive_data
FOR ALL
TO authenticated
USING (
((select auth.jwt()) ->> 'role') = 'admin'
);
-- Manager sees org data
CREATE POLICY "manager_org_access"
ON sensitive_data
FOR SELECT
TO authenticated
USING (
((select auth.jwt()) ->> 'role') = 'manager' AND
org_id = ((select auth.jwt()) ->> 'org_id')::uuid
);
-- User sees own data only
CREATE POLICY "user_own_access"
ON sensitive_data
FOR SELECT
TO authenticated
USING (
((select auth.jwt()) ->> 'role') = 'user' AND
user_id = (select auth.uid())
);
```
### Policy: Role Hierarchy (Admin > Manager > User)
```sql
CREATE POLICY "role_hierarchy"
ON resources
FOR ALL
TO authenticated
USING (
CASE ((select auth.jwt()) ->> 'role')
WHEN 'admin' THEN true -- Admin sees everything
WHEN 'manager' THEN org_id = ((select auth.jwt()) ->> 'org_id')::uuid
ELSE user_id = (select auth.uid()) -- User sees own only
END
);
```
## Pattern 9: Multi-Factor Authentication (AAL2)
**Use case**: Sensitive operations require MFA.
```sql
CREATE POLICY "mfa_required_for_sensitive_ops"
ON sensitive_operations
FOR INSERT
TO authenticated
USING (
((select auth.jwt()) ->> 'aal') = 'aal2' -- Assurance Level 2 (MFA)
);
```
**AAL levels**:
- `aal1` - Single factor (password only)
- `aal2` - Multi-factor (password + OTP/biometric)
## Pattern 10: IP-Based Restrictions
**Use case**: Restrict admin operations to office IP.
```sql
CREATE POLICY "admin_office_only"
ON admin_operations
FOR ALL
TO authenticated
USING (
((select auth.jwt()) ->> 'role') = 'admin' AND
inet_client_addr() << '192.168.1.0/24'::inet -- Office network
);
```
**Note**: `inet_client_addr()` returns client IP.
## Advanced Patterns Summary
| Pattern | Use Case | Complexity | Performance Impact |
|---------|----------|------------|-------------------|
| Time-based | Scheduled content | Low | Low (with index) |
| Hierarchical | Org > Team > User | Medium | Medium (needs indexes) |
| Role-based claims | RBAC | Low | Low (JWT cached) |
| AAL2 MFA | Sensitive ops | Low | None |
| IP restrictions | Office-only | Low | None |
**Best practices**:
- Prefer JWT claims over database lookups (faster)
- Always index columns used in policies
- Use security definer functions for complex checks
- Test with EXPLAIN ANALYZE
- Wrap auth functions with SELECT for caching
elicit: false
- id: testing
title: "RLS Testing Strategy"
instruction: |
How to test and validate RLS policies:
## Unit Tests
Test individual policies with different auth contexts:
```sql
-- Test as user A
SET request.jwt.claims = '{"sub": "user-a-uuid"}';
SELECT * FROM table_name; -- Should only see user A's records
-- Test as user B
SET request.jwt.claims = '{"sub": "user-b-uuid"}';
SELECT * FROM table_name; -- Should only see user B's records
```
## Integration Tests
Test with actual Supabase client:
```typescript
// Test authenticated access
const { data, error } = await supabase
.from('table_name')
.select('*');
// Verify only authorized records returned
```
## Security Audit
Checklist for RLS validation:
- [ ] All tables with sensitive data have RLS enabled
- [ ] No accidental policy holes (test with unauthorized users)
- [ ] Service role usage is documented and justified
- [ ] Policies perform well (no slow queries)
- [ ] Cross-tenant data leakage tested
- [ ] Anonymous vs authenticated access verified
- [ ] Edge cases tested (null values, missing context)
## Automated Testing
Script or framework for continuous validation.
elicit: false
- id: migration
title: "RLS Migration Scripts"
instruction: |
Concrete SQL migration for implementing these policies:
## Migration: `YYYYMMDDHHMMSS_add_rls_policies.sql`
```sql
-- Enable RLS on tables
ALTER TABLE table1 ENABLE ROW LEVEL SECURITY;
ALTER TABLE table2 ENABLE ROW LEVEL SECURITY;
-- Drop existing policies if re-running
DROP POLICY IF EXISTS "policy_name" ON table_name;
-- Create policies
CREATE POLICY "policy_name_select"
ON table_name
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "policy_name_insert"
ON table_name
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- ... additional policies ...
-- Create indexes to support policies
CREATE INDEX IF NOT EXISTS idx_table_user_id
ON table_name(user_id);
-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON table_name TO authenticated;
GRANT SELECT ON table_name TO anon;
```
## Rollback Migration
```sql
-- Remove policies
DROP POLICY IF EXISTS "policy_name_select" ON table_name;
DROP POLICY IF EXISTS "policy_name_insert" ON table_name;
-- Disable RLS
ALTER TABLE table_name DISABLE ROW LEVEL SECURITY;
```
elicit: false
- id: monitoring
title: "RLS Monitoring & Debugging"
instruction: |
How to monitor and debug RLS policies:
## Query Performance
Identify slow queries caused by RLS:
```sql
-- Check query plans
EXPLAIN ANALYZE
SELECT * FROM table_name;
```
## Policy Effectiveness
Verify policies are being applied:
```sql
-- Check active policies
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE tablename = 'your_table';
```
## Common Issues
### Issue: Policy not applying
- Check RLS is enabled on table
- Verify user role matches policy target
- Check auth context is set correctly
### Issue: Performance degradation
- Add indexes for policy columns
- Simplify policy expressions
- Consider denormalization
### Issue: Unexpected access
- Audit all policies on table
- Check for permissive vs restrictive policies
- Verify no service role leakage
## Logging
Log policy violations or unexpected access patterns.
elicit: false
- id: best-practices
title: "RLS Best Practices"
instruction: |
## Design Principles
1. **Start Restrictive**: Default deny, explicitly allow
2. **Minimize Policy Complexity**: Simpler policies are easier to audit
3. **Use Helper Functions**: Encapsulate complex logic
4. **Index Policy Columns**: Performance is critical
5. **Test Extensively**: Security bugs are costly
6. **Document Everything**: Future you will thank you
7. **Audit Regularly**: Policies drift over time
8. **Avoid Service Role**: Use it only when absolutely necessary
## Common Pitfalls
- Forgetting to enable RLS on new tables
- Policy expressions with poor performance
- Not testing with actual user contexts
- Overly permissive catch-all policies
- Mixing USING and WITH CHECK incorrectly
- Not considering null values in policies
## Security Checklist
- [ ] All sensitive tables have RLS enabled
- [ ] Policies tested with unauthorized users
- [ ] Anonymous access limited to public data only
- [ ] Service role usage is minimal and documented
- [ ] Policies use indexes for performance
- [ ] Multi-tenant isolation is verified
- [ ] Edge cases tested (nulls, empty results)
- [ ] Policies are documented and reviewed
elicit: false