Skip to content

Admin User Management

Flow ID: AD-58 | Module(s): auth | Complexity: Medium Last Updated: 2026-04-21

Business Context

Admin user management covers the full lifecycle of back-office user accounts: creating users, assigning roles, editing credentials, soft-deleting and restoring accounts, and allowing users to change their own passwords. The platform ships a dual-path implementation — a legacy CodeIgniter controller that powers the admin panel UI, and a modern domain + REST layer introduced in the feature/admin-users-domain branch (merged 2026-04-08).

Roles are database-driven as of release 4.99.10. Nine roles are stored in a roles DB table and referenced by the user_role pivot table through a MANY_TO_MANY relation. Role constants are still defined in application/config/constants.php for backward compatibility. Non-advisable administrators cannot see or assign the Advisable (1) or Developer (2) roles.

The flow has a deliberate RBAC split: all user-management actions (list, create, edit, delete, undelete, admin-initiated password reset) require ADVISABLE or ADMIN role, while self-service password change is available to any authenticated backend user.

API Reference

REST Endpoints (Modern Layer)

application/config/rest_routes.php:1662-1693

MethodPathController MethodAuthMin Role
GET/rest/admin/usersUser::index()backend JWTADMIN
GET/rest/admin/users/itemUser::item()backend JWTADMIN
GET/rest/admin/users/{id}User::show()backend JWTADMIN
POST/rest/admin/usersUser::store()backend JWTADMIN
POST/rest/admin/users/{id}User::update()backend JWTADMIN
POST/rest/admin/users/{id}/undeleteUser::undelete()backend JWTADMIN
POST/rest/admin/users/{id}/passwordUser::resetPassword()backend JWTADMIN
POST/rest/admin/users/change-passwordUser::changePassword()backend JWTany
DELETE/rest/admin/users/{id}User::destroy()backend JWTADMIN
GET/rest/admin/meMe::show()backend JWTany
GET/rest/admin/rolesRole::index()backend JWTADMIN
GET/rest/admin/roles/itemRole::item()backend JWTADMIN
GET/rest/admin/roles/{id}Role::show()backend JWTADMIN

All endpoints have locale-prefixed variants (e.g., /el/rest/admin/users). The change-password static path is declared before the (:num) wildcard to prevent routing ambiguity (application/config/rest_routes.php:1666,1670). ADVISABLE users bypass role checks entirely via AuthorizationMiddleware superuser detection (src/Rest/Middleware/AuthorizationMiddleware.php:69).

REST policies are declared in application/config/rest_policies.php:659-669:

  • AdminUser::class default: backend auth + [AUTH_ROLE_ADMIN]
  • AdminUser::class changePassword action: backend auth + [] (any backend user)
  • AdminRole::class default: backend auth + [AUTH_ROLE_ADMIN]
  • AdminMe::class default: backend auth + [] (any backend user)

Legacy Admin Routes

application/config/routes.php:477-482

RouteControllerNotes
authAdv_authExplicit route to index()
auth/tasks/(:num)Adv_authExplicit route for task detail (see AD-55)
auth/(.+)Adv_authWildcard — all sub-actions (add, edit, delete, undelete, changePassword, changeMyPassword, etc.) are handled by CI default routing through this pattern
(\w{2})/authAdv_authLocale-prefixed index
(\w{2})/auth/(.+)Adv_authLocale-prefixed wildcard for all sub-actions

Per-action RBAC is enforced inside the controller methods, not by the route table. add(), edit(), delete(), undelete(), and changePassword() call allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN]); changeMyPassword() carries no RBAC check.

Admin menu entries:

  • auth — group SETTINGS, roles [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN] (application/config/admin_menu.php:143-149)
  • auth/changeMyPassword — group PROFILE, roles [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_ORDERS] (application/config/admin_menu.php:378-384)

Code Flow

User Creation (Modern Path)

POST /rest/admin/users
  └─ User::store()  [src/Rest/Admin/Controllers/User.php]
       └─ doStore()
            └─ WriteService::create(array $data, bool $isAdvisable = false): ?Entity
                 |-- Validator::validateForCreate($writeData)
                 |     |-- username unique check (DB)
                 |     |-- password min 8 chars
                 |     └─ role IDs exist in Role\Service (was: RoleProvider — deprecated in 4.99.10)
                 |-- Validator::validateRoleAuthorization($roleIds, $isAdvisable)
                 |     └─ non-advisable cannot assign role 1 or 2
                 |-- AuthHashService::hash($password)  → bcrypt
                 |-- WriteRepository::insert($data)
                 |-- WriteRepository::syncRoles($userId, $roleIds)
                 |     |-- load current role IDs from user_role
                 |     |-- compute toAdd / toRemove delta
                 |     |-- DELETE removed rows, INSERT added rows
                 |     └─ (diff-based, same logic as legacy updateUserRoles)
                 └─ Repository::get($id, ['roles']) → returns {id, username, softDelete, roles[{id, name, home}]}

User Creation (Legacy Path)

POST auth/add
  └─ Adv_auth::add()  [ecommercen/auth/controllers/Adv_auth.php:52-81]
       |-- allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN])
       |-- addValidation(): password required|trim|min_length[6], username is_unique, roles required
       |-- Adv_users_model::addUser($userData, $roleIds)  [ecommercen/auth/models/Adv_users_model.php:15]
       |     └─ internally calls $this->user_role_model->insertUserRoles() [line 25]
       |          └─ delegates to Adv_user_role_model::updateUserRoles()  (diff-based sync)
       └─ afterAdd($userId)  [extension hook — empty by default]

Self-Service Password Change (Modern Path)

POST /rest/admin/users/change-password
  └─ User::changePassword()  [src/Rest/Admin/Controllers/User.php]
       └─ WriteService::changeOwnPassword($userId, $currentPwd, $newPwd)
            |-- AuthHashService::verify($currentPwd, storedHash)  → bcrypt verify
            |-- Validator: new password min 8 chars
            └─ WriteRepository::updatePassword($userId, hash($newPwd))

Admin Profile Self-Service

src/Rest/Admin/Controllers/Me.php

GET /rest/admin/me
  └─ Me::show()  [src/Rest/Admin/Controllers/Me.php]
       |-- guard: resourceContext missing or !isBackend() → 401
       |-- userId = resourceContext->getUserId()
       |-- guard: !$userId → 401
       |-- if is_numeric($userId):
       |     |-- service->get($userId, parsedRelations)  [Admin\User\Service]
       |     └─ 404 if null (deleted user), else view($entity)  → AdminUserResource {id, username, softDelete, roles}
       └─ if !is_numeric (config user, e.g. "advisable"):
             |-- roleIds = resourceContext->getRoles()  (from JWT)
             |-- foreach roleId: roleService->get($roleId)  [Admin\Role\Service]
             └─ sendOutput {id: null, username, softDelete: false, roles[{id, name|null}], isConfigUser: true}

The $id parameter in show(int|string $id = 0) is always ignored — identity is taken from resourceContext->getUserId(). The with query param (e.g. ?with=roles) is forwarded to the service call for DB users. For config users no DB lookup is performed; role names are resolved one-by-one via Admin\Role\Service::get($roleId) from the role IDs embedded in the JWT. A role ID that no longer exists in DB yields name: null in the response. POST /me is intentionally absent — username and password changes have dedicated endpoints (POST /rest/admin/users/{id} and POST /rest/admin/users/change-password).

Soft Delete with Self-Delete Guard (Modern Path)

DELETE /rest/admin/users/{id}
  └─ User::destroy()  [src/Rest/Admin/Controllers/User.php:246-260]
       |-- if $id === $this->resourceContext->getUserId(): return 403
       └─ WriteService::delete($id)
            └─ WriteRepository::softDelete($id)  [sets soft_delete = 1]

The legacy Adv_auth::delete($id) (ecommercen/auth/controllers/Adv_auth.php:123-135) performs the same soft-delete but without the self-delete guard.

Role List (Modern Path)

GET /rest/admin/roles
  └─ Role::index()  [src/Rest/Admin/Controllers/Role.php:53-60]
       |-- if non-advisable: filter out role IDs 1 (ADVISABLE) and 2 (DEVELOPER)
       └─ Role\Service::all(ListRequest)  [src/Domains/Admin/Role/]
            |-- Role\Repository::match() queries `roles` table
            └─ return [{id, name}, ...]

Note: Prior to 4.99.10, RoleProvider::getAll(includeHidden:) served this path. RoleProvider is now deprecated (see Domain Layer).

Data Model

users table

database/initial/initial.sql:2336-2345

ColumnTypeNotes
idINT PK AIUser ID
usernameVARCHAR(50) UNIQUELogin name
passwordVARCHAR(255)Bcrypt hash
soft_deleteTINYINT(1) DEFAULT 00 = active, 1 = deleted

Indexes: PRIMARY KEY on id, UNIQUE on username, KEY on soft_delete, composite KEY on (username, soft_delete). Foreign keys: none.

user_role pivot table

database/initial/initial.sql:2347-2353

ColumnTypeNotes
user_idINTReferences users.id (no FK constraint)
role_idINTReferences roles.id (no FK constraint)

Indexes: UNIQUE composite on (user_id, role_id), KEY on user_id, KEY on role_id. Foreign key: role_id → roles.id (added in migration 20260409131959); user_id has no FK constraint — orphan rows possible on user deletion (see Known Issues).

As of 4.99.10 the User RepositoryConfigurator uses a MANY_TO_MANY relation through user_role to the roles table. Prior to 4.99.10 it used a ONE_TO_MANY relation to the UserRole pivot entity only.

roles table (added in 4.99.10)

database/migrations/20260409131959_create_roles_table.php:42-50

ColumnTypeNotes
idINT PKRole ID (matches existing AUTH_ROLE_* constants)
nameVARCHAR(50) UNIQUEHuman-readable role name
homeVARCHAR(100) NULLDefault redirect path after login for this role

The nine pre-existing roles were seeded into this table as part of the #166 migration. Role constants in application/config/constants.php:99-107 remain as aliases for backward compatibility.

Roles — database-driven (as of 4.99.10)

application/config/constants.php:99-107

ConstantValueName
AUTH_ROLE_ADVISABLE1Advisable (hidden from non-advisable users)
AUTH_ROLE_DEVELOPER2Developer (hidden from non-advisable users)
AUTH_ROLE_ADMIN3Admin
AUTH_ROLE_CMS4CMS
AUTH_ROLE_PRODUCTS5Products
AUTH_ROLE_ORDERS6Orders
AUTH_ROLE_REPORTING7Reporting
AUTH_ROLE_MARKETING8Marketing
AUTH_ROLE_MEDIA9Media

Role names and home URLs are now read from the roles DB table. The auth_roles config key has been removed from application/config/auth.php. RoleProvider (DI-injected config array) is deprecated — superseded by Role\Service and Role\Repository.

Domain Layer

Modern Domain (src/Domains/Admin/)

FileLinesResponsibility
src/Domains/Admin/User/Repository/Entity.php16Entity: id, username, password, soft_delete
src/Domains/Admin/User/Repository/Repository.php16BaseRepository for users table
src/Domains/Admin/User/Repository/RepositoryConfigurator.php23MANY_TO_MANY relation through user_role pivot to roles table (changed in 4.99.10; was ONE_TO_MANY to UserRole entity)
src/Domains/Admin/User/Repository/WriteRepository.php61insert(), update(), syncRoles(), deleteRoles(), updatePassword(), softDelete(), undelete()
src/Domains/Admin/User/Service.php80Read service; enrichRoles() removed in 4.99.10 — the MANY_TO_MANY relation loader now hydrates full Role entities directly
src/Domains/Admin/User/WriteService.php104create(), update(), delete(), softDelete(), undelete(), resetPassword(), changeOwnPassword()
src/Domains/Admin/User/Validator.php124Username uniqueness, password ≥ 8 chars, role existence, role authorization
src/Domains/Admin/User/WriteData.php56DTO: username, password, roleIds. toArray() excludes password and roleIds
src/Domains/Admin/User/ListRequest.php30Filters: id (exact), username (partial), softDelete (exact). Sorts: id, username
src/Domains/Admin/UserRole/Repository/Entity.php14Pivot entity: user_id, role_id
src/Domains/Admin/UserRole/Repository/Repository.php17BaseRepository for user_role. Uses NullRelationConfigurator
src/Domains/Admin/Role/RoleProvider.php57Deprecated in 4.99.10. Was config-driven: getAll(), getById(), exists(), resolveRoleIds(). Superseded by Role\Service + Role\Repository reading from the roles DB table
src/Domains/Admin/Role/Repository/New in 4.99.10: Entity, Repository, RepositoryConfigurator for the roles table
src/Domains/Admin/Role/Service.phpNew in 4.99.10: replaces RoleProvider; reads from DB, filters hidden roles
src/Domains/Admin/container.php28DI registration; RoleProvider config injection removed in 4.99.10 — use_sections bug also resolved (see Known Issues)

Support layer:

FileLinesResponsibility
src/Domains/Support/Auth/AuthHashService.php29Wraps legacy Advauth for hash() and verify(); isolates CI dependency from modern domain

REST Layer (src/Rest/Admin/)

FileLinesResponsibility
src/Rest/Admin/Controllers/User.php392Full CRUD + undelete(), resetPassword(), changePassword(). Uses HandlesRestfulActions + HandlesWriteActions. Overrides doStore()/doUpdate() to pass isAdvisable flag. Self-delete guard in destroy()
src/Rest/Admin/Controllers/Role.php112Read-only role list. Filters hidden roles for non-advisable callers. index() + item() + show()
src/Rest/Admin/Controllers/Me.php~95GET-only self-service profile. Guards on isBackend(). Handles DB users (service lookup) and config users (synthesized from JWT + role name lookup via Admin\Role\Service). POST /me intentionally absent.
src/Rest/Admin/Resources/User/Resource.php48Transforms to {id, username, softDelete, roles[{id, name}]}. Schema name: AdminUserResource
src/Rest/Admin/Resources/User/Collection.php16Collection wrapper
src/Rest/Admin/container.phpDI registration for REST controllers, including Me controller wired with Admin\User\Service, Admin\Role\Service, AdminUserResource, AdminUserCollection, and Admin\User\ListRequest (src/Rest/Admin/container.php)

DI module registration:

  • Domain: application/config/container/modules.php:29
  • REST: application/config/container/modules.php:50

Legacy Layer (ecommercen/auth/)

FileLinesResponsibility
ecommercen/auth/controllers/Adv_auth.php598User management (lines 25-194) + task management (lines 195+, see AD-55). 8 empty extension hooks (lines 559-598). Extends Admin_c
ecommercen/auth/models/Adv_users_model.php179addUser(), editUser(), updatePassword(), deleteUser() (soft), undeleteUser(), getByUsername(), getAll(), getUsersAdmin(). guardUserEdit() strips soft_delete and password from update payloads
ecommercen/auth/models/Adv_user_role_model.php135getUserRoles(), getUserRolesIds(), getUsersRolesIds() (bulk), updateUserRoles() (diff-based sync)
ecommercen/auth/models/Adv_roles_model.php90Role model — now reads from the roles DB table (fixed in 4.99.10; was config-driven with no DB table). dropdown(), getRoles(), getRole($roleId), getRolesByIds(), getRolesByName(). Note: getRolesByName() null-crash bug resolved (#175)
application/libraries/Advauth.php207login() (session-based), statelessAdminLogin() (REST JWT), advAuthHash() (bcrypt), advAuthVerify()
ecommercen/helpers/auth_helper.phpallowRole($roles, $roleIds) — legacy RBAC check; mergeUsersRoles() — attaches role arrays to user objects

Configuration

Auth config (application/config/auth.php, 66 lines)

KeyValuePurpose
simple_authtrueEnable config-based static users
db_authtrueEnable database user authentication
auth_hash'password-hash-mcrypt'Hashing algorithm (bcrypt in practice)
config_autharrayStatic users loaded from env vars
auth_roles(removed in 4.99.10)Was: nine roles with name and home keys. Role data now lives in the roles DB table

Environment variables

The four static-auth env vars (APP_ADMIN_USERNAME, APP_ADMIN_PWD, APP_ADMIN_ADVISABLE_USERNAME, APP_ADMIN_ADVISABLE_PWD) are used by the login flow and are documented in full in (see AD-01 Admin Auth & SSO for static config-auth env vars and how they feed into Advauth::login()).

Scribe How (user tour)

ecommercen/language/english/adv_scribe_lang.php:19,120

  • Title key: scribe.how.settings.users.title = "Χρήστες | Προσθήκη users & tasks" (Greek label; ecommercen/language/english/adv_scribe_lang.php:19)
  • Iframe link key: scribe.how.settings.users.iframe.link

Client Extension Points

Legacy hooks in Adv_auth

ecommercen/auth/controllers/Adv_auth.php:559-598 — eight empty protected methods intended for client-repo overrides:

MethodTrigger
afterAdd($userId)After user created
afterEdit($userId)After user edited
afterDelete($userId)After user soft-deleted
afterUndelete($userId)After user restored
afterChangePassword($userId)After admin password reset
afterAddTask($taskId)After task created (see AD-55)
afterEditTask($taskId)After task edited (see AD-55)
afterTaskDelete($taskId)After task deleted (see AD-55)

Client repos override these methods by extending Adv_auth in application/controllers/.

Modern REST layer

src/Rest/Admin/Controllers/User.phpdoStore() and doUpdate() pass the isAdvisable flag derived from the JWT token, enabling client repos to adjust behavior per request context without modifying validation logic.

Business Rules

  1. ADVISABLE or ADMIN required for all user management actions. Every legacy CRUD action calls allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN]). REST policies enforce the same via rest_policies.php:659-669. (ecommercen/auth/controllers/Adv_auth.php:28,55,86,126,140,153)

  2. Any authenticated backend user may change their own password. changeMyPassword() has no RBAC guard in the legacy path (ecommercen/auth/controllers/Adv_auth.php:176); the REST change-password endpoint policy specifies [] (any backend user) (application/config/rest_policies.php:663).

2a. Any authenticated backend user may read their own profile via GET /rest/admin/me. Config users (defined in config_auth in application/config/auth.php) are fully supported — their profile is synthesized from the JWT payload rather than a DB lookup; id is null and isConfigUser: true in the response. The in-controller isBackend() guard provides a second enforcement layer beyond the policy (src/Rest/Admin/Controllers/Me.php).

  1. Non-advisable admins cannot see or assign roles 1 and 2. Legacy: role dropdown filters them in defaultRender() (ecommercen/auth/controllers/Adv_auth.php:12-23). Modern: Validator blocks assignment (src/Domains/Admin/User/Validator.php:70-83); Role::index() controller (previously RoleProvider::getAll(includeHidden: false)) filters them from the list (src/Rest/Admin/Controllers/Role.php:53-60).

  2. Soft delete, not hard delete. deleteUser() sets soft_delete = 1 (ecommercen/auth/models/Adv_users_model.php); WriteRepository::softDelete() does the same (src/Domains/Admin/User/Repository/WriteRepository.php). Records are recoverable via undelete.

  3. Users cannot delete themselves (REST only). User::destroy() compares the target $id against the current user's JWT identity and returns 403 on match (src/Rest/Admin/Controllers/User.php:246-260). The legacy controller does not enforce this guard.

  4. Role sync is diff-based. Both legacy updateUserRoles() and modern WriteRepository::syncRoles() compute the delta between current and desired role IDs, then insert additions and delete removals atomically. (ecommercen/auth/models/Adv_user_role_model.php:108-134, src/Domains/Admin/User/Repository/WriteRepository.php:12-37)

  5. Password hashing algorithm is bcrypt. auth_hash = 'password-hash-mcrypt' maps to bcrypt in Advauth::advAuthHash() (application/libraries/Advauth.php:168-187). Delegated to AuthHashService in the modern path (src/Domains/Support/Auth/AuthHashService.php:20-23).

  6. Password minimum length differs by path. Legacy: 6 characters (ecommercen/auth/controllers/Adv_auth.php:556). Modern: 8 characters (src/Domains/Admin/User/Validator.php:31,87). See Known Issues.

  7. Self-service password change requires current password verification. WriteService::changeOwnPassword() verifies the current password via AuthHashService::verify() before hashing and persisting the new one (src/Domains/Admin/User/WriteService.php:88-103).

  8. Roles are resolved via MANY_TO_MANY relation — full Role entities are hydrated. As of 4.99.10 the User RepositoryConfigurator uses a MANY_TO_MANY relation through user_role to the roles table. The relation loader hydrates complete {id, name, home} Role entities. Service::enrichRoles() has been removed; it was a workaround for the former absence of a DB roles table (resolved in #166).

  9. Role data is database-driven. Role names and home paths are now read from the roles DB table via Role\Repository. Changes to roles take effect immediately without a container cache clear. The auth_roles config key and RoleProvider injection have been removed from src/Domains/Admin/container.php.

  10. guardUserEdit() protects sensitive fields on update. The model strips soft_delete and password from raw update payloads so callers cannot accidentally overwrite them via a bulk edit (ecommercen/auth/models/Adv_users_model.php:82-93).

Known Issues & Security Gaps

  1. user_role.user_id has no FK to users.id — orphan rows are possible on user deletion. role_id gained a FK constraint (role_id → roles.id) in migration 20260409131959, so role-side orphans are now prevented. However, deleting a user directly leaves orphan pivot rows because user_id remains unconstrained. (database/initial/initial.sql:2347-2353, database/migrations/20260409131959_create_roles_table.php)

  2. Password minimum-length mismatch: 6 (legacy) vs 8 (modern). A password that satisfies the legacy form but fails the REST validator (or vice versa) creates user confusion and potential inconsistency between creation paths. (ecommercen/auth/controllers/Adv_auth.php:556, src/Domains/Admin/User/Validator.php:31,87)

  3. Config-auth password comparison uses plain-text ==. Static users defined in config_auth are verified with a direct string equality check, bypassing the advAuthVerify() bcrypt path. (application/libraries/Advauth.php:79,134)

  4. changeMyPassword menu entry has overly restrictive role list. The admin menu shows the "Change My Password" link only for ADVISABLE, ADMIN, and ORDERS users (application/config/admin_menu.php:382), but the endpoint itself accepts any backend user. CMS, Products, Reporting, Marketing, and Media users can access the endpoint but cannot discover it via the menu.

  5. Legacy delete() has no self-delete guard. An ADMIN or ADVISABLE user can soft-delete their own account via the legacy UI path (ecommercen/auth/controllers/Adv_auth.php:123-135). The modern REST path blocks this (src/Rest/Admin/Controllers/User.php:246-260).

  6. Adv_roles_model::getRolesByName() always returns an empty array. (Resolved in 4.99.10 — Adv_roles_model now reads from the roles DB table; null-crash and dead-return bugs fixed as part of #175.)

  7. No explicit CSRF protection on legacy user management forms. CodeIgniter 3 CSRF protection must be enabled globally; there is no controller-level evidence of per-form CSRF tokens for the auth user management pages. (ecommercen/auth/controllers/Adv_auth.php:52-194)

  8. Roles are not persisted to the database — tracked as GitHub issue #166. (Resolved in 4.99.10 — roles migrated to the roles DB table; Service::enrichRoles() removed; MANY_TO_MANY relation hydrates full Role entities.)

  9. container.php use_sections = true misconfiguration — tracked as GitHub issue #172. (Resolved in 4.99.10 — use_sections flag removed from src/Domains/Admin/container.php; DI registration now follows standard module pattern.)

  10. WriteService::create() and update() return stale, role-name-less entities. (Resolved in 4.99.10 — the MANY_TO_MANY relation loader now hydrates full Role entities with id and name; Service::enrichRoles() bypass is moot. Tracked as #170.)

  11. No database integration tests for the Admin domain. All 27 tests are unit-level (mocked repositories). There are no integration tests exercising the actual users, user_role, or roles tables. (tests/Unit/Domains/Admin/)

Tests

FileLinesTestsWhat is covered
tests/Unit/Domains/Admin/User/ValidatorTest.php13212Username validation, password length, role existence, role authorization, duplicate username detection
tests/Unit/Domains/Admin/User/WriteServiceTest.php1668Create + role sync, update, soft delete, undelete, admin password reset, self-service password change
tests/Unit/Domains/Admin/Role/RoleProviderTest.php807getAll() with and without hidden roles, getById(), exists(), resolveRoleIds() — tests the now-deprecated RoleProvider; coverage for the new DB-backed Role\Repository/Role\Service is pending

Total: 27 unit tests across 378 lines.

Coverage gaps:

  • No integration/database tests against real users, user_role, or roles tables
  • No REST controller tests (src/Rest/Admin/Controllers/User.php, Role.php, Me.php)
  • No test for WriteRepository::syncRoles() in isolation
  • No tests for new Role\Repository / Role\Service (DB-backed, added in 4.99.10)
  • No tests for any legacy path (Adv_auth, Adv_users_model, Adv_user_role_model, Adv_roles_model)
  • AD-01 Admin Auth & SSO — covers the login flow, session-based auth, statelessAdminLogin() for REST JWT, and SSO. Auth library (Advauth) is shared with this flow.
  • AD-32 Audit Logging — covers the user_audit_logs table written by audit-instrumented admin actions.
  • AD-55 Task Management — tasks and user assignments live in the same Adv_auth controller (lines 195+); extension hooks afterAddTask, afterEditTask, afterTaskDelete are sibling to the user hooks in this flow.