Skip to content

ADR-0022 — Row Level Security Strategy

Status: Accepted Date: 2026-06-30

Context

Decksmith stores user-owned data in PostgreSQL via Supabase. The API authenticates requests with the Supabase service_role key and accesses the database through Prisma (direct connection as superuser). Both bypass Row Level Security by design.

Without RLS, the database itself provides no data isolation between users. Authorization is enforced entirely at the application layer (assertOwnership preHandler on Fastify routes).

Decision

Apply RLS policies to all user-owned tables as defense-in-depth — a second layer of protection that operates independently of the application layer.

Policies are defined in packages/db/sql/rls-policies.sql and applied manually via the Supabase SQL Editor or psql. Prisma does not manage RLS natively; the SQL file is version-controlled and idempotent (safe to re-run).

What RLS Protects Against

Attack surfaceProtected by app layerProtected by RLS
API routes missing assertOwnership✅ (for PostgREST access)
Direct Supabase PostgREST access with a user JWT
Prisma queries via service_role✅ (assertOwnership)❌ (bypassed by design)

RLS is enforced for the authenticated role (PostgREST). Our API uses service_role, which bypasses RLS. This means:

  • RLS does not protect our API today. assertOwnership is the authoritative guard.
  • RLS protects direct PostgREST access — anyone with a valid user JWT making requests to https://[project].supabase.co/rest/v1/ can only see their own rows.
  • RLS is the last resort if an application-layer bug slips through in the future.

Why service_role Bypasses RLS

service_role is intentionally a superuser-equivalent key. It bypasses RLS so that server-side code can perform administrative operations (user registration, cross-user queries for admin features, background jobs) without being filtered by user-scoped policies. This is the correct architecture: trust is granted at the key level, and the application is responsible for enforcing scoping.

Policy Convention

All policies follow the same pattern:

sql
-- SELECT
USING (auth.uid()::text = <owner_column>)

-- UPDATE (both clauses required)
USING (auth.uid()::text = <owner_column>)
WITH CHECK (auth.uid()::text = <owner_column>)

auth.uid() returns UUID; our columns are TEXT (Prisma String without @db.Uuid), hence the ::text cast.

INSERT and DELETE are not granted to the authenticated role. All inserts go through the service_role (e.g., user registration creates both users and user_preferences rows). Account deletion is not yet implemented.

Tables Covered

Policies are added incrementally — only for tables with implemented API routes.

TableStatusImplemented in
usersrls-policies.sql
user_preferencesrls-policies.sql
decks, deck_sections, deck_cardsWhen Phase 7 routes are implemented
collection_folders, collection_entriesWhen Phase 6 routes are implemented
tags, deck_tags, collection_entry_tagsWhen Phase 6/7 routes are implemented

Future Evolution

When Prisma supports connection-level JWT passing (or if we introduce a PostgREST proxy layer), we could migrate to using the authenticated role for all DB access. At that point, RLS would become the primary authorization mechanism and assertOwnership could be simplified or removed.

Consequences

  • Positive: defense-in-depth; protects PostgREST access; audit-friendly; incrementally extensible as new tables are implemented.
  • Negative: policies must be applied manually (not tracked by Prisma migrations); risk of drift between schema and policies if a table is renamed.
  • Mitigation: the SQL file is idempotent and version-controlled; rls-policies.sql must be updated whenever a new user-owned module is added (tracked in this ADR's table above).

Built with VitePress