diff --git a/implementation-plan-api-dashboards.md b/implementation-plan-api-dashboards.md index ea0c470..15a1cfe 100644 --- a/implementation-plan-api-dashboards.md +++ b/implementation-plan-api-dashboards.md @@ -118,11 +118,30 @@ Build order with dependencies and verification gates. Each step must be verified ### Step 1 — Confirm spend-log scoping (the riskiest unknown) -*Resolves the "Risks" item that could invalidate the whole member dashboard.* +**COMPLETED (2026-09-13). Outcome: the member dashboard cannot read LiteLLM directly — all reads AND writes must go through the broker/portal.** -- Query `/spend/logs` and `/user/info` filtered by a single key/team; confirm a team-scoped request returns **only that member's** rows, not everyone's. -- Also confirm the broker can read *only* the target member's team (no cross-team enumeration). -- **Gate:** if scoping isn't clean, the member dashboard must go through the broker for *reads too*, which changes the architecture. Resolve before any build. +Findings: + +- **Member keys cannot read spend endpoints.** A member's own key (both `sk-` virtual keys and legacy raw-hash tokens) gets `401 "Only proxy admin"` on `/spend/logs`, `/spend/keys`, and `/user/info`. Only the master key can query spend. +- **Consequence:** the member dashboard must route *reads* through the portal (which holds the master key) too, not just writes. This actually *simplifies* the security model — the member dashboard holds **no LiteLLM credential at all**; it just calls the portal, which already authenticates members and already holds the master key. +- **`/spend/keys` is the clean per-member read primitive** — it returns one row per key with `key_alias` (`member:`), `spend`, and `team_id`. Filtering by alias gives a member's spend directly. +- **`/spend/logs` filtering is inconsistent:** `api_key=` filters correctly, but `team_id=` is silently ignored (returned all 92 rows for a bogus team id). Do not rely on `team_id` filtering on `/spend/logs`; prefer `/spend/keys` + `key_alias`. +- **Data inconsistency found (must fix in Step 2):** three members (Dan, Ed, Nathan) have **raw-hash key tokens** (not valid LiteLLM virtual keys), while four (Lee, Joseph, Liz, Roz) have `sk-` keys. The raw-hash tokens are a legacy artifact; the Teams migration will recreate all keys consistently as `sk-` virtual keys. +- **`/user/info?user_id=`** works with the master key but shows `spend: 0` and empty `keys`/`teams` because current keys have `user_id: null` (key-only, not user-bound). After the Teams migration binds keys to teams, per-team spend becomes queryable. + +Revised architecture (all LiteLLM access via the portal): + +``` +Member (browser) ──SSO──▶ Member Dashboard (new Cloudron app, "members" group) + │ holds NO LiteLLM/Cloudron/OC credentials + │ calls the portal for everything + ▼ + Portal (holds master key; authenticates member) + │ read: /spend/keys?key_alias=member: + │ write: mint/revoke key via master key + ▼ + LiteLLM Gateway (Teams + budgets + spend logs) +``` ### Step 2 — Teams migration (Phase 1)