Row Level Security

Last updated

Row Level Security (RLS) is a PostgreSQL feature that attaches an access policy to a table itself. When RLS is on, the database — not the application — decides which rows a query may return or modify. Hockystick has RLS enabled on every table in the production schema.

What RLS gives you

Most data leaks in multi-tenant SaaS come from an application bug: a missing WHERE user_id = ... clause, a forgotten permission check on one endpoint. With RLS, that class of bug stops mattering for confidentiality. A query that forgets its filter returns your rows only — the policy runs inside PostgreSQL on every statement, keyed to the authenticated user's identity (auth.uid()), and cannot be skipped by client code.

Coverage: 107 of 107

As of 8 July 2026, the production database contains 107 tables in the public schema, and all 107 have RLS enabled — zero exceptions. This is checked directly against the live database, not inferred from migrations:

SELECT count(*)                                    AS total_tables,
       count(*) FILTER (WHERE c.relrowsecurity)    AS rls_enabled
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r';

-- total_tables: 107, rls_enabled: 107

Policy patterns

  • Owner-scoped tables (watchlists, notes, preferences): policies test user_id = auth.uid() directly.
  • Membership-scoped tables (deal room documents, Q&A, activity): policies consult membership through SECURITY DEFINER helper functions such as is_startup_founder and get_user_team_startup_ids, never through a self-referencing subquery — a deliberate rule after an early policy that queried its own table caused infinite recursion.
  • Write policies use WITH CHECK clauses so inserts and updates are validated with the same rigor as reads.
  • Public pages (published profiles, verification reports) read through policies that require an explicit published flag — unpublished data is invisible even to a direct API call.

How we verify it

New tables ship with RLS in the same migration that creates them, and bulk imports insert grandfathered rows in the same migration rather than as a follow-up. The end-to-end test suite runs against the live schema with real accounts, which has caught RLS regressions before users did — including a silent insert rejection caused by an auth.uid() call inside a subquery.