# 👥 Authorization — Groups & Permissions Daycry Auth includes a full **Role-Based Access Control (RBAC)** system built on two concepts: - **Groups** — Named roles (e.g., `admin`, `editor`, `user`) - **Permissions** — Specific actions (e.g., `posts.create`, `users.delete`) Both are stored in the database and can be assigned freely to any user. ## 📋 Table of Contents - [Quick Reference](#quick-reference) - [Groups](#groups) - [Permissions](#permissions) - [Permission Inheritance](#permission-inheritance) - [Gates & Policies](#gates--policies) - [Authorization in Controllers](#authorization-in-controllers) - [Authorization in Views](#authorization-in-views) - [Route Filters](#route-filters) - [Permission Cache](#permission-cache) - [Default Group for New Users](#default-group-for-new-users) - [Admin Panel](#admin-panel) - [Best Practices](#best-practices) --- ## Quick Reference ```php // Groups $user->inGroup('admin'); // bool $user->addGroup('editor'); // void $user->removeGroup('editor'); // void $user->getGroups(); // array of group names // Permissions $user->can('posts.create'); // bool $user->addPermission('posts.edit'); // void $user->removePermission('posts.edit'); // void $user->getPermissions(); // array of permission names // Shortcuts (throws AuthorizationException on failure) $user->authorize('posts.delete'); // Multiple groups / permissions $user->inGroup('admin', 'moderator'); // true if in ANY of them $user->hasAnyPermission('posts.edit', 'posts.delete'); // true if has ANY ``` --- ## Groups ### What Is a Group? A group is a named role. Users can belong to **multiple groups**. Example groups: `admin`, `editor`, `subscriber`, `premium`. Groups are stored in the `auth_groups` table. You create them through the Admin Panel or directly via SQL/migrations. ### Checking Group Membership ```php $user = auth()->user(); // Single group check if ($user->inGroup('admin')) { echo "Welcome, administrator!"; } // Check multiple groups (true if the user is in ANY of them) if ($user->inGroup('admin', 'moderator')) { echo "Welcome, elevated user!"; } // Get all groups the user belongs to $groups = $user->getGroups(); // Returns: ['admin', 'editor'] ``` ### Assigning and Removing Groups ```php $user = auth()->user(); // Add to a group $user->addGroup('editor'); $user->addGroup('premium', 'beta-tester'); // Multiple groups at once // Remove from a group $user->removeGroup('editor'); // Check and conditionally assign if (! $user->inGroup('subscriber')) { $user->addGroup('subscriber'); } ``` ### Getting All Users in a Group ```php use Daycry\Auth\Models\GroupModel; $groupModel = model(GroupModel::class); $adminGroup = $groupModel->where('name', 'admin')->first(); // Get all users in the admin group $adminUsers = model(\Daycry\Auth\Models\UserModel::class) ->join('auth_groups_users', 'auth_groups_users.user_id = users.id') ->join('auth_groups', 'auth_groups.id = auth_groups_users.group_id') ->where('auth_groups.name', 'admin') ->findAll(); ``` --- ## Permissions ### Permission Format Permissions follow a `resource.action` convention: ``` posts.create posts.edit posts.delete users.view users.edit admin.panel ``` Using dots keeps permissions organized and makes wildcard patterns intuitive. ### Checking Permissions ```php $user = auth()->user(); // Direct check if ($user->can('posts.create')) { // User can create posts } // Negation if (! $user->can('users.delete')) { return redirect()->back()->with('error', 'You cannot delete users.'); } // Check multiple permissions (true if the user has ALL of them) if ($user->can('posts.create') && $user->can('posts.publish')) { // User can create AND publish } // Check if user has ANY of several permissions if ($user->hasAnyPermission('posts.edit', 'posts.delete')) { // Can edit or delete } ``` ### Assigning and Removing Permissions Permissions can be assigned **directly to a user** (user-level) or **to a group** (group-level, then inherited by all group members). ```php $user = auth()->user(); // Direct user permissions $user->addPermission('posts.edit'); $user->addPermission('posts.publish', 'posts.feature'); // Multiple at once // Remove a permission $user->removePermission('posts.edit'); // Get all permissions the user has (direct + inherited from groups) $allPermissions = $user->getPermissions(); ``` ### Assigning Permissions to a Group ```php use Daycry\Auth\Models\GroupModel; $groupModel = model(GroupModel::class); $editorGroup = $groupModel->where('name', 'editor')->first(); // Add permission to the group — all editors inherit this $editorGroup->addPermission('posts.create'); $editorGroup->addPermission('posts.edit'); ``` --- ## Permission Inheritance ``` User ├── Direct permissions: [posts.delete] └── Groups: ├── editor → permissions: [posts.create, posts.edit] └── premium → permissions: [posts.feature] Effective permissions: [posts.delete, posts.create, posts.edit, posts.feature] ``` `$user->can('posts.create')` returns `true` because `editor` group has that permission, and the user is in the `editor` group. ### Wildcard Permissions Use `*` as a wildcard: ```php // Grant access to all "posts" permissions $user->addPermission('posts.*'); $user->can('posts.create'); // true $user->can('posts.edit'); // true $user->can('posts.delete'); // true $user->can('users.view'); // false (different resource) ``` ```php // Grant ALL permissions (superadmin) $user->addPermission('*'); $user->can('posts.delete'); // true $user->can('users.view'); // true ``` --- ## Gates & Policies The RBAC system above answers questions like *"does this user have permission `posts.update`?"* — **a property of the user only**. For *"is this user allowed to update **this specific post**?"* — a question that depends on the resource — daycry/auth ships a Gate / Policy layer inspired by Laravel's `Gate` facade. Use both: RBAC for static role checks, Gates for context-dependent rules. ### Closure-based abilities Register a closure to define an ability inline. The first parameter is the authenticated user (or `null` for guests); any further arguments are the resource(s) being checked: ```php // app/Config/Events.php (or any bootstrap point) use Daycry\Auth\Authorization\Gate; use Daycry\Auth\Entities\User; service('gate')->define('post.update', static function (?User $user, Post $post): bool { return $user !== null && $user->id === $post->author_id; }); ``` Then call from anywhere: ```php if (service('gate')->allows('post.update', $post)) { // ... } // Negation: service('gate')->denies('post.update', $post); // Fail-fast — throws AuthorizationException on a deny: service('gate')->authorize('post.update', $post); ``` The User entity exposes the same checks as `canDo()` / `cantDo()`: ```php $user->canDo('post.update', $post); // bool $user->cantDo('post.delete', $post); // bool ``` > `canDo()` and `cantDo()` are deliberately distinct from `can()` / `cant()` so the existing RBAC method `can('posts.update')` (string-only permission identifier) keeps its signature. ### Class-based policies For resources with multiple actions, group the rules into a `Policy` subclass: ```php namespace App\Policies; use App\Models\Post; use Daycry\Auth\Authorization\Policy; use Daycry\Auth\Authorization\PolicyResponse; use Daycry\Auth\Entities\User; class PostPolicy extends Policy { public function update(?User $user, Post $post): bool { return $user !== null && $user->id === $post->author_id; } public function delete(?User $user, Post $post): PolicyResponse { if ($user === null) { return PolicyResponse::deny('You must be logged in.'); } return $user->id === $post->author_id ? PolicyResponse::allow() : PolicyResponse::deny('Only the author can delete this post.'); } /** * Optional pre-flight hook. Return true / false / a PolicyResponse to * short-circuit the action method. Return null to fall through. */ public function before(?User $user, string $ability, array $arguments): bool|PolicyResponse|null { if ($user !== null && $user->inGroup('admin')) { return true; // admins bypass every action on this resource } return null; } } ``` #### Auto-discovery By default, the Gate looks up `App\Policies\{ResourceShortName}Policy` for any resource passed to a check. So `service('gate')->allows('update', $post)` automatically resolves `App\Models\Post` → `App\Policies\PostPolicy::update()`. Override the namespace in `app/Config/Auth.php`: ```php public string $policyNamespace = 'App\\Authorization\\Policies\\'; public bool $gateAutoDiscover = true; // false to require explicit registration ``` #### Explicit registration If your resources don't follow the convention, map them by hand: ```php service('gate')->policy(\App\Models\Post::class, \App\Policies\PostPolicy::class); ``` #### Action name convention Ability names ending in `.action` (e.g. `post.update`) dispatch to the action method (`update`). Bare names (e.g. `update`) also work. This lets you namespace abilities for clarity in routes / configs while keeping policy method names short. ### `gate:` route filter For abilities that depend **only on the authenticated user** (not on a resource instance) — typical for "section-level" gates such as accessing a dashboard or a billing area — apply the `gate:` filter directly: ```php $routes->get('admin', 'Admin::index', ['filter' => 'gate:admin.access']); // Multiple abilities (AND): $routes->get('billing/cancel', 'Billing::cancel', [ 'filter' => 'gate:billing.access,billing.cancel', ]); ``` Failure responses follow the same shape as `permission:` — JSON 403 for API requests, redirect to `Auth::permissionDeniedRedirect()` for web. For abilities that **need a resource argument** (a `Post`, an `Order`...), use the Gate API directly inside the controller method — the filter cannot reach the resource yet: ```php public function update(int $id) { $post = $this->postModel->find($id); service('gate')->authorize('post.update', $post); // throws on deny // ...persist update... } ``` ### When to use what | You want to check | Use | |-------------------|-----| | Does this user have permission `users.edit`? | RBAC — `$user->can('users.edit')` | | Does this user belong to the admin group? | RBAC — `$user->inGroup('admin')` | | Can this user update *this specific post*? | Gate — `$user->canDo('post.update', $post)` | | Block a route based on the user alone | `permission:` or `group:` filter | | Block a route based on a resource | `gate:` filter for ability-only checks; controller `Gate::authorize()` for resource-aware checks | --- ## Authorization in Controllers ### Pattern 1: Early Return ```php user()->can('posts.delete')) { return redirect()->back()->with('error', 'You are not authorized to delete posts.'); } // Delete the post model(\App\Models\PostModel::class)->delete($id); return redirect()->to('posts')->with('message', 'Post deleted.'); } } ``` ### Pattern 2: Exception-Based ```php use Daycry\Auth\Exceptions\AuthorizationException; public function delete(int $id) { // Throws AuthorizationException if not authorized auth()->user()->authorize('posts.delete'); model(\App\Models\PostModel::class)->delete($id); return redirect()->to('posts')->with('message', 'Post deleted.'); } ``` Set a global exception handler to catch `AuthorizationException` and redirect to a "403 Forbidden" page: ```php // app/Config/Exceptions.php or a custom ExceptionHandler use Daycry\Auth\Exceptions\AuthorizationException; // In your BaseController or exception handler: if ($e instanceof AuthorizationException) { return redirect()->to('/')->with('error', 'You do not have permission to do that.'); } ``` ### Pattern 3: Group + Permission Check Together ```php public function adminPanel() { $user = auth()->user(); if (! $user->inGroup('admin') && ! $user->can('admin.panel')) { return redirect()->to('/')->with('error', 'Access denied.'); } return view('admin/index'); } ``` --- ## Authorization in Views Show or hide UI elements based on the current user's permissions: ```html user()?->can('posts.edit')): ?> Edit user()?->inGroup('admin')): ?> user()?->inGroup('admin', 'moderator')): ?> ``` > **Note**: Use `auth()->user()?->can(...)` (null-safe operator) when the user might not be logged in. --- ## Route Filters Protect entire route groups using filters — no controller code needed: ```php // app/Config/Routes.php // Require 'admin' group $routes->group('admin', ['filter' => 'session,group:admin'], static function ($routes) { $routes->get('/', 'Admin\DashboardController::index'); $routes->get('users', 'Admin\UsersController::index'); }); // Require a specific permission $routes->group('posts', ['filter' => 'session,permission:posts.create'], static function ($routes) { $routes->get('new', 'PostController::new'); $routes->post('create', 'PostController::create'); }); // Multiple groups (user must be in AT LEAST ONE) $routes->get('moderation', 'ModerationController::index', ['filter' => 'session,group:admin,moderator']); // Multiple permissions (user must have ALL) $routes->post('posts/publish/(:num)', 'PostController::publish/$1', ['filter' => 'session,permission:posts.publish,posts.edit']); ``` ### Register Filter Aliases ```php // app/Config/Filters.php public array $aliases = [ 'session' => \Daycry\Auth\Filters\AuthSessionFilter::class, 'group' => \Daycry\Auth\Filters\GroupFilter::class, 'permission' => \Daycry\Auth\Filters\PermissionFilter::class, ]; ``` ### What Happens on Denial? When a user fails a group or permission check: - **`GroupFilter`** redirects to the URL defined in `config('Auth')->redirects['group_denied']` - **`PermissionFilter`** redirects to `config('Auth')->redirects['permission_denied']` Configure these in `app/Config/Auth.php`: ```php public array $redirects = [ 'group_denied' => '/', // Redirect when not in required group 'permission_denied' => '/', // Redirect when missing required permission ]; ``` --- ## Permission Cache In production, repeated permission checks can generate many database queries. Enable the permission cache to store each user's groups and permissions in the CI4 cache: ```php // app/Config/Auth.php public bool $permissionCacheEnabled = true; public int $permissionCacheTTL = 300; // Seconds (5 minutes) ``` ### Cache Invalidation The cache is **automatically invalidated** whenever you call: ```php $user->addGroup('admin'); $user->removeGroup('editor'); $user->addPermission('posts.create'); $user->removePermission('posts.delete'); ``` For manual invalidation: ```php $user->clearPermissionCache(); ``` ### When to Enable the Cache | Environment | Recommendation | |-------------|----------------| | Development | Disable (easier debugging) | | Staging | Enable (realistic performance testing) | | Production | Always enable | --- ## Default Group for New Users Automatically assign all new registrations to a group: ```php // app/Config/Auth.php public string $defaultGroup = 'user'; ``` During registration, `UserModel::addToDefaultGroup()` is called automatically. The group must exist in the `auth_groups` table — create it via the Admin Panel or a migration. --- ## Admin Panel Daycry Auth ships with a Bootstrap 5 admin panel for managing groups and permissions without writing any code: ```php // app/Config/Routes.php $routes->group('admin/auth', ['filter' => 'session,group:admin', 'namespace' => 'Daycry\Auth\Controllers\Admin'], static function ($routes) { $routes->get('/', 'DashboardController::index', ['as' => 'auth-admin']); $routes->resource('users', ['controller' => 'UsersController']); $routes->resource('groups', ['controller' => 'GroupsController']); $routes->resource('permissions', ['controller' => 'PermissionsController']); }); ``` From the admin panel you can: - Create and edit groups - Assign permissions to groups - Assign users to groups - View and revoke individual user permissions --- ## Best Practices ### 1. Use Groups for Roles, Permissions for Actions ```php // Good: Groups for broad roles $user->addGroup('editor'); // Good: Permissions for specific capabilities $user->addPermission('posts.publish'); // Avoid: Over-granular groups // $user->addGroup('post-editor'); // Use a group + permission instead ``` ### 2. Check Permissions, Not Groups, in Business Logic ```php // Preferred: permission check (flexible) if ($user->can('posts.delete')) { ... } // Avoid: group check in business logic (brittle) if ($user->inGroup('admin')) { ... } // What if a 'moderator' should also delete? ``` ### 3. Enable Cache in Production ```php // app/Config/Auth.php public bool $permissionCacheEnabled = ENVIRONMENT === 'production'; ``` ### 4. Keep Permission Names Consistent Use `resource.action` format consistently: ``` users.view users.create users.edit users.delete posts.view posts.create posts.edit posts.delete posts.publish admin.panel admin.settings admin.logs ``` --- 🔗 **See also**: - [Filters](04-filters.md) — Route-level authorization filters - [Controllers](05-controllers.md) — Controller-level authorization patterns - [Configuration](02-configuration.md) — Permission cache and redirect configuration