Skip to content

Tenant Isolation

The PostgreSQL Permissions Model implements complete tenant isolation where everything except users and permissions is tenant-specific. This design ensures secure separation between tenants while allowing efficient management of global resources.

Core Isolation Principle

What is Tenant-Specific

Everything is isolated by tenant except: - Users (auth.user_info) - Global across all tenants - Permissions (auth.permission) - Global permission hierarchy

Everything else is tenant-specific: - Permission sets and their contents - Groups and group memberships - Permission assignments - Tenant preferences and settings - API keys and technical users - Audit events and logs

graph TB
    subgraph "Global Resources"
        U[Users<br/>auth.user_info<br/>🌐 Shared across tenants]
        P[Permissions<br/>auth.permission<br/>🌐 Global hierarchy]
    end

    subgraph "Tenant A Resources"
        PSA[Permission Sets<br/>🏢 Tenant A specific]
        GA[Groups<br/>🏢 Tenant A specific]
        PAA[Permission Assignments<br/>🏢 Tenant A specific]
        AKA[API Keys<br/>🏢 Tenant A specific]
    end

    subgraph "Tenant B Resources"
        PSB[Permission Sets<br/>🏢 Tenant B specific]
        GB[Groups<br/>🏢 Tenant B specific]
        PAB[Permission Assignments<br/>🏢 Tenant B specific]
        AKB[API Keys<br/>🏢 Tenant B specific]
    end

    U --> PAA
    U --> PAB
    P --> PSA
    P --> PSB
    PSA --> PAA
    PSB --> PAB

Why This Design?

Global Users

  • Single Identity: A user can access multiple tenants with one account
  • Cross-Tenant Access: Users can switch between tenants they have access to
  • Simplified Management: One user record, multiple tenant memberships

Global Permissions

  • Consistent Structure: Same permission hierarchy across all tenants
  • Standardization: Applications use the same permission codes everywhere
  • Maintenance: Single place to define and update permission structure

Tenant-Specific Everything Else

  • Security Isolation: Complete separation of tenant data and access
  • Customization: Each tenant can have different permission sets and groups
  • Billing/Compliance: Clear tenant boundaries for auditing and billing

Isolation Implementation

Database Schema Isolation

Every tenant-specific table includes tenant_id:

-- Tenant-specific tables
CREATE TABLE auth.perm_set (
    perm_set_id uuid PRIMARY KEY,
    tenant_id uuid REFERENCES auth.tenant,  -- 🏢 Tenant isolation
    code text NOT NULL,
    name text NOT NULL,
    UNIQUE (tenant_id, code)  -- Unique within tenant
);

CREATE TABLE auth.user_group (
    user_group_id uuid PRIMARY KEY,
    tenant_id uuid REFERENCES auth.tenant,  -- 🏢 Tenant isolation
    code text NOT NULL,
    name text NOT NULL,
    UNIQUE (tenant_id, code)  -- Same group name can exist in different tenants
);

CREATE TABLE auth.permission_assignment (
    permission_assignment_id uuid PRIMARY KEY,
    tenant_id uuid REFERENCES auth.tenant,  -- 🏢 Tenant isolation
    user_id uuid REFERENCES auth.user_info,
    user_group_id uuid REFERENCES auth.user_group,
    -- Assignments are always tenant-specific
);

Global Tables (No tenant_id)

-- Global users table
CREATE TABLE auth.user_info (
    user_id uuid PRIMARY KEY,
    -- No tenant_id - users are global
    username text UNIQUE NOT NULL,
    email text,
    display_name text NOT NULL
);

-- Global permissions table
CREATE TABLE auth.permission (
    permission_id uuid PRIMARY KEY,
    -- No tenant_id - permissions are global
    code text UNIQUE NOT NULL,
    name text NOT NULL,
    path ltree  -- Hierarchical structure
);

Tenant Access Control

User-Tenant Membership

Users gain access to tenants through explicit membership:

-- User-tenant relationship
CREATE TABLE auth.tenant_user (
    tenant_id uuid REFERENCES auth.tenant,
    user_id uuid REFERENCES auth.user_info,
    is_active boolean DEFAULT true,
    joined_at timestamptz DEFAULT now(),
    PRIMARY KEY (tenant_id, user_id)
);

-- Check user's tenant access
SELECT t.name, t.code
FROM auth.tenant t
JOIN auth.tenant_user tu ON t.tenant_id = tu.tenant_id
WHERE tu.user_id = 'user-uuid'
  AND tu.is_active = true;

Cross-Tenant Security

All permission checks require tenant context:

-- Permission check always includes tenant
SELECT auth.has_permission(
    _tenant_id := 'tenant-uuid',  -- Required tenant context
    _user_id := 'user-uuid',
    _perm_code := 'users.create'
);

-- User might have permission in Tenant A but not Tenant B
-- Even with same permission sets, assignments are tenant-specific

Practical Examples

Same User, Different Tenants

-- User John exists globally
INSERT INTO auth.user_info (user_id, username, email, display_name)
VALUES ('john-uuid', 'john.doe', 'john@company.com', 'John Doe');

-- John has access to multiple tenants
INSERT INTO auth.tenant_user (tenant_id, user_id) VALUES
    ('tenant-a-uuid', 'john-uuid'),  -- Member of Tenant A
    ('tenant-b-uuid', 'john-uuid');  -- Member of Tenant B

-- John has different permissions in each tenant
-- Tenant A: John is an admin
SELECT auth.assign_permission(
    'tenant-a-uuid', 'john-uuid', NULL, 'ADMIN', NULL
);

-- Tenant B: John is read-only
SELECT auth.assign_permission(
    'tenant-b-uuid', 'john-uuid', NULL, 'VIEWER', NULL
);

Same Permission, Different Permission Sets

-- Global permission exists once
INSERT INTO auth.permission (permission_id, code, name, path)
VALUES ('perm-uuid', 'users.create', 'Create Users', 'users.create');

-- Tenant A includes it in Admin set
INSERT INTO auth.perm_set_perm (perm_set_id, permission_id)
VALUES (
    (SELECT perm_set_id FROM auth.perm_set
     WHERE tenant_id = 'tenant-a-uuid' AND code = 'ADMIN'),
    'perm-uuid'
);

-- Tenant B includes it in Manager set (different grouping)
INSERT INTO auth.perm_set_perm (perm_set_id, permission_id)
VALUES (
    (SELECT perm_set_id FROM auth.perm_set
     WHERE tenant_id = 'tenant-b-uuid' AND code = 'MANAGER'),
    'perm-uuid'
);

Tenant-Specific Groups

-- Same group name, different tenants
INSERT INTO auth.user_group (user_group_id, tenant_id, code, name) VALUES
    ('group-a-uuid', 'tenant-a-uuid', 'ADMINS', 'Administrators'),
    ('group-b-uuid', 'tenant-b-uuid', 'ADMINS', 'Administrators');

-- Groups can have different members and permissions per tenant
-- Even though they have the same name, they are completely separate

Benefits of This Isolation Model

Security Benefits

  • Complete Separation: No risk of cross-tenant data access
  • Permission Isolation: Tenant A's permissions don't affect Tenant B
  • Audit Trails: Clear tenant boundaries for all operations

Management Benefits

  • Single User Account: Users don't need separate accounts per tenant
  • Consistent Permissions: Same permission structure across all tenants
  • Flexible Assignment: Different permission combinations per tenant

Performance Benefits

  • Efficient Queries: All tenant-specific data filtered by tenant_id
  • Index Optimization: Composite indexes on (tenant_id, other_columns)
  • Scalable Design: Clear partitioning boundaries

Isolation Validation

Check Tenant Isolation

-- Verify no cross-tenant data leakage
-- Groups should be completely separate
SELECT
    'Groups per tenant' as check_type,
    t.name as tenant_name,
    COUNT(ug.user_group_id) as group_count
FROM auth.tenant t
LEFT JOIN auth.user_group ug ON t.tenant_id = ug.tenant_id
GROUP BY t.tenant_id, t.name;

-- Permission assignments should be tenant-specific
SELECT
    'Assignments per tenant' as check_type,
    t.name as tenant_name,
    COUNT(pa.permission_assignment_id) as assignment_count
FROM auth.tenant t
LEFT JOIN auth.permission_assignment pa ON t.tenant_id = pa.tenant_id
GROUP BY t.tenant_id, t.name;

Cross-Tenant Access Prevention

-- This query should return no results (good isolation)
-- Users should not have assignments in tenants they're not members of
SELECT
    u.username,
    t.name as tenant_name,
    'User has permissions but no tenant membership' as issue
FROM auth.permission_assignment pa
JOIN auth.user_info u ON pa.user_id = u.user_id
JOIN auth.tenant t ON pa.tenant_id = t.tenant_id
LEFT JOIN auth.tenant_user tu ON (pa.tenant_id = tu.tenant_id AND pa.user_id = tu.user_id)
WHERE tu.tenant_id IS NULL;  -- Should be empty

Best Practices

Application Design

  1. Always Pass Tenant Context: Every permission check must include tenant_id
  2. Validate Tenant Membership: Verify user belongs to tenant before operations
  3. Use Tenant Filters: All queries should filter by tenant_id
  4. Audit with Tenant: Include tenant information in all audit logs

Database Queries

-- ✅ Good - includes tenant filter
SELECT * FROM auth.user_group
WHERE tenant_id = 'tenant-uuid';

-- ❌ Bad - no tenant filter (security risk)
SELECT * FROM auth.user_group
WHERE code = 'ADMINS';  -- Could return multiple tenants' admin groups

Permission Checks

-- ✅ Good - tenant-scoped permission check
IF auth.has_permission('tenant-uuid', 'user-uuid', 'users.create') THEN
    -- User has permission in this specific tenant
    PERFORM create_user_in_tenant();
END IF;

-- ❌ Bad - no tenant context
-- This pattern is not supported by the system

What's Next