c77_rbac/USAGE.md

45 KiB

c77_rbac Comprehensive Usage Guide

This guide explains every aspect of the c77_rbac extension in detail, with practical examples for Laravel developers. If you're new to database-level authorization, this guide will walk you through everything step by step.

Table of Contents

  1. Understanding the Concepts
  2. Installation Walk-through
  3. Core Functions Explained
  4. Setting Up Your First RBAC System
  5. Laravel Integration Guide
  6. Common Patterns and Examples
  7. Advanced Usage
  8. Troubleshooting Guide
  9. Performance Considerations
  10. Security Best Practices

Understanding the Concepts

What is c77_rbac?

c77_rbac moves authorization from your application code to the database. Instead of writing if statements in Laravel to check permissions, the database automatically filters data based on user permissions.

Key Terms

  1. Subject: A user in your system

    • Has an external_id (usually your Laravel User model's ID)
    • Example: User with ID 123 in your Laravel app
  2. Role: A named set of permissions

    • Examples: 'admin', 'manager', 'employee'
    • Roles are just names - they get their power from features
  3. Feature: A specific permission

    • Examples: 'view_reports', 'edit_users', 'delete_posts'
    • These are what actually get checked by the database
  4. Scope: The context where a role applies

    • Type + ID combination
    • Examples: 'department/sales', 'region/north', 'global/all'
  5. Policy: A database rule that enforces security

    • Automatically filters rows based on user permissions
    • Invisible to your application code

How It Works

User (external_id: '123') 
  → has Role ('manager') 
  → with Scope ('department/sales')
  → Role has Features ('view_reports', 'edit_reports')
  → Policy checks these Features
  → Database returns only allowed rows

Installation Walk-through

Step 1: Database Administrator Tasks

These steps require PostgreSQL superuser access:

-- 1. Connect as postgres superuser
sudo -u postgres psql

-- 2. Create your database
CREATE DATABASE myapp_db;

-- 3. Create application user
CREATE USER myapp_user WITH PASSWORD 'secure_password_here';

-- 4. Connect to your database
\c myapp_db

-- 5. Install the extension
CREATE EXTENSION c77_rbac;

-- 6. Grant necessary privileges
GRANT CREATE ON DATABASE myapp_db TO myapp_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO myapp_user;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO myapp_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO myapp_user;

-- 7. Set default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA public 
    GRANT SELECT ON TABLES TO myapp_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public 
    GRANT EXECUTE ON FUNCTIONS TO myapp_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public 
    GRANT USAGE, SELECT ON SEQUENCES TO myapp_user;

Step 2: Verify Installation

Connect as your application user:

psql -U myapp_user -d myapp_db -h localhost

Test the extension:

-- Should return empty results (no subjects yet)
SELECT * FROM public.c77_rbac_subjects;

-- Should return true
SELECT EXISTS (
    SELECT 1 FROM pg_extension WHERE extname = 'c77_rbac'
) as extension_installed;

Core Functions Explained

1. c77_rbac_grant_feature(role_name, feature_name)

Purpose: Gives a permission to a role.

Parameters:

  • role_name: The role to grant the feature to (creates if doesn't exist)
  • feature_name: The permission to grant (creates if doesn't exist)

Example:

-- Give 'editor' role the ability to view posts
SELECT public.c77_rbac_grant_feature('editor', 'view_posts');

-- Give 'editor' role the ability to edit posts
SELECT public.c77_rbac_grant_feature('editor', 'edit_posts');

-- Give 'admin' role all post-related features
SELECT public.c77_rbac_grant_feature('admin', 'view_posts');
SELECT public.c77_rbac_grant_feature('admin', 'edit_posts');
SELECT public.c77_rbac_grant_feature('admin', 'delete_posts');
SELECT public.c77_rbac_grant_feature('admin', 'publish_posts');

Important: Admin roles need explicit grants for each feature!

2. c77_rbac_assign_subject(external_id, role_name, scope_type, scope_id)

Purpose: Assigns a role to a user with a specific scope.

Parameters:

  • external_id: Your application's user ID (as text)
  • role_name: The role to assign
  • scope_type: The type of scope (e.g., 'department', 'region', 'global')
  • scope_id: The specific scope value (e.g., 'sales', 'north', 'all')

Examples:

-- User 123 is an editor in the marketing department
SELECT public.c77_rbac_assign_subject('123', 'editor', 'department', 'marketing');

-- User 456 is a manager for the north region
SELECT public.c77_rbac_assign_subject('456', 'manager', 'region', 'north');

-- User 1 is a global admin (can access everything)
SELECT public.c77_rbac_assign_subject('1', 'admin', 'global', 'all');

-- User 789 is a viewer with no specific scope
SELECT public.c77_rbac_assign_subject('789', 'viewer', 'none', 'none');

3. c77_rbac_apply_policy(table_name, feature_name, scope_type, scope_column)

Purpose: Creates a Row-Level Security policy on a table.

Parameters:

  • table_name: The table to protect (can include schema: 'myschema.posts')
  • feature_name: The required feature to access rows
  • scope_type: The scope type to check
  • scope_column: The column in the table containing the scope value

Examples:

-- Protect posts table by department
SELECT public.c77_rbac_apply_policy(
    'posts',           -- table name
    'view_posts',      -- required feature
    'department',      -- scope type
    'department_id'    -- column with department ID
);

-- Protect orders by region  
SELECT public.c77_rbac_apply_policy(
    'sales.orders',    -- schema-qualified table
    'view_orders',     -- required feature
    'region',          -- scope type
    'region_code'      -- column with region code
);

-- Protect user profiles (users can only see their own)
SELECT public.c77_rbac_apply_policy(
    'user_profiles',   -- table name
    'view_profile',    -- required feature
    'user',            -- scope type
    'user_id'          -- column with user ID
);

4. c77_rbac_can_access(feature_name, external_id, scope_type, scope_id)

Purpose: Checks if a user has access to a feature with a specific scope.

Parameters:

  • feature_name: The feature to check
  • external_id: The user's ID
  • scope_type: The scope type
  • scope_id: The scope value

Returns: Boolean (true/false)

Examples:

-- Can user 123 view posts in marketing department?
SELECT public.c77_rbac_can_access('view_posts', '123', 'department', 'marketing');

-- Can user 456 edit orders in north region?
SELECT public.c77_rbac_can_access('edit_orders', '456', 'region', 'north');

-- Can user 1 (admin) delete users globally?
SELECT public.c77_rbac_can_access('delete_users', '1', 'global', 'all');

5. c77_rbac_sync_admin_features()

Purpose: Automatically grants all existing features to the 'admin' role.

Usage:

-- After adding new features, sync them to admin
SELECT public.c77_rbac_sync_admin_features();

When to use:

  • After adding new features to your system
  • During initial setup for admin roles
  • As part of deployment scripts

6. c77_rbac_sync_global_admin_features()

Purpose: Grants all features to any role that has 'global/all' scope.

Usage:

-- Sync all features to all global admin roles
SELECT public.c77_rbac_sync_global_admin_features();

When to use: When you have multiple admin-type roles with global scope.

Setting Up Your First RBAC System

Let's build a complete example for a blog application:

Step 1: Create Your Schema and Tables

-- Create application schema
CREATE SCHEMA blog;

-- Create tables
CREATE TABLE blog.posts (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT,
    author_id INTEGER NOT NULL,
    department TEXT NOT NULL,
    status TEXT DEFAULT 'draft',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE blog.comments (
    id SERIAL PRIMARY KEY,
    post_id INTEGER REFERENCES blog.posts(id),
    author_id INTEGER NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insert test data
INSERT INTO blog.posts (title, content, author_id, department, status) VALUES
('Marketing Strategy 2024', 'Content...', 100, 'marketing', 'published'),
('Engineering Best Practices', 'Content...', 200, 'engineering', 'published'),
('Sales Targets Q1', 'Content...', 300, 'sales', 'draft'),
('HR Policy Update', 'Content...', 400, 'hr', 'published');

Step 2: Define Your Permission Structure

-- Define features for different roles
-- Viewer role: can only read published posts
SELECT public.c77_rbac_grant_feature('viewer', 'view_published_posts');

-- Editor role: can view and edit posts in their department
SELECT public.c77_rbac_grant_feature('editor', 'view_posts');
SELECT public.c77_rbac_grant_feature('editor', 'edit_posts');
SELECT public.c77_rbac_grant_feature('editor', 'create_posts');

-- Manager role: all editor permissions plus publishing
SELECT public.c77_rbac_grant_feature('manager', 'view_posts');
SELECT public.c77_rbac_grant_feature('manager', 'edit_posts');
SELECT public.c77_rbac_grant_feature('manager', 'create_posts');
SELECT public.c77_rbac_grant_feature('manager', 'publish_posts');
SELECT public.c77_rbac_grant_feature('manager', 'delete_posts');

-- Admin role: everything
SELECT public.c77_rbac_grant_feature('admin', 'view_posts');
SELECT public.c77_rbac_grant_feature('admin', 'edit_posts');
SELECT public.c77_rbac_grant_feature('admin', 'create_posts');
SELECT public.c77_rbac_grant_feature('admin', 'publish_posts');
SELECT public.c77_rbac_grant_feature('admin', 'delete_posts');
SELECT public.c77_rbac_grant_feature('admin', 'manage_users');

Step 3: Assign Users to Roles

-- Marketing team
SELECT public.c77_rbac_assign_subject('101', 'editor', 'department', 'marketing');
SELECT public.c77_rbac_assign_subject('102', 'manager', 'department', 'marketing');

-- Engineering team  
SELECT public.c77_rbac_assign_subject('201', 'editor', 'department', 'engineering');
SELECT public.c77_rbac_assign_subject('202', 'viewer', 'department', 'engineering');

-- Admin user
SELECT public.c77_rbac_assign_subject('1', 'admin', 'global', 'all');

Step 4: Apply Security Policies

-- Editors and managers can only see posts in their department
SELECT public.c77_rbac_apply_policy(
    'blog.posts',
    'view_posts',
    'department',
    'department'
);

-- Anyone can see published posts (you'd implement this differently)
-- For now, let's protect unpublished posts

Step 5: Test the Security

-- Set user context and test
SET "c77_rbac.external_id" TO '101';  -- Marketing editor

-- Should only see marketing posts
SELECT * FROM blog.posts;

-- Switch to admin
SET "c77_rbac.external_id" TO '1';

-- Should see all posts
SELECT * FROM blog.posts;

Laravel Integration Guide

Step 1: Configure Database Connection

In .env:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=myapp_db
DB_USERNAME=myapp_user
DB_PASSWORD=your_secure_password

Step 2: Create Middleware

Create app/Http/Middleware/SetRbacContext.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

class SetRbacContext
{
    public function handle($request, Closure $next)
    {
        if (Auth::check()) {
            // Set the RBAC context for the current user
            DB::statement('SET "c77_rbac.external_id" TO ?', [Auth::id()]);
        } else {
            // Clear the context for guests
            DB::statement('RESET "c77_rbac.external_id"');
        }
        
        return $next($request);
    }
    
    public function terminate($request, $response)
    {
        // Clean up after the request
        DB::statement('RESET "c77_rbac.external_id"');
    }
}

Register in app/Http/Kernel.php:

protected $middlewareGroups = [
    'web' => [
        // ... other middleware
        \App\Http\Middleware\SetRbacContext::class,
    ],
    
    'api' => [
        // ... other middleware
        \App\Http\Middleware\SetRbacContext::class,
    ],
];

Step 3: Create RBAC Service

Create app/Services/RbacService.php:

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;

class RbacService
{
    /**
     * Check if current user has access to a feature
     */
    public function can(string $feature, string $scopeType, string $scopeId): bool
    {
        if (!Auth::check()) {
            return false;
        }
        
        // Cache permission checks for performance
        $cacheKey = "rbac:{$feature}:{$scopeType}:{$scopeId}:" . Auth::id();
        
        return Cache::remember($cacheKey, 300, function () use ($feature, $scopeType, $scopeId) {
            $result = DB::selectOne(
                'SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS allowed',
                [$feature, Auth::id(), $scopeType, $scopeId]
            );
            
            return $result->allowed ?? false;
        });
    }
    
    /**
     * Assign a role to a user
     */
    public function assignRole(int $userId, string $role, string $scopeType, string $scopeId): void
    {
        DB::statement(
            'SELECT public.c77_rbac_assign_subject(?, ?, ?, ?)',
            [(string) $userId, $role, $scopeType, $scopeId]
        );
        
        // Clear cache for this user
        Cache::tags(["rbac:user:{$userId}"])->flush();
    }
    
    /**
     * Grant a feature to a role
     */
    public function grantFeature(string $role, string $feature): void
    {
        DB::statement(
            'SELECT public.c77_rbac_grant_feature(?, ?)',
            [$role, $feature]
        );
    }
    
    /**
     * Apply RLS policy to a table
     */
    public function applyPolicy(string $table, string $feature, string $scopeType, string $scopeColumn): void
    {
        DB::statement(
            'SELECT public.c77_rbac_apply_policy(?, ?, ?, ?)',
            [$table, $feature, $scopeType, $scopeColumn]
        );
    }
    
    /**
     * Sync all features to admin roles
     */
    public function syncAdminFeatures(): void
    {
        DB::statement('SELECT public.c77_rbac_sync_admin_features()');
    }
    
    /**
     * Get all roles for a user
     */
    public function getUserRoles(int $userId): array
    {
        return DB::select('
            SELECT r.name as role, sr.scope_type, sr.scope_id
            FROM public.c77_rbac_subjects s
            JOIN public.c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
            JOIN public.c77_rbac_roles r ON sr.role_id = r.role_id
            WHERE s.external_id = ?
        ', [(string) $userId]);
    }
    
    /**
     * Get all features for a role
     */
    public function getRoleFeatures(string $role): array
    {
        return DB::select('
            SELECT f.name as feature
            FROM public.c77_rbac_roles r
            JOIN public.c77_rbac_role_features rf ON r.role_id = rf.role_id
            JOIN public.c77_rbac_features f ON rf.feature_id = f.feature_id
            WHERE r.name = ?
        ', [$role]);
    }
}

Step 4: Create Migrations

Create base migration for RBAC setup:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use App\Services\RbacService;

class SetupRbacRoles extends Migration
{
    protected RbacService $rbac;
    
    public function __construct()
    {
        $this->rbac = new RbacService();
    }
    
    public function up()
    {
        // Define your application's roles and features
        $roles = [
            'admin' => [
                'manage_users', 'view_all_data', 'edit_all_data',
                'delete_all_data', 'view_reports', 'export_data'
            ],
            'manager' => [
                'view_reports', 'edit_department_data', 
                'approve_content', 'export_data'
            ],
            'editor' => [
                'create_content', 'edit_content', 'view_content'
            ],
            'viewer' => [
                'view_content', 'view_reports'
            ]
        ];
        
        // Create roles and grant features
        foreach ($roles as $role => $features) {
            foreach ($features as $feature) {
                $this->rbac->grantFeature($role, $feature);
            }
        }
        
        // Sync admin features
        $this->rbac->syncAdminFeatures();
    }
    
    public function down()
    {
        // Note: This doesn't remove the roles/features
        // You might want to add cleanup logic here
    }
}

Migration for applying RLS to tables:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Services\RbacService;

class ApplyRlsToPosts extends Migration
{
    protected RbacService $rbac;
    
    public function __construct()
    {
        $this->rbac = new RbacService();
    }
    
    public function up()
    {
        // Add department column if it doesn't exist
        if (!Schema::hasColumn('posts', 'department_id')) {
            Schema::table('posts', function (Blueprint $table) {
                $table->unsignedInteger('department_id')->nullable();
            });
        }
        
        // Apply RLS policy
        $this->rbac->applyPolicy(
            'posts',              // table
            'view_content',       // required feature
            'department',         // scope type
            'department_id'       // scope column
        );
    }
    
    public function down()
    {
        // Remove RLS policy
        DB::statement('DROP POLICY IF EXISTS c77_rbac_policy ON posts');
        DB::statement('ALTER TABLE posts DISABLE ROW LEVEL SECURITY');
    }
}

Step 5: User Model Integration

Add methods to your User model:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use App\Services\RbacService;

class User extends Authenticatable
{
    protected static ?RbacService $rbacService = null;
    
    protected static function getRbacService(): RbacService
    {
        if (!self::$rbacService) {
            self::$rbacService = app(RbacService::class);
        }
        return self::$rbacService;
    }
    
    /**
     * Assign a role to this user
     */
    public function assignRole(string $role, string $scopeType = 'global', string $scopeId = 'all'): void
    {
        self::getRbacService()->assignRole($this->id, $role, $scopeType, $scopeId);
    }
    
    /**
     * Check if user has permission
     */
    public function can($feature, $scopeType = null, $scopeId = null): bool
    {
        // If using Laravel's built-in authorization
        if (is_string($feature) && strpos($feature, ':') !== false) {
            [$feature, $scope] = explode(':', $feature);
            [$scopeType, $scopeId] = explode('/', $scope);
        }
        
        return self::getRbacService()->can($feature, $scopeType ?? 'global', $scopeId ?? 'all');
    }
    
    /**
     * Get all roles for this user
     */
    public function getRoles(): array
    {
        return self::getRbacService()->getUserRoles($this->id);
    }
    
    /**
     * Check if user has a specific role
     */
    public function hasRole(string $role, ?string $scopeType = null, ?string $scopeId = null): bool
    {
        $roles = $this->getRoles();
        
        foreach ($roles as $userRole) {
            if ($userRole->role === $role) {
                if ($scopeType === null || $userRole->scope_type === $scopeType) {
                    if ($scopeId === null || $userRole->scope_id === $scopeId) {
                        return true;
                    }
                }
            }
        }
        
        return false;
    }
    
    /**
     * Check if user is admin
     */
    public function isAdmin(): bool
    {
        return $this->hasRole('admin', 'global', 'all');
    }
}

Step 6: Controller Integration

Example controller using RBAC:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use App\Services\RbacService;
use Illuminate\Http\Request;

class PostController extends Controller
{
    protected RbacService $rbac;
    
    public function __construct(RbacService $rbac)
    {
        $this->rbac = $rbac;
    }
    
    public function index()
    {
        // Posts are automatically filtered by RLS
        $posts = Post::all();
        
        return view('posts.index', compact('posts'));
    }
    
    public function create()
    {
        // Check if user can create posts in their department
        if (!auth()->user()->can('create_content:department/' . auth()->user()->department_id)) {
            abort(403, 'Unauthorized');
        }
        
        return view('posts.create');
    }
    
    public function edit(Post $post)
    {
        // Check if user can edit this specific post
        if (!$this->rbac->can('edit_content', 'department', $post->department_id)) {
            abort(403, 'Unauthorized');
        }
        
        return view('posts.edit', compact('post'));
    }
    
    public function destroy(Post $post)
    {
        // Only managers and admins can delete
        if (!$this->rbac->can('delete_content', 'department', $post->department_id)) {
            abort(403, 'Unauthorized');
        }
        
        $post->delete();
        
        return redirect()->route('posts.index');
    }
}

Step 7: Blade Templates

Use in Blade templates:

@if(auth()->user()->can('create_content:department/' . auth()->user()->department_id))
    <a href="{{ route('posts.create') }}" class="btn btn-primary">Create Post</a>
@endif

@foreach($posts as $post)
    <div class="post">
        <h2>{{ $post->title }}</h2>
        <p>{{ $post->content }}</p>
        
        @if(auth()->user()->can('edit_content:department/' . $post->department_id))
            <a href="{{ route('posts.edit', $post) }}">Edit</a>
        @endif
        
        @if(auth()->user()->can('delete_content:department/' . $post->department_id))
            <form action="{{ route('posts.destroy', $post) }}" method="POST">
                @csrf
                @method('DELETE')
                <button type="submit">Delete</button>
            </form>
        @endif
    </div>
@endforeach

Step 8: API Authentication

For API routes, ensure the context is set:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class ApiController extends Controller
{
    public function __construct()
    {
        $this->middleware(function ($request, $next) {
            if ($request->user()) {
                DB::statement('SET "c77_rbac.external_id" TO ?', [$request->user()->id]);
            }
            
            return $next($request);
        });
    }
}

Common Patterns and Examples

Pattern 1: Department-Based Access

// Migration
public function up()
{
    // Define department roles
    $this->rbac->grantFeature('dept_manager', 'view_dept_data');
    $this->rbac->grantFeature('dept_manager', 'edit_dept_data');
    $this->rbac->grantFeature('dept_manager', 'approve_dept_requests');
    
    $this->rbac->grantFeature('dept_member', 'view_dept_data');
    $this->rbac->grantFeature('dept_member', 'create_requests');
    
    // Apply policy to department data
    $this->rbac->applyPolicy(
        'department_data',
        'view_dept_data',
        'department',
        'dept_id'
    );
}

// Assigning users
$user->assignRole('dept_manager', 'department', 'sales');
$user->assignRole('dept_member', 'department', 'engineering');

Pattern 2: Multi-Tenant Application

// Migration for tenant isolation
public function up()
{
    // Define tenant roles
    $this->rbac->grantFeature('tenant_admin', 'manage_tenant');
    $this->rbac->grantFeature('tenant_user', 'view_tenant_data');
    
    // Apply policies to all tenant tables
    $tables = ['accounts', 'projects', 'invoices', 'users'];
    
    foreach ($tables as $table) {
        $this->rbac->applyPolicy(
            "tenants.{$table}",
            'view_tenant_data',
            'tenant',
            'tenant_id'
        );
    }
}

// Middleware for tenant context
public function handle($request, Closure $next)
{
    $tenant = $request->user()->tenant;
    
    // Set tenant context
    DB::statement('SET "app.current_tenant" TO ?', [$tenant->id]);
    DB::statement('SET "c77_rbac.external_id" TO ?', [$request->user()->id]);
    
    return $next($request);
}

Pattern 3: Hierarchical Permissions

// Regional hierarchy: Global > Region > Branch > Store
public function setupHierarchy()
{
    // Global admin sees everything
    $this->rbac->grantFeature('global_admin', 'view_all_stores');
    $this->rbac->assignRole($userId, 'global_admin', 'global', 'all');
    
    // Regional manager sees their region
    $this->rbac->grantFeature('regional_manager', 'view_region_stores');
    $this->rbac->assignRole($userId, 'regional_manager', 'region', 'west');
    
    // Branch manager sees their branch
    $this->rbac->grantFeature('branch_manager', 'view_branch_stores');
    $this->rbac->assignRole($userId, 'branch_manager', 'branch', 'west-1');
    
    // Store manager sees their store
    $this->rbac->grantFeature('store_manager', 'view_store_data');
    $this->rbac->assignRole($userId, 'store_manager', 'store', 'west-1-a');
}

Pattern 4: Time-Based Permissions (Advanced)

// Create a scheduled job to manage temporary permissions
class ManageTemporaryPermissions extends Command
{
    public function handle()
    {
        // Remove expired temporary roles
        DB::statement("
            DELETE FROM public.c77_rbac_subject_roles
            WHERE (scope_type, scope_id) IN (
                SELECT 'temporary', id::text
                FROM temporary_permissions
                WHERE expires_at < NOW()
            )
        ");
        
        // Add new temporary roles
        $newTemp = TemporaryPermission::where('starts_at', '<=', now())
            ->where('applied', false)
            ->get();
            
        foreach ($newTemp as $temp) {
            $this->rbac->assignRole(
                $temp->user_id,
                $temp->role,
                'temporary',
                $temp->id
            );
            
            $temp->update(['applied' => true]);
        }
    }
}

Advanced Usage

Custom Scopes

You can create any scope type that makes sense for your application:

// Example: Project-based access
$this->rbac->assignRole($userId, 'project_lead', 'project', $projectId);
$this->rbac->assignRole($userId, 'developer', 'project', $projectId);

// Example: Customer-based access for support
$this->rbac->assignRole($userId, 'support_agent', 'customer', $customerId);
$this->rbac->assignRole($userId, 'account_manager', 'customer_tier', 'platinum');

// Example: Geographic access
$this->rbac->assignRole($userId, 'country_manager', 'country', 'US');
$this->rbac->assignRole($userId, 'city_coordinator', 'city', 'NYC');

Dynamic Policy Application

Create a service to dynamically apply policies based on your schema:

class DynamicPolicyService
{
    protected RbacService $rbac;
    
    public function applyPoliciesForModel(string $modelClass)
    {
        $model = new $modelClass;
        $table = $model->getTable();
        
        // Get policy configuration for this model
        $policies = config("rbac.policies.{$modelClass}", []);
        
        foreach ($policies as $policy) {
            $this->rbac->applyPolicy(
                $table,
                $policy['feature'],
                $policy['scope_type'],
                $policy['scope_column']
            );
        }
    }
    
    public function applyAllPolicies()
    {
        $models = config('rbac.protected_models', []);
        
        foreach ($models as $model) {
            $this->applyPoliciesForModel($model);
        }
    }
}

// Config file: config/rbac.php
return [
    'protected_models' => [
        \App\Models\Post::class,
        \App\Models\Document::class,
        \App\Models\Invoice::class,
    ],
    
    'policies' => [
        \App\Models\Post::class => [
            [
                'feature' => 'view_posts',
                'scope_type' => 'department',
                'scope_column' => 'department_id',
            ],
        ],
        \App\Models\Invoice::class => [
            [
                'feature' => 'view_invoices',
                'scope_type' => 'customer',
                'scope_column' => 'customer_id',
            ],
            [
                'feature' => 'edit_invoices',
                'scope_type' => 'department',
                'scope_column' => 'created_by_dept',
            ],
        ],
    ],
];

Complex Permission Checks

For complex business rules that go beyond simple RBAC:

class AdvancedPermissionService
{
    protected RbacService $rbac;
    
    public function canApproveExpense(User $user, Expense $expense): bool
    {
        // Basic RBAC check
        if (!$this->rbac->can('approve_expenses', 'department', $expense->department_id)) {
            return false;
        }
        
        // Additional business rules
        if ($expense->amount > 10000 && !$user->hasRole('senior_manager')) {
            return false;
        }
        
        // Check spending limits
        $monthlySpent = $this->getMonthlySpending($user->department_id);
        if ($monthlySpent + $expense->amount > $user->department->budget_limit) {
            return false;
        }
        
        return true;
    }
    
    public function canEditDocument(User $user, Document $document): bool
    {
        // Owner can always edit their draft documents
        if ($document->author_id === $user->id && $document->status === 'draft') {
            return true;
        }
        
        // Editors can edit within their scope
        if ($this->rbac->can('edit_documents', 'department', $document->department_id)) {
            return true;
        }
        
        // Admins can edit anything
        if ($user->hasRole('admin', 'global', 'all')) {
            return true;
        }
        
        return false;
    }
}

Audit Logging

Track all permission changes:

class RbacAuditService
{
    public function logRoleAssignment(int $userId, string $role, string $scopeType, string $scopeId, ?int $assignedBy = null)
    {
        DB::table('rbac_audit_log')->insert([
            'action' => 'role_assigned',
            'user_id' => $userId,
            'details' => json_encode([
                'role' => $role,
                'scope_type' => $scopeType,
                'scope_id' => $scopeId,
            ]),
            'performed_by' => $assignedBy ?? auth()->id(),
            'created_at' => now(),
        ]);
    }
    
    public function logPermissionCheck(int $userId, string $feature, string $scopeType, string $scopeId, bool $granted)
    {
        // Only log denied permissions or critical features
        if ($granted && !$this->isCriticalFeature($feature)) {
            return;
        }
        
        DB::table('rbac_audit_log')->insert([
            'action' => 'permission_checked',
            'user_id' => $userId,
            'details' => json_encode([
                'feature' => $feature,
                'scope_type' => $scopeType,
                'scope_id' => $scopeId,
                'granted' => $granted,
            ]),
            'created_at' => now(),
        ]);
    }
    
    protected function isCriticalFeature(string $feature): bool
    {
        $critical = ['delete_users', 'manage_billing', 'export_all_data'];
        return in_array($feature, $critical);
    }
}

Testing RBAC

Create comprehensive tests:

class RbacTest extends TestCase
{
    protected RbacService $rbac;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->rbac = app(RbacService::class);
        
        // Clean up any existing test data
        DB::statement("DELETE FROM public.c77_rbac_subjects WHERE external_id LIKE 'test_%'");
    }
    
    public function test_department_isolation()
    {
        // Create test users
        $salesUser = User::factory()->create(['id' => 1001]);
        $engineeringUser = User::factory()->create(['id' => 1002]);
        
        // Assign roles
        $this->rbac->assignRole($salesUser->id, 'editor', 'department', 'sales');
        $this->rbac->assignRole($engineeringUser->id, 'editor', 'department', 'engineering');
        
        // Create test posts
        $salesPost = Post::factory()->create(['department_id' => 'sales']);
        $engineeringPost = Post::factory()->create(['department_id' => 'engineering']);
        
        // Test as sales user
        $this->actingAs($salesUser);
        $visiblePosts = Post::all();
        
        $this->assertTrue($visiblePosts->contains($salesPost));
        $this->assertFalse($visiblePosts->contains($engineeringPost));
    }
    
    public function test_admin_sees_everything()
    {
        $admin = User::factory()->create(['id' => 1003]);
        $this->rbac->assignRole($admin->id, 'admin', 'global', 'all');
        $this->rbac->syncAdminFeatures();
        
        // Create posts in different departments
        $posts = [
            Post::factory()->create(['department_id' => 'sales']),
            Post::factory()->create(['department_id' => 'engineering']),
            Post::factory()->create(['department_id' => 'hr']),
        ];
        
        $this->actingAs($admin);
        $visiblePosts = Post::all();
        
        foreach ($posts as $post) {
            $this->assertTrue($visiblePosts->contains($post));
        }
    }
    
    public function test_permission_caching()
    {
        $user = User::factory()->create();
        $this->rbac->assignRole($user->id, 'editor', 'department', 'sales');
        
        // First call should hit database
        $startQueries = DB::getQueryLog();
        $canEdit = $this->rbac->can('edit_posts', 'department', 'sales');
        $endQueries = DB::getQueryLog();
        
        $this->assertTrue(count($endQueries) > count($startQueries));
        
        // Second call should use cache
        $startQueries = DB::getQueryLog();
        $canEdit2 = $this->rbac->can('edit_posts', 'department', 'sales');
        $endQueries = DB::getQueryLog();
        
        $this->assertEquals(count($startQueries), count($endQueries));
        $this->assertEquals($canEdit, $canEdit2);
    }
}

Troubleshooting Guide

Problem: No Data Returned

Symptoms: Queries return empty results when data should be visible.

Diagnosis Steps:

  1. Check if the session variable is set:
SELECT current_setting('c77_rbac.external_id', true);
  1. Verify the user has the correct role:
SELECT s.external_id, r.name, sr.scope_type, sr.scope_id
FROM public.c77_rbac_subjects s
JOIN public.c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
JOIN public.c77_rbac_roles r ON sr.role_id = r.role_id
WHERE s.external_id = 'your_user_id';
  1. Check if the role has the required feature:
SELECT r.name as role, f.name as feature
FROM public.c77_rbac_roles r
JOIN public.c77_rbac_role_features rf ON r.role_id = rf.role_id
JOIN public.c77_rbac_features f ON rf.feature_id = f.feature_id
WHERE r.name = 'your_role';
  1. Verify the policy is applied correctly:
SELECT * FROM pg_policies 
WHERE tablename = 'your_table';
  1. Test the access function directly:
SELECT public.c77_rbac_can_access('feature', 'user_id', 'scope_type', 'scope_id');

Common Solutions:

  • Ensure middleware is setting the external_id
  • Check for typos in feature names
  • Verify scope values match exactly
  • Remember admin roles need explicit feature grants

Problem: Performance Issues

Symptoms: Slow queries on tables with RLS policies.

Diagnosis:

EXPLAIN ANALYZE SELECT * FROM your_table;

Solutions:

  1. Add indexes on scope columns:
CREATE INDEX idx_posts_department_id ON posts(department_id);
  1. Use connection pooling carefully:
// In database.php
'pgsql' => [
    'sticky' => true,  // Reuse connections
    // ... other settings
],
  1. Cache permission checks:
public function can($feature, $scopeType, $scopeId): bool
{
    return Cache::tags(['rbac', "user:{$this->id}"])->remember(
        "can:{$feature}:{$scopeType}:{$scopeId}",
        300,
        fn() => $this->checkPermission($feature, $scopeType, $scopeId)
    );
}

Problem: Middleware Not Working

Symptoms: User context not set, permissions not enforced.

Diagnosis:

// Add to middleware
\Log::info('RBAC Middleware', [
    'user_id' => Auth::id(),
    'external_id' => DB::selectOne("SELECT current_setting('c77_rbac.external_id', true) as id")->id
]);

Solutions:

  1. Check middleware registration order
  2. Ensure auth middleware runs before RBAC middleware
  3. Handle API authentication separately

Problem: Admin Can't Access Data

Symptoms: Admin users with global/all scope can't see data.

Solution:

-- Sync all features to admin
SELECT public.c77_rbac_sync_admin_features();

-- Or manually grant specific features
SELECT public.c77_rbac_grant_feature('admin', 'specific_feature');

Performance Considerations

1. Index Strategy

Always index columns used in RLS policies:

-- For department-based access
CREATE INDEX idx_table_department ON table_name(department_id);

-- For user-based access
CREATE INDEX idx_table_user ON table_name(user_id);

-- For composite scopes
CREATE INDEX idx_table_composite ON table_name(department_id, status);

2. Query Optimization

Use EXPLAIN ANALYZE to understand query performance:

-- Before applying RLS
EXPLAIN ANALYZE SELECT * FROM posts WHERE department_id = 'sales';

-- After applying RLS
SET "c77_rbac.external_id" TO '123';
EXPLAIN ANALYZE SELECT * FROM posts;

3. Caching Strategy

Implement multi-level caching:

class CachedRbacService extends RbacService
{
    public function can(string $feature, string $scopeType, string $scopeId): bool
    {
        // L1: In-memory cache (request lifecycle)
        static $cache = [];
        $key = "{$feature}:{$scopeType}:{$scopeId}:" . Auth::id();
        
        if (isset($cache[$key])) {
            return $cache[$key];
        }
        
        // L2: Redis/Memcached cache
        $result = Cache::remember("rbac:{$key}", 300, function () use ($feature, $scopeType, $scopeId) {
            return parent::can($feature, $scopeType, $scopeId);
        });
        
        $cache[$key] = $result;
        return $result;
    }
}

4. Batch Operations

For bulk operations, temporarily switch to a privileged user:

public function bulkImport(array $data)
{
    // Save current user
    $currentUser = DB::selectOne("SELECT current_setting('c77_rbac.external_id', true) as id")->id;
    
    try {
        // Switch to system user with full access
        DB::statement('SET "c77_rbac.external_id" TO ?', ['system']);
        
        // Perform bulk operations
        DB::transaction(function () use ($data) {
            foreach ($data as $record) {
                Model::create($record);
            }
        });
    } finally {
        // Restore original user
        DB::statement('SET "c77_rbac.external_id" TO ?', [$currentUser]);
    }
}

Security Best Practices

1. Input Validation

Always validate scope parameters:

public function assignRole(Request $request)
{
    $request->validate([
        'user_id' => 'required|integer|exists:users,id',
        'role' => 'required|string|in:admin,manager,editor,viewer',
        'scope_type' => 'required|string|in:global,department,region,project',
        'scope_id' => 'required|string|max:50',
    ]);
    
    // Sanitize scope_id to prevent SQL injection
    $scopeId = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->scope_id);
    
    $this->rbac->assignRole(
        $request->user_id,
        $request->role,
        $request->scope_type,
        $scopeId
    );
}

2. Principle of Least Privilege

Start with minimal permissions:

// Default role for new users
public function registered(User $user)
{
    // Give only viewing permissions by default
    $user->assignRole('viewer', 'department', $user->department_id);
}

// Elevate privileges only when needed
public function promoteToEditor(User $user)
{
    // Remove viewer role first
    $this->rbac->removeRole($user->id, 'viewer', 'department', $user->department_id);
    
    // Add editor role
    $user->assignRole('editor', 'department', $user->department_id);
}

3. Session Security

Clean up sessions properly:

class SessionCleanupMiddleware
{
    public function handle($request, Closure $next)
    {
        $response = $next($request);
        
        // Always reset on response
        DB::statement('RESET "c77_rbac.external_id"');
        
        return $response;
    }
    
    public function terminate($request, $response)
    {
        // Extra cleanup for long-running processes
        DB::statement('RESET ALL');
    }
}

4. Audit Critical Operations

Log all permission changes and critical accesses:

class AuditableRbacService extends RbacService
{
    public function assignRole(int $userId, string $role, string $scopeType, string $scopeId): void
    {
        parent::assignRole($userId, $role, $scopeType, $scopeId);
        
        activity()
            ->performedOn(User::find($userId))
            ->causedBy(auth()->user())
            ->withProperties([
                'role' => $role,
                'scope_type' => $scopeType,
                'scope_id' => $scopeId,
            ])
            ->log('Role assigned');
    }
    
    public function can(string $feature, string $scopeType, string $scopeId): bool
    {
        $result = parent::can($feature, $scopeType, $scopeId);
        
        // Log only critical features or denials
        if (!$result || $this->isCriticalFeature($feature)) {
            activity()
                ->causedBy(auth()->user())
                ->withProperties([
                    'feature' => $feature,
                    'scope_type' => $scopeType,
                    'scope_id' => $scopeId,
                    'granted' => $result,
                ])
                ->log('Permission check');
        }
        
        return $result;
    }
}

5. Regular Security Reviews

Create a command to audit your RBAC setup:

class AuditRbacCommand extends Command
{
    protected $signature = 'rbac:audit';
    
    public function handle()
    {
        $this->info('RBAC Security Audit');
        
        // Check for users with multiple admin roles
        $multiAdmins = DB::select("
            SELECT s.external_id, COUNT(*) as admin_roles
            FROM public.c77_rbac_subjects s
            JOIN public.c77_rbac_subject_roles sr ON s.subject_id = sr.subject_id
            JOIN public.c77_rbac_roles r ON sr.role_id = r.role_id
            WHERE r.name = 'admin'
            GROUP BY s.external_id
            HAVING COUNT(*) > 1
        ");
        
        if (count($multiAdmins) > 0) {
            $this->warn('Users with multiple admin roles: ' . count($multiAdmins));
        }
        
        // Check for overly permissive policies
        $policies = DB::select("
            SELECT schemaname, tablename, policyname, qual
            FROM pg_policies
            WHERE policyname = 'c77_rbac_policy'
        ");
        
        foreach ($policies as $policy) {
            if (strpos($policy->qual, 'true') !== false) {
                $this->error("Potentially overly permissive policy on {$policy->schemaname}.{$policy->tablename}");
            }
        }
        
        // Check for orphaned roles
        $orphanedRoles = DB::select("
            SELECT r.name
            FROM public.c77_rbac_roles r
            LEFT JOIN public.c77_rbac_subject_roles sr ON r.role_id = sr.role_id
            WHERE sr.role_id IS NULL
        ");
        
        if (count($orphanedRoles) > 0) {
            $this->warn('Orphaned roles (no users): ' . count($orphanedRoles));
        }
        
        $this->info('Audit complete');
    }
}

Summary

The c77_rbac extension provides a powerful, database-centric approach to authorization. Key takeaways:

  1. Database-Level Security: RLS policies ensure consistent access control
  2. Flexible Scoping: Support for any type of organizational structure
  3. Framework Agnostic: Works with any application framework
  4. Performance: Optimized with indexes and caching strategies
  5. Maintainable: Clear separation of concerns between features, roles, and policies

Remember to:

  • Always grant specific features to admin roles
  • Use caching for permission checks
  • Index columns used in policies
  • Audit your RBAC setup regularly
  • Clean up database sessions properly

With proper setup and maintenance, c77_rbac provides enterprise-grade authorization that scales with your application.