capyrls

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 4 Imported by: 0

README

capyrls

Supabase RLS → any Postgres. A converter that rewrites auth.uid()-style row-level-security policies into portable, vanilla PostgreSQL.

Supabase RLS is standard CREATE POLICY plus a platform-provided context: auth.uid(), auth.jwt(), auth.role(), the anon/authenticated/ service_role pseudo-roles, and PostgREST injecting a verified JWT into a session GUC on every request. None of that exists on plain Postgres - which is why RLS is usually the thing that keeps a project stuck. capyrls re-homes the policies so they run anywhere: RDS, self-hosted, CapyDB, any Postgres.

# from your Supabase migrations folder
capyrls convert supabase/migrations

# from a schema dump
pg_dump --schema-only "$SUPABASE_URL" | capyrls convert -

# straight from the running database (most faithful: server-normalized policies)
capyrls convert --db "$SUPABASE_URL"

Output is a small SQL bundle plus a report:

capyrls_out/
  capyrls_01_prelude.sql    # the new auth context (schema + accessor functions)
  capyrls_02_roles.sql      # role separation (or FORCE RLS for single-role setups)
  capyrls_03_policies.sql   # your policies, converted
  capyrls_report.md         # the contract your app now fulfils + anything needing a human

What the conversion does

Vanilla mode (default) - the idiomatic plain-Postgres convention: a small app.* schema of stable accessor functions over transaction-local GUCs. The database stops knowing JWTs exist; your app verifies the caller at the edge and sets typed facts per transaction:

Supabase becomes
auth.uid() (select app.user_id())
auth.jwt() ->> 'org_id' (select app.org_id()) - each claim promoted to its own GUC
auth.role() / auth.email() (select app.role()) / (select app.email())
deep claim paths (select app.claims()) blob fallback, flagged in the report
TO authenticated runtime role + (select app.user_id()) is not null
TO anon (select app.user_id()) is null
service_role a BYPASSRLS role (or the single-role service escape)
FOR ALL policies split into per-command policies (disable with --keep-for-all)

Your app sets the context inside each transaction - set_config(..., true) is SET LOCAL semantics, safe behind transaction pooling:

begin;
select set_config('app.user_id', '5f4d...', true);
-- queries run under RLS here
commit;

Unset context reads as NULL, so every policy fails closed.

Compat mode (--mode supabase-compat) - a zero-risk lift-and-shift: emits an auth.* shim backed by the request.jwt.claims GUC and ports policies verbatim. Good first step; adopt the vanilla convention later.

Role models

  • --role-model split (default): creates app_user (runtime, cannot bypass RLS) and app_service (BYPASSRLS, replaces service_role). Owners bypass RLS in Postgres - runtime traffic must never connect as the role that owns the tables, and this model makes that structural.
  • --role-model single: for managed platforms where the app connects as the table owner. Emits FORCE ROW LEVEL SECURITY plus an optional GUC-gated service escape (--no-service-escape to omit). The escape adds convenience, not exposure: an owner can disable RLS anyway.

What it refuses to guess

Anything that cannot port mechanically is surfaced, never silently dropped or mistranslated:

  • policies referencing auth.users (port that data into your own schema first)
  • policies on Supabase-managed schemas (storage, realtime, ...)
  • function bodies referencing auth.* (listed for manual review)
  • deep auth.jwt() paths that fall back to the claims blob

Run with --strict in CI to fail when anything needs manual attention.

Rewrite mode

If you want to keep your existing migration history instead of adopting a fresh bundle:

capyrls rewrite supabase/migrations --out rewritten/

rewrites auth.* calls in place (byte-identical everywhere else), keeps your TO clauses, and emits role stubs for anon/authenticated/service_role.

Library

The converter is an importable, dependency-free Go library:

import "github.com/capy-base/capyrls"

result, err := capyrls.Convert(sources, capyrls.Options{})

Live introspection lives in github.com/capy-base/capyrls/live and takes any *sql.DB.

Install

go install github.com/capy-base/capyrls/cmd/capyrls@latest

Or grab a release binary. CapyDB users get the same converter as capydb migrate rls.

Development

make check   # fmt + vet + test

License

MIT

Documentation

Overview

Package capyrls converts Supabase row-level-security policies to portable, vanilla PostgreSQL.

Supabase RLS is standard CREATE POLICY plus a platform-provided context: auth.uid()/auth.jwt()/auth.role(), the anon/authenticated/service_role pseudo-roles, and PostgREST injecting a verified JWT into a session GUC. None of that exists on plain Postgres. capyrls re-homes the policies onto one of two conventions:

  • vanilla (default): a small app.* schema of accessor functions over transaction-local GUCs (app.user_id, app.role, promoted claims). The database stops knowing JWTs exist; the app sets typed facts per transaction. Policies are rewritten to the new accessors.
  • supabase-compat: an auth.* shim backed by the request.jwt.claims GUC. Policies port verbatim; useful for a zero-risk lift-and-shift.

Input is either SQL sources (Supabase migration folders, pg_dump --schema-only) or a live database via the live subpackage. Output is a SQL bundle plus a report describing the context contract the application must now fulfil.

Index

Constants

View Source
const Version = "1.1.0"

Version is the capyrls release version.

Variables

This section is empty.

Functions

func QuoteIdent

func QuoteIdent(s string) string

QuoteIdent quotes a PostgreSQL identifier only when required.

Types

type Catalog

type Catalog struct {
	Tables   map[string]*Table
	Policies []*Policy
	Defaults []ColumnDefault
	Routines []Routine
	Notes    []string
}

Catalog is the RLS-relevant state extracted from SQL files or a live database: tables with row security, policies in final state, and the auth.*-referencing objects that need conversion or review.

func NewCatalog

func NewCatalog() *Catalog

func ParseSQL

func ParseSQL(sources []Source) (*Catalog, error)

ParseSQL builds a Catalog from SQL sources, applying statements in order.

func (*Catalog) AddDefault

func (c *Catalog) AddDefault(d ColumnDefault)

AddDefault records an auth-referencing column default.

func (*Catalog) AddPolicy

func (c *Catalog) AddPolicy(p *Policy)

AddPolicy inserts or replaces a policy by (name, table).

func (*Catalog) AddRoutine

func (c *Catalog) AddRoutine(r Routine)

AddRoutine records an auth-referencing function or procedure.

func (*Catalog) SetTableRLS

func (c *Catalog) SetTableRLS(q QName, enabled, forced bool)

SetTableRLS records a table's row-security flags. Used by external catalog builders such as the live subpackage.

type ClaimMapping

type ClaimMapping struct {
	Claim    string `json:"claim"`
	Accessor string `json:"accessor"`
	GUC      string `json:"guc"`
}

type ColumnDefault

type ColumnDefault struct {
	Table  QName
	Column string
	Expr   string
	Origin string
}

ColumnDefault is a column default expression that references auth.*.

type GUCSpec

type GUCSpec struct {
	Name        string `json:"name"`
	Type        string `json:"type"`
	Description string `json:"description"`
}

type Mode

type Mode int

Mode selects the output convention.

const (
	// ModeVanilla rewrites policies onto app.* accessor functions over
	// transaction-local GUCs - the portable, vendor-neutral convention.
	ModeVanilla Mode = iota
	// ModeCompat keeps auth.* calls and emits a shim schema providing them.
	ModeCompat
)

func (Mode) String

func (m Mode) String() string

type Options

type Options struct {
	Mode      Mode
	RoleModel RoleModel
	// NoSplitAll keeps FOR ALL policies intact instead of splitting them
	// into per-command policies.
	NoSplitAll bool
	// NoServiceEscape suppresses the GUC-gated bypass policies emitted for
	// the single-role model.
	NoServiceEscape bool
	// Prefix is the schema and GUC namespace, default "app".
	Prefix string
	// AppRole and ServiceRole name the roles for the role-split model.
	AppRole     string
	ServiceRole string
}

Options control a conversion. The zero value is the recommended setup: vanilla mode, role-split model, FOR ALL policies split per command, service escape enabled for the single-role model.

type OutFile

type OutFile struct {
	Name string
	SQL  string
}

OutFile is one produced file.

type Policy

type Policy struct {
	Name       string
	Table      QName
	Permissive bool
	Cmd        PolicyCmd
	Roles      []string // lower-cased role names; empty means PUBLIC
	Using      string   // raw expression SQL without the outer parens; "" if absent
	WithCheck  string
	Origin     string // "<file>:<line>" or "database"
}

Policy is one row-security policy in its final (post-ALTER) state.

type PolicyCmd

type PolicyCmd string

PolicyCmd is the command class a policy applies to.

const (
	CmdAll    PolicyCmd = "ALL"
	CmdSelect PolicyCmd = "SELECT"
	CmdInsert PolicyCmd = "INSERT"
	CmdUpdate PolicyCmd = "UPDATE"
	CmdDelete PolicyCmd = "DELETE"
)

type PolicyOutcome

type PolicyOutcome struct {
	Policy string `json:"policy"`
	Table  string `json:"table"`
	Status string `json:"status"` // converted | skipped | blocked
	Detail string `json:"detail,omitempty"`
}

type QName

type QName struct {
	Schema string
	Name   string
}

QName is a possibly schema-qualified identifier. Schema is "" when the source SQL left the name unqualified; Key() folds that to "public", which matches how unqualified DDL resolves in the dumps this tool consumes.

func (QName) EffectiveSchema

func (q QName) EffectiveSchema() string

func (QName) Key

func (q QName) Key() string

func (QName) String

func (q QName) String() string

type Report

type Report struct {
	Tool      string          `json:"tool"`
	Version   string          `json:"version"`
	Mode      string          `json:"mode"`
	RoleModel string          `json:"role_model"`
	Policies  []PolicyOutcome `json:"policies"`
	Claims    []ClaimMapping  `json:"claims"`
	GUCs      []GUCSpec       `json:"gucs"`
	Defaults  []string        `json:"column_defaults"`
	Routines  []string        `json:"routines_to_review"`
	Warnings  []string        `json:"warnings"`
	Notes     []string        `json:"notes"`
}

func (*Report) Markdown

func (r *Report) Markdown() string

Markdown renders the human-facing report.

type Result

type Result struct {
	Files  []OutFile
	Report Report
}

Result is a finished conversion.

func Convert

func Convert(sources []Source, opts Options) (*Result, error)

Convert parses SQL sources and converts the final policy state into a fresh, ordered SQL bundle (prelude, roles, policies) plus a report.

func ConvertCatalog

func ConvertCatalog(cat *Catalog, opts Options) (*Result, error)

ConvertCatalog converts an already-built catalog (see ParseSQL and the live subpackage).

func Rewrite

func Rewrite(sources []Source, opts Options) (*Result, error)

Rewrite transforms SQL sources in place: auth.* helper calls are rewritten to the target convention while everything else stays byte-identical. Use it to keep an existing migration history instead of adopting a fresh bundle. The returned files mirror the inputs, plus the prelude and report.

type RoleModel

type RoleModel int

RoleModel describes how the converted database separates privileges.

const (
	// RoleSplit creates a non-owning runtime role (RLS applies) and a
	// BYPASSRLS service role - the classic three-role convention.
	RoleSplit RoleModel = iota
	// RoleSingle assumes the app connects as the table owner (the common
	// managed-Postgres setup) and FORCEs row security instead.
	RoleSingle
)

func (RoleModel) String

func (r RoleModel) String() string

type Routine

type Routine struct {
	Name   QName
	Def    string
	Origin string
}

Routine is a function or procedure whose body references auth.*. Def carries the full CREATE definition when introspected live; it is "" when the reference was found while scanning SQL files.

type Source

type Source struct {
	Name string
	SQL  string
}

Source is one SQL input (a file, a dump, stdin).

type Table

type Table struct {
	Name       QName
	RLSEnabled bool
	RLSForced  bool
}

Table records row-security state for one relation.

Directories

Path Synopsis
cmd
capyrls command
capyrls converts Supabase row-level-security policies to portable, vanilla PostgreSQL.
capyrls converts Supabase row-level-security policies to portable, vanilla PostgreSQL.
Package live builds a capyrls.Catalog by introspecting a running database instead of parsing SQL files.
Package live builds a capyrls.Catalog by introspecting a running database instead of parsing SQL files.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL