tasq/.claude/skills/moai-platform-supabase/modules/row-level-security.md

7.0 KiB

name description parent-skill version updated
row-level-security RLS policies for multi-tenant data isolation and access control moai-platform-supabase 1.0.0 2026-01-06

Row-Level Security (RLS) Module

Overview

Row-Level Security provides automatic data isolation at the database level, ensuring users can only access data they are authorized to see.

Basic Setup

Enable RLS on a table:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

Policy Types

RLS policies can be created for specific operations:

  • SELECT: Controls read access
  • INSERT: Controls creation
  • UPDATE: Controls modification
  • DELETE: Controls removal
  • ALL: Applies to all operations

Basic Tenant Isolation

JWT-Based Tenant Isolation

Extract tenant ID from JWT claims:

CREATE POLICY "tenant_isolation" ON projects FOR ALL
  USING (tenant_id = (auth.jwt() ->> 'tenant_id')::UUID);

Owner-Based Access

Restrict access to resource owners:

CREATE POLICY "owner_access" ON projects FOR ALL
  USING (owner_id = auth.uid());

Hierarchical Access Patterns

Organization Membership

Allow access based on organization membership:

CREATE POLICY "org_member_select" ON organizations FOR SELECT
  USING (id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));

Role-Based Modification

Restrict modifications to specific roles:

CREATE POLICY "org_admin_modify" ON organizations FOR UPDATE
  USING (id IN (
    SELECT org_id FROM org_members
    WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
  ));

Cascading Project Access

Grant project access through organization membership:

CREATE POLICY "project_access" ON projects FOR ALL
  USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));

Service Role Bypass

Allow service role to bypass RLS for server-side operations:

CREATE POLICY "service_bypass" ON organizations FOR ALL TO service_role USING (true);

Multi-Tenant SaaS Schema

Complete Schema Setup

-- Organizations (tenants)
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
  settings JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Organization members with roles
CREATE TABLE organization_members (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL,
  role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  joined_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(organization_id, user_id)
);

-- Projects within organizations
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  owner_id UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Enable RLS on All Tables

ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

Comprehensive RLS Policies

-- Organization read access
CREATE POLICY "org_member_select" ON organizations FOR SELECT
  USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));

-- Organization admin update
CREATE POLICY "org_admin_update" ON organizations FOR UPDATE
  USING (id IN (SELECT organization_id FROM organization_members
    WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));

-- Project member access
CREATE POLICY "project_member_access" ON projects FOR ALL
  USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));

-- Member management (admin only)
CREATE POLICY "member_admin_manage" ON organization_members FOR ALL
  USING (organization_id IN (SELECT organization_id FROM organization_members
    WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));

Helper Functions

Check Organization Membership

CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM organization_members
    WHERE organization_id = org_id AND user_id = auth.uid()
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Check Organization Role

CREATE OR REPLACE FUNCTION has_org_role(org_id UUID, required_roles TEXT[])
RETURNS BOOLEAN AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM organization_members
    WHERE organization_id = org_id
      AND user_id = auth.uid()
      AND role = ANY(required_roles)
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Usage in Policies

CREATE POLICY "project_admin_delete" ON projects FOR DELETE
  USING (has_org_role(organization_id, ARRAY['owner', 'admin']));

Performance Optimization

Index for RLS Queries

Create indexes on foreign keys used in RLS policies:

CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_projects_org ON projects(organization_id);

Materialized View for Complex Policies

For complex permission checks, use materialized views:

CREATE MATERIALIZED VIEW user_accessible_projects AS
SELECT p.id as project_id, om.user_id, om.role
FROM projects p
JOIN organization_members om ON p.organization_id = om.organization_id;

CREATE INDEX idx_uap_user ON user_accessible_projects(user_id);

REFRESH MATERIALIZED VIEW CONCURRENTLY user_accessible_projects;

Testing RLS Policies

Test as Authenticated User

SET request.jwt.claim.sub = 'user-uuid-here';
SET request.jwt.claims = '{"role": "authenticated"}';

SELECT * FROM projects;  -- Returns only accessible projects

Verify Policy Restrictions

-- Should fail if not a member
INSERT INTO projects (organization_id, name, owner_id)
VALUES ('non-member-org-id', 'Test', auth.uid());

Common Patterns

Public Read, Owner Write

CREATE POLICY "public_read" ON posts FOR SELECT USING (true);
CREATE POLICY "owner_write" ON posts FOR INSERT WITH CHECK (author_id = auth.uid());
CREATE POLICY "owner_update" ON posts FOR UPDATE USING (author_id = auth.uid());
CREATE POLICY "owner_delete" ON posts FOR DELETE USING (author_id = auth.uid());

Draft vs Published

CREATE POLICY "published_read" ON articles FOR SELECT
  USING (status = 'published' OR author_id = auth.uid());

Time-Based Access

CREATE POLICY "active_subscription" ON premium_content FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM subscriptions
      WHERE user_id = auth.uid()
        AND expires_at > NOW()
    )
  );

Context7 Query Examples

For latest RLS documentation:

Topic: "row level security policies supabase" Topic: "auth.uid auth.jwt functions" Topic: "rls performance optimization"


Related Modules:

  • auth-integration.md - Authentication patterns
  • typescript-patterns.md - Client-side access patterns