Skip to content

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 references is_superadmin().
  • Public access: Public endpoints (e.g., org resolve) use AppPool without session variables. Policies with current_app_role() IS NULL allow limited SELECT access for unauthenticated requests.

Session Variables

RLS policies read from these PostgreSQL session variables (set by middleware on every request):

VariableTypeSet ByUsed By
app.current_user_idBIGINTOrganizationContext middlewareUser-scoped policies
app.current_org_idBIGINTOrganizationContext middlewareOrg-scoped policies
app.current_roleTEXTOrganizationContext middlewareRole-based policies

Helper Functions

sql
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.

sql
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)

sql
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)

sql
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)

sql
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):

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:

go
// 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_app role). Superadmins use the AdminPool (restartix owner role) which bypasses all RLS entirely. Therefore, no policy includes is_superadmin(). Policies for future features (not yet implemented) follow this same pattern — when those migrations are written, they will not include is_superadmin().

Core Features

users

sql
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

sql
CREATE POLICY user_organizations_access ON user_organizations
    USING (
        organization_id = current_app_org_id()
    );

organizations

sql
CREATE POLICY organizations_access ON organizations
    USING (
        id = current_app_org_id()
    );

organization_integrations

sql
CREATE POLICY organization_integrations_access ON organization_integrations
    USING (
        organization_id = current_app_org_id()
    );

Appointments & Scheduling

appointments

sql
CREATE POLICY appointments_org_isolation ON appointments
    USING (organization_id = current_app_org_id());

appointment_files

sql
CREATE POLICY appointment_files_org_isolation ON appointment_files
    USING (
        organization_id = current_app_org_id()
    );

appointment_templates

sql
CREATE POLICY appointment_templates_org_isolation ON appointment_templates
    USING (organization_id = current_app_org_id());

appointment_template_forms

sql
CREATE POLICY appointment_template_forms_org_isolation ON appointment_template_forms
    USING (organization_id = current_app_org_id());

appointment_template_specialists

sql
CREATE POLICY appointment_template_specialists_org_isolation ON appointment_template_specialists
    USING (organization_id = current_app_org_id());

appointment_template_attachments

sql
CREATE POLICY appointment_template_attachments_org_isolation ON appointment_template_attachments
    USING (organization_id = current_app_org_id());

specialist_hours

sql
CREATE POLICY specialist_hours_org_isolation ON specialist_hours
    USING (organization_id = current_app_org_id());

specialist_date_overrides

sql
CREATE POLICY specialist_date_overrides_org_isolation ON specialist_date_overrides
    USING (organization_id = current_app_org_id());

Profiles

patient_persons

sql
-- 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

sql
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

sql
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

sql
CREATE POLICY specialists_org_isolation ON specialists
    USING (organization_id = current_app_org_id());

specialist_specialties

sql
CREATE POLICY specialist_specialties_org_isolation ON specialist_specialties
    USING (organization_id = current_app_org_id());

specialties

sql
CREATE POLICY specialties_org_isolation ON specialties
    USING (organization_id = current_app_org_id());

Forms System

forms

sql
CREATE POLICY forms_org_isolation ON forms
    USING (organization_id = current_app_org_id());

form_templates (part of forms feature)

sql
CREATE POLICY form_templates_org_isolation ON form_templates
    USING (organization_id = current_app_org_id());

form_template_versions

sql
CREATE POLICY form_template_versions_org_isolation ON form_template_versions
    USING (organization_id = current_app_org_id());

custom_fields

sql
CREATE POLICY custom_fields_org_isolation ON custom_fields
    USING (organization_id = current_app_org_id());

custom_field_values

sql
CREATE POLICY custom_field_values_org_isolation ON custom_field_values
    USING (organization_id = current_app_org_id());

segments

sql
CREATE POLICY segments_org_isolation ON segments
    USING (organization_id = current_app_org_id());

segment_members

sql
CREATE POLICY segment_members_org_isolation ON segment_members
    USING (organization_id = current_app_org_id());

segment_versions

sql
CREATE POLICY segment_versions_org_isolation ON segment_versions
    USING (organization_id = current_app_org_id());

Telerehabilitation

exercises (dual-scope: global + org)

sql
-- 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

sql
-- 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

sql
-- 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)

sql
-- 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

sql
-- 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

sql
-- 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

sql
-- 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

sql
-- 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

sql
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

sql
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

sql
CREATE POLICY appointment_documents_org_isolation ON appointment_documents
    USING (organization_id = current_app_org_id());

appointment_document_files

sql
CREATE POLICY appointment_document_files_org_isolation ON appointment_document_files
    USING (organization_id = current_app_org_id());

webhook_subscriptions

sql
CREATE POLICY webhook_subscriptions_org_isolation ON webhook_subscriptions
    USING (organization_id = current_app_org_id());

webhook_events

sql
CREATE POLICY webhook_events_org_isolation ON webhook_events
    USING (organization_id = current_app_org_id());

audit_log

sql
CREATE POLICY audit_log_org_isolation ON audit_log
    USING (organization_id = current_app_org_id());

Testing RLS

Integration Tests

Set session variables manually:

go
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)

sql
-- 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 appointments

Performance Considerations

Indexes Are Critical

Every table with organization_id must have an index:

sql
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:

  1. Middleware sets app.current_org_id = 1
  2. RLS policy: WHERE organization_id = current_app_org_id()
  3. Query: SELECT * FROM appointments
  4. PostgreSQL rewrites to: SELECT * FROM appointments WHERE organization_id = 1
  5. 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