Skip to content

Follow-up Audit Prompt — Admin-API exposure + MenuService cache cleanup

Paste this whole file into a fresh session. Self-contained brief for two distinct, low-severity follow-ups spun out of the partner admin-access audit (see PARTNER_ADMIN_ACCESS_FINDINGS.md). The main leak (a type='user' marketplace partner seeing platform-admin nav) is already fixed and live in prodNavbarService + MenuService now hide dashboard.admin.* from non-admins (PRs #1737,

1738), and team-invite seat enforcement shipped (#1739). **This prompt is the leftover cleanup, not

the main bug.** Recon below is done — verify, then act.

Two independent work items (do both, or split): - A — Admin-API field exposure: two Admin\*Controller "available" JSON endpoints are reachable by any authenticated user and return AWS ARNs / Bedrock identifiers of platform-scoped resources. - B — MenuService cache dead code: a role-aware cache-key system + cacheClearBlade() exist but are dead/misleading; generate() uses a single constant key. No live bug — a trap to remove.

Plus two related observations (§Related) worth a verdict.


Context / why this exists

The partner admin-access audit (2026-06-18) found and fixed the real issue: admin menu rows lacking an admin gate rendered in the sidebar for type='user' partners (routes were always enforced by the admin middleware, so it was information-disclosure / broken-nav, not privilege escalation). While auditing, two adjacent items were flagged but deliberately not fixed (scope discipline). This prompt resolves them.

Authorization model recap (don't re-derive): three layers — platform role (users.type enum user/admin/super_admin/demo), spatie roles (fine-grained admin-route perms via AdminPermissionMiddleware), and team capabilities (account-level "billing power user", owner shortcut). The live sidebar renders from App\Services\Navbar\NavbarService::getMenuForUser() (NOT MenuService — that's legacy, but still feeds AdminPermissionMiddleware). See PARTNER_ADMIN_ACCESS_FINDINGS.md and the memory note reference_auth_model_and_menu_gating.


Work Item A — Admin\*Controller "available" endpoints leak infra ARNs to non-admins

Known facts (verified 2026-06-18 on dev; route middleware confirmed on prod read-only)

  • Two GET routes sit outside the admin middleware group (only auth, updateUserActivity, track.session — confirmed via prod route:list), in routes/panel.php (~L1496, ~L1500):
  • dashboard.api.guardrails.availableApp\Http\Controllers\Admin\GuardrailController@availableGuardrailService::getAvailableGuardrails($user)BedrockGuardrail::availableForUser($user).
  • dashboard.api.knowledge-bases.availableApp\Http\Controllers\Admin\PlatformKnowledgeBaseController@availablePlatformKnowledgeBase::availableForUser($user).
  • Row scoping is fineavailableForUser($user) returns platform-scoped + the user's own + their team's rows. The problem is field scoping: both return full model rows via ->toArray(), and those models expose AWS identifiers:
  • BedrockGuardrail fillable includes guardrail_arn (+ guardrail identifier/version).
  • PlatformKnowledgeBase fillable includes knowledge_base_id and knowledge_base_arn. ⇒ a type='user' marketplace partner receives ARNs of Vell's platform-scoped Bedrock resources (ARNs disclose AWS account id, region, and resource names).
  • Callers (so you don't break them):
  • Agent-creation wizard (used by regular users): app/Extensions/ContentManager/resources/views/agents/create.blade.php (~L1480) fetches dashboard.api.guardrails.available.
  • Admin tools page: resources/views/default/panel/admin/config/tools.blade.php (~L650).
  • Parallel user-facing controllers already exist: app/Http/Controllers/User/GuardrailController@available (~L37) and User\UserKnowledgeBaseController (OpenAPI path /api/knowledge-bases/available). So a non-admin path may already be intended.

The question to answer

Is exposing platform Bedrock ARNs/IDs to authenticated non-admin partners acceptable? Almost certainly no — the wizard only needs id, name, description, maybe type/scope. ARNs are internal infra.

Remediation options (pick after confirming the wizard's real field needs)

  1. Scope the response (preferred): return a thin shape (API Resource / explicit array) with only the fields the wizard renders — drop *_arn, knowledge_base_id, and any account/config internals — for the non-admin path. Keep full detail only on the admin-gated endpoints.
  2. Route to the user controllers: point the wizard at User\GuardrailController@available / User\UserKnowledgeBaseController@available (verify they're already field-scoped), then move the two Admin\*@available actions under the admin middleware so they stop being publicly reachable.
  3. Confirm the agent-creation wizard still works end-to-end for a type='user' after the change.

Severity

Low–moderate info-disclosure (Vell's own infra ARNs leaked to Vell's own partners). Confirm exact field sensitivity before sizing effort. Not a privilege escalation (no write/admin action is reachable).


Work Item B — MenuService cache-key dead code (cleanup; no live bug)

Known facts (dev, app/Services/Common/MenuService.php; line numbers approximate, use symbol names)

  • generate() (~L135) caches the merged menu under a constant key: cache()->rememberForever(self::MENU_KEY /* 'dynamic_menu_key' */, …).
  • A role/plan/locale/theme-aware cacheKey() (~L34) and fullCacheKey($userType,$planId) (~L49) exist — including a config('app.menu_cache_by_user_id') branch — but cacheKey() is never called anywhere (dead). The role-aware key is not what generate() actually uses.
  • cacheClearBlade() (~L80) forgets the fullCacheKey() variants (i.e. the dead keys), not MENU_KEY. It is only ever invoked by regenerate() (~L64) — and regenerate() separately calls cache()->forget(self::MENU_KEY) (~L68), which is what actually clears the live cache. So there is no staleness bug today (every real caller uses regenerate()); cacheClearBlade()'s forgets are pure dead weight, and the role-aware key system is a trap.
  • Now that per-user state was removed from the cached array (PR #1737 stripped the $admin-derived items), the single global constant-key cache is correct (per-user gating happens live in Blade / NavbarService).

Remediation options

  1. Simplify (preferred): delete dead cacheKey() / fullCacheKey() and the menu_cache_by_user_id branch; make cacheClearBlade() (or fold it into regenerate()) forget self::MENU_KEY. Verify no external callers reference the deleted methods (grep -rn 'cacheKey\|fullCacheKey\|menu_cache_by_user_id').
  2. Or, if per-user/role menu caching is genuinely wanted for perf: wire generate() to cache under cacheKey() and make clearing forget every variant. Only do this if there's a measured reason — the blade/NavbarService already gate per-user, so the constant key is sufficient.

Severity

Cosmetic / maintainability. Goal: remove the trap so nobody "fixes" cacheClearBlade() believing it clears the live cache, or relies on the dead role-aware key. Confirm regenerate()'s ~20 callers still clear correctly after the change.


  1. 3PI-partner add path bypasses seat enforcement. PR #1739 gated TeamController::storeInvitation with Team::hasAvailableSeat(), but Team\ThreePIPartnerWebController@store and Api/V1/ThreePIPartnerController@store create members via TeamMember::createPartner3PI() with no seat check. If 3PI partners consume team seats (same team_members table), apply the same gate; if they're a distinct co-sell concept with separate limits, document why they're exempt.
  2. NavbarService legacy visibility fallback is permissive. NavbarService::checkLegacyPlanVisibility() returns true for any menus row lacking visibility_rules (it never enforces plan/admin). Admin is now covered by the route-prefix backstop, but plan-feature gating for non-admin rows without rules relies on this permissive path. Consider populating visibility_rules for all rows and tightening the fallback (or confirm every row that needs gating already has rules).

Guardrails (read carefully)

  • Prod is read-only. SSM select-only tinker (sudo -u apache HOME=/tmp php artisan tinker, profile vell-prod-admin); never write prod DB/.env. Prod app path /var/www/html; cache driver = redis. Diagnose env by IP (10.42 prod / 10.43 dev / 10.44 demo).
  • Work app code in code/social (dev); stage only your own files (tree carries unrelated dirty dirs). php -l + a scripts/smoke-*.php on the Herd DB before committing (Pest is blocked by sqlite-vs-MySQL).
  • dev→main = squash only, merge-commits blocked; git fetch first (a concurrent session pushes often); pause for explicit human approval before promoting anything prod-affecting. Cherry-pick your commit onto a branch off origin/main → PR → squash-merge (don't merge the whole dev backlog).
  • This is defensive work on the operator's own platform — in scope. Keep prod queries minimal + read-only.

Deliverables

  1. Item A verdict + fix: confirm whether non-admins receive sensitive fields; if so, field-scope the response (or route to user controllers + admin-gate the Admin actions); prove the agent-creation wizard still works for type='user'. Add/extend a smoke asserting the non-admin available payload contains no *_arn / knowledge_base_id.
  2. Item B fix: remove the dead key system (or wire it up, with justification); ensure menu-mutating flows still clear the live cache (MENU_KEY). Note: deploys run view:clear not full cache:clear, and prod cache = redis — confirm the clear path is driver-agnostic.
  3. Related observations: a one-paragraph verdict each (fix now, ticket, or documented intentional).
  4. Append findings to docs/security/PARTNER_ADMIN_ACCESS_FINDINGS.md and update memory.

First moves (suggested)

  • Read Admin\GuardrailController@available, Admin\PlatformKnowledgeBaseController@available, and their User\* equivalents; diff the payloads. Confirm BedrockGuardrail / PlatformKnowledgeBase fillable ARNs reach the JSON.
  • grep -rn "api/guardrails/available\|api/knowledge-bases/available" to enumerate callers before changing routes.
  • Read MenuService cacheKey/fullCacheKey/cacheClearBlade/regenerate/generate; grep for any caller of the dead methods; confirm regenerate() is the only path that clears the live cache.
  • Decide scope with the human, fix on dev, smoke, hold for dev→main approval.