π Supabase Row-Level Security (RLS) Policies¶
Scope: MVP - Data Security Layer Status: Critical for multi-tenant isolation Database: PostgreSQL with RLS
Overview¶
Supabase RLS policies enforce tenant isolation at the database level, ensuring complete data separation between customers without application-level filtering.
Multi-Tenant Architecture¶
Core Principle¶
-- Every table has tenant_id
-- Every query automatically filtered by tenant
-- No data leakage possible
Tenant Identification¶
-- JWT contains tenant_id claim
CREATE OR REPLACE FUNCTION auth.tenant_id()
RETURNS UUID AS $$
SELECT (current_setting('request.jwt.claims', true)::json->>'
tenant_id')::UUID;
$$ LANGUAGE SQL STABLE;
-- Alternative: from auth.users metadata
CREATE OR REPLACE FUNCTION auth.tenant_id_from_user()
RETURNS UUID AS $$
SELECT (auth.jwt() -> 'user_metadata' ->> 'tenant_id')::UUID;
$$ LANGUAGE SQL STABLE;
RLS Policy Templates¶
1. Basic Tenant Isolation¶
-- Enable RLS on table
ALTER TABLE policies ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their tenant's data
CREATE POLICY "Tenant Isolation" ON policies
FOR ALL
USING (tenant_id = auth.tenant_id());
-- Policy: Service role bypasses RLS
CREATE POLICY "Service Role Full Access" ON policies
FOR ALL
TO service_role
USING (true);
2. Role-Based Access¶
-- Users table with roles
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'manager', 'viewer')),
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS policies by role
CREATE POLICY "Admins can manage users" ON users
FOR ALL
TO authenticated
USING (
tenant_id = auth.tenant_id() AND
EXISTS (
SELECT 1 FROM users u
WHERE u.id = auth.uid()
AND u.role = 'admin'
AND u.tenant_id = auth.tenant_id()
)
);
CREATE POLICY "Users can view their tenant users" ON users
FOR SELECT
TO authenticated
USING (tenant_id = auth.tenant_id());
CREATE POLICY "Users can update themselves" ON users
FOR UPDATE
TO authenticated
USING (id = auth.uid())
WITH CHECK (id = auth.uid() AND tenant_id = auth.tenant_id());
3. Document Access Control¶
-- Documents with ownership
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content JSONB,
owner_id UUID REFERENCES users(id),
shared_with UUID[], -- Array of user IDs
is_public BOOLEAN DEFAULT false,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Complex document access
CREATE POLICY "Document access" ON documents
FOR SELECT
TO authenticated
USING (
tenant_id = auth.tenant_id() AND (
-- Owner can see
owner_id = auth.uid() OR
-- Shared users can see
auth.uid() = ANY(shared_with) OR
-- Public documents in tenant
is_public = true OR
-- Admins can see all
EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid()
AND role = 'admin'
AND tenant_id = auth.tenant_id()
)
)
);
MVP Table Policies¶
1. Compliance Assessments¶
CREATE TABLE compliance_assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
framework TEXT NOT NULL,
score NUMERIC(5,2),
evidence JSONB,
assessed_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- View policy
CREATE POLICY "View assessments" ON compliance_assessments
FOR SELECT TO authenticated
USING (tenant_id = auth.tenant_id());
-- Create policy (managers and admins)
CREATE POLICY "Create assessments" ON compliance_assessments
FOR INSERT TO authenticated
WITH CHECK (
tenant_id = auth.tenant_id() AND
EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid()
AND role IN ('admin', 'manager')
AND tenant_id = auth.tenant_id()
)
);
2. Tasks and Assignments¶
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
assigned_to UUID REFERENCES users(id),
priority TEXT CHECK (priority IN ('critical', 'high', 'medium', 'low')),
status TEXT DEFAULT 'pending',
due_date DATE,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Everyone can view tasks
CREATE POLICY "View tasks" ON tasks
FOR SELECT TO authenticated
USING (tenant_id = auth.tenant_id());
-- Assigned users can update their tasks
CREATE POLICY "Update assigned tasks" ON tasks
FOR UPDATE TO authenticated
USING (
tenant_id = auth.tenant_id() AND
assigned_to = auth.uid()
)
WITH CHECK (
tenant_id = auth.tenant_id() AND
assigned_to = auth.uid()
);
-- Managers can manage all tasks
CREATE POLICY "Managers manage tasks" ON tasks
FOR ALL TO authenticated
USING (
tenant_id = auth.tenant_id() AND
EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid()
AND role IN ('admin', 'manager')
AND tenant_id = auth.tenant_id()
)
);
3. Question Bank with Sharing¶
CREATE TABLE questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canonical_question TEXT NOT NULL,
answer JSONB,
is_global BOOLEAN DEFAULT false, -- GetCimple provided
tenant_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Complex policy for global + tenant questions
CREATE POLICY "Access questions" ON questions
FOR SELECT TO authenticated
USING (
-- Global questions available to all
is_global = true OR
-- Tenant questions only for tenant
(tenant_id = auth.tenant_id() AND is_global = false)
);
-- Only tenant questions can be modified
CREATE POLICY "Modify tenant questions" ON questions
FOR ALL TO authenticated
USING (
tenant_id = auth.tenant_id() AND
is_global = false
)
WITH CHECK (
tenant_id = auth.tenant_id() AND
is_global = false
);
Security Functions¶
1. Secure Tenant Creation¶
CREATE OR REPLACE FUNCTION create_tenant_and_admin(
tenant_name TEXT,
admin_email TEXT,
admin_password TEXT
) RETURNS JSON AS $$
DECLARE
new_tenant_id UUID;
new_user_id UUID;
BEGIN
-- Create tenant
INSERT INTO tenants (name)
VALUES (tenant_name)
RETURNING id INTO new_tenant_id;
-- Create admin user
INSERT INTO auth.users (
email, encrypted_password, email_confirmed_at, raw_user_meta_data
)
VALUES (
admin_email,
crypt(admin_password, gen_salt('bf')),
NOW(),
jsonb_build_object('tenant_id', new_tenant_id)
)
RETURNING id INTO new_user_id;
-- Create user profile
INSERT INTO users (id, email, role, tenant_id)
VALUES (new_user_id, admin_email, 'admin', new_tenant_id);
RETURN json_build_object(
'tenant_id', new_tenant_id,
'user_id', new_user_id,
'message', 'Tenant and admin created successfully'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
2. Cross-Tenant Security Check¶
-- Function to verify no cross-tenant access
CREATE OR REPLACE FUNCTION verify_tenant_isolation(
table_name TEXT,
user_id UUID
) RETURNS BOOLEAN AS $$
DECLARE
user_tenant UUID;
other_tenant_count INTEGER;
BEGIN
-- Get user's tenant
SELECT tenant_id INTO user_tenant
FROM users WHERE id = user_id;
-- Check if user can see other tenant data
EXECUTE format('
SELECT COUNT(*) FROM %I
WHERE tenant_id != $1
', table_name)
INTO other_tenant_count
USING user_tenant;
-- Should always be 0
RETURN other_tenant_count = 0;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Testing RLS Policies¶
1. Unit Tests¶
-- Test tenant isolation
DO $$
DECLARE
tenant1_id UUID := gen_random_uuid();
tenant2_id UUID := gen_random_uuid();
user1_id UUID := gen_random_uuid();
user2_id UUID := gen_random_uuid();
BEGIN
-- Create test data
INSERT INTO users (id, email, role, tenant_id)
VALUES
(user1_id, 'user1@test.com', 'admin', tenant1_id),
(user2_id, 'user2@test.com', 'admin', tenant2_id);
INSERT INTO policies (title, tenant_id)
VALUES
('Tenant 1 Policy', tenant1_id),
('Tenant 2 Policy', tenant2_id);
-- Set session to user1
PERFORM set_config('request.jwt.claims',
json_build_object('tenant_id', tenant1_id, 'sub', user1_id)::text, true);
-- User1 should only see 1 policy
IF (SELECT COUNT(*) FROM policies) != 1 THEN
RAISE EXCEPTION 'RLS policy failed: user can see other tenant data';
END IF;
RAISE NOTICE 'RLS tests passed';
END $$;
2. Integration Tests¶
// Supabase client tests
import { createClient } from '@supabase/supabase-js'
describe('RLS Policies', () => {
it('enforces tenant isolation', async () => {
// Login as tenant1 user
const {
data: { user },
} = await supabase.auth.signIn({
email: 'user1@tenant1.com',
password: 'password',
})
// Try to access all policies
const { data: policies } = await supabase.from('policies').select('*')
// Should only see tenant1 policies
expect(
policies.every((p) => p.tenant_id === user.user_metadata.tenant_id)
).toBe(true)
})
it('prevents cross-tenant updates', async () => {
// Try to update another tenant's data
const { error } = await supabase
.from('policies')
.update({ title: 'Hacked!' })
.eq('id', 'other-tenant-policy-id')
expect(error.code).toBe('42501') // Insufficient privilege
})
})
Performance Considerations¶
1. Index Strategy¶
-- Always index tenant_id
CREATE INDEX idx_policies_tenant_id ON policies(tenant_id);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
CREATE INDEX idx_tasks_tenant_id ON tasks(tenant_id);
-- Composite indexes for common queries
CREATE INDEX idx_tasks_tenant_assigned ON tasks(tenant_id, assigned_to);
CREATE INDEX idx_documents_tenant_owner ON documents(tenant_id, owner_id);
2. Query Optimization¶
-- Bad: Filtering after fetch
SELECT * FROM policies WHERE tenant_id = 'uuid';
-- Good: RLS automatically applies filter
SELECT * FROM policies; -- tenant_id filter applied by RLS
-- Monitor RLS performance
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM policies;
Migration Safety¶
1. Adding RLS to Existing Tables¶
-- Safe migration process
BEGIN;
-- 1. Add tenant_id if missing
ALTER TABLE existing_table
ADD COLUMN tenant_id UUID;
-- 2. Backfill tenant_id
UPDATE existing_table
SET tenant_id = (SELECT tenant_id FROM users WHERE ...);
-- 3. Make NOT NULL
ALTER TABLE existing_table
ALTER COLUMN tenant_id SET NOT NULL;
-- 4. Enable RLS
ALTER TABLE existing_table ENABLE ROW LEVEL SECURITY;
-- 5. Add policies
CREATE POLICY "Tenant Isolation" ON existing_table
FOR ALL USING (tenant_id = auth.tenant_id());
-- 6. Test before commit
-- Run tests here
COMMIT;
Monitoring & Alerts¶
1. RLS Bypass Detection¶
-- Log any service_role usage
CREATE OR REPLACE FUNCTION log_service_role_access()
RETURNS TRIGGER AS $$
BEGIN
IF current_user = 'service_role' THEN
INSERT INTO audit_log (action, table_name, user_role, timestamp)
VALUES (TG_OP, TG_TABLE_NAME, current_user, NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
2. Failed Access Attempts¶
-- Monitor RLS violations
CREATE TABLE rls_violations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
table_name TEXT,
operation TEXT,
attempted_at TIMESTAMPTZ DEFAULT NOW()
);
Checklist for New Tables¶
- Add
tenant_id UUID NOT NULL - Create index on
tenant_id - Enable RLS:
ALTER TABLE x ENABLE ROW LEVEL SECURITY - Add tenant isolation policy
- Add role-based policies if needed
- Test with multiple tenants
- Add to migration scripts
- Document special access rules
Related Documents: