Skip to main content

Overview

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.

Access Model

Users gain access to tenant data through two paths:
Direct Membership:    user → tenant_memberships → tenant_id
Partner Membership:   user → partner_memberships → partner_tenant_links → tenant_id

Direct Access

tenant_memberships links a user directly to a tenant with a role (owner, admin, member).

Partner Access

partner_memberships + partner_tenant_links gives MSP partner users access to all tenants managed by that partner.

Policy Pattern

Most RLS policies follow this standard pattern:
-- Enable RLS on the table
ALTER TABLE tenant_controls ENABLE ROW LEVEL SECURITY;

-- SELECT policy: direct or partner membership
CREATE 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 owner
CREATE 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

Key Tables with RLS

Tenant Data Tables

TableSELECTINSERTUPDATEDELETE
tenant_controlsMember+Admin+Admin+Owner
tenant_evidence_itemsMember+Member+Admin+Admin+
tenant_policiesMember+Admin+Admin+Owner
tenant_risk_snapshotsMember+System onlyNoneNone
tenant_framework_selectionsMember+Admin+Admin+Admin+

Integration Tables

TableSELECTINSERTUPDATEDELETE
integration_connectionsAdmin+Admin+Admin+Admin+
integration_entitiesMember+System onlySystem onlySystem only
integration_findingsMember+System onlySystem onlySystem only
integration_sync_jobsAdmin+System onlySystem onlyNone
“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.

Billing Tables

TableSELECTINSERTUPDATEDELETE
billing_customersOwnerSystem onlySystem onlyNone
subscriptionsOwner/AdminSystem onlySystem onlyNone
billing_eventsOwnerSystem onlyNoneNone

Admin Client Bypass

The service-role client bypasses all RLS policies. It is used for:
  • Webhook handlers: Stripe webhooks, integration callbacks
  • Cron jobs: Nightly sync, risk recompute
  • Edge Functions: Integration syncs that write entities/findings
  • System operations: Assessment conversion, bulk operations
// This bypasses RLS — only use for system operations
const 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.

Security Functions

Helper functions used in RLS policies:
-- 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 member
CREATE 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.

Testing RLS

To verify RLS policies are working correctly:
-- As a specific user (set JWT claims)
SET request.jwt.claim.sub = 'user-uuid-here';

-- This should return only the user's tenant data
SELECT * FROM tenant_controls;

-- Reset
RESET request.jwt.claim.sub;
In application code, compare results between the authenticated and admin clients:
// Authenticated: returns only user's tenant data
const { 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