This guide explains how to set up PostgreSQL with Row-Level Security for BottleCRM multi-tenancy.
RLS provides database-level tenant isolation. Every query automatically filters by organization, ensuring data protection even if application code misses a filter.
Key Points:
- RLS uses PostgreSQL session variable
app.current_orgto identify the current tenant - Superusers bypass RLS - the application must use a non-superuser database account
- 24 tables are RLS-protected (see list below)
-- Connect as postgres superuser
sudo -u postgres psql
-- Create the database
CREATE DATABASE bottlecrm;CRITICAL: The application user must NOT be a superuser, otherwise RLS is completely bypassed.
-- Connect as postgres superuser
sudo -u postgres psql
-- Create non-superuser for the application
CREATE USER crm_user WITH PASSWORD 'crm_password';
-- Verify user is NOT a superuser
SELECT usename, usesuper FROM pg_user WHERE usename = 'crm_user';
-- Should show: crm_user | f-- Connect to bottlecrm database
\c bottlecrm
-- Grant connection rights
GRANT CONNECT ON DATABASE bottlecrm TO crm_user;
-- Grant schema privileges (required for migrations to create/alter tables)
GRANT ALL ON SCHEMA public TO crm_user;
-- Grant table permissions (SELECT, INSERT, UPDATE, DELETE)
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO crm_user;
-- Grant sequence permissions (for auto-increment fields)
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO crm_user;
-- Grant default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO crm_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO crm_user;Note: Granting schema/table privileges does NOT weaken RLS. These control DDL operations (CREATE/ALTER/DROP), while RLS controls row-level access. Only
BYPASSRLSattribute or superuser status bypasses RLS.
Set these environment variables for Django:
export DBNAME=bottlecrm
export DBUSER=crm_user
export DBPASSWORD=crm_password
export DBHOST=localhost
export DBPORT=5432Or in your .env file:
DBNAME=bottlecrm
DBUSER=crm_user
DBPASSWORD=crm_password
DBHOST=localhost
DBPORT=5432Migrations automatically enable RLS on all protected tables:
cd backend
source venv/bin/activate
python manage.py migratepython manage.py manage_rls --statusExpected output:
RLS Status:
Database user "crm_user" is not a superuser - RLS will be enforced
lead: ENABLED (forced)
accounts: ENABLED (forced)
contacts: ENABLED (forced)
...
Enabled: 24, Disabled: 0
python manage.py manage_rls --verify-userExpected output:
Verifying database user...
Current user: crm_user
Is superuser: False
Can create DB: False
Database user is properly configured for RLS
python manage.py manage_rls --testThis tests that:
- Data is visible with correct org context
- Data is isolated between organizations
- No data is returned without context (fail-safe)
| Category | Tables |
|---|---|
| Core Business | lead, accounts, contacts, opportunity, case, task, invoice |
| Supporting | comment, commentFiles, attachments, document, teams, activity, tags, address, solution |
| Boards (Kanban) | board, board_column, board_task, board_member |
| Settings/Email | apiSettings, account_email, emailLogs, invoice_history |
| Security | security_audit_log |
- Application database user is NOT a superuser
- All 24 tables show RLS ENABLED (forced)
-
manage_rls --testpasses -
manage_rls --verify-userconfirms non-superuser - Environment variables are securely stored (not in version control)
The database user is a superuser. Create a non-superuser as shown in Step 2.
Run migrations again or check if the table has an org_id column:
SELECT column_name FROM information_schema.columns
WHERE table_name = 'your_table' AND column_name = 'org_id';If manage_rls --test shows data without context, the RLS policies may need updating:
python manage.py migrate commonGrant permissions again (Step 3), especially after creating new tables:
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_user;Each request sets the org context via middleware:
SELECT set_config('app.current_org', '<org_uuid>', true);Each table has two policies:
-
Isolation Policy (SELECT/UPDATE/DELETE):
CREATE POLICY org_isolation ON "lead" FOR ALL USING (org_id::text = NULLIF(current_setting('app.current_org', true), ''));
-
Insert Check Policy:
CREATE POLICY org_insert_check ON "lead" FOR INSERT WITH CHECK (org_id::text = NULLIF(current_setting('app.current_org', true), ''));
The NULLIF(..., '') ensures no rows are returned when context is not set (fail-safe).
ALTER TABLE "lead" FORCE ROW LEVEL SECURITY;This ensures RLS applies even to the table owner.
- Inherit from
BaseOrgModelin your model - Add table name to
ORG_SCOPED_TABLESincommon/rls/__init__.py - Create migration:
from django.db import migrations
from common.rls import get_enable_policy_sql
def enable_rls(apps, schema_editor):
if schema_editor.connection.vendor != 'postgresql':
return
with schema_editor.connection.cursor() as cursor:
cursor.execute(get_enable_policy_sql('your_table_name'))
class Migration(migrations.Migration):
dependencies = [...]
operations = [
migrations.RunPython(enable_rls, migrations.RunPython.noop),
]Background tasks don't go through middleware, so you must set context manually:
from common.tasks import set_rls_context
@app.task
def my_task(data_id, org_id):
set_rls_context(org_id) # REQUIRED before any query
obj = MyModel.objects.get(id=data_id)# Check RLS status
python manage.py manage_rls --status
# Verify non-superuser
python manage.py manage_rls --verify-user
# Test isolation
python manage.py manage_rls --test