Findings — Partner Admin-Access (authorization audit)¶
⚠️ CORRECTION (2026-06-18, after browser screenshot)¶
The live sidebar is rendered by
NavbarService::getMenuForUser()(viacomponents/navbar/navbar-new.blade.php→menu-tree-new.blade.php), not byMenuService/partials/menu.blade.php. The first fix (MenuServiceis_adminbackstop) was on the legacy path and did not change the rendered sidebar — a browser check still showed the full ADMIN block, and running the liveNavbarServiceasron@vell.aireturned 81 admin-routed items. Real fix:NavbarService::isVisible()/isVisibleArray()now hide anydashboard.admin.*item from non-admins (route-derived backstop), mirroring the MenuService change. There is no persistent navbar cache (per-request only), so clearing cache was never the fix. The MenuService change is retained — it's still correct and feedsAdminPermissionMiddleware. Smoke now also asserts the NavbarService path (section [3b]).Resolves
PARTNER_ADMIN_ACCESS_SECURITY_AUDIT_PROMPT.md. Audited 2026-06-18. Prod queried read-only via SSMselect-only tinker (profilevell-prod-admin, instancei-046123142bd134712, us-east-1). Fix applied on dev (code/social), smoke passing; held for human review before dev→main.
TL;DR¶
The audit's working premise — that ron@vell.ai is a type='admin' account (privilege
escalation) — is false. On live prod ron@vell.ai (id 6) is type='user', isAdmin()==false,
no spatie roles, marketplace-linked, created today via the marketplace flow. He is a correct
partner account.
The admin nav he saw is a menu-definition bug, not a role bug: several admin menu rows were
added to the menus table by migrations without an is_admin flag, so the sidebar Blade
rendered them for every authenticated user. The underlying routes were always enforced by the
admin middleware, so a partner clicking them is redirected — this is information disclosure /
broken nav, not privilege escalation.
Classification: neither (a) nor (b) from the prompt — it's (c) a menu-flag defect¶
| Severity | Low–Medium (info disclosure + broken UX; no unauthorized access) |
| Privilege escalation? | No — admin routes enforced server-side (admin middleware) |
| Role model correct? | Yes — ron@vell.ai is type='user'; marketplace register() never sets type/assignRole |
Evidence (prod, read-only)¶
ron@vell.ai — id=6, type=user, isAdmin=0, isSuperAdmin=0, spatie_roles=(none),
marketplace_customer_id=i5MzSPW7LgH, plan_id=NULL, created_at=2026-06-18, status=1.
Over-privilege table — every admin + every marketplace-linked account:
| id | type | marketplace? | flag | |
|---|---|---|---|---|
| 1 | admin@vell.ai | super_admin | no | legit |
| 2 | admin@vell.io | super_admin | no | legit |
| 6 | ron@vell.ai | user | yes (i5MzSPW7LgH) | correct partner |
Zero overlap — no marketplace-linked account is admin. Only two admin accounts exist, both
legitimate super_admin operators, neither marketplace-linked.
What ron actually renders (live menu, evaluated through the exact Blade gate as ron): 5 admin
menu items with is_admin=null leak through the Blade @else branch:
admin_skill_library_dropdown→dashboard.admin.skill-library.indexadmin_knowledge_bases_dropdown→dashboard.admin.knowledge-bases.indexadmin_api_testing→dashboard.admin.api-testing.indexadmin_url_tracking_codes→dashboard.admin.url-tracking-codesadmin_roi_calculator→dashboard.admin.roi-calculator
The correctly-flagged sections (user_management, settings, finance, themes, pages,
marketplace, admin_label) are is_admin=true and do not render for ron. (The broader list
in the symptom is the super_admin control sidebar / a prior forever-cache state — see Caching note.)
All 5 leaked routes carry the admin middleware → a type='user' hitting them is redirected to the
dashboard by AdminPermissionMiddleware. No data or admin function is reachable.
Root cause¶
menus has no is_admin column (2024_05_16_092520_create_menus_table.php). The is_admin
flag is supplied only by the static MenuService::data() array and merged onto matching DB rows.
Admin menu rows added purely via migration (no static data() counterpart) therefore arrive with
is_admin=null:
2025_11_16_000000_add_api_testing_admin_menu_item.php2025_12_23_000003_add_admin_knowledge_bases_menu.php2025_12_26_000002_add_url_tracking_codes_menu_items.php2026_01_09_084000_add_roi_calculator_menu_items.php2026_04_10_000013_add_skill_library_menus.php
The sidebar (resources/views/default/panel/layout/partials/menu.blade.php) gates is_admin items
on the live isAdmin() + checkPermission() (correct), but routes any item with a falsy
is_admin to the @else branch, which renders on show_condition && is_active alone — no role gate.
Fix (dev — held for review)¶
MenuService::merge()— route-derived backstop (primary). Any menu item whoseroutestarts withdashboard.admin.is forcedis_admin=truebefore the menu is cached. This is the admin route group (server-side enforced by theadminmiddleware), so it is by definition admin-only. Route-derived (not per-user) → safe in the shared cache, and future-proof: any new admin menu row is auto-flagged.- Cache-safety cleanup.
data()baked the per-request$admin = isAdmin()into 3 items (bank_transactionsshow_condition,mobile_paymentis_admin,plagiarism_extensionshow_condition). Sincegenerate()caches the merged menu forever under one constant key (MENU_KEY='dynamic_menu_key'), per-user state must not be baked in. Replaced with static values (these are already route-gated admin items). Removed the now-dead$adminlocal. 2026_06_18_000001_clear_menu_cache_for_admin_flag_fix.php— driver-agnosticCache::forget(MenuService::MENU_KEY)so the corrected menu takes effect at deploy (prod/demo run the redis cache driver; the oldDB::table('cache')…'%menu%'purge pattern is a no-op there).scripts/smoke-menu-admin-leak.php— regression guard (12/12 pass on Herd DB): atype='user'hasisAdmin/checkPermission==false; renders zerodashboard.admin.*nav items; everydashboard.admin.*item isis_admin-flagged after merge(); admin sample routes carry theadminmiddleware (with a user-route control).
Marketplace registration path — confirmed safe¶
MarketplaceSubscriptionController::processRegistration() → User::create([...]) sets no type/
assignRole (defaults to user); email is unique:users,email (cannot reuse/elevate an existing
account). App-wide, the only code that sets type to admin/super_admin is install bootstrap
(InstallationController), one-time historical migrations, and DemoSeeder (seeds admin@vell.ai,
demo env only). No path elevates a self-registered/marketplace user.
Caching note (why the observed set varies)¶
generate() caches the merged menu via rememberForever(MENU_KEY) — a constant, role-agnostic
key. A role-aware cacheKey()/fullCacheKey() exists but is dead code (never called), and
cacheClearBlade() forgets those dead keys, never the constant key actually used (only regenerate()
and %menu%-DB-purge migrations clear the real key — and the latter is a no-op on redis). Once the
fix removes all per-user state from the cached array (item 2 above), the constant-key global cache is
correct (per-user gating happens live in Blade). The dead cacheKey() and mis-targeted
cacheClearBlade() are a cleanup follow-up, not a security issue.
Secondary findings (flagged, not fixed here — scope discipline)¶
- Two
Admin\*ControllerGET endpoints sit outside the admin middleware (auth-only):dashboard.api.guardrails.availableanddashboard.api.knowledge-bases.available(read-only "available" lists consumed by the agent-creation wizard, which regular users use). Confirm intended; if admin-only, move under theadmingroup. dashboard.coupons.validateanddashboard.support.*are outside admin middleware by design (checkout coupon validation; a user's own support tickets) — not defects.- Dead
MenuService::cacheKey()/fullCacheKey()+cacheClearBlade()targeting the wrong keys — cleanup follow-up.
Status / next steps¶
- Fix on dev (
code/social), smoke 12/12. Changed files:app/Services/Common/MenuService.php,database/migrations/2026_06_18_000001_clear_menu_cache_for_admin_flag_fix.php,scripts/smoke-menu-admin-leak.php. - Hold for human approval before commit + dev→main (prod-promotion protocol; fetch/diff/pause).
- No prod write performed or needed (role data is already correct).