Add canonical user access profile table
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m20s

Create user_access_profiles as a denormalized access index over the existing roles and user_roles tables. Store primary_category plus is_admin, is_teacher, and is_parent flags so permission checks can use a single canonical source for broad user groups.

Classify teacher and teacher_assistant/TA roles as teacher access, parent as parent access, and every other active staff role as admin access. Backfill all existing users during migration and keep profiles synchronized when role assignments are inserted, updated, or deleted.

Expose the computed access profile in web sessions and auth API responses while preserving the existing detailed roles array and route-filter behavior. Add unit coverage for TA, staff-admin, and multi-role parent/teacher classification.
This commit is contained in:
root
2026-08-29 23:20:40 -04:00
parent 48ae2805d3
commit 0f8ad86b4f
5 changed files with 429 additions and 8 deletions
+64 -6
View File
@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use App\Models\UserAccessProfileModel;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -212,6 +213,7 @@ class AuthController extends BaseController
// Fetch roles
$roleNames = $this->getUserRoleNames((int) $user['id']);
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
// Build roles map (object with keys per example)
$rolesMap = [];
@@ -248,6 +250,10 @@ class AuthController extends BaseController
'id' => (int) $user['id'],
'name' => $payload['name'],
'roles' => (object) $rolesMap,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
}
@@ -274,6 +280,10 @@ class AuthController extends BaseController
'type' => session()->get('user_type'),
'roles' => $roles,
'role' => $activeRole,
'primary_category' => session()->get('primary_category'),
'is_admin' => (bool) session()->get('is_admin'),
'is_teacher' => (bool) session()->get('is_teacher'),
'is_parent' => (bool) session()->get('is_parent'),
],
]);
}
@@ -371,6 +381,7 @@ class AuthController extends BaseController
'iat' => $now,
'exp' => $exp,
];
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
@@ -384,6 +395,10 @@ class AuthController extends BaseController
'name' => $payload['name'],
'email' => $userData['email'],
'roles' => $payload['roles'],
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
],
]);
} catch (\Exception $e) {
@@ -477,11 +492,17 @@ class AuthController extends BaseController
protected function getUserRoleNames(int $userId): array
{
$userRoleModel = new UserRoleModel();
$rolesRows = $userRoleModel->select('roles.name')
$db = \Config\Database::connect();
$builder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $userId)
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$builder->where('user_roles.deleted_at', null);
}
$rolesRows = $builder->get()->getResultArray();
return array_column($rolesRows, 'name');
}
@@ -565,11 +586,17 @@ class AuthController extends BaseController
private function loginUser($user, ?string $redirectTo = null)
{
$userRoleModel = new UserRoleModel();
$roles = $userRoleModel->select('roles.name')
$db = \Config\Database::connect();
$rolesBuilder = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
->where('user_roles.user_id', $user['id'])
->get()
->getResultArray();
->where('COALESCE(roles.is_active, 1) = 1', null, false);
if ($db->fieldExists('deleted_at', 'user_roles')) {
$rolesBuilder->where('user_roles.deleted_at', null);
}
$roles = $rolesBuilder->get()->getResultArray();
if (empty($roles)) {
log_message('error', 'No roles found for user ID: ' . $user['id']);
@@ -577,6 +604,7 @@ class AuthController extends BaseController
}
$roleNames = array_column($roles, 'name');
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
session()->regenerate(true);
session()->set([
@@ -588,6 +616,10 @@ class AuthController extends BaseController
'login_time' => time(),
'last_activity' => time(),
'roles' => $roleNames,
'primary_category' => $accessProfile['primary_category'],
'is_admin' => $accessProfile['is_admin'],
'is_teacher' => $accessProfile['is_teacher'],
'is_parent' => $accessProfile['is_parent'],
'semester' => $this->semester,
'school_year' => $this->schoolYear,
]);
@@ -612,6 +644,32 @@ class AuthController extends BaseController
}
private function accessProfileForUser(int $userId, array $roleNames): array
{
try {
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
if ($profile !== null) {
return [
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
'is_admin' => (bool) ($profile['is_admin'] ?? false),
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
'is_parent' => (bool) ($profile['is_parent'] ?? false),
];
}
} catch (\Throwable $e) {
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
}
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
return [
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
'is_admin' => (bool) $flags['is_admin'],
'is_teacher' => (bool) $flags['is_teacher'],
'is_parent' => (bool) $flags['is_parent'],
];
}
private function applyStylePreferences(int $userId): void
{
if (!$userId) {