Skip to content

Findings — Partner Admin-Access (authorization audit)

⚠️ CORRECTION (2026-06-18, after browser screenshot)

The live sidebar is rendered by NavbarService::getMenuForUser() (via components/navbar/navbar-new.blade.phpmenu-tree-new.blade.php), not by MenuService / partials/menu.blade.php. The first fix (MenuService is_admin backstop) was on the legacy path and did not change the rendered sidebar — a browser check still showed the full ADMIN block, and running the live NavbarService as ron@vell.ai returned 81 admin-routed items. Real fix: NavbarService::isVisible()/isVisibleArray() now hide any dashboard.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 feeds AdminPermissionMiddleware. 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 SSM select-only tinker (profile vell-prod-admin, instance i-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? Yesron@vell.ai is type='user'; marketplace register() never sets type/assignRole

Evidence (prod, read-only)

ron@vell.aiid=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 email 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_dropdowndashboard.admin.skill-library.index
  • admin_knowledge_bases_dropdowndashboard.admin.knowledge-bases.index
  • admin_api_testingdashboard.admin.api-testing.index
  • admin_url_tracking_codesdashboard.admin.url-tracking-codes
  • admin_roi_calculatordashboard.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.php
  • 2025_12_23_000003_add_admin_knowledge_bases_menu.php
  • 2025_12_26_000002_add_url_tracking_codes_menu_items.php
  • 2026_01_09_084000_add_roi_calculator_menu_items.php
  • 2026_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)

  1. MenuService::merge() — route-derived backstop (primary). Any menu item whose route starts with dashboard.admin. is forced is_admin=true before the menu is cached. This is the admin route group (server-side enforced by the admin middleware), 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.
  2. Cache-safety cleanup. data() baked the per-request $admin = isAdmin() into 3 items (bank_transactions show_condition, mobile_payment is_admin, plagiarism_extension show_condition). Since generate() 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 $admin local.
  3. 2026_06_18_000001_clear_menu_cache_for_admin_flag_fix.php — driver-agnostic Cache::forget(MenuService::MENU_KEY) so the corrected menu takes effect at deploy (prod/demo run the redis cache driver; the old DB::table('cache')…'%menu%' purge pattern is a no-op there).
  4. scripts/smoke-menu-admin-leak.php — regression guard (12/12 pass on Herd DB): a type='user' has isAdmin/checkPermission==false; renders zero dashboard.admin.* nav items; every dashboard.admin.* item is is_admin-flagged after merge(); admin sample routes carry the admin middleware (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\*Controller GET endpoints sit outside the admin middleware (auth-only): dashboard.api.guardrails.available and dashboard.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 the admin group.
  • dashboard.coupons.validate and dashboard.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).