Area: mobieusLearn (audit p8) · Surface: GET /admin/learn/enrollments, /admin/learn/audit, /admin/learn/analytics, /admin/learn/analytics/drilldown, /admin/learn/ai-usage, /admin/learn/authors, /admin/learn/reviewers, /admin/learn/categories, /admin/learn/tags, /admin/learn/templates, /admin/learn/courses/{id}/analytics, /admin/learn/item-analysis, /admin/learn/assessments/{id}/item-stats · Dimension: security · Severity: major
On any Pro/Creator-Plus/Sovereign tenant, a user whose tenant role is Moderator/File Mod (role>=3, the AdminMiddleware floor) — and who has NO Learn capability at all (learn_role='none') — can open these mobieusLearn admin pages and read the full enrollment roster with learner usernames/emails, the Learn audit log, tenant-wide analytics, and AI token/cost spend. This is a horizontal/vertical RBAC bypass: these surfaces should require Learn capabilities (CAP_VIEW_ADMIN_UI / CAP_MANAGE_ENROLLMENTS / CAP_VIEW_COURSE) exactly as the sibling controllers do. SQL is bound (`:q`, `:cur`), so this is access-control only, not injection. The inconsistency strongly indicates these methods predate the M9 RBAC migration and were never converted from the old plan-only gate.
Evidence
platform/src/Controllers/Learn/LearnAdminController.php:259 `public function enrollments(): void { $this->gatePlan(); ...` and the gate itself at the same file: `private function gatePlan(): void { $plan = \App\Services\TenantPlan::current(); if (!in_array((string)$plan, self::ALLOWED_PLANS, true)) { http_response_code(403); ... } }` — it checks ONLY the tenant plan, never the user's Learn role/capability. The enrollments query returns learner PII: `u.username, u.email, TRIM(CONCAT_WS(' ', u.first_name, u.last_name)) AS display_name`. The route group at routes.php:1479 only adds AuthMiddleware+AdminMiddleware, and AdminMiddleware requires merely role>=3 (src/Middleware/AdminMiddleware.php:56 `if (!$user || (int)$user['role'] < 3)`), i.e. Tenant Mod / File Mod. Contrast: LearnAdminController::index() at line 31 correctly does `$this->requireCap(\App\Services\Learn\LearnAuthz::CAP_VIEW_ADMIN_UI)`, and EVERY other Learn admin controller gates each action with requireCap (LearnCoursesController 13, LearnAssessmentsController 15, LearnCohortsController 9, etc.). In LearnAdminController requireCap appears only 4 times vs 11 gatePlan + 9 inline ALLOWED_PLANS checks. courseAnalytics() even documents 'Scoped via CAP_VIEW_COURSE' (line ~550 docblock) but the body only checks the plan.
Suggested fix. Replace gatePlan()/inline ALLOWED_PLANS checks in LearnAdminController with the appropriate LearnAuthz capability check at the top of each handler, matching the sibling controllers: enrollments → requireCap(CAP_MANAGE_ENROLLMENTS); audit/analytics/drilldown/aiUsage/itemAnalysis/assessmentItemStats → requireCap(CAP_VIEW_ADMIN_UI) (or a dedicated analytics cap); courseAnalytics → requireCourseCap($id, CAP_VIEW_COURSE) as its own docblock already promises; authors/reviewers/categories/tags/templates → requireCap(CAP_VIEW_ADMIN_UI) or CAP_MANAGE_ROLES. Keep the plan check as a secondary gate if desired, but capability must be primary.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus