From 39976f150008e767c208e8bb17deeede09058962 Mon Sep 17 00:00:00 2001 From: trogers1884 Date: Sun, 13 Apr 2025 07:24:32 -0500 Subject: [PATCH] Revision 1.0.0 c77_rbac_laravel --- LICENSE.md | 21 ++ README.md | 217 ++++++++++++ USEAGE.md | 675 ++++++++++++++++++++++++++++++++++++ c77_rbac_laravel--1.0.0.sql | 21 ++ c77_rbac_laravel.control | 6 + 5 files changed, 940 insertions(+) create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 USEAGE.md create mode 100644 c77_rbac_laravel--1.0.0.sql create mode 100644 c77_rbac_laravel.control diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..197801a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2025 c77_rbac_laravel Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70b946f --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# c77_rbac_laravel + +A PostgreSQL extension that provides Laravel integration for the `c77_rbac` Role-Based Access Control system. This extension simplifies the use of `c77_rbac` in Laravel applications by providing Laravel-specific functions and utilities. + +## Prerequisites + +- PostgreSQL 13 or later (tested on 17) +- `c77_rbac` extension must be installed +- Laravel 9.0 or later + +## Installation + +### Step 1: Install the Extension + +Copy the extension files to your PostgreSQL extension directory: + +```bash +sudo cp c77_rbac_laravel--1.0.sql c77_rbac_laravel.control /usr/share/postgresql/17/extension/ +``` + +### Step 2: Create/Connect to Your Database + +Ensure you have a database with the `c77_rbac` extension already installed: + +```sql +-- Connect to your database +\c your_database_name + +-- Verify c77_rbac is installed +SELECT * FROM pg_extension WHERE extname = 'c77_rbac'; +``` + +### Step 3: Enable the Laravel Extension + +```sql +CREATE EXTENSION c77_rbac_laravel; +``` + +## Features + +The extension provides two key functions: + +1. **c77_rbac_laravel_auth_id()** + - Retrieves the current Laravel user ID from the session variable + - Used in RLS policies to identify the current user + - Returns NULL if the session variable is not set + +2. **c77_rbac_laravel_assign_user()** + - Assigns a Laravel user to a role with optional scope + - Automatically converts Laravel's integer IDs to the text format used by `c77_rbac` + +## Integration with Laravel + +### Middleware Setup + +Create a middleware to set the current user's ID as the `c77_rbac.external_id` session variable: + +```php + [ + // Other middleware... + \App\Http\Middleware\SetRbacExternalId::class, + ], +]; +``` + +### Setting Up RBAC in Migrations + +```php +get(); +``` + +### Manual Access Checks + +You can also perform manual access checks in your code: + +```php +$userId = Auth::id(); +$canAccess = DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result +", ['edit_content', $userId, 'department', 'marketing'])->result; + +if ($canAccess) { + // Allow the action +} else { + // Deny the action +} +``` + +## Connection Pooling Considerations + +If you're using connection pooling (e.g., PgBouncer), be aware that session variables like `c77_rbac.external_id` persist for the duration of the database connection. For session pooling to work correctly: + +1. Ensure your pooling is configured in "Session" mode, not "Transaction" mode +2. Set the external ID at the beginning of each request via middleware +3. Consider using a cleanup middleware for long-lived connections + +## Troubleshooting + +### No Rows Returned + +If your queries return no rows when you expect data: + +1. Verify the middleware is setting the external ID: + ```php + $externalId = DB::selectOne("SELECT current_setting('c77_rbac.external_id', true) AS id")->id; + ``` + +2. Check if your user has the required role and feature: + ```php + $hasAccess = DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result + ", ['required_feature', Auth::id(), 'scope_type', 'scope_id'])->result; + ``` + +### Database Errors + +For "undefined_object" errors related to `c77_rbac.external_id`, ensure your middleware is running and setting the variable correctly. + +## License + +MIT License. See `LICENSE` file for details. + +## Related Projects + +- [c77_rbac](https://github.com/yourusername/c77_rbac) - The core RBAC extension for PostgreSQL + +--- + +For more detailed information on the underlying RBAC system, please refer to the documentation for the `c77_rbac` extension. diff --git a/USEAGE.md b/USEAGE.md new file mode 100644 index 0000000..f0e5384 --- /dev/null +++ b/USEAGE.md @@ -0,0 +1,675 @@ +# c77_rbac_laravel Usage Guide + +This guide provides detailed instructions for using the `c77_rbac_laravel` PostgreSQL extension, which integrates the `c77_rbac` Role-Based Access Control system with Laravel applications. The guide assumes you have already installed both the `c77_rbac` and `c77_rbac_laravel` extensions. + +## Table of Contents + +1. [Extension Functions](#extension-functions) +2. [Laravel Integration Setup](#laravel-integration-setup) +3. [Common Usage Patterns](#common-usage-patterns) +4. [Advanced Usage](#advanced-usage) +5. [Best Practices](#best-practices) +6. [Troubleshooting](#troubleshooting) + +## Extension Functions + +The `c77_rbac_laravel` extension provides two key functions that bridge Laravel's authentication system with PostgreSQL's RBAC implementation: + +### c77_rbac_laravel_auth_id() + +Retrieves the current Laravel user ID from the session variable. + +```sql +SELECT public.c77_rbac_laravel_auth_id(); +``` + +- **Returns**: TEXT value of the current user's ID, or NULL if not set +- **Use Case**: Used within RLS policies to identify the current user +- **Example**: + ```sql + CREATE POLICY user_data_policy ON user_data + FOR ALL TO PUBLIC + USING (user_id::text = public.c77_rbac_laravel_auth_id()); + ``` + +### c77_rbac_laravel_assign_user() + +Assigns a Laravel user to a role with optional scope. + +```sql +SELECT public.c77_rbac_laravel_assign_user( + p_laravel_id BIGINT, -- Laravel user ID (integer) + p_role_name TEXT, -- Role name to assign + p_scope_type TEXT, -- Optional scope type + p_scope_id TEXT -- Optional scope ID +); +``` + +- **Parameters**: + - `p_laravel_id`: Laravel's integer user ID (will be converted to TEXT) + - `p_role_name`: Name of the role to assign + - `p_scope_type`: Scope category (can be NULL) + - `p_scope_id`: Scope value (can be NULL) +- **Example**: + ```sql + -- Assign user ID 1 to the 'editor' role for the 'marketing' department + SELECT public.c77_rbac_laravel_assign_user(1, 'editor', 'department', 'marketing'); + + -- Assign user ID 2 to the global 'admin' role + SELECT public.c77_rbac_laravel_assign_user(2, 'admin', NULL, NULL); + ``` + +## Laravel Integration Setup + +### Setting Up the Middleware + +Create a middleware to set the `c77_rbac.external_id` session variable for each request: + +```php + [ + // Other middleware... + \App\Http\Middleware\SetRbacExternalId::class, + ], +]; +``` + +### Database Migrations + +Create migrations to set up your RBAC rules: + +```php + ['view_dashboard', 'edit_content', 'publish_content', 'manage_users', 'view_reports'], + 'editor' => ['view_dashboard', 'edit_content', 'publish_content', 'view_reports'], + 'viewer' => ['view_dashboard', 'view_reports'] + ]; + + foreach ($roleFeatures as $role => $features) { + foreach ($features as $feature) { + DB::statement(" + SELECT public.c77_rbac_grant_feature(?, ?) + ", [$role, $feature]); + } + } + } + + public function down() + { + // Clean up if needed + DB::statement("TRUNCATE public.c77_rbac_role_features CASCADE"); + } +} +``` + +```php +get(); + foreach ($adminUsers as $admin) { + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$admin->id, 'admin', NULL, NULL]); + } + + // Assign department editors + $editors = User::where('role', 'editor')->get(); + foreach ($editors as $editor) { + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$editor->id, 'editor', 'department', $editor->department]); + } + + // Assign viewers + $viewers = User::where('role', 'viewer')->get(); + foreach ($viewers as $viewer) { + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$viewer->id, 'viewer', 'department', $viewer->department]); + } + } + + public function down() + { + // Clean up if needed + DB::statement("TRUNCATE public.c77_rbac_subject_roles CASCADE"); + } +} +``` + +### Applying RLS to Tables + +Create migrations to enable RLS on your tables: + +```php + $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'department' => $request->department, + ]); + + // Assign default role with department scope + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$user->id, 'viewer', 'department', $user->department]); + + // Continue with registration process... +} +``` + +### Role Management + +Create a controller for managing user roles: + +```php +public function assignRole(User $user, Request $request) +{ + $request->validate([ + 'role' => 'required|string|exists:c77_rbac_roles,name', + 'scope_type' => 'nullable|string', + 'scope_id' => 'nullable|string', + ]); + + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [ + $user->id, + $request->role, + $request->scope_type, + $request->scope_id + ]); + + return back()->with('success', 'Role assigned successfully'); +} +``` + +### Checking Permissions in Controllers + +Check if a user has a specific permission: + +```php +public function edit(Content $content) +{ + $canEdit = DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result + ", ['edit_content', Auth::id(), 'department', $content->department])->result; + + if (!$canEdit) { + abort(403, 'Unauthorized action.'); + } + + return view('content.edit', compact('content')); +} +``` + +### Blade Directives + +Create custom Blade directives for permission checks: + +```php +// In AppServiceProvider.php +public function boot() +{ + Blade::directive('can', function ($expression) { + list($feature, $scopeType, $scopeId) = explode(',', $expression); + + return "id(), {$scopeType}, {$scopeId}])->result): ?>"; + }); + + Blade::directive('endcan', function () { + return ""; + }); +} +``` + +Usage in Blade: + +```blade +@can('edit_content', 'department', $content->department) + Edit +@endcan +``` + +## Advanced Usage + +### Batch User Assignments + +For batch operations, use a transaction to improve performance: + +```php +DB::transaction(function () use ($users, $role, $scopeType, $scopeId) { + foreach ($users as $user) { + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$user->id, $role, $scopeType, $scopeId]); + } +}); +``` + +### Dynamic Scope Resolution + +Create a helper for dynamic scope resolution: + +```php +// In a helper file or service +function resolveScope($model) +{ + switch (get_class($model)) { + case 'App\Models\Department': + return ['department', $model->id]; + case 'App\Models\Project': + return ['project', $model->id]; + case 'App\Models\Team': + return ['team', $model->id]; + default: + return [null, null]; + } +} + +// Usage +list($scopeType, $scopeId) = resolveScope($department); +DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) +", [$user->id, 'manager', $scopeType, $scopeId]); +``` + +### Command-Line Role Management + +Create an Artisan command for role management: + +```php +argument('user')); + $role = $this->argument('role'); + $scopeType = $this->option('scope-type'); + $scopeId = $this->option('scope-id'); + + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$user->id, $role, $scopeType, $scopeId]); + + $this->info("Role '$role' assigned to user #{$user->id}"); + + return 0; + } +} +``` + +Usage: + +```bash +php artisan rbac:assign-role 1 editor --scope-type=department --scope-id=marketing +``` + +## Best Practices + +### Connection Management + +1. **Connection Pooling**: If using connection pooling (like PgBouncer), always reset the `c77_rbac.external_id` variable at the end of each request: + +```php +// In a middleware or terminating callback +public function terminate($request, $response) +{ + DB::statement('RESET "c77_rbac.external_id"'); +} +``` + +2. **Long-running Jobs**: For queued jobs, set the external ID at the beginning of the job: + +```php +public function handle() +{ + // Set the RBAC external ID for this job + DB::statement('SET "c77_rbac.external_id" TO ?', [$this->user_id]); + + // Job logic... + + // Reset when done + DB::statement('RESET "c77_rbac.external_id"'); +} +``` + +### Security + +1. **Middleware Priority**: Ensure your RBAC middleware runs early in the request cycle to protect all database queries. + +2. **Authorization Wrapper**: Create a service for all authorization checks: + +```php +// app/Services/RbacService.php +class RbacService +{ + public function can($feature, $scopeType = null, $scopeId = null) + { + $userId = auth()->id(); + if (!$userId) return false; + + return DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result + ", [$feature, $userId, $scopeType, $scopeId])->result; + } + + public function assignRole($userId, $role, $scopeType = null, $scopeId = null) + { + DB::statement(" + SELECT public.c77_rbac_laravel_assign_user(?, ?, ?, ?) + ", [$userId, $role, $scopeType, $scopeId]); + } +} +``` + +3. **Policy Integration**: Integrate with Laravel's policy system: + +```php +// app/Policies/ContentPolicy.php +class ContentPolicy +{ + protected $rbac; + + public function __construct(RbacService $rbac) + { + $this->rbac = $rbac; + } + + public function update(User $user, Content $content) + { + return $this->rbac->can('edit_content', 'department', $content->department); + } +} +``` + +### Performance + +1. **Eager Loading**: When loading related models that will be filtered by RLS, use eager loading with care: + +```php +// This will load ALL departments first, then filter +$user->load('departments'); + +// Better approach for large datasets +$departments = Department::all(); // Already filtered by RLS +``` + +2. **Cache Role Assignments**: Cache role assignments for frequently accessed permissions: + +```php +public function can($feature, $scopeType = null, $scopeId = null) +{ + $cacheKey = "rbac:{$feature}:{$scopeType}:{$scopeId}:" . auth()->id(); + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($feature, $scopeType, $scopeId) { + return DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result + ", [$feature, auth()->id(), $scopeType, $scopeId])->result; + }); +} +``` + +## Troubleshooting + +### Common Issues + +1. **No Data Returned** + + **Symptom**: Queries return empty results when you expect data. + + **Possible Causes**: + - External ID not set + - User doesn't have required permissions + - RLS policy incorrectly configured + + **Solution**: + ```php + // Check if external ID is set + $externalId = DB::selectOne("SELECT current_setting('c77_rbac.external_id', true) AS id")->id; + + // Check direct permissions + $hasPermission = DB::selectOne(" + SELECT public.c77_rbac_can_access(?, ?, ?, ?) AS result + ", ['required_feature', auth()->id(), 'scope_type', 'scope_id'])->result; + + // Verify RLS policy + $policyExists = DB::selectOne(" + SELECT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'your_table' AND policyname = 'your_policy' + ) AS exists + ")->exists; + ``` + +2. **Permission Denied Errors** + + **Symptom**: PostgreSQL returns permission denied errors. + + **Possible Causes**: + - Database user doesn't have execute permission on functions + - RLS preventing access to tables + + **Solution**: + ```sql + -- Grant execute permission + GRANT EXECUTE ON FUNCTION public.c77_rbac_laravel_auth_id() TO app_user; + GRANT EXECUTE ON FUNCTION public.c77_rbac_laravel_assign_user(BIGINT, TEXT, TEXT, TEXT) TO app_user; + + -- Check for explicit denials in RLS + SELECT tablename, policyname, cmd, qual + FROM pg_policies + WHERE tablename = 'your_table'; + ``` + +3. **Middleware Not Working** + + **Symptom**: RBAC checks fail despite user being logged in. + + **Possible Causes**: + - Middleware not registered or running + - Auth system not initialized when middleware runs + + **Solution**: + - Check middleware registration in Kernel.php + - Add debug code to verify middleware execution: + ```php + // In your middleware + public function handle($request, Closure $next) + { + \Log::debug('RBAC Middleware - User: ' . (Auth::check() ? Auth::id() : 'Guest')); + + if (Auth::check()) { + DB::statement('SET "c77_rbac.external_id" TO ?', [Auth::id()]); + \Log::debug('Set c77_rbac.external_id to ' . Auth::id()); + } + + return $next($request); + } + ``` + +4. **Role Assignment Issues** + + **Symptom**: User assignments aren't taking effect. + + **Possible Causes**: + - Transaction issues + - Scopes not matching exactly + + **Solution**: + - Ensure transactions are committed + - Check for exact scope matches: + ```sql + SELECT * FROM c77_rbac_subject_roles + WHERE subject_id = ( + SELECT subject_id FROM c77_rbac_subjects + WHERE external_id = '1' + ); + ``` + +For persistent issues, you can enable PostgreSQL query logging to see exactly what's happening: + +```php +// In a debug route or controller +DB::listen(function ($query) { + \Log::debug($query->sql, [ + 'bindings' => $query->bindings, + 'time' => $query->time + ]); +}); +``` \ No newline at end of file diff --git a/c77_rbac_laravel--1.0.0.sql b/c77_rbac_laravel--1.0.0.sql new file mode 100644 index 0000000..68d524f --- /dev/null +++ b/c77_rbac_laravel--1.0.0.sql @@ -0,0 +1,21 @@ +-- /usr/share/postgresql/17/extension/c77_rbac_laravel--1.0.sql +\echo Use "CREATE EXTENSION c77_rbac_laravel" to load this file. \quit + +CREATE OR REPLACE FUNCTION public.c77_rbac_laravel_auth_id() RETURNS TEXT AS $$ +BEGIN + RETURN current_setting('c77_rbac.external_id', true); +EXCEPTION WHEN OTHERS THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql STABLE; + +CREATE OR REPLACE FUNCTION public.c77_rbac_laravel_assign_user( + p_laravel_id BIGINT, + p_role_name TEXT, + p_scope_type TEXT, + p_scope_id TEXT +) RETURNS VOID AS $$ +BEGIN + PERFORM public.c77_rbac_assign_subject(p_laravel_id::TEXT, p_role_name, p_scope_type, p_scope_id); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/c77_rbac_laravel.control b/c77_rbac_laravel.control new file mode 100644 index 0000000..b893071 --- /dev/null +++ b/c77_rbac_laravel.control @@ -0,0 +1,6 @@ +# /usr/share/postgresql/17/extension/c77_rbac_laravel.control +comment = 'Laravel integration for c77_rbac' +default_version = '1.0' +module_pathname = '$libdir/c77_rbac_laravel' +relocatable = true +requires = 'c77_rbac' \ No newline at end of file