c77_rbac/BEST-PRACTICE.md

24 KiB

c77_rbac Best Practices Guide

This guide provides recommended patterns and practices for implementing c77_rbac in production environments. While the tutorials demonstrate basic usage, this document focuses on enterprise-grade implementations.

Table of Contents

  1. Database Organization
  2. Permission Design Patterns
  3. Performance Optimization
  4. Security Hardening
  5. Multi-Tenant Architectures
  6. Migration Strategies
  7. Framework-Specific Patterns
  8. Monitoring and Maintenance
  9. Common Pitfalls
  10. Production Checklist

Database Organization

Schema Architecture

Recommended Production Structure:

-- Extensions and system objects only
public.*              

-- Core application tables
application.*         

-- Multi-tenant schemas (if applicable)
tenant_<id>.*        

-- Audit and compliance data
audit.*              

-- Read-only views and reports
reporting.*          

-- Temporary processing tables
staging.*            

Implementation Example:

-- As database owner or superuser
CREATE EXTENSION c77_rbac;  -- Goes in public

-- As application user
CREATE SCHEMA application;
CREATE SCHEMA audit;
CREATE SCHEMA reporting;

-- Create tables in appropriate schemas
CREATE TABLE application.users (
    id SERIAL PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    -- ...
);

-- Apply RBAC policies with schema qualification
SELECT c77_rbac_apply_policy(
    'application.users',
    'view_users',
    'department',
    'department_id'
);

Why Schema Separation Matters

  1. Security Isolation: Each schema can have different access rules
  2. Clear Boundaries: Prevents mixing system and application objects
  3. Easier Maintenance: Can backup/restore schemas independently
  4. Better Performance: Optimizer works better with organized schemas
  5. Compliance: Easier to demonstrate data separation for audits

Permission Design Patterns

Role Naming Conventions

Pattern: <scope>_<function>_<access_level>

-- Good role names
SELECT c77_rbac_grant_feature('dept_expense_approver', 'approve_expenses');
SELECT c77_rbac_grant_feature('global_report_viewer', 'view_all_reports');
SELECT c77_rbac_grant_feature('tenant_data_editor', 'edit_tenant_data');

-- Avoid generic names
-- Bad: 'admin', 'user', 'role1', 'temp'

Feature (Permission) Naming

Pattern: <action>_<resource>_<qualifier>

-- Well-structured permissions
SELECT c77_rbac_grant_feature('finance_manager', 'approve_expenses_over_1000');
SELECT c77_rbac_grant_feature('hr_staff', 'view_employee_personal_data');
SELECT c77_rbac_grant_feature('sales_rep', 'edit_own_opportunities');

-- Create feature hierarchies
SELECT c77_rbac_grant_feature('viewer', 'view_public_data');
SELECT c77_rbac_grant_feature('editor', 'view_public_data');  -- Inherits viewer permissions
SELECT c77_rbac_grant_feature('editor', 'edit_public_data');

Scope Design Patterns

-- Hierarchical scopes
'global/all'                    -- System-wide access
'region/north_america'          -- Regional access
'country/usa'                   -- Country-specific
'state/california'              -- State-level
'office/san_francisco'          -- Office-level

-- Functional scopes
'department/engineering'        -- Department-based
'project/apollo'               -- Project-based
'team/mobile_dev'              -- Team-based
'cost_center/cc_12345'         -- Financial scopes

-- Multi-tenant scopes
'tenant/customer_abc'          -- Tenant isolation
'subscription/enterprise'       -- Feature-based access
'api_client/client_xyz'        -- API client scoping

Performance Optimization

Index Strategy for RLS

-- Always index columns used in RLS policies
CREATE INDEX idx_users_department_id ON application.users(department_id);
CREATE INDEX idx_documents_tenant_id ON application.documents(tenant_id);

-- Composite indexes for complex policies
CREATE INDEX idx_projects_dept_status ON application.projects(department_id, status);

-- Partial indexes for common filters
CREATE INDEX idx_active_users_dept ON application.users(department_id) 
WHERE is_active = true;

-- Hash indexes for exact matches
CREATE INDEX idx_users_external_id_hash ON application.users USING hash(external_id);

Query Optimization Patterns

-- Use CTEs for complex permission checks
WITH user_permissions AS (
    SELECT * FROM c77_rbac_user_permissions 
    WHERE external_id = current_setting('c77_rbac.external_id', true)
)
SELECT d.* 
FROM application.documents d
JOIN user_permissions up ON d.department_id = up.scope_id
WHERE up.feature_name = 'view_documents';

-- Materialized views for permission matrices
CREATE MATERIALIZED VIEW reporting.user_effective_permissions AS
SELECT 
    s.external_id,
    f.name as feature,
    sr.scope_type,
    sr.scope_id,
    'direct' as permission_type
FROM c77_rbac_subjects s
JOIN c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
JOIN c77_rbac_role_features rf ON sr.role_id = rf.role_id
JOIN c77_rbac_features f ON rf.feature_id = f.feature_id;

CREATE UNIQUE INDEX idx_user_perms_unique 
ON reporting.user_effective_permissions(external_id, feature, scope_type, scope_id);

-- Refresh strategy
REFRESH MATERIALIZED VIEW CONCURRENTLY reporting.user_effective_permissions;

Connection Pool Optimization

-- Pre-warm permission cache function
CREATE OR REPLACE FUNCTION warmup_user_permissions(p_user_id TEXT)
RETURNS void AS $$
BEGIN
    -- Pre-load common permission checks
    PERFORM c77_rbac_can_access('view_data', p_user_id, 'global', 'all');
    PERFORM c77_rbac_can_access('edit_data', p_user_id, 'department', 
        (SELECT department_id FROM application.users WHERE external_id = p_user_id));
END;
$$ LANGUAGE plpgsql;

Security Hardening

Connection Security

# Python/Django example
def get_db_connection(user_id):
    with connection.cursor() as cursor:
        # Always reset context first
        cursor.execute("RESET c77_rbac.external_id")
        cursor.execute("RESET role")  # Reset any SET ROLE commands
        
        # Set new context
        cursor.execute("SET c77_rbac.external_id = %s", [user_id])
        
        # Verify context was set
        cursor.execute("SELECT current_setting('c77_rbac.external_id', true)")
        if cursor.fetchone()[0] != str(user_id):
            raise SecurityError("Failed to set security context")
// PHP/Laravel example
class SecureConnection
{
    public function setUserContext($userId)
    {
        // Use transaction to ensure atomic context setting
        DB::transaction(function () use ($userId) {
            DB::statement("RESET c77_rbac.external_id");
            DB::statement("SET LOCAL c77_rbac.external_id = ?", [$userId]);
            
            // Verify
            $result = DB::selectOne("SELECT current_setting(?, true) as ctx", 
                ['c77_rbac.external_id']);
            
            if ($result->ctx !== (string)$userId) {
                throw new SecurityException('Context verification failed');
            }
        });
    }
}

Audit Logging

-- Comprehensive audit table
CREATE TABLE audit.rbac_changes (
    id BIGSERIAL PRIMARY KEY,
    event_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    event_type TEXT NOT NULL, -- 'role_assigned', 'role_revoked', etc.
    performed_by TEXT NOT NULL,
    target_user TEXT,
    role_name TEXT,
    scope_type TEXT,
    scope_id TEXT,
    success BOOLEAN NOT NULL,
    error_message TEXT,
    client_ip INET,
    session_id TEXT,
    additional_context JSONB
);

-- Audit trigger for role changes
CREATE OR REPLACE FUNCTION audit.log_rbac_changes()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO audit.rbac_changes (
        event_type,
        performed_by,
        target_user,
        role_name,
        scope_type,
        scope_id,
        success,
        client_ip,
        additional_context
    ) VALUES (
        TG_OP,
        current_setting('c77_rbac.external_id', true),
        CASE 
            WHEN TG_OP = 'DELETE' THEN OLD.external_id
            ELSE NEW.external_id
        END,
        TG_ARGV[0],  -- Pass role name as trigger argument
        NEW.scope_type,
        NEW.scope_id,
        true,
        inet_client_addr(),
        jsonb_build_object(
            'table', TG_TABLE_NAME,
            'when', TG_WHEN,
            'level', TG_LEVEL
        )
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Preventing Privilege Escalation

-- Function to validate role assignments
CREATE OR REPLACE FUNCTION validate_role_assignment()
RETURNS TRIGGER AS $$
DECLARE
    assigner_roles TEXT[];
BEGIN
    -- Get roles of the user making the assignment
    SELECT array_agg(r.name) INTO assigner_roles
    FROM c77_rbac_subjects s
    JOIN c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
    JOIN c77_rbac_roles r ON sr.role_id = r.role_id
    WHERE s.external_id = current_setting('c77_rbac.external_id', true);
    
    -- Prevent non-admins from assigning admin roles
    IF NEW.role_name = 'admin' AND NOT ('admin' = ANY(assigner_roles)) THEN
        RAISE EXCEPTION 'Only admins can assign admin role'
            USING HINT = 'Contact an administrator for this operation';
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Multi-Tenant Architectures

Strict Isolation Pattern

-- Each tenant gets their own schema
CREATE OR REPLACE FUNCTION create_tenant_schema(p_tenant_id TEXT)
RETURNS void AS $$
DECLARE
    schema_name TEXT;
BEGIN
    schema_name := 'tenant_' || regexp_replace(p_tenant_id, '[^a-z0-9]', '_', 'g');
    
    -- Create schema
    EXECUTE format('CREATE SCHEMA %I', schema_name);
    
    -- Create standard tables in tenant schema
    EXECUTE format('
        CREATE TABLE %I.users (LIKE application.users INCLUDING ALL)',
        schema_name
    );
    
    EXECUTE format('
        CREATE TABLE %I.documents (LIKE application.documents INCLUDING ALL)',
        schema_name
    );
    
    -- Apply RLS policies
    PERFORM c77_rbac_apply_policy(
        format('%I.users', schema_name),
        'view_tenant_data',
        'tenant',
        'tenant_id'
    );
    
    -- Grant default tenant admin role
    PERFORM c77_rbac_assign_subject(
        p_tenant_id || '_admin',
        'tenant_admin',
        'tenant',
        p_tenant_id
    );
END;
$$ LANGUAGE plpgsql;

Shared Tables Pattern

-- Single table with tenant isolation
CREATE TABLE application.documents (
    id SERIAL PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    title TEXT NOT NULL,
    content TEXT,
    -- ... other columns
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Index for tenant isolation
CREATE INDEX idx_documents_tenant_id ON application.documents(tenant_id);

-- Apply tenant isolation policy
SELECT c77_rbac_apply_policy(
    'application.documents',
    'view_tenant_data',
    'tenant',
    'tenant_id'
);

-- Ensure complete isolation with additional check
ALTER TABLE application.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON application.documents
    FOR ALL
    USING (
        tenant_id = ANY(
            SELECT scope_id 
            FROM c77_rbac_subject_roles sr
            JOIN c77_rbac_subjects s ON sr.subject_id = s.subject_id
            WHERE s.external_id = current_setting('c77_rbac.external_id', true)
            AND sr.scope_type = 'tenant'
        )
    );

Hybrid Pattern (Best of Both)

-- Shared tables for common data
application.users           -- All users across tenants
application.subscriptions   -- Tenant subscriptions
application.features        -- Available features

-- Tenant-specific schemas for isolated data
tenant_abc.invoices
tenant_abc.customers
tenant_xyz.invoices
tenant_xyz.customers

-- Cross-tenant reporting schema
reporting.tenant_metrics
reporting.usage_summary

Migration Strategies

New Project Setup

-- 1. Initial database setup
CREATE DATABASE myapp;
CREATE USER myapp_user WITH PASSWORD 'secure_password';

\c myapp
CREATE EXTENSION c77_rbac;

-- 2. Create application schemas
CREATE SCHEMA application;
CREATE SCHEMA audit;
CREATE SCHEMA reporting;

-- 3. Grant privileges
GRANT ALL PRIVILEGES ON DATABASE myapp TO myapp_user;
GRANT ALL ON SCHEMA application, audit, reporting TO myapp_user;

-- 4. Set default privileges
ALTER DEFAULT PRIVILEGES FOR USER myapp_user 
    GRANT ALL ON TABLES TO myapp_user;
ALTER DEFAULT PRIVILEGES FOR USER myapp_user 
    GRANT ALL ON SEQUENCES TO myapp_user;

Existing Application Migration

-- Phase 1: Parallel implementation
-- Keep existing auth while testing c77_rbac

-- Add tracking column
ALTER TABLE users ADD COLUMN rbac_migrated BOOLEAN DEFAULT false;

-- Migrate in batches
CREATE OR REPLACE FUNCTION migrate_users_batch(p_limit INTEGER = 1000)
RETURNS INTEGER AS $$
DECLARE
    migrated_count INTEGER := 0;
    user_record RECORD;
BEGIN
    FOR user_record IN 
        SELECT * FROM users 
        WHERE NOT rbac_migrated 
        ORDER BY id 
        LIMIT p_limit
    LOOP
        -- Map existing roles to c77_rbac
        CASE user_record.role
            WHEN 'admin' THEN
                PERFORM c77_rbac_assign_subject(
                    user_record.id::TEXT,
                    'admin',
                    'global',
                    'all'
                );
            WHEN 'manager' THEN
                PERFORM c77_rbac_assign_subject(
                    user_record.id::TEXT,
                    'manager',
                    'department',
                    user_record.department_id::TEXT
                );
            ELSE
                PERFORM c77_rbac_assign_subject(
                    user_record.id::TEXT,
                    'employee',
                    'department',
                    user_record.department_id::TEXT
                );
        END CASE;
        
        -- Mark as migrated
        UPDATE users SET rbac_migrated = true WHERE id = user_record.id;
        migrated_count := migrated_count + 1;
    END LOOP;
    
    RETURN migrated_count;
END;
$$ LANGUAGE plpgsql;

-- Phase 2: Gradual table migration
-- Start with low-risk tables
SELECT c77_rbac_apply_policy('public_content', 'view_content', 'global', 'all');

-- Phase 3: Critical tables with testing
-- Apply policies but keep old auth active
SELECT c77_rbac_apply_policy('users', 'view_users', 'department', 'department_id');

-- Phase 4: Cutover
-- Remove old auth code
-- Remove rbac_migrated column
ALTER TABLE users DROP COLUMN rbac_migrated;

Framework-Specific Patterns

Laravel Integration

// app/Traits/UsesRbac.php
trait UsesRbac
{
    public static function bootUsesRbac()
    {
        // Set context on model events
        static::creating(function ($model) {
            if (Auth::check()) {
                DB::statement('SET LOCAL c77_rbac.external_id = ?', [Auth::id()]);
            }
        });
    }
    
    // Scope for RBAC-protected queries
    public function scopeAuthorized($query)
    {
        if (!Auth::check()) {
            return $query->whereRaw('1=0'); // No results
        }
        
        DB::statement('SET LOCAL c77_rbac.external_id = ?', [Auth::id()]);
        return $query;
    }
}

// Usage in models
class Document extends Model
{
    use UsesRbac;
    
    // Automatically filtered by RLS
    public static function allForUser()
    {
        return static::authorized()->get();
    }
}

Django Integration

# rbac/middleware.py
class RbacContextMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated:
            with connection.cursor() as cursor:
                cursor.execute(
                    "SET LOCAL c77_rbac.external_id = %s",
                    [str(request.user.id)]
                )
        
        response = self.get_response(request)
        
        # Clean up
        with connection.cursor() as cursor:
            cursor.execute("RESET c77_rbac.external_id")
        
        return response

# rbac/models.py
class RbacProtectedModel(models.Model):
    class Meta:
        abstract = True
    
    @classmethod
    def apply_rbac_policy(cls, feature, scope_type, scope_column):
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT c77_rbac_apply_policy(%s, %s, %s, %s)",
                [cls._meta.db_table, feature, scope_type, scope_column]
            )

Rails Integration

# app/models/concerns/rbac_protected.rb
module RbacProtected
  extend ActiveSupport::Concern

  included do
    # Set user context before queries
    default_scope -> {
      if Current.user
        connection.execute("SET LOCAL c77_rbac.external_id = '#{Current.user.id}'")
      end
      all
    }
  end

  class_methods do
    def apply_rbac_policy(feature, scope_type, scope_column)
      connection.execute(
        "SELECT c77_rbac_apply_policy('#{table_name}', '#{feature}', '#{scope_type}', '#{scope_column}')"
      )
    end
  end
end

# Usage
class Document < ApplicationRecord
  include RbacProtected
  
  # Apply policy after migrations
  # Document.apply_rbac_policy('view_documents', 'department', 'dept_id')
end

Monitoring and Maintenance

Health Check Queries

-- Daily health check function
CREATE OR REPLACE FUNCTION daily_rbac_health_check()
RETURNS TABLE(
    check_name TEXT,
    status TEXT,
    details TEXT,
    action_required BOOLEAN
) AS $$
BEGIN
    -- Check for users without roles
    RETURN QUERY
    SELECT 
        'Orphaned Users'::TEXT,
        CASE WHEN COUNT(*) > 0 THEN 'WARNING' ELSE 'OK' END,
        format('%s users without any roles', COUNT(*)),
        COUNT(*) > 0
    FROM c77_rbac_subjects s
    WHERE NOT EXISTS (
        SELECT 1 FROM c77_rbac_subject_roles sr 
        WHERE sr.subject_id = s.subject_id
    );
    
    -- Check for excessive permissions
    RETURN QUERY
    SELECT 
        'Permission Sprawl'::TEXT,
        CASE WHEN COUNT(*) > 50 THEN 'WARNING' ELSE 'OK' END,
        format('%s users with global admin access', COUNT(*)),
        COUNT(*) > 50
    FROM c77_rbac_subject_roles
    WHERE scope_type = 'global' AND scope_id = 'all';
    
    -- Check for stale roles
    RETURN QUERY
    SELECT 
        'Unused Roles'::TEXT,
        CASE WHEN COUNT(*) > 10 THEN 'WARNING' ELSE 'OK' END,
        format('%s roles with no assigned users', COUNT(*)),
        COUNT(*) > 10
    FROM c77_rbac_roles r
    WHERE NOT EXISTS (
        SELECT 1 FROM c77_rbac_subject_roles sr 
        WHERE sr.role_id = r.role_id
    );
END;
$$ LANGUAGE plpgsql;

-- Schedule via pg_cron or external scheduler
-- SELECT cron.schedule('daily-rbac-check', '0 2 * * *', 'SELECT daily_rbac_health_check()');

Performance Monitoring

-- Track slow permission checks
CREATE TABLE monitoring.slow_permission_checks (
    id BIGSERIAL PRIMARY KEY,
    query_start TIMESTAMP,
    duration INTERVAL,
    feature_name TEXT,
    external_id TEXT,
    scope_type TEXT,
    scope_id TEXT
);

-- Monitor function with timing
CREATE OR REPLACE FUNCTION c77_rbac_can_access_monitored(
    p_feature_name TEXT,
    p_external_id TEXT,
    p_scope_type TEXT,
    p_scope_id TEXT
) RETURNS BOOLEAN AS $$
DECLARE
    start_time TIMESTAMP;
    result BOOLEAN;
    duration INTERVAL;
BEGIN
    start_time := clock_timestamp();
    
    result := c77_rbac_can_access(p_feature_name, p_external_id, p_scope_type, p_scope_id);
    
    duration := clock_timestamp() - start_time;
    
    -- Log slow checks (> 10ms)
    IF duration > interval '10 milliseconds' THEN
        INSERT INTO monitoring.slow_permission_checks 
        (query_start, duration, feature_name, external_id, scope_type, scope_id)
        VALUES (start_time, duration, p_feature_name, p_external_id, p_scope_type, p_scope_id);
    END IF;
    
    RETURN result;
END;
$$ LANGUAGE plpgsql;

Common Pitfalls

1. Forgetting to Set Context

Problem: Queries return no data because context isn't set.

Solution: Always verify context is set:

-- Add to your application's base query class
CREATE OR REPLACE FUNCTION verify_context_set()
RETURNS void AS $$
BEGIN
    IF current_setting('c77_rbac.external_id', true) IS NULL THEN
        RAISE EXCEPTION 'Security context not set'
            USING HINT = 'Call SET c77_rbac.external_id before querying';
    END IF;
END;
$$ LANGUAGE plpgsql;

2. Connection Pool Context Leakage

Problem: User A sees User B's data due to connection reuse.

Solution: Always reset context:

# Python context manager
from contextlib import contextmanager

@contextmanager
def rbac_context(user_id):
    cursor = connection.cursor()
    try:
        cursor.execute("SAVEPOINT rbac_context")
        cursor.execute("SET LOCAL c77_rbac.external_id = %s", [user_id])
        yield cursor
    finally:
        cursor.execute("ROLLBACK TO SAVEPOINT rbac_context")

3. Over-Granting Permissions

Problem: Giving users more access than needed.

Solution: Regular permission audits:

-- Find over-privileged users
SELECT 
    s.external_id,
    COUNT(DISTINCT f.name) as permission_count,
    array_agg(DISTINCT r.name) as roles
FROM c77_rbac_subjects s
JOIN c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
JOIN c77_rbac_roles r ON sr.role_id = r.role_id
JOIN c77_rbac_role_features rf ON r.role_id = rf.role_id
JOIN c77_rbac_features f ON rf.feature_id = f.feature_id
GROUP BY s.external_id
HAVING COUNT(DISTINCT f.name) > 20  -- Threshold for investigation
ORDER BY permission_count DESC;

4. Inefficient RLS Policies

Problem: Complex policies causing slow queries.

Solution: Optimize policy conditions:

-- Bad: Multiple subqueries
CREATE POLICY slow_policy ON documents
USING (
    author_id IN (SELECT user_id FROM team_members WHERE team_id IN 
        (SELECT team_id FROM user_teams WHERE user_id = current_user_id()))
);

-- Good: Single optimized query
CREATE POLICY fast_policy ON documents
USING (
    EXISTS (
        SELECT 1 FROM team_members tm
        JOIN user_teams ut ON tm.team_id = ut.team_id
        WHERE tm.user_id = author_id
        AND ut.user_id = current_user_id()
    )
);

Production Checklist

Pre-Deployment

  • All schemas created with proper ownership
  • Indexes created on all RLS policy columns
  • Connection pool context handling implemented
  • Audit logging configured
  • Monitoring queries scheduled
  • Backup strategy includes c77_rbac tables
  • Performance baseline established

Deployment

  • Extension installed by superuser
  • Application user has required privileges
  • Initial roles and features configured
  • RLS policies applied to all sensitive tables
  • Context setting added to application layer
  • Health check queries return OK

Post-Deployment

  • Monitor slow query logs
  • Review audit logs daily for first week
  • Check for orphaned users/roles
  • Verify no permission escalation attempts
  • Document any custom policies
  • Train team on RBAC concepts

Ongoing Maintenance

  • Weekly: Run health check queries
  • Monthly: Review and clean up unused roles
  • Quarterly: Permission audit and optimization
  • Annually: Review and update security policies

Conclusion

Following these best practices will help you build a secure, performant, and maintainable authorization system with c77_rbac. Remember that security is an ongoing process - regular monitoring and updates are essential for maintaining a robust system.

For specific implementation questions or advanced scenarios not covered here, refer to the main documentation or open an issue in the project repository.