Skip to content

feat(api): per-user budget write/clear endpoints + dashboard editor#3224

Merged
houko merged 5 commits into
mainfrom
feat/rbac-user-budget-write-api
Apr 26, 2026
Merged

feat(api): per-user budget write/clear endpoints + dashboard editor#3224
houko merged 5 commits into
mainfrom
feat/rbac-user-budget-write-api

Conversation

@houko

@houko houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the M5 follow-up gap: UserConfig.budget was settable only by editing config.toml directly. PR #3209 (M6 dashboard) shipped a UserBudgetPage stub linking to PR #3203 with "wire up after M5 lands". M5 has landed (per-user enforcement is real — MeteringEngine::check_user_budget denies over-budget calls, audit fires BudgetExceeded), so this connects the management plane.

Surface

  • PUT /api/budget/users/{user_id} — admin-only upsert. Body mirrors UserBudgetConfig:
    { "max_hourly_usd": 1.5, "max_daily_usd": 12.0,
      "max_monthly_usd": 100.0, "alert_threshold": 0.75 }
    Any window at 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. Sets UserConfig.budget = None. Idempotent.
  • Both persist via users::persist_users (toml_edit round-trip + kernel reload), so the new cap is in effect on the next LLM call. Both record a RoleChange audit event with the caller's user_id.

Plumbing

  • users::persist_users + PersistError lifted from module-private to pub(crate) so the budget handler shares the same write path. No semantic change.
  • UserBudgetResponse in api.ts retyped from M5-stub guess to the real server shape ({user_id, name, role, hourly{spend,limit,pct}, daily, monthly, alert_threshold, alert_breach, enforced}).
  • UserBudgetPage rewritten from "Pending M5" placeholder to a live editor with spend bars + limit form + clear button.

Why admin (not owner)?

Mirrors update_agent_budget and the existing /api/budget/users GET ranking — budget management is an admin operation, not the same blast radius as /api/users CRUD which is owner-only. Same require_admin_for_user_budget gate 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_trip
    • test_user_budget_put_rejects_invalid_payload
    • test_user_budget_put_rejects_viewer_with_403
    • test_user_budget_put_unknown_user_returns_404
  • Live integration test on a real daemon — deferred to CI / reviewer.

Refs #3054 (RBAC umbrella), follow-up to #3203 (M5).

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added size/L 250-999 lines changed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
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.
@houko

houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a016aff8 addressing the review.

Changes

  1. cargo fmt — fixed the two split format!() calls that broke the Quality job.
  2. PUT is now full-replace, partial bodies → 400. Previously a {"max_hourly_usd": 5} would silently reset daily/monthly/alert to defaults via UserBudgetConfig's #[serde(default)], clearing an existing cap without warning. Wrong-typed values ("1.0" as a string) also now 400 instead of being coerced to 0.0. Doc comment updated to match.
  3. Audit detail includes target user_idformat!("user_budget updated for {user_id_param}: ...") so "who changed Bob's budget" is one log line.
  4. AuditAction::ConfigChange instead of RoleChange. RoleChange stays reserved for admin/editor/viewer flips.

New test: test_user_budget_put_rejects_partial_payload pins all four shapes that must 400 — missing single key, multiple missing, empty object, string-typed number — and confirms an existing cap survives the rejected partials. Existing 404/403/round-trip/invalid-value tests updated to use full-shape bodies (since the field-required gate now runs before the persist step).

Verified locally:

  • cargo fmt --all -- --check clean
  • cargo clippy -p librefang-api --tests -- -D warnings clean
  • cargo test -p librefang-api --test api_integration_test -- test_user_budget — 6/6 pass

Live integration test (real daemon PUT → reload → metering enforcement) is still on the operator side before this rolls out.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution needs-changes Changes requested by reviewer labels Apr 26, 2026
# Conflicts:
#	crates/librefang-api/tests/api_integration_test.rs
@houko
houko force-pushed the feat/rbac-user-budget-write-api branch from 7a827b3 to 0ad33ff Compare April 26, 2026 12:22
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
…-write-api

# Conflicts:
#	crates/librefang-api/tests/api_integration_test.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
@houko
houko merged commit 8cf299d into main Apr 26, 2026
20 checks passed
@houko
houko deleted the feat/rbac-user-budget-write-api branch April 26, 2026 13:21
@github-actions github-actions Bot removed the ready-for-review PR is ready for maintainer review label Apr 26, 2026
@houko houko mentioned this pull request Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant