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 (atype='user'marketplace partner seeing platform-admin nav) is already fixed and live in prod —NavbarService+MenuServicenow hidedashboard.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
adminmiddleware group (onlyauth, updateUserActivity, track.session— confirmed via prodroute:list), inroutes/panel.php(~L1496, ~L1500): dashboard.api.guardrails.available→App\Http\Controllers\Admin\GuardrailController@available→GuardrailService::getAvailableGuardrails($user)→BedrockGuardrail::availableForUser($user).dashboard.api.knowledge-bases.available→App\Http\Controllers\Admin\PlatformKnowledgeBaseController@available→PlatformKnowledgeBase::availableForUser($user).- Row scoping is fine —
availableForUser($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: BedrockGuardrailfillable includesguardrail_arn(+ guardrail identifier/version).PlatformKnowledgeBasefillable includesknowledge_base_idandknowledge_base_arn. ⇒ atype='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) fetchesdashboard.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) andUser\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)¶
- 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. - Route to the user controllers: point the wizard at
User\GuardrailController@available/User\UserKnowledgeBaseController@available(verify they're already field-scoped), then move the twoAdmin\*@availableactions under theadminmiddleware so they stop being publicly reachable. - 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) andfullCacheKey($userType,$planId)(~L49) exist — including aconfig('app.menu_cache_by_user_id')branch — butcacheKey()is never called anywhere (dead). The role-aware key is not whatgenerate()actually uses. cacheClearBlade()(~L80) forgets thefullCacheKey()variants (i.e. the dead keys), notMENU_KEY. It is only ever invoked byregenerate()(~L64) — andregenerate()separately callscache()->forget(self::MENU_KEY)(~L68), which is what actually clears the live cache. So there is no staleness bug today (every real caller usesregenerate());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¶
- Simplify (preferred): delete dead
cacheKey()/fullCacheKey()and themenu_cache_by_user_idbranch; makecacheClearBlade()(or fold it intoregenerate()) forgetself::MENU_KEY. Verify no external callers reference the deleted methods (grep -rn 'cacheKey\|fullCacheKey\|menu_cache_by_user_id'). - Or, if per-user/role menu caching is genuinely wanted for perf: wire
generate()to cache undercacheKey()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.
Related observations (give a verdict; may become separate tickets)¶
- 3PI-partner add path bypasses seat enforcement. PR #1739 gated
TeamController::storeInvitationwithTeam::hasAvailableSeat(), butTeam\ThreePIPartnerWebController@storeandApi/V1/ThreePIPartnerController@storecreate members viaTeamMember::createPartner3PI()with no seat check. If 3PI partners consume team seats (sameteam_memberstable), apply the same gate; if they're a distinct co-sell concept with separate limits, document why they're exempt. - NavbarService legacy visibility fallback is permissive.
NavbarService::checkLegacyPlanVisibility()returnstruefor anymenusrow lackingvisibility_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 populatingvisibility_rulesfor 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, profilevell-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+ ascripts/smoke-*.phpon the Herd DB before committing (Pest is blocked by sqlite-vs-MySQL). - dev→main = squash only, merge-commits blocked;
git fetchfirst (a concurrent session pushes often); pause for explicit human approval before promoting anything prod-affecting. Cherry-pick your commit onto a branch offorigin/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¶
- 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-adminavailablepayload contains no*_arn/knowledge_base_id. - 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 runview:clearnot fullcache:clear, and prod cache = redis — confirm the clear path is driver-agnostic. - Related observations: a one-paragraph verdict each (fix now, ticket, or documented intentional).
- Append findings to
docs/security/PARTNER_ADMIN_ACCESS_FINDINGS.mdand update memory.
First moves (suggested)¶
- Read
Admin\GuardrailController@available,Admin\PlatformKnowledgeBaseController@available, and theirUser\*equivalents; diff the payloads. ConfirmBedrockGuardrail/PlatformKnowledgeBasefillable ARNs reach the JSON. grep -rn "api/guardrails/available\|api/knowledge-bases/available"to enumerate callers before changing routes.- Read
MenuServicecacheKey/fullCacheKey/cacheClearBlade/regenerate/generate;grepfor any caller of the dead methods; confirmregenerate()is the only path that clears the live cache. - Decide scope with the human, fix on dev, smoke, hold for dev→main approval.