Row-Level Security (RLS) Policies
Consolidated reference for all RLS policies across the system.
Overview
Every table (except migrations) has Row-Level Security enabled. Policies enforce tenant isolation and role-based access at the database level, not application level.
Key principles:
- Two-pool architecture: The Core API uses two PostgreSQL connection pools. Superadmins use the AdminPool (
restartix— table owner, bypasses RLS entirely). All other users and public endpoints use the AppPool (restartix_app— restricted role, RLS enforced). - Org-scoped: Most tables filter by
organization_id = current_app_org_id() - User-scoped: Some tables filter by
user_id = current_app_user_id() - Role-based: Policies check
current_app_role() - Superadmin bypass: Superadmins bypass RLS via the owner pool (AdminPool), not via
is_superadmin()in policies. No RLS policy referencesis_superadmin(). - Public access: Public endpoints (e.g., org resolve) use AppPool without session variables. Policies with
current_app_role() IS NULLallow limited SELECT access for unauthenticated requests.
Session Variables
RLS policies read from these PostgreSQL session variables (set by middleware on every request):
| Variable | Type | Set By | Used By |
|---|---|---|---|
app.current_user_id | BIGINT | OrganizationContext middleware | User-scoped policies |
app.current_org_id | BIGINT | OrganizationContext middleware | Org-scoped policies |
app.current_role | TEXT | OrganizationContext middleware | Role-based policies |
Helper Functions
CREATE OR REPLACE FUNCTION current_app_user_id() RETURNS BIGINT AS $$
SELECT NULLIF(current_setting('app.current_user_id', true), '')::BIGINT;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION current_app_org_id() RETURNS BIGINT AS $$
SELECT NULLIF(current_setting('app.current_org_id', true), '')::BIGINT;
$$ LANGUAGE SQL STABLE;
CREATE OR REPLACE FUNCTION current_app_role() RETURNS TEXT AS $$
SELECT NULLIF(current_setting('app.current_role', true), '');
$$ LANGUAGE SQL STABLE;STABLE keyword: Tells PostgreSQL the function won't change during the transaction, enabling index usage.
Note: There is no
is_superadmin()function. Superadmins bypass RLS entirely by using the AdminPool (owner role), not via policy checks.
Policy Patterns
Pattern 1: Org-Scoped (Most Common)
Example: appointments, patients, forms, etc.
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
CREATE POLICY appointments_org_isolation ON appointments
USING (organization_id = current_app_org_id());Access:
- Patient/Specialist/Admin (AppPool): See only their organization's data
- Superadmin (AdminPool): Bypasses RLS entirely — sees all data
Applies to: appointments, patients, specialists, forms, form_templates, segments, custom_fields, webhooks, documents
Note: No
is_superadmin()in policies. Superadmins use the AdminPool (table owner) which bypasses all RLS. This applies to every policy pattern below.
Pattern 2: User-Scoped
Example: users table (multi-org membership)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_own_record ON users
USING (
id = current_app_user_id()
OR EXISTS (
SELECT 1 FROM user_organizations
WHERE user_id = users.id
AND organization_id = current_app_org_id()
)
);Access:
- User (AppPool): See own record
- Admin (AppPool): See users in their organization (via user_organizations join)
- Superadmin (AdminPool): Bypasses RLS — sees all users
Pattern 3: Role-Based + Org-Scoped
Example: patients (org-patient link — staff see their org's patients, patients see their own)
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
CREATE POLICY patients_access ON patients
USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR patient_person_id IN (SELECT current_user_patient_person_ids())
)
);Access:
- Patient / manager (AppPool): See their own record and managed dependents' records (via
current_user_patient_person_ids()) - Specialist / Admin / Support (AppPool): See all patients in their organization
- Superadmin (AdminPool): Bypasses RLS — sees all patients across all organizations
Pattern 4: User-scoped without org (patient_persons)
Example: patient_persons (portable profile — no organization_id)
ALTER TABLE patient_persons ENABLE ROW LEVEL SECURITY;
-- The person themselves, their managers, or org staff (via patients JOIN)
CREATE POLICY patient_persons_select ON patient_persons FOR SELECT USING (
id IN (SELECT current_user_patient_person_ids())
OR id IN (SELECT patient_person_id FROM patients WHERE organization_id = current_app_org_id())
);
-- Only the person or their managers can update the portable profile
CREATE POLICY patient_persons_update ON patient_persons FOR UPDATE USING (
id IN (SELECT current_user_patient_person_ids())
);RLS helper function (defined in patient-persons.sql):
-- Returns all patient_person IDs the current user can act on behalf of
CREATE OR REPLACE FUNCTION current_user_patient_person_ids() RETURNS SETOF BIGINT AS $$
SELECT id FROM patient_persons WHERE user_id = current_app_user_id()
UNION
SELECT patient_person_id FROM patient_person_managers WHERE manager_user_id = current_app_user_id()
$$ LANGUAGE SQL STABLE;Pattern 4: Private Field Filtering
Example: forms with private fields
Forms have private fields that are only visible to specialists/admins. This is handled in the application layer (Go service), not RLS.
Why? RLS operates at the row level, not column level. We can't filter JSONB fields at the database level without expensive queries.
Implementation:
// internal/domain/form/repository.go
func (r *Repository) Get(ctx context.Context, id int64) (*Form, error) {
user := middleware.UserFromContext(ctx)
form, err := r.fetchForm(ctx, id)
if err != nil {
return nil, err
}
// Filter private fields based on role
if user.Role == "patient" && form.UserID != user.ID {
return nil, ErrForbidden
}
if user.Role == "patient" {
form.Values = filterPrivateFields(form.Fields, form.Values)
}
return form, nil
}
func filterPrivateFields(fields []FieldDef, values map[string]any) map[string]any {
filtered := make(map[string]any)
for _, field := range fields {
if !field.Private {
filtered[field.Key] = values[field.Key]
}
}
return filtered
}Policy Reference
Architecture note: All policies below are written for the AppPool (
restartix_approle). Superadmins use the AdminPool (restartixowner role) which bypasses all RLS entirely. Therefore, no policy includesis_superadmin(). Policies for future features (not yet implemented) follow this same pattern — when those migrations are written, they will not includeis_superadmin().
Core Features
users
CREATE POLICY users_own_record ON users
USING (
id = current_app_user_id()
OR EXISTS (
SELECT 1 FROM user_organizations
WHERE user_id = users.id
AND organization_id = current_app_org_id()
)
);user_organizations
CREATE POLICY user_organizations_access ON user_organizations
USING (
organization_id = current_app_org_id()
);organizations
CREATE POLICY organizations_access ON organizations
USING (
id = current_app_org_id()
);organization_integrations
CREATE POLICY organization_integrations_access ON organization_integrations
USING (
organization_id = current_app_org_id()
);Appointments & Scheduling
appointments
CREATE POLICY appointments_org_isolation ON appointments
USING (organization_id = current_app_org_id());appointment_files
CREATE POLICY appointment_files_org_isolation ON appointment_files
USING (
organization_id = current_app_org_id()
);appointment_templates
CREATE POLICY appointment_templates_org_isolation ON appointment_templates
USING (organization_id = current_app_org_id());appointment_template_forms
CREATE POLICY appointment_template_forms_org_isolation ON appointment_template_forms
USING (organization_id = current_app_org_id());appointment_template_specialists
CREATE POLICY appointment_template_specialists_org_isolation ON appointment_template_specialists
USING (organization_id = current_app_org_id());appointment_template_attachments
CREATE POLICY appointment_template_attachments_org_isolation ON appointment_template_attachments
USING (organization_id = current_app_org_id());specialist_hours
CREATE POLICY specialist_hours_org_isolation ON specialist_hours
USING (organization_id = current_app_org_id());specialist_date_overrides
CREATE POLICY specialist_date_overrides_org_isolation ON specialist_date_overrides
USING (organization_id = current_app_org_id());Profiles
patient_persons
-- SELECT: the person themselves, their managers, or any org staff with the person as a patient
CREATE POLICY patient_persons_select ON patient_persons FOR SELECT USING (
id IN (SELECT current_user_patient_person_ids())
OR id IN (SELECT patient_person_id FROM patients WHERE organization_id = current_app_org_id())
);
-- UPDATE: only the person or their managers
CREATE POLICY patient_persons_update ON patient_persons FOR UPDATE USING (
id IN (SELECT current_user_patient_person_ids())
);
-- INSERT: any authenticated user
CREATE POLICY patient_persons_insert ON patient_persons FOR INSERT WITH CHECK (
current_app_user_id() IS NOT NULL
);patient_person_managers
CREATE POLICY person_managers_select ON patient_person_managers FOR SELECT USING (
manager_user_id = current_app_user_id()
OR patient_person_id IN (SELECT id FROM patient_persons WHERE user_id = current_app_user_id())
);
CREATE POLICY person_managers_modify ON patient_person_managers FOR ALL USING (
manager_user_id = current_app_user_id()
);patients
CREATE POLICY patients_select ON patients FOR SELECT USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR patient_person_id IN (SELECT current_user_patient_person_ids())
))
);
CREATE POLICY patients_modify ON patients FOR ALL USING (
organization_id = current_app_org_id() AND (
patient_person_id IN (SELECT current_user_patient_person_ids())
OR current_app_role() IN ('admin', 'customer_support')
))
);specialists
CREATE POLICY specialists_org_isolation ON specialists
USING (organization_id = current_app_org_id());specialist_specialties
CREATE POLICY specialist_specialties_org_isolation ON specialist_specialties
USING (organization_id = current_app_org_id());specialties
CREATE POLICY specialties_org_isolation ON specialties
USING (organization_id = current_app_org_id());Forms System
forms
CREATE POLICY forms_org_isolation ON forms
USING (organization_id = current_app_org_id());form_templates (part of forms feature)
CREATE POLICY form_templates_org_isolation ON form_templates
USING (organization_id = current_app_org_id());form_template_versions
CREATE POLICY form_template_versions_org_isolation ON form_template_versions
USING (organization_id = current_app_org_id());custom_fields
CREATE POLICY custom_fields_org_isolation ON custom_fields
USING (organization_id = current_app_org_id());custom_field_values
CREATE POLICY custom_field_values_org_isolation ON custom_field_values
USING (organization_id = current_app_org_id());segments
CREATE POLICY segments_org_isolation ON segments
USING (organization_id = current_app_org_id());segment_members
CREATE POLICY segment_members_org_isolation ON segment_members
USING (organization_id = current_app_org_id());segment_versions
CREATE POLICY segment_versions_org_isolation ON segment_versions
USING (organization_id = current_app_org_id());Telerehabilitation
exercises (dual-scope: global + org)
-- SELECT: Global exercises (org_id IS NULL) visible to all + org exercises
CREATE POLICY exercises_select ON exercises FOR SELECT USING (
organization_id IS NULL
OR (organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR (current_app_role() = 'patient' AND status = 'published' AND deleted_at IS NULL)
))
);
-- INSERT/UPDATE/DELETE: Admin for org, superadmin for global
CREATE POLICY exercises_insert ON exercises FOR INSERT WITH CHECK (
organization_id = current_app_org_id() AND current_app_role() = 'admin'
);
CREATE POLICY exercises_update ON exercises FOR UPDATE USING (
organization_id IS NOT NULL AND organization_id = current_app_org_id()
AND current_app_role() = 'admin'
);New pattern: organization_id IS NULL in SELECT enables global visibility. UPDATE/DELETE explicitly exclude IS NULL for non-superadmins.
exercise_categories, exercise_body_regions, exercise_equipment
-- Same dual-scope pattern as exercises
CREATE POLICY exercise_categories_select ON exercise_categories FOR SELECT USING (
organization_id IS NULL OR organization_id = current_app_org_id()
);
CREATE POLICY exercise_categories_modify ON exercise_categories FOR ALL USING (
organization_id IS NOT NULL AND organization_id = current_app_org_id()
AND current_app_role() = 'admin'
);exercise_tags, exercise_instructions, exercise_contraindications
-- Sub-query through parent exercise (no organization_id column)
CREATE POLICY exercise_instructions_select ON exercise_instructions FOR SELECT USING (
EXISTS (
SELECT 1 FROM exercises e WHERE e.id = exercise_instructions.exercise_id
AND (e.organization_id IS NULL
OR e.organization_id = current_app_org_id())
)
);treatment_plans (dual-scope: global + org)
-- SELECT: Global plans (org_id IS NULL) visible to all authenticated users
-- Patients only see published plans with library visibility
-- Org plans visible to org staff + patients (if published + library)
CREATE POLICY treatment_plans_select ON treatment_plans FOR SELECT USING (
organization_id IS NULL AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR (current_app_role() = 'patient' AND status = 'published'
AND visibility = 'library')
)
OR (organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR (current_app_role() = 'patient' AND status = 'published'
AND visibility = 'library')
))
);
-- INSERT/UPDATE/DELETE: Superadmin for global plans, admin/specialist for org plans
-- Global plans (org_id IS NULL) can only be modified by superadmins
CREATE POLICY treatment_plans_insert ON treatment_plans FOR INSERT WITH CHECK (
organization_id = current_app_org_id()
AND current_app_role() IN ('admin', 'specialist')
);
CREATE POLICY treatment_plans_update ON treatment_plans FOR UPDATE USING (
organization_id IS NOT NULL AND organization_id = current_app_org_id()
AND current_app_role() IN ('admin', 'specialist')
);
CREATE POLICY treatment_plans_delete ON treatment_plans FOR DELETE USING (
organization_id IS NOT NULL AND organization_id = current_app_org_id()
AND current_app_role() IN ('admin', 'specialist')
);Dual-scope pattern: Same as exercises -- organization_id IS NULL in SELECT enables global visibility. UPDATE/DELETE explicitly require organization_id IS NOT NULL for non-superadmins, ensuring global plans can only be modified by superadmins. Patients see only plans with status = 'published' and visibility = 'library'.
treatment_plan_versions, treatment_plan_sessions, treatment_plan_session_exercises
-- SELECT: Org-scoped + global plan children (org_id IS NULL)
-- Mirrors the treatment_plans dual-scope pattern for child records
CREATE POLICY tpv_select ON treatment_plan_versions FOR SELECT USING (
organization_id IS NULL
OR (organization_id = current_app_org_id()
AND current_app_role() IN ('admin', 'specialist', 'customer_support'))
);
-- INSERT/UPDATE/DELETE: Superadmin for global plan children, admin/specialist for org
CREATE POLICY tpv_modify ON treatment_plan_versions FOR ALL USING (
organization_id IS NOT NULL AND organization_id = current_app_org_id()
AND current_app_role() IN ('admin', 'specialist')
);Note: Child tables of treatment_plans now also allow organization_id IS NULL for global plan children. The same dual-scope pattern applies: all authenticated staff can read global plan children, but only superadmins can modify them. The same policies apply to treatment_plan_sessions and treatment_plan_session_exercises.
patient_treatment_plans
-- Patient sees own (via patients → patient_person_id), staff sees all org
CREATE POLICY patient_treatment_plans_select ON patient_treatment_plans FOR SELECT USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR patient_id IN (
SELECT id FROM patients
WHERE organization_id = current_app_org_id()
AND patient_person_id IN (SELECT current_user_patient_person_ids())
)
))
);
-- INSERT: Staff can assign any plan; patients can self-assign
CREATE POLICY patient_treatment_plans_insert ON patient_treatment_plans FOR INSERT WITH CHECK (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist')
OR (current_app_role() = 'patient'
AND self_assigned = TRUE
AND patient_id IN (
SELECT id FROM patients
WHERE organization_id = current_app_org_id()
AND patient_person_id IN (SELECT current_user_patient_person_ids())
))
))
);Patient self-assignment: Patients can INSERT their own patient_treatment_plans rows when self_assigned = TRUE and the patient_id matches their own patient record (resolved via current_user_patient_person_ids()). This enables patients to add published library plans to their own treatment schedule without requiring specialist intervention.
patient_session_completions
-- Sub-query through patient_treatment_plans → patients → patient_person_id
CREATE POLICY psc_select ON patient_session_completions FOR SELECT USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR EXISTS (
SELECT 1 FROM patient_treatment_plans ptp
JOIN patients p ON p.id = ptp.patient_id
WHERE ptp.id = patient_session_completions.patient_treatment_plan_id
AND p.organization_id = current_app_org_id()
AND p.patient_person_id IN (SELECT current_user_patient_person_ids())
)
))
);patient_exercise_logs
-- Sub-query through patient_session_completions → patient_treatment_plans → patients → patient_person_id
CREATE POLICY pel_select ON patient_exercise_logs FOR SELECT USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR EXISTS (
SELECT 1 FROM patient_session_completions psc
JOIN patient_treatment_plans ptp ON ptp.id = psc.patient_treatment_plan_id
JOIN patients p ON p.id = ptp.patient_id
WHERE psc.id = patient_exercise_logs.session_completion_id
AND p.organization_id = current_app_org_id()
AND p.patient_person_id IN (SELECT current_user_patient_person_ids())
)
))
);Services & Products
service_plan_products
ALTER TABLE service_plan_products ENABLE ROW LEVEL SECURITY;
-- SELECT: org match (superadmins bypass RLS via AdminPool)
CREATE POLICY service_plan_products_select ON service_plan_products FOR SELECT USING (
organization_id = current_app_org_id()
);
-- INSERT/UPDATE/DELETE: org match AND admin
CREATE POLICY service_plan_products_modify ON service_plan_products FOR ALL USING (
organization_id = current_app_org_id() AND current_app_role() = 'admin'
);patient_product_orders
ALTER TABLE patient_product_orders ENABLE ROW LEVEL SECURITY;
-- SELECT: org match AND (staff roles OR patient owns the order)
CREATE POLICY patient_product_orders_select ON patient_product_orders FOR SELECT USING (
organization_id = current_app_org_id() AND (
current_app_role() IN ('admin', 'specialist', 'customer_support')
OR patient_id IN (
SELECT id FROM patients
WHERE organization_id = current_app_org_id()
AND patient_person_id IN (SELECT current_user_patient_person_ids())
)
))
);
-- INSERT/UPDATE/DELETE: org match AND admin/specialist
CREATE POLICY patient_product_orders_modify ON patient_product_orders FOR ALL USING (
organization_id = current_app_org_id() AND current_app_role() IN ('admin', 'specialist')
);Supporting Features
appointment_documents
CREATE POLICY appointment_documents_org_isolation ON appointment_documents
USING (organization_id = current_app_org_id());appointment_document_files
CREATE POLICY appointment_document_files_org_isolation ON appointment_document_files
USING (organization_id = current_app_org_id());webhook_subscriptions
CREATE POLICY webhook_subscriptions_org_isolation ON webhook_subscriptions
USING (organization_id = current_app_org_id());webhook_events
CREATE POLICY webhook_events_org_isolation ON webhook_events
USING (organization_id = current_app_org_id());audit_log
CREATE POLICY audit_log_org_isolation ON audit_log
USING (organization_id = current_app_org_id());Testing RLS
Integration Tests
Set session variables manually:
func TestRLS(t *testing.T) {
org1 := createOrg(t, "Org 1")
org2 := createOrg(t, "Org 2")
user1 := createUser(t, org1.ID, "patient")
user2 := createUser(t, org2.ID, "patient")
// Set RLS context for org 1
conn := testDB.Acquire(ctx)
defer conn.Release()
conn.Exec(ctx, `
SELECT set_config('app.current_user_id', $1::text, true),
set_config('app.current_org_id', $2::text, true),
set_config('app.current_role', $3, true)
`, user1.ID, org1.ID, "patient")
// Query appointments
var count int
conn.QueryRow(ctx, "SELECT COUNT(*) FROM appointments").Scan(&count)
// Verify: Only org 1's appointments visible
assert.Equal(t, org1AppointmentCount, count)
}Manual Verification (psql)
-- Set session vars
SELECT set_config('app.current_org_id', '1', true);
SELECT set_config('app.current_role', 'patient', true);
-- Query (RLS applies)
SELECT * FROM appointments;
-- Should only return org 1's appointments
-- Switch org
SELECT set_config('app.current_org_id', '2', true);
SELECT * FROM appointments;
-- Should only return org 2's appointmentsPerformance Considerations
Indexes Are Critical
Every table with organization_id must have an index:
CREATE INDEX idx_{table}_org ON {table}(organization_id);Without these, RLS queries become sequential scans (extremely slow).
Query Plan Caching
PostgreSQL caches query plans. RLS policies are baked into plans, so there's no per-query overhead for RLS filtering after the first execution.
Benchmark: RLS adds < 1ms to query time when indexes exist.
Security Guarantees
Tenant Isolation
Guaranteed by PostgreSQL, not application code:
- Middleware sets
app.current_org_id = 1 - RLS policy:
WHERE organization_id = current_app_org_id() - Query:
SELECT * FROM appointments - PostgreSQL rewrites to:
SELECT * FROM appointments WHERE organization_id = 1 - Even if query has
WHERE id = 999, RLS adds the org filter
Impossible scenarios:
- ❌ Org 1 sees Org 2's data (RLS blocks at DB level)
- ❌ Request without org context sees data (no
current_app_org_id()→ NULL → no matches) - ❌ Superadmin org context pollutes patient request (session vars are transaction-scoped)
Transaction Scope
Session variables are set with set_config(..., true):
- Third parameter =
true: Transaction-scoped - Automatic cleanup: Variables reset when transaction ends
- No cross-request pollution: Each request gets a clean connection
Related Documentation
- Session Management - Middleware stack, connection lifecycle
- Database Overview - All tables
- RBAC Permissions - Role-based access control
- Features Index - Feature-by-feature documentation