Row Level Security (RLS) is the primary mechanism for tenant data isolation in SecurAtlas. Every tenant-scoped table has RLS enabled, ensuring that users can only access data for tenants they belong to.
RLS is not optional. Every new table that contains tenant-scoped data must have RLS policies before deployment. A table without RLS is accessible to any authenticated user.
-- Enable RLS on the tableALTER TABLE tenant_controls ENABLE ROW LEVEL SECURITY;-- SELECT policy: direct or partner membershipCREATE POLICY "tenant_controls_select" ON tenant_controls FOR SELECT USING ( tenant_id IN ( SELECT tenant_id FROM tenant_memberships WHERE user_id = auth.uid() ) OR tenant_id IN ( SELECT ptl.tenant_id FROM partner_tenant_links ptl JOIN partner_memberships pm ON pm.partner_id = ptl.partner_id WHERE pm.user_id = auth.uid() ) );-- INSERT policy: must be admin or ownerCREATE POLICY "tenant_controls_insert" ON tenant_controls FOR INSERT WITH CHECK ( tenant_id IN ( SELECT tenant_id FROM tenant_memberships WHERE user_id = auth.uid() AND role IN ('owner', 'admin') ) OR tenant_id IN ( SELECT ptl.tenant_id FROM partner_tenant_links ptl JOIN partner_memberships pm ON pm.partner_id = ptl.partner_id WHERE pm.user_id = auth.uid() AND pm.role IN ('owner', 'admin') ) );-- UPDATE and DELETE follow similar patterns with role checks
“System only” means the operation is performed by Edge Functions or cron jobs using the admin client (SUPABASE_SERVICE_ROLE_KEY), which bypasses RLS entirely.
Edge Functions: Integration syncs that write entities/findings
System operations: Assessment conversion, bulk operations
// This bypasses RLS — only use for system operationsconst admin = getSupabaseServerAdminClient();await admin.from('integration_entities').upsert(entities);
Never use getSupabaseServerAdminClient() for user-initiated requests. It bypasses all tenant isolation and would expose data across tenants.
-- auth.uid(): Returns the current user's UUID from the JWT-- auth.jwt(): Returns the full JWT claims-- Custom helper: check if user is tenant memberCREATE OR REPLACE FUNCTION is_tenant_member(p_tenant_id uuid)RETURNS boolean AS $$ SELECT EXISTS ( SELECT 1 FROM tenant_memberships WHERE user_id = auth.uid() AND tenant_id = p_tenant_id ) OR EXISTS ( SELECT 1 FROM partner_tenant_links ptl JOIN partner_memberships pm ON pm.partner_id = ptl.partner_id WHERE pm.user_id = auth.uid() AND ptl.tenant_id = p_tenant_id );$$ LANGUAGE sql SECURITY DEFINER STABLE;
Using SECURITY DEFINER helper functions in RLS policies can improve performance by avoiding repeated subquery evaluation and allowing the query planner to optimize the access check.
-- As a specific user (set JWT claims)SET request.jwt.claim.sub = 'user-uuid-here';-- This should return only the user's tenant dataSELECT * FROM tenant_controls;-- ResetRESET request.jwt.claim.sub;
In application code, compare results between the authenticated and admin clients:
// Authenticated: returns only user's tenant dataconst { data: scoped } = await userClient.from('tenant_controls').select('*');// Admin: returns all data (bypass RLS)const { data: all } = await adminClient.from('tenant_controls').select('*');// scoped.length should be < all.length for multi-tenant databases