Rowguard - RLS Policy DSL - v0.2.0
    Preparing search index...

    Rowguard - RLS Policy DSL - v0.2.0

    Rowguard - RLS Policy DSL

    npm version Docs License: MIT pkg.pr.new

    One line per authorization pattern. Maximum performance by default.

    A TypeScript DSL for defining PostgreSQL Row Level Security (RLS) policies — with a template system that covers the most common patterns in a single call, a typed API for compile-time validation, and performance optimizations applied automatically.

    Warning: This is an experimental project and not an official Supabase library. Use with caution in production.

    Try the live demo at https://rowguard-demo.vercel.app/

    The demo includes live database testing using the real Supabase migration workflow:

    • Save as Migration Files - Generate timestamped migration files from your policies
    • Apply with Supabase CLI - Use standard supabase db reset to apply migrations
    • Browse Database Schema - View all tables and columns from your local instance
    • Test as Different Users - Sign in as test users to verify RLS enforcement
    • Verify in Real-Time - See exactly which rows each user can access with RLS active

    Run the full demo locally with database:

    pnpm install
    pnpm demo:dev:full # Starts Supabase + demo

    Or run in SQL-only mode (no database):

    pnpm demo:dev
    

    The demo source code is in the demo/ directory. See demo/README.md for detailed setup instructions.

    • One-liner templatesowned, shared, membership, tenant, role, immutable, admin, open
    • Type-safe schema integration — autocomplete and compile-time validation with Supabase-generated types
    • Performance by default(SELECT auth.uid()) initPlan caching and TO authenticated on every policy, automatically
    • Idempotent SQLDROP POLICY IF EXISTS before every CREATE POLICY; safe to re-run
    • Zero dependencies — pure TypeScript, works everywhere
    • Universal — Node.js, Deno, Bun, browsers, edge functions
    npm install rowguard
    
    # pnpm
    pnpm add rowguard

    # yarn
    yarn add rowguard

    # bun
    bun add rowguard

    Preview builds are available via pkg-pr-new for each PR/commit:

    # Install a preview build from a PR (check pkg.pr.new badge for latest URL)
    npm install https://pkg.pr.new/supabase-community/rowguard@{pr-number}

    Generate types from your Supabase schema, then get autocomplete and compile-time validation for every table and column name.

    # For remote project
    npx supabase gen types typescript --project-id "$PROJECT_REF" > database.types.ts

    # For local development
    npx supabase gen types typescript --local > database.types.ts
    import { createRowguard, policiesToSQL } from 'rowguard';
    import type { Database } from './database.types';

    const rg = createRowguard<Database>();

    const sql = policiesToSQL([
    ...rg.policies.owned({ tables: ['posts', 'comments'] }),
    ...rg.policies.shared({ tables: ['projects'], publicColumn: 'is_public' }),
    ...rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'project_id' }),
    ...rg.policies.tenant({ tables: ['invoices', 'orders'] }),
    ...rg.policies.role({ tables: ['admin_logs'], is: 'admin' }),
    ...rg.policies.open({ tables: ['announcements'] }),
    ]);
    // → ALTER TABLE "posts" ENABLE ROW LEVEL SECURITY;
    // → DROP POLICY IF EXISTS ... (idempotent)
    // → CREATE POLICY ...
    import { policies, policiesToSQL } from 'rowguard';

    const sql = policiesToSQL([
    ...policies.owned({ tables: ['posts', 'comments'] }),
    ...policies.tenant({ tables: ['invoices'] }),
    ]);
    Template Purpose Default TO
    owned User owns every row (full CRUD) authenticated
    shared Owner writes; public can read via flag column authenticated
    membership Access via join table (e.g. project members) authenticated
    tenant Hard tenant isolation (RESTRICTIVE) + owner CRUD authenticated
    role JWT claim or roles table check authenticated
    immutable Append-only rows (INSERT only, no UPDATE/DELETE) authenticated
    admin Admin bypass (no TO restriction by default) (none — all roles)
    open Public read public

    Full CRUD restricted to the row owner. Generates four policies (SELECT, INSERT, UPDATE, DELETE).

    policies.owned({ tables: ['posts', 'comments'] })
    policies.owned({ tables: ['posts'], userColumn: 'author_id', operations: ['SELECT', 'UPDATE'] })

    Owner has full write access; any user can read rows where a boolean flag column is true.

    policies.shared({ tables: ['documents'], publicColumn: 'is_public' })
    

    Access granted through a join table. Useful for team/project membership patterns.

    policies.membership({
    tables: ['projects'],
    via: 'project_members',
    key: 'project_id',
    })

    Creates a RESTRICTIVE isolation policy (tenant boundary cannot be bypassed) plus per-operation owner policies.

    policies.tenant({ tables: ['invoices', 'orders'] })
    policies.tenant({ tables: ['invoices'], column: 'org_id', ownerPolicies: false })

    Checks a JWT claim or a roles table. Defaults to checking auth.jwt() ->> 'user_role'.

    policies.role({ tables: ['admin_logs'], is: 'admin', operations: ['SELECT'] })
    policies.role({ tables: ['reports'], is: ['editor', 'admin'], via: { table: 'user_roles' } })

    Append-only rows. Allows INSERT; blocks UPDATE and DELETE. Optionally adds a SELECT policy for the owner.

    policies.immutable({ tables: ['audit_log'], allowRead: true })
    

    Admin bypass — no TO role restriction applied by default, so all database roles can match.

    policies.admin({ tables: ['admin_settings'], is: 'admin' })
    

    Public SELECT access (no authentication required).

    policies.open({ tables: ['announcements', 'pricing'] })
    

    createRowguard<Database>() narrows all template configs to your actual schema:

    const rg = createRowguard<Database>();

    // table names autocomplete; typos are compile errors
    rg.policies.owned({ tables: ['posts'] });

    // column names autocomplete; they're validated against the table
    rg.policies.shared({ tables: ['docs'], publicColumn: 'is_public' });

    // membership.key autocompletes to columns of the via table specifically
    rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'project_id' });

    // @ts-expect-error — 'nonexistent' is not a column of 'project_members'
    rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'nonexistent' });

    // the fluent builder also narrows to your schema
    rg.policy('user_documents')
    .on('documents') // ← autocomplete for all table names
    .read()
    .when(rg.column('documents', 'user_id').eq(rg.auth.uid()));
    // ↑ autocomplete columns of 'documents'

    Two optimizations are applied automatically to every generated policy:

    (SELECT auth.uid()) instead of auth.uid() — PostgreSQL evaluates auth.uid() once per query (initPlan) instead of once per row. In benchmarks this produces a 94.97% speedup on large tables.

    TO authenticated on every policy — unauthenticated (anon) queries skip policy evaluation entirely rather than evaluating a condition that returns false for every row. Benchmark result: 99.78% speedup for anon traffic.

    Both are applied by default. No configuration needed.

    import { policiesToSQL, applyPolicies, enableRLS } from 'rowguard';

    // Generate SQL string (for migration files)
    const sql = policiesToSQL(builders); // idempotent by default

    // Apply directly to a database client
    await applyPolicies(builders, client); // runs in a transaction

    // Just ENABLE RLS on tables
    enableRLS(['posts', 'comments', 'projects']);

    For custom policies that go beyond the templates, the fluent builder is available directly:

    import { policy, column, auth, from, session } from 'rowguard';

    policy('user_documents')
    .on('documents')
    .read()
    .when(column('user_id').eq(auth.uid()));

    policy('project_access')
    .on('projects')
    .read()
    .when(
    column('is_public')
    .eq(true)
    .or(column('user_id').eq(auth.uid()))
    .or(column('organization_id').eq(session.get('app.org_id', 'uuid')))
    );

    policy('member_access')
    .on('projects')
    .read()
    .when(
    column('id').in(
    from('project_members')
    .select('project_id')
    .where(column('user_id').eq(auth.uid()))
    )
    );
    policy(name)
    .on(table) // Target table
    .read() // Allow reading (SELECT)
    .write() // Allow creating (INSERT)
    .update() // Allow updating (UPDATE)
    .delete() // Allow deleting (DELETE)
    .all() // Allow all operations (ALL)
    .for(operation) // SELECT | INSERT | UPDATE | DELETE | ALL
    .to(role?) // Optional role restriction
    .when(condition) // USING clause (read filter)
    .allow(condition) // Type-safe USING/WITH CHECK based on operation
    .withCheck(condition) // WITH CHECK clause (write validation)
    .requireAll() // All policies must pass (RESTRICTIVE)
    .allowAny() // Any policy can grant access (PERMISSIVE, default)
    .restrictive() // Mark as RESTRICTIVE
    .permissive() // Mark as PERMISSIVE (default)
    .toSQL() // Generate PostgreSQL statement
    column('status').eq('active');
    column('age').gt(18);
    column('price').lte(100);

    column('email').like('%@company.com');
    column('name').ilike('john%');

    column('status').in(['active', 'pending']);
    column('tags').contains(['important']);

    column('deleted_at').isNull();
    column('verified_at').isNotNull();

    column('user_id').isOwner(); // eq(auth.uid())
    column('is_public').isPublic(); // eq(true)

    column('user_id')
    .eq(auth.uid())
    .or(column('is_public').eq(true))
    .and(column('status').eq('active'));
    import { column, from, auth } from 'rowguard';

    column('id').in(
    from('project_members')
    .select('project_id')
    .where(column('user_id').eq(auth.uid()))
    );

    column('id').in(
    from('projects', 'p')
    .select('p.id')
    .join('members', column('m.project_id').eq('p.id'), 'inner', 'm')
    .where(column('m.user_id').eq(auth.uid()))
    );
    auth.uid();                     // Current authenticated user
    session.get(key, type); // Type-safe session variable
    currentUser(); // Current database user

    We welcome contributions! Please see our Contributing Guide for details on:

    • Setting up your development environment
    • Running tests and building the project
    • Code style guidelines
    • How to submit pull requests
    • Testing your changes with preview deployments
    # Install dependencies
    pnpm install

    # Build the library
    pnpm run build

    # Run tests
    pnpm test

    # Run integration tests (requires Supabase CLI)
    pnpm run test:integration:full

    # Run the interactive demo
    pnpm run demo:dev

    For more detailed information, see CONTRIBUTING.md.

    This project uses automated releases via release-please.

    • All commits must follow Conventional Commits format
    • Releases are automatically published to npm when maintainers merge the release PR
    • For detailed information, see RELEASE.md

    MIT - see LICENSE file for details