What You’ll Learn
- Comparison of data isolation patterns for multi-tenant SaaS
- Practical usage of PostgreSQL Row-Level Security (RLS)
- RLS policy design for 4-tier hierarchy (System/Provider/Reseller/Consumer)
- Setting RLS context with Go + pgx
- Detecting RLS leaks through testing
Introduction
As introduced in Part 1, Saru is a multi-tenant SaaS with a 4-tier account structure.
System Admin (manages the entire SMS platform)
└── Provider (offers services)
├── Reseller (sells services)
│ └── Consumer (purchases/manages)
└── Consumer (direct sales)
In this structure, data isolation is critical.
- Provider A’s customer data must not be visible to Provider B
- Reseller A’s sales records must not be visible to Reseller B
- Consumer A’s subscription info must not be visible to Consumer B
Preventing this entirely at the application layer is difficult. Forgotten WHERE clauses and missing permission checks are especially common in solo development.
That’s why I adopted PostgreSQL Row-Level Security (RLS).
1. Comparing Multi-Tenant Isolation Patterns
There are three main approaches to multi-tenant data isolation.
Pattern Comparison
| Pattern | Isolation Level | Implementation Cost | Operational Cost | Scalability |
|---|---|---|---|---|
| Database per Tenant | Highest (physical) | High | High | Performance isolation excellent, operations challenging |
| Schema per Tenant | High (logical) | Medium | Medium (automation required) | Medium |
| Shared Schema + RLS | High (design-dependent) | Low | Low | High (design-dependent) |
Database per Tenant
Each tenant has an independent database.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Provider A │ │ Provider B │ │ Provider C │
│ Database │ │ Database │ │ Database │
└─────────────┘ └─────────────┘ └─────────────┘
Pros: Complete isolation, easy per-tenant backup/restore
Cons: DB instances grow with tenant count. Unmanageable for solo development.
Schema per Tenant
Separate schemas for each tenant within a single database.
┌────────────────────────────────────┐
│ Single Database │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ A. │ │ B. │ │ C. │ │
│ │schema│ │schema│ │schema│ │
│ └──────┘ └──────┘ └──────┘ │
└────────────────────────────────────┘
Pros: Logical isolation, per-tenant operations possible
Cons: Schema creation and migration automation required for new tenants. Operations can get complex.
Shared Schema + RLS (Saru’s Choice)
All tenants share the same schema, with RLS controlling access.
┌────────────────────────────────────┐
│ Single Schema │
│ ┌──────────────────────────────┐ │
│ │ accounts, products, │ │
│ │ subscriptions, ... │ │
│ │ + RLS Policies │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
Pros: Simple operations, easy migrations, adding tenants is just inserting rows
Cons: RLS policy design and testing are critical. Per-tenant recovery is difficult.
2. Why I Chose RLS
The Reality of Solo Development
| Challenge | RLS Solution |
|---|---|
| Forgotten WHERE clauses | Automatic filtering at DB level |
| Missing permission checks | Policy violations = invisible data |
| Onboarding new developers | Policies ensure automatic isolation |
| Adding tenants | Just insert a new row |
The Greatest Benefit: Last Line of Defense
Even if there are bugs in application code, RLS prevents data leaks.
| |
Prerequisites:
- Tenant context set via session variables (e.g.,
SET app.account_id = '...') - RLS policies defined on target tables
Exceptions:
- Superusers and roles with
BYPASSRLSprivilege can bypass RLS - DB admin roles need separate protection
Still, a design where “app bugs don’t cause leaks” is reassuring for solo development.
3. RLS Policy Design for 4-Tier Hierarchy
3.1 RLS Context Variables
Use PostgreSQL’s SET LOCAL to set context per request.
| Variable | Description | Example |
|---|---|---|
app.account_id | Current account ID | 550e8400-e29b-... |
app.account_type | Account type | provider, consumer |
app.bypass_rls | RLS bypass flag (System only) | true, false |
Important: SET LOCAL is only effective within a transaction. It automatically resets when the transaction ends.
3.2 Policies for the accounts Table
Define policies for each of the 4 tiers.
| |
Notes:
current_setting(..., true)returnsNULLwhen unset (no error)- Comparing with
NULLyieldsNULL, which RLS treats as denied - Roles with
BYPASSRLSprivilege can bypass even withFORCE ROW LEVEL SECURITY
3.3 Policy Behavior Visualization
Logged in as Provider A (app.account_type = 'provider', app.account_id = A)
├── Provider A's data: ✓ Visible
├── Reseller A1's data: ✓ Visible (provider_id = A)
├── Consumer A1's data: ✓ Visible (provider_id = A)
├── Provider B's data: ✗ Not visible
└── Consumer B1's data: ✗ Not visible
3.4 INSERT/UPDATE Control with WITH CHECK
USING filters reads, but WITH CHECK is needed for writes.
Saru uses this on the api_keys table.
| |
Current limitation:
WITH CHECKis not defined on theaccountstable. Currently controlled by foreign key constraints and application layer, but considering adding it.
4. Implementation with Go + pgx
4.1 Tenant Context Struct
| |
4.2 RLS Context Setting Function
Execute SET LOCAL within a transaction.
| |
Future improvement: String concatenation with
fmt.Sprintfposes risks if values contain special characters. Test code usesset_config($1, $2, true)format, and production code should be updated similarly.
1 2 3 4 5// Safer implementation _, err := tx.Exec(ctx, "SELECT set_config('app.account_id', $1, true)", tc.AccountID.String(), )
4.3 Context Builders
Generate context for each account type.
| |
5. Detecting RLS Leaks Through Testing
5.1 Integration Test Design Philosophy
RLS tests verify “what should be visible is visible, what shouldn’t is not”.
| |
5.2 Test Helper
CountWithTenant executes queries in an RLS-enabled session.
| |
5.3 Test Cases to Verify
| Test Case | Verification |
|---|---|
| Same-tier isolation | Provider A cannot see Provider B |
| Parent-child relationship | Provider can see subordinate Resellers/Consumers |
| Cross-hierarchy | Reseller A cannot see Reseller B’s Consumers |
| System Admin | Can access all data |
Future additions: Leak tests for JOIN queries, verification with aggregate queries
6. Lessons Learned from RLS Design
6.1 FORCE ROW LEVEL SECURITY is Required
| |
If you see all data during development, check for FORCE.
Exception: Superusers and roles with BYPASSRLS attribute can bypass even with FORCE.
6.2 The Second Argument of current_setting
| |
Use true in RLS policies for fallback behavior when unset.
Comparing with NULL yields NULL, which RLS treats as denied.
6.3 SET LOCAL Only Works Within Transactions
| |
SET LOCAL automatically resets when the transaction ends.
pgx note: Connections borrowed from a pool must stay within BEGIN~COMMIT on the same connection.
7. RLS Performance
General Trends
RLS overhead varies significantly based on data volume, query patterns, and policy complexity. External benchmarks report the following, though results vary by conditions:
| Source | Conditions | Reported Value |
|---|---|---|
| Supabase | Simple policies + indexed | ~5-15% |
| AntStack | No index, large data | 10x+ degradation |
Note: Queries with many JOINs can increase costs even with simple policies.
Saru’s Approach
Data volume is currently small, so formal benchmarking hasn’t been done. However, these considerations were made during design to prevent degradation:
- Indexes on RLS-target columns like
provider_id,owner_id - Policies use only simple equality comparisons (no subqueries or function calls)
- Cast comparison values to match column type (UUID), not the column itself
Future Plans
As data grows, the following will be implemented:
Regular checks with EXPLAIN ANALYZE
- Verify RLS policies are using indexes
- Check for plan degradation in JOIN queries
Comparative measurements on representative queries
- Simple SELECT / JOIN-heavy / Aggregate queries
- Impact when row counts are skewed by tenant
Optimization as needed
- Wrap
current_setting()in(SELECT current_setting(...))for initPlan - Consider security barrier views
- Wrap
8. Future Improvements
The implementation described has room for improvement:
| Item | Current State | Improvement |
|---|---|---|
| WITH CHECK on accounts | Not defined | Add constraints for INSERT/UPDATE |
| SET LOCAL implementation | fmt.Sprintf | Change to set_config($1, $2, true) format |
| bypass_rls protection | App-layer control | Restrict with DB role permissions for SET |
| JOIN tests | Not implemented | Add leak tests for related table joins |
In solo development, I take the approach of “build something that works, then improve” rather than aiming for perfection. Having RLS as a defense layer allows for confident incremental improvements.
Summary
| Item | Implementation |
|---|---|
| Isolation method | Shared Schema + RLS |
| Context setting | SET LOCAL app.* via pgx |
| Policy design | Separate policies per tier |
| Bypass | app.bypass_rls = 'true' (System Admin only) |
| Testing | Integration tests verify “visible/not visible” |
RLS is incredibly reassuring as a “last line of defense.” Even if the app has bugs, the DB prevents data leaks.
For complex multi-tenant systems in solo development, RLS is a strong choice.
Series Articles
- Part 1: Tackling Unmaintainable Complexity with Automation
- Part 2: Automated WebAuthn Testing in CI
- Part 3: Next.js × Go Monorepo Architecture
- Part 4: PostgreSQL RLS for Multi-Tenant Isolation (This article)