# 02 — Admin RBAC (Roles & Permissions)

## Overview

Role-based access control for the admin panel. Eight static roles, eight permission groups, plus three superadmin levels. Enforced by the `UserPermission` middleware which sits on every protected route and gates BOTH menu rendering AND backend access.

**Status:** Live

This is the **single most opaque** subsystem in VisoAdmin. The mapping between roles → permissions → URIs is hardcoded in `app/Helpers/GlobalMethods.php`, not in the database.

---

## User Stories

| ID | As a | I want to | So that |
|----|------|-----------|---------|
| RBAC-01 | Superadmin | Toggle which permissions each role has | I can build new role bundles without code changes |
| RBAC-02 | Superadmin | Mark a user as superadmin / coordinator / normal | I can grant elevated bypass access |
| RBAC-03 | Role-1 user | Only see Settings + Master Data menu items | I'm not confused by features I can't use |
| RBAC-04 | Coordinator (`superadmin=2`) | Land on `/coordinator` after login | I'm immediately in my workbench |
| RBAC-05 | Any non-superadmin | Get a 403 if I manually navigate to a disallowed URL | The menu hide is also a backend guard |

---

## Screens & Flows

```
┌────────────────────┐      GET /permission      ┌─────────────────────────┐
│ Permissions page   │◀──────────────────────────│ PermissionController    │
│ /permission        │                           │ ::index()               │
└─────────┬──────────┘                           └─────────────────────────┘
          │
          │  Admin checks boxes for each role × menu
          │
          ▼
┌────────────────────┐  POST /assignPermission   ┌─────────────────────────┐
│ assignRolePermission │────────────────────────▶│ UPSERT role_permissions │
└────────────────────┘                           │ (one row per role)      │
                                                 └─────────────────────────┘
```

### Routes & Actions

| Route | Method | Handler | Description |
|-------|--------|---------|-------------|
| `/permission` | GET | `PermissionController::index()` | Render role × permission matrix |
| `/assignPermission` | POST | `PermissionController::assignRolePermission()` | Save permissions for all roles in one POST |

### The Permission Map (`GlobalMethods.php`)

`permissionGroup($uri)` returns an integer 1-8 (or 'all', or 0) for any given URI. Hardcoded:

| Group | Label | Example URIs |
|-------|-------|--------------|
| 1 | Change Application Level Data | `setting`, `setting/*`, `location/*`, `faq/*`, `services`, `suggestions`, `setting/addContact` |
| 2 | Event Level Details | `event/list`, `event/details`, `event/updateservices`, `update-event` |
| 3 | Analytics | `event/analytics` |
| 4 | Reporting | `reports` |
| 5 | User-Level Data | `user/*` (add-client, add-vendor, vendorlist, clientlist, adminlist, ...) |
| 6 | Debug / Impersonate | `user/activity` |
| 7 | Coordinator | anything containing the string `coordinator` |
| 8 | RM User | `rm/rm-requests`, `rm/rm-request-details`, `rm/create-event`, `rm/store-event` |
| `'all'` | Bypass | `notifications`, `notificationCount` |

### Role List (`GlobalMethods.php :: roles()`)

| ID | Label |
|----|-------|
| 1 | Project Manager |
| 2 | Host Coordinator |
| 3 | Partner Coordinator |
| 4 | Customer Care |
| 5 | Applications Manager |
| 6 | Analytics Viewer |
| 7 | Business Development |
| 8 | Relationship Manager (RM) |

### Superadmin Levels (`admins.superadmin`)

| Value | Meaning | Bypasses |
|-------|---------|----------|
| `0` | Normal admin | Permission-checked by group |
| `1` | Full Superadmin | All checks bypassed (see `UserPermission::handle()` lines 90-92) |
| `2` | Coordinator | `Coordinator` middleware lets through, also has coordinator permission group `[7]` |

---

## Data Model

### `role_permissions` table

```php
// App\Models\RolePermission
protected $table = 'role_permissions';
protected $fillable = ['role_id', 'permission'];
public $timestamps = false;

{
  id:         int,
  role_id:    int,            // 1..8 (matches roles())
  permission: string,         // CSV of permission group IDs, e.g. "1,2,3"
}
```

### `roles` table (created by migration but not actively read)

```php
{
  id:   int,
  role: string,
}
```

> Note: the **labels** are still pulled from `roles()` helper, not from this table. The table exists but is effectively unused for label lookup.

### Computed: `User::has_permission`

```php
// App\Models\User
public function getHaspermissionAttribute() {
    $permission = $this->hasOne(RolePermission::class, 'role_id', 'role_id')->first();
    return !empty($permission->permission) ? explode(',', $permission->permission) : [];
}
```

So `auth()->user()->has_permission` is `int[]` like `[1, 2, 3]`.

---

## Validations & Business Rules

| Rule | Detail |
|------|--------|
| Permission CSV is stored as a string | `implode(',', $request->permission[$role])` |
| Superadmin = 1 bypasses everything | `if (auth()->user()->superadmin == 1) return $next($request);` |
| Coordinator (`superadmin = 2`) can access permission group 7 even if not explicitly granted | `(auth()->user()->superadmin == 2 && in_array($currentRequest, $coordinatorPermission))` |
| AJAX endpoints are always allowed | `ajaxRequest($uri)` whitelist bypass — see helper |
| Default URLs are always allowed | `GlobalVariable::$defaultUrl = ['welcome', 'login', 'logout']` |
| Unauthenticated XHR → 401 JSON | XHR/JSON `$request->expectsJson() \|\| $request->ajax()` returns `{status:401, message:...}` |
| Unauthenticated HTML → guest redirect | `redirect()->guest('/login')` (preserves intended URL) |
| Route `{params}` are stripped before permission lookup | `permissionGroup` is called with the static prefix only |

---

## API Endpoints

| Method | Path | Auth | Request | Response | Consumer |
|--------|------|------|---------|----------|----------|
| GET | `/permission` | session | `?user=<id>` (optional, selects an admin to inspect) | HTML (Blade) | Browser |
| POST | `/assignPermission` | session | `permission[role_id][] = group_id` | redirect back | Browser |

---

## Upstream Impact

- **`admins.role_id` + `admins.superadmin`** — written by Feature 03 (Admin User Management).
- **`roles()` helper output** — must match `admins.role_id` integer values.
- **Every route registered in `web.php`** — must have a `permissionGroup()` entry, otherwise it's only accessible to superadmin.

---

## Downstream Impact

- **The sidebar** (`resources/views/includes/aside.blade.php`) — uses `in_array($n, $permissions) || $allow_all_permission == 1` for every menu item.
- **Every protected controller action** — gated by middleware.
- **`getRedirection()` helper** — drives post-login landing per role.

---

## Impact of Changes

| If you change... | Risk to... | Level | Type |
|-----------------|------------|-------|------|
| Adding a new route to `web.php` without updating `permissionGroup()` | The new route is invisible to all non-superadmin users | High | Guard |
| Renumbering permission groups in `permissionMenus()` | All saved `role_permissions.permission` CSVs become semantically wrong (the IDs they contain map to new labels) | Critical | Data |
| Adding a new role to `roles()` | Existing `admins.role_id` rows still work, but UI matrix won't render the new role until form is updated | Medium | UI |
| Changing `Coordinator` middleware | `/coordinator` access for `superadmin=2` users breaks | High | Guard |
| Removing the ajax whitelist in `ajaxRequest()` | DataTable + dropdown endpoints start enforcing per-permission checks → likely breaks UI | High | Guard |
| Changing `superadmin` semantics from int to bool | `WelcomeController`, `UserPermission`, and `Coordinator` middleware all break | Critical | Data |
| Changing the Blade sidebar template | Menu visibility regressions per role | Medium | UI |

---

## Known Issues / Gotchas

- **Two permission stores exist.** `admins.permission` is a legacy per-user CSV. `role_permissions.permission` is the active per-role CSV. `User::getHaspermissionAttribute` only reads the role one. Any data in `admins.permission` is effectively dead — but the `PermissionController::assignPermission` (singular) action still exists in code, just not routed.
- **Permission `'all'`** — `notifications` and `notificationCount` return the string `'all'` from `permissionGroup`. The middleware's `in_array($currentRequest, $assignedPermission)` check will silently fail unless you have a role with permission `'all'` (which the UI doesn't allow setting). They currently survive only because they're whitelisted in `ajaxRequest()`.
- **A URL with a `{param}` is stripped to its prefix** — so `event/details/123` becomes `event/details` for the permission lookup. Adding a route like `event/details-edit/{id}` will fall through this stripping correctly, but adding `event-details/{id}` (with a hyphen) needs its own entry.
