feat(api): per-user budget write/clear endpoints + dashboard editor#3224
Merged
Conversation
Closes the M5 follow-up gap: `UserConfig.budget` was set-via-config.toml only, but the M6 dashboard already shipped a `UserBudgetPage` stub that linked back to PR #3203 saying "wire up after M5 lands". M5 has landed (per-user enforcement is real), so this connects the management plane. Surface - `PUT /api/budget/users/{user_id}` — admin-only upsert. Body mirrors `UserBudgetConfig` (max_hourly_usd / max_daily_usd / max_monthly_usd / alert_threshold). Validates negatives / NaN / infinite + threshold range before touching disk so a bad payload returns 400 cleanly. On success, `users::persist_users` writes the toml and reloads the kernel so the new cap takes effect on the next LLM call. - `DELETE /api/budget/users/{user_id}` — admin-only clear. Sets `UserConfig.budget = None` and persists. Idempotent (cleared user gets 200, no error). - Both record a `RoleChange` audit event with the caller's user_id, same shape as `update_user`. Both reuse `require_admin_for_user_budget` (the existing gate on the GET sibling) — Owner-level isn't required because budget management mirrors `update_agent_budget` semantically. `users::persist_users` and `PersistError` lifted from module-private to `pub(crate)` so the budget handler can share the same toml-write path that user CRUD uses. Dashboard - `api.ts`: `UserBudgetResponse` retyped to match the actual server shape (`{user_id, name, role, hourly{spend,limit,pct}, daily{...}, monthly{...}, alert_threshold, alert_breach, enforced}`). The previous shape was an M5-stub guess that never matched the wire format. - New `useUpdateUserBudget` / `useDeleteUserBudget` mutations; both invalidate `userBudgetKeys.detail(name)` plus `userKeys.detail(name)` + `userKeys.lists()` since `UserConfig.budget` is part of `UserItem`. - `UserBudgetPage` rewritten from stub to live editor: spend bars per window, limit / threshold form, validation (negative / out-of-range) matched to server; "Clear cap" button that calls DELETE. Tests - `test_user_budget_put_get_delete_round_trip`: PUT 1.5/12/100 USD caps, assert GET reflects them, DELETE, assert GET is back to 0. - `test_user_budget_put_rejects_invalid_payload`: -1.0 hourly, threshold=1.5, threshold=-0.1 — all 400. - `test_user_budget_put_rejects_viewer_with_403`: Bob (viewer) PUT against Alice → 403, gate from in-handler `require_admin_for_user_budget`. - `test_user_budget_put_unknown_user_returns_404`: PUT against NonExistent surfaces 404 instead of silent insert.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Addresses review on #3224: 1. cargo fmt — collapsed two split format!() literals on budget.rs that tripped the Quality CI check. 2. PUT /api/budget/users/{id} now rejects partial bodies with 400 instead of silently zeroing missing windows. UserBudgetConfig has #[serde(default)], so a previous `{"max_hourly_usd": 5}` would have reset daily/monthly/alert to defaults — clearing an existing cap without warning. Also rejects wrong-typed values (`"1.0"` as a string) with 400 instead of coercing to 0.0. 3. Audit detail now records the target user_id alongside the new values, so "who changed Bob's budget" is one log line instead of a timestamp+number-fishing exercise. 4. AuditAction switched from RoleChange → ConfigChange. RoleChange is reserved for admin/editor/viewer flips; budget edits are config changes. Keeps the audit filter clean. New regression test test_user_budget_put_rejects_partial_payload pins all four shapes that must 400: missing key, multiple-missing, empty object, and string-typed number. Existing 404 / 403 / round-trip / invalid-value tests updated to send full-shape bodies.
Contributor
Author
|
Pushed Changes
New test: Verified locally:
Live integration test (real daemon PUT → reload → metering enforcement) is still on the operator side before this rolls out. |
6 tasks
# Conflicts: # crates/librefang-api/tests/api_integration_test.rs
houko
force-pushed
the
feat/rbac-user-budget-write-api
branch
from
April 26, 2026 12:22
7a827b3 to
0ad33ff
Compare
…-write-api # Conflicts: # crates/librefang-api/tests/api_integration_test.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the M5 follow-up gap:
UserConfig.budgetwas settable only by editingconfig.tomldirectly. PR #3209 (M6 dashboard) shipped aUserBudgetPagestub linking to PR #3203 with "wire up after M5 lands". M5 has landed (per-user enforcement is real —MeteringEngine::check_user_budgetdenies over-budget calls, audit firesBudgetExceeded), so this connects the management plane.Surface
PUT /api/budget/users/{user_id}— admin-only upsert. Body mirrorsUserBudgetConfig:{ "max_hourly_usd": 1.5, "max_daily_usd": 12.0, "max_monthly_usd": 100.0, "alert_threshold": 0.75 }0.0= "unlimited on that window" (same semantics as the kernel-side check). Validates negatives / NaN / Infinity / threshold range before touching disk → 400 if bad.DELETE /api/budget/users/{user_id}— admin-only clear. SetsUserConfig.budget = None. Idempotent.users::persist_users(toml_edit round-trip + kernel reload), so the new cap is in effect on the next LLM call. Both record aRoleChangeaudit event with the caller'suser_id.Plumbing
users::persist_users+PersistErrorlifted from module-private topub(crate)so the budget handler shares the same write path. No semantic change.UserBudgetResponseinapi.tsretyped from M5-stub guess to the real server shape ({user_id, name, role, hourly{spend,limit,pct}, daily, monthly, alert_threshold, alert_breach, enforced}).UserBudgetPagerewritten from "Pending M5" placeholder to a live editor with spend bars + limit form + clear button.Why admin (not owner)?
Mirrors
update_agent_budgetand the existing/api/budget/usersGET ranking — budget management is an admin operation, not the same blast radius as/api/usersCRUD which is owner-only. Samerequire_admin_for_user_budgetgate as the GET sibling.Test plan
cargo check -p librefang-api— clean.cargo test -p librefang-api --test api_integration_test -- test_user_budget— 4 new tests + 2 pre-existing ones, all green:test_user_budget_put_get_delete_round_triptest_user_budget_put_rejects_invalid_payloadtest_user_budget_put_rejects_viewer_with_403test_user_budget_put_unknown_user_returns_404Refs #3054 (RBAC umbrella), follow-up to #3203 (M5).