diff --git a/gateway/internal/adminapi/hotstate_test.go b/gateway/internal/adminapi/hotstate_test.go index 0cec2862f..b94c498b9 100644 --- a/gateway/internal/adminapi/hotstate_test.go +++ b/gateway/internal/adminapi/hotstate_test.go @@ -220,6 +220,8 @@ func TestHotState_RedisUnconfigured503(t *testing.T) { {http.MethodPost, "/_plugin/agents/coder/kill"}, {http.MethodGet, "/_plugin/agents/coder/state"}, {http.MethodPost, "/_plugin/revoke/nonce/aaaa000000000000000000000000aaaa"}, + {http.MethodPut, "/_plugin/revoke/user/u_1"}, + {http.MethodGet, "/_plugin/revoke/users"}, } { resp := bearerDo(t, srv, c.method, c.path, "") resp.Body.Close() @@ -236,6 +238,9 @@ func TestHotState_RequiresAuth(t *testing.T) { {http.MethodGet, "/_plugin/runs/r_1/state"}, {http.MethodPost, "/_plugin/agents/coder/kill"}, {http.MethodPost, "/_plugin/revoke/nonce/aaaa000000000000000000000000aaaa"}, + {http.MethodPut, "/_plugin/revoke/user/u_1"}, + {http.MethodGet, "/_plugin/revoke/user/u_1"}, + {http.MethodGet, "/_plugin/revoke/users"}, } { req, _ := http.NewRequest(c.method, srv.URL+c.path, nil) resp, err := srv.Client().Do(req) diff --git a/gateway/internal/adminapi/revoke.go b/gateway/internal/adminapi/revoke.go index 000436216..07683f579 100644 --- a/gateway/internal/adminapi/revoke.go +++ b/gateway/internal/adminapi/revoke.go @@ -10,21 +10,38 @@ import ( ) // Revocation routes — the HTTP face of auth/admin.go's helpers. -// Bearer-only: revocation is issuer territory (Hive's -// /macaroons/revoke fans out here), never a dashboard click. // -// POST /_plugin/revoke/nonce/:nonce body {exp?} → 200 RevokeNonceResponse -// DELETE /_plugin/revoke/nonce/:nonce → 204 -// PUT /_plugin/revoke/user/:user_id body {before?} → 200 RevokeUserResponse +// POST /_plugin/revoke/nonce/:nonce body {exp?} → 200 RevokeNonceResponse bearer only +// DELETE /_plugin/revoke/nonce/:nonce → 204 bearer only +// PUT /_plugin/revoke/user/:user_id body {before?} → 200 RevokeUserResponse cookie or bearer // GET /_plugin/revoke/user/:user_id → 200 RevokeUserResponse | 404 // DELETE /_plugin/revoke/user/:user_id → 204 +// GET /_plugin/revoke/users → 200 RevokeUsersResponse +// +// Two auth postures under one prefix (server.go splits them): +// +// - Nonce revocation stays bearer-only. It is issuer territory — +// Hive's /macaroons/revoke fans out here — and the dashboard has +// no surface that knows a nonce anyway. +// - User revocation is cookie-or-bearer, the same posture as the +// run and agent kill switches (CSRF header on cookie-authed +// mutations). It is the dashboard's third kill axis: per swarm, +// in Redis, readable back by Hive's reconcile sweep through the +// same GET routes. Blast radius is the same class as an agent +// kill — every run of one principal — so the SPA gates it behind +// the same typed confirmation. // // `exp` is the RFC3339 expiry of the layer whose nonce is being // revoked; the tombstone's TTL is derived from it (phase-6: "TTL = // layer.exp"). Omitted ⇒ the 7d ceiling, which is always safe // (macaroon layers never outlive it). `before` defaults to now. -const revokePrefixPath = "/_plugin/revoke/" +const ( + revokePrefixPath = "/_plugin/revoke/" + // revokeNoncePath is the bearer-only subtree; everything else + // under revokePrefixPath is cookie-or-bearer. + revokeNoncePath = revokePrefixPath + "nonce/" +) // RevokeNonceRequest is the body for POST /_plugin/revoke/nonce/:nonce. type RevokeNonceRequest struct { @@ -48,13 +65,26 @@ type RevokeUserResponse struct { Before string `json:"before"` // RFC3339 UTC } +// RevokeUsersResponse is the wire shape for GET /_plugin/revoke/users: +// every user with a cutoff on this swarm, newest cutoff first. Backs +// the People list's "Revoked" column and Hive's per-swarm read. An +// entry whose stored cutoff won't parse has an empty `before`. +type RevokeUsersResponse struct { + Users []RevokeUserResponse `json:"users"` +} + type revokeHandlers struct{} func newRevokeHandlers() *revokeHandlers { return &revokeHandlers{} } -// dispatch routes /_plugin/revoke/{nonce,user}/. +// dispatch routes /_plugin/revoke/{nonce,user}/ and +// /_plugin/revoke/users. func (h *revokeHandlers) dispatch(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.TrimPrefix(r.URL.Path, revokePrefixPath), "/") + if len(parts) == 1 && parts[0] == "users" { + h.users(w, r) + return + } if len(parts) != 2 || parts[1] == "" { http.NotFound(w, r) return @@ -163,3 +193,25 @@ func (h *revokeHandlers) user(w http.ResponseWriter, r *http.Request, userID str methodNotAllowed(w, http.MethodPut, http.MethodGet, http.MethodDelete) } } + +// users handles GET /_plugin/revoke/users. +func (h *revokeHandlers) users(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + list, err := auth.ListUserRevokeCutoffs(r.Context(), 0) + if err != nil { + writeHotStateErr(w, err, "revoke.list_users") + return + } + out := RevokeUsersResponse{Users: make([]RevokeUserResponse, 0, len(list))} + for _, u := range list { + before := "" + if !u.Before.IsZero() { + before = u.Before.UTC().Format(time.RFC3339) + } + out.Users = append(out.Users, RevokeUserResponse{UserID: u.UserID, Before: before}) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/gateway/internal/adminapi/revoke_test.go b/gateway/internal/adminapi/revoke_test.go new file mode 100644 index 000000000..0b6291278 --- /dev/null +++ b/gateway/internal/adminapi/revoke_test.go @@ -0,0 +1,170 @@ +package adminapi + +import ( + "net/http" + "strings" + "testing" +) + +// User revocation is the dashboard's third kill axis, so its routes +// take the session cookie (with the CSRF header on mutations) like +// run and agent kills do. Nonce revocation stays bearer-only. These +// tests pin that split; the bearer round-trips live in +// hotstate_test.go next to the other hot-state routes. + +// cookieDo issues a request on the cookie-jar'd client. `csrf` +// toggles the X-Bifrost-CSRF header the SPA always sends. +func cookieDo(t *testing.T, client *http.Client, method, url, body string, csrf bool) *http.Response { + t.Helper() + var rd *strings.Reader + if body != "" { + rd = strings.NewReader(body) + } + var req *http.Request + if rd != nil { + req, _ = http.NewRequest(method, url, rd) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(method, url, nil) + } + if csrf { + req.Header.Set(csrfHeader, "1") + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func loginCookie(t *testing.T, srv string, client *http.Client) { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, srv+"/_plugin/login", nil) + req.Header.Set("Authorization", basicHeader("admin", "hunter2")) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d", resp.StatusCode) + } +} + +func TestRevokeUser_CookieAuth_WithCSRF(t *testing.T) { + srv, client := newAuthTestServer(t) + loginCookie(t, srv.URL, client) + + // Mutation without the CSRF header is refused even with a valid + // cookie — the same rule the kill routes enforce. + resp := cookieDo(t, client, http.MethodPut, srv.URL+"/_plugin/revoke/user/u_cookie", "", false) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("PUT without CSRF: want 403, got %d", resp.StatusCode) + } + + resp = cookieDo(t, client, http.MethodPut, srv.URL+"/_plugin/revoke/user/u_cookie", + `{"before":"2026-09-01T00:00:00Z"}`, true) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT with cookie+CSRF: want 200, got %d", resp.StatusCode) + } + var ur RevokeUserResponse + decodeBody(t, resp, &ur) + if ur.UserID != "u_cookie" || ur.Before != "2026-09-01T00:00:00Z" { + t.Fatalf("response: %+v", ur) + } + + // Reads need no CSRF header. + resp = cookieDo(t, client, http.MethodGet, srv.URL+"/_plugin/revoke/user/u_cookie", "", false) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET with cookie: want 200, got %d", resp.StatusCode) + } + resp.Body.Close() + resp = cookieDo(t, client, http.MethodGet, srv.URL+"/_plugin/revoke/users", "", false) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET users with cookie: want 200, got %d", resp.StatusCode) + } + var list RevokeUsersResponse + decodeBody(t, resp, &list) + if len(list.Users) != 1 || list.Users[0].UserID != "u_cookie" { + t.Fatalf("list: %+v", list) + } + + resp = cookieDo(t, client, http.MethodDelete, srv.URL+"/_plugin/revoke/user/u_cookie", "", true) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("DELETE with cookie+CSRF: want 204, got %d", resp.StatusCode) + } +} + +func TestRevokeNonce_StaysBearerOnly(t *testing.T) { + srv, client := newAuthTestServer(t) + loginCookie(t, srv.URL, client) + + const nonce = "dddd000000000000000000000000dddd" + for _, method := range []string{http.MethodPost, http.MethodDelete} { + resp := cookieDo(t, client, method, srv.URL+"/_plugin/revoke/nonce/"+nonce, "", true) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("%s nonce with cookie: want 401, got %d", method, resp.StatusCode) + } + } +} + +func TestRevokeUsers_List_NewestFirst_MethodGuard(t *testing.T) { + srv, mr := newBudgetTestServer(t, nil) + + resp := bearerDo(t, srv, http.MethodGet, "/_plugin/revoke/users", "") + var list RevokeUsersResponse + decodeBody(t, resp, &list) + if resp.StatusCode != http.StatusOK || len(list.Users) != 0 { + t.Fatalf("empty: %d %+v", resp.StatusCode, list) + } + + for _, c := range []struct{ id, before string }{ + {"u_a", "2026-09-01T00:00:00Z"}, + {"u_b", "2026-09-03T00:00:00Z"}, + {"u_c", "2026-09-02T00:00:00Z"}, + } { + resp := bearerDo(t, srv, http.MethodPut, "/_plugin/revoke/user/"+c.id, `{"before":"`+c.before+`"}`) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("set %s: %d", c.id, resp.StatusCode) + } + } + + resp = bearerDo(t, srv, http.MethodGet, "/_plugin/revoke/users", "") + decodeBody(t, resp, &list) + ids := make([]string, 0, len(list.Users)) + for _, u := range list.Users { + ids = append(ids, u.UserID) + } + if strings.Join(ids, ",") != "u_b,u_c,u_a" { + t.Fatalf("order: %v", ids) + } + if list.Users[0].Before != "2026-09-03T00:00:00Z" { + t.Fatalf("before: %+v", list.Users[0]) + } + + // Clearing one drops it from the list; a direct Redis DEL of + // another (index left behind) is healed on read. + resp = bearerDo(t, srv, http.MethodDelete, "/_plugin/revoke/user/u_b", "") + resp.Body.Close() + mr.Del("bifrost:revoke_user_before:u_c") + resp = bearerDo(t, srv, http.MethodGet, "/_plugin/revoke/users", "") + decodeBody(t, resp, &list) + if len(list.Users) != 1 || list.Users[0].UserID != "u_a" { + t.Fatalf("after clear+del: %+v", list.Users) + } + + resp = bearerDo(t, srv, http.MethodPost, "/_plugin/revoke/users", "") + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST users: want 405, got %d", resp.StatusCode) + } + resp = bearerDo(t, srv, http.MethodPut, "/_plugin/revoke/user/has%20space", "") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("bad user id: want 400, got %d", resp.StatusCode) + } +} diff --git a/gateway/internal/adminapi/server.go b/gateway/internal/adminapi/server.go index 76f2d60ba..9f29f45d5 100644 --- a/gateway/internal/adminapi/server.go +++ b/gateway/internal/adminapi/server.go @@ -189,13 +189,14 @@ func methodMuxedAuth( // Auth model // ---------- // - `/_plugin/health`, `/_plugin/login`: anonymous. -// - `/_plugin/admin-credentials`, `/_plugin/trust/*`, -// `/_plugin/revoke/*`, `/_plugin/tlog/sth`: bearer only (Hive's -// machine-to-plugin path; cookies are not honoured). -// - Everything else (observability, kill/state, tlog/status, /me, -// /logout): +// - `/_plugin/admin-credentials`, `/_plugin/trust/*` mutations, +// `/_plugin/revoke/nonce/*`, `/_plugin/tlog/sth`: bearer only +// (Hive's machine-to-plugin path; cookies are not honoured). +// - Everything else (observability, kill/state, user revocation +// under `/_plugin/revoke/user/*` + `/_plugin/revoke/users`, +// tlog/status, /me, /logout): // cookie OR bearer, with cookie tried first. Cookie-authed -// mutations (kill, unkill, toggles) need the CSRF header. +// mutations (kill, unkill, revoke, toggles) need the CSRF header. // // The /_plugin/ui/* SPA is also cookie-or-bearer so curl with a // bearer can pull it for diagnostics, but browsers always reach it @@ -315,9 +316,20 @@ func registerRoutes(mux *http.ServeMux, deps routeDeps) { } mux.HandleFunc("/_plugin/runs/", cookieOrBearer(runsSubtree)) - // Revocation admin (bearer-only; issuer territory). + // Revocation admin. One prefix, two postures (revoke.go explains): + // nonce tombstones are issuer territory and stay bearer-only; the + // user cutoff is the dashboard's third kill switch and rides the + // same cookie-or-bearer (+ CSRF) chain as run and agent kills. rv := newRevokeHandlers() - mux.HandleFunc(revokePrefixPath, bearer(rv.dispatch)) + rvNonce := bearer(rv.dispatch) + rvUser := cookieOrBearer(rv.dispatch) + mux.HandleFunc(revokePrefixPath, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, revokeNoncePath) { + rvNonce(w, r) + return + } + rvUser(w, r) + }) // Phase-8.5 per-agent budget view. Reads cap from plugin config // (auth.GetConfig().AgentBudgets) and current-bucket spend from diff --git a/gateway/internal/adminapi/ui/AGENTS.md b/gateway/internal/adminapi/ui/AGENTS.md index ff4c569de..d99d77702 100644 --- a/gateway/internal/adminapi/ui/AGENTS.md +++ b/gateway/internal/adminapi/ui/AGENTS.md @@ -47,7 +47,7 @@ ui/ │ ├── tables/ # DataTable (sortable) │ ├── controls/ # WindowPicker │ ├── icons.tsx # UserIcon, BotIcon, StopIcon (inline SVG) - │ ├── KillConfirmModal.tsx # kill / unkill confirm; typed for agents + │ ├── KillConfirmModal.tsx # kill / unkill / revoke confirm; typed for agents + users │ ├── StatusBadge.tsx # running/killed/exceeded/done + derivation │ ├── TlogCard.tsx # phase-12 transparency-log status card (Dashboard) │ ├── EmptyState.tsx @@ -55,8 +55,8 @@ ui/ ├── pages/ │ ├── Login.tsx # Basic auth → session cookie │ ├── Dashboard.tsx # KPIs + cost-by-agent chart + top-5 tables + tlog card - │ ├── People.tsx # users in the window - │ ├── UserDetail.tsx # one user's KPIs + chart + agents-used + runs + │ ├── People.tsx # users in the window + revoked column + │ ├── UserDetail.tsx # one user's KPIs + chart + agents-used + runs + user revoke switch │ ├── Agents.tsx # agents in the window: budget meter + kill-state column │ ├── AgentDetail.tsx # one agent's chart + budget card + runs + agent kill switch │ ├── RunDetail.tsx # Live-state card + kill switch, Provenance card, call log + drawer @@ -156,9 +156,10 @@ four mutation hooks (`useKillRun`, `useUnkillRun`, `useKillAgent`, once done, 500ms for 30s right after a kill/unkill; agent state 10s on the detail page, 30s per row on the list. - **Confirmation is `KillConfirmModal`** — plain confirm for runs, - typed agent name for agents (swarm-wide blast radius). No - `window.confirm()` (Hive's iframe sandbox suppresses it) and no - optimistic update: the modal closes only on 200. + typed agent name for agents and typed user id for users (swarm-wide + blast radius). No `window.confirm()` (Hive's iframe sandbox + suppresses it) and no optimistic update: the modal closes only on + 200. - **Status derivation is `StatusBadge.deriveRunStatus` / `deriveAgentStatus`.** Keep new pages on the same derivation rather than inventing a second notion of "running". @@ -166,6 +167,20 @@ four mutation hooks (`useKillRun`, `useUnkillRun`, `useKillAgent`, sets it on every request, so per-call code does nothing extra. - Not exposed: `enforce_macaroons` / `enforce_budgets`. The modal carries a static "only enforced when enforce_macaroons=true" hint. +- **User revocation is the third kill axis.** `UserDetail` drives + `PUT|DELETE /_plugin/revoke/user/:id` through `useRevokeUser` / + `useClearUserRevoke`, reads the cutoff back with `useUserRevoke` + (10s, 500ms boost after a mutation; 404 ⇒ `{ before: null }`, 503 ⇒ + `null`), and `People` reads every cutoff in one call with + `useRevokedUsers` (`GET /_plugin/revoke/users`, 30s). The cutoff + rejects every macaroon whose user authorization was issued before + it — all of the user's in-flight runs, whatever agent, and new + spawns under the current authorization — until Hive re-issues. Per + swarm, no TTL. The card and badge say "issued before are + rejected", not "revoked": after Hive re-issues the cutoff is still + set and still true. The modal's blast-radius line reads the in-flight + list off `/users/:id/quota` (`useUserQuota`, only while open). + `/_plugin/revoke/nonce/*` stays bearer-only and has no UI. - **Cap meters** on RunDetail's live-state card read `max_cost_usd` / `max_steps` / `ancestors` off `/runs/:id/state` (the accumulator's `meta:run:` record) and draw one cost + one steps bar per @@ -202,9 +217,11 @@ non-run states; `status` then only picks the tone. | 401 anywhere | n/a | SPA's QueryCache catches → `setLocation('/login?next=…')` | The SPA never sends a bearer token. Hive uses bearer; the dashboard -uses the cookie. Some endpoints accept either (the read-only ones); -the trust mutations (POST/DELETE on `/_plugin/trust/*`) and Hive's -bootstrap (`/_plugin/admin-credentials`) are bearer-only by design. +uses the cookie. Some endpoints accept either (the read-only ones and +the kill / revoke-user switches); the trust mutations (POST/DELETE on +`/_plugin/trust/*`), nonce revocation (`/_plugin/revoke/nonce/*`) and +Hive's bootstrap (`/_plugin/admin-credentials`) are bearer-only by +design. ## When adding a new page diff --git a/gateway/internal/adminapi/ui/src/api/client.ts b/gateway/internal/adminapi/ui/src/api/client.ts index 93d19a5d4..c179b7c9c 100644 --- a/gateway/internal/adminapi/ui/src/api/client.ts +++ b/gateway/internal/adminapi/ui/src/api/client.ts @@ -40,7 +40,7 @@ export class ApiCallError extends Error { } export interface ApiFetchOptions { - method?: "GET" | "POST" | "PATCH" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: unknown; /** Extra headers (e.g. Authorization: Basic on the login call). */ headers?: Record; diff --git a/gateway/internal/adminapi/ui/src/api/queries.ts b/gateway/internal/adminapi/ui/src/api/queries.ts index 6723eaf63..13acb293d 100644 --- a/gateway/internal/adminapi/ui/src/api/queries.ts +++ b/gateway/internal/adminapi/ui/src/api/queries.ts @@ -27,12 +27,15 @@ import type { CallDetailResponse, HistogramCostResponse, MeResponse, + RevokeUserResponse, + RevokeUsersResponse, RunDetailResponse, SpendByAgentResponse, SpendByAgentUserResponse, SpendByUserResponse, TlogStatusResponse, UserDetailResponse, + UserQuotaResponse, } from "./types"; import type { TrustOrg, @@ -547,6 +550,132 @@ export function useUnkillAgent(name: string) { }); } +// ─── user revocation: /revoke/user/:id · /revoke/users ───────────── +// +// The third kill axis, next to run and agent. `PUT /revoke/user/:id` +// writes `revoke_user_before:` = now; the hot path then rejects +// every macaroon whose user authorization was issued before that, so +// every in-flight run and every new spawn under the user's current +// authorization stops on its next LLM call until Hive issues a fresh +// one. Per swarm, no TTL, cleared with DELETE. The routes are +// cookie-or-bearer like the kill routes (revoke.go / server.go). +// +// `GET /revoke/user/:id` 404s when no cutoff is set, so the hook +// folds that into `{ before: null }`; a 503 (no Redis on this swarm) +// folds into `data === null` like the other hot-state hooks. Cadence +// mirrors agent state: 10s on UserDetail, 500ms for KILL_BOOST_MS +// after a revoke/clear, 30s for the People list's one-call read. + +/** `before` is the RFC3339 cutoff, or null when the user has none. */ +export interface UserRevokeState { + before: string | null; +} + +const userRevokeKey = (userID: string) => + ["revoke", "user", userID] as const; +const revokedUsersKey = ["revoke", "users"] as const; + +export function useUserRevoke(userID: string | undefined) { + return useQuery({ + queryKey: userRevokeKey(userID ?? ""), + queryFn: async (): Promise => { + try { + const r = await apiFetch( + `/revoke/user/${encodeURIComponent(userID!)}`, + ); + return { before: r.before }; + } catch (e) { + if (e instanceof ApiCallError && e.status === 404) { + return { before: null }; + } + if (e instanceof ApiCallError && e.status === 503) { + return null; // redis not configured on this swarm + } + throw e; + } + }, + enabled: !!userID, + refetchInterval: (q) => { + if (q.state.data === null) return 60_000; + if (userID && boosted("user:" + userID)) return 500; + return 10_000; + }, + staleTime: 0, + retry: false, + }); +} + +// One call for the People list: every user with a cutoff on this +// swarm, newest first. `null` ⇒ redis off (the column renders "—"). +export function useRevokedUsers() { + return useQuery({ + queryKey: revokedUsersKey, + queryFn: () => fetchHotState("/revoke/users"), + refetchInterval: (q) => (q.state.data === null ? 60_000 : 30_000), + staleTime: 0, + retry: false, + }); +} + +// Same shape as the kill mutations: no optimistic update, boost the +// state poll on success, invalidate both the per-user state and the +// list on settle so the People column and the detail page agree. +export function useRevokeUser(userID: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch( + `/revoke/user/${encodeURIComponent(userID)}`, + // Empty body ⇒ the server stamps `before = now`. Sending `{}` + // keeps the Content-Type uniform with the other mutations. + { method: "PUT", body: {} }, + ), + retry: false, + onSuccess: () => boost("user:" + userID), + onSettled: () => { + qc.invalidateQueries({ queryKey: userRevokeKey(userID) }); + qc.invalidateQueries({ queryKey: revokedUsersKey }); + }, + }); +} + +export function useClearUserRevoke(userID: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiFetch(`/revoke/user/${encodeURIComponent(userID)}`, { + method: "DELETE", + }), + retry: false, + onSuccess: () => boost("user:" + userID), + onSettled: () => { + qc.invalidateQueries({ queryKey: userRevokeKey(userID) }); + qc.invalidateQueries({ queryKey: revokedUsersKey }); + }, + }); +} + +// ─── /users/:id/quota ─────────────────────────────────────────────── +// +// Read only while the revoke modal is open: it is the one route that +// lists a user's in-flight runs (from the accumulator's runs:user +// index), which is the blast radius the modal spells out. The budget +// half of the response is ignored here — quotas are Bifrost's, not +// the plugin's. No polling; the modal is open for seconds. + +export function useUserQuota(userID: string | undefined, enabled: boolean) { + return useQuery({ + queryKey: ["users", userID, "quota"], + queryFn: () => + apiFetch( + `/users/${encodeURIComponent(userID!)}/quota`, + ), + enabled: !!userID && enabled, + staleTime: 0, + retry: false, + }); +} + // ─── /trust/:org_id ───────────────────────────────────────────────── // // Reads one org's trust-registry entry. Used by the Provenance card diff --git a/gateway/internal/adminapi/ui/src/api/types.ts b/gateway/internal/adminapi/ui/src/api/types.ts index a832cfca6..06405c8d9 100644 --- a/gateway/internal/adminapi/ui/src/api/types.ts +++ b/gateway/internal/adminapi/ui/src/api/types.ts @@ -657,6 +657,15 @@ export interface RevokeUserResponse { user_id: string; before: string; // RFC3339 UTC } +/** + * RevokeUsersResponse is the wire shape for GET /_plugin/revoke/users: + * every user with a cutoff on this swarm, newest cutoff first. Backs + * the People list's "Revoked" column and Hive's per-swarm read. An + * entry whose stored cutoff won't parse has an empty `before`. + */ +export interface RevokeUsersResponse { + users: RevokeUserResponse[]; +} ////////// // source: server.go diff --git a/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx b/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx index de16b4764..eec5919ec 100644 --- a/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx +++ b/gateway/internal/adminapi/ui/src/components/KillConfirmModal.tsx @@ -1,14 +1,20 @@ -// KillConfirmModal — the one confirmation dialog for every kill and -// unkill in the SPA. Two modes, picked by the target: +// KillConfirmModal — the one confirmation dialog for every kill, +// unkill, revoke and clear in the SPA. Three modes, picked by the +// target: // // run plain confirm. Blast radius is one run plus the sub-agents // it spawned. // agent typed confirm — the operator must type the agent name. // Blast radius is every run of that agent, swarm-wide, so // the friction is deliberately higher (phase 9 "Destructive"). +// user typed confirm — the operator must type the user id. Sets +// the user's revoke cutoff: every macaroon issued before now +// is rejected, whatever agent carries it, and new spawns are +// cut off too until Hive re-issues. Same friction as agent. // -// Unkill uses the same modal with the same friction: clearing a -// swarm-wide agent kill is as consequential as setting it. +// Unkill / clear use the same modal with the same friction: clearing +// a swarm-wide agent kill or a user cutoff is as consequential as +// setting it. // // A custom element rather than window.confirm(): Hive embeds this SPA // in a sandboxed iframe without `allow-modals`, where confirm() is @@ -21,11 +27,20 @@ import { useEffect, useRef, useState } from "preact/hooks"; +import type { InflightRun } from "../api/types"; import { StopIcon } from "./icons"; export type KillTarget = | { kind: "run"; id: string } - | { kind: "agent"; name: string }; + | { kind: "agent"; name: string } + | { + kind: "user"; + id: string; + /** The user's in-flight runs, for the blast-radius line. + * `undefined` while loading, `null` when unavailable (no + * logstore / no Redis); either way the line is omitted. */ + inflight?: InflightRun[] | null; + }; export type KillAction = "kill" | "unkill"; @@ -48,10 +63,10 @@ export function KillConfirmModal({ onConfirm, onClose, }: Props) { - const typed = target.kind === "agent"; - const targetLabel = target.kind === "run" ? target.id : target.name; + const typed = target.kind !== "run"; + const targetLabel = target.kind === "agent" ? target.name : target.id; const [input, setInput] = useState(""); - const ready = !typed || input.trim() === target.name; + const ready = !typed || input.trim() === targetLabel; // Focus the input (agent) or the confirm button (run) on open so // keyboard operators can drive the whole flow without a mouse. @@ -76,24 +91,35 @@ export function KillConfirmModal({ onConfirm(); }; + const isUser = target.kind === "user"; const title = action === "kill" ? target.kind === "run" ? "Kill run" - : "Kill agent" + : target.kind === "agent" + ? "Kill agent" + : "Revoke user" : target.kind === "run" ? "Clear kill on run" - : "Clear kill on agent"; + : target.kind === "agent" + ? "Clear kill on agent" + : "Clear revoke on user"; const confirmLabel = pending ? action === "kill" - ? "Killing…" + ? isUser + ? "Revoking…" + : "Killing…" : "Clearing…" : action === "kill" ? target.kind === "run" ? "Kill run" - : "Kill agent" - : "Clear kill"; + : target.kind === "agent" + ? "Kill agent" + : "Revoke user" + : isUser + ? "Clear revoke" + : "Clear kill"; return (
- +
+ + +
+ + {q.isError ? (
{getErrorMessage(q.error)}
) : ( @@ -282,3 +316,154 @@ export function UserDetail({ userID }: Props) { ); } + +// ─── user revocation ─────────────────────────────────────────────── +// +// The cutoff is `revoke_user_before:` in Redis. Once set, +// the hot path rejects every macaroon whose user authorization was +// issued before it — every in-flight run of this user, whatever agent +// carries it, and every new spawn under the current authorization — +// until Hive issues a fresh one. Per swarm, no TTL. The card states +// that precisely ("issued before are rejected") rather than +// "revoked", because after Hive re-issues, the cutoff is still set +// and still true, but the user is working again. + +const REVOKED_TITLE = + "Revoke cutoff set. Macaroons issued before it are rejected on the next LLM call when enforce_macaroons=true; logged only in shadow mode."; + +function AuthorizationCard({ + state, +}: { + state: UserRevokeState | null | undefined; +}) { + const unavailable = state === null; + const before = state?.before ?? null; + return ( +
+
+
Authorization
+ {unavailable ? ( + + hot state unavailable on this swarm (no Redis) + + ) : before ? ( + + ) : state ? ( + + ) : ( + … + )} +
+ {before ? ( + <> +
+
Cutoff
+
+ {fmtTs(before)}{" "} + ({fmtRelative(before)}) +
+
+ {/* Prose lives outside the kvgrid: `.kvgrid-val` breaks + words anywhere (it is sized for opaque ids), which + splits sentences mid-word. */} +

+ Authorizations issued before the cutoff are rejected on this + swarm. In-flight runs stop on their next LLM call; new spawns + need Hive to issue a fresh authorization. No expiry — clear it + to lift. +

+ + ) : ( +

+ {unavailable + ? "Revocation state lives in Redis; this swarm has none configured, so the switch is off." + : "No cutoff set. Every valid authorization this user holds is accepted on this swarm."} +

+ )} +
+ ); +} + +// Revoke / Clear for one user, swarm-wide. `state` is the +// /revoke/user/:id snapshot: undefined while loading, null when the +// swarm has no Redis (switch disabled with a tooltip). Typed +// confirmation in the modal because the blast radius is every run +// of this user, not just the ones on this page. The in-flight list +// for the modal comes from /users/:id/quota and is fetched only +// while the modal is open. +function RevokeUserSwitch({ + userID, + state, +}: { + userID: string; + state: UserRevokeState | null | undefined; +}) { + const revoke = useRevokeUser(userID); + const clear = useClearUserRevoke(userID); + const [modal, setModal] = useState<"kill" | "unkill" | null>(null); + const active = modal === "kill" ? revoke : clear; + const unavailable = state === null; + const quota = useUserQuota(userID, modal === "kill"); + + const open = (which: "kill" | "unkill") => { + revoke.reset(); + clear.reset(); + setModal(which); + }; + + return ( + <> + {state?.before ? ( + + ) : ( + + )} + {modal ? ( + { + const done = { onSuccess: () => setModal(null) }; + if (modal === "kill") revoke.mutate(undefined, done); + else clear.mutate(undefined, done); + }} + onClose={() => setModal(null)} + /> + ) : null} + + ); +} diff --git a/gateway/internal/auth/admin.go b/gateway/internal/auth/admin.go index e35e5be53..97d7e4b0d 100644 --- a/gateway/internal/auth/admin.go +++ b/gateway/internal/auth/admin.go @@ -14,22 +14,37 @@ import ( // Admin-side helpers for managing revocation state. The HTTP routes // that expose these (`/_plugin/revoke/nonce/:nonce`, -// `/_plugin/revoke/user/:user_id`) live in gateway/internal/adminapi -// (revoke.go) and call into this file. The kill-switch and per-run / -// per-agent state primitives live next door in kill.go. +// `/_plugin/revoke/user/:user_id`, `/_plugin/revoke/users`) live in +// gateway/internal/adminapi (revoke.go) and call into this file. The +// kill-switch and per-run / per-agent state primitives live next door +// in kill.go. // -// These helpers exist now so a swarm operator can: +// These helpers exist so a swarm operator (or Hive, fanning out) can: // // - Revoke a specific nonce (any layer: UA, invocation, attenuation) // before its natural exp. Use case: an employee leaves mid-week // and the org leader pushes a kill before re-issuing. // - Set a user-level cutoff (revoke_user_before) so every macaroon -// issued before time T for that user is rejected. Use case: full -// user offboarding regardless of how many UAs are out. +// issued before time T for that user is rejected. This is the +// dashboard's third kill axis next to run and agent: every +// in-flight run and every new spawn under the user's current +// authorization stops on its next LLM call, until Hive issues a +// fresh authorization. Per swarm, like the other two. // // Every helper is a thin wrapper around the redis ops described in // gateway/plans/phases/phase-6-plugin-enforcement.md "Redis schema". +// revokeUsersIndexKey is the ZSET that lists every user with a cutoff +// set: member = user_id, score = cutoff as unix seconds. Maintained by +// SetUserRevokeCutoff / ClearUserRevokeCutoff so the dashboard's +// People list and Hive's reconcile sweep can read "who is revoked on +// this swarm" in one round trip instead of scanning the keyspace. The +// per-user string key stays the source of truth — the hot path never +// reads the index, and ListUserRevokeCutoffs re-reads the string keys +// and prunes members whose key is gone (a direct Redis DEL, or a +// cutoff written before this index existed and cleared since). +const revokeUsersIndexKey = "revoke_users" + // adminTimeout bounds a single admin Redis op. These run outside the // request hot path so we can afford to be more generous than // pipelineTimeout. @@ -91,9 +106,12 @@ func UnrevokeNonce(ctx context.Context, nonce string) error { // permanent state until explicitly cleared (phase-6 schema). // // `cutoff` is normalized to UTC and serialized as RFC 3339. +// +// Also records the user in the revoke_users index (score = cutoff) +// so ListUserRevokeCutoffs can enumerate without a keyspace scan. func SetUserRevokeCutoff(ctx context.Context, userID string, cutoff time.Time) error { - if userID == "" { - return errors.New("user_id is required") + if err := validateKillID("user_id", userID); err != nil { + return err } rdb := redisclient.Client() if rdb == nil { @@ -101,18 +119,26 @@ func SetUserRevokeCutoff(ctx context.Context, userID string, cutoff time.Time) e } octx, cancel := context.WithTimeout(ctx, adminTimeout) defer cancel() - return rdb.Set(octx, + cutoff = cutoff.UTC() + pipe := rdb.Pipeline() + pipe.Set(octx, redisclient.Key(revokeUserBeforePrefix+userID), - cutoff.UTC().Format(time.RFC3339), + cutoff.Format(time.RFC3339), 0, - ).Err() + ) + pipe.ZAdd(octx, redisclient.Key(revokeUsersIndexKey), redis.Z{ + Score: float64(cutoff.Unix()), + Member: userID, + }) + _, err := pipe.Exec(octx) + return err } // ClearUserRevokeCutoff removes the revoke_user_before: // entry, re-allowing macaroons issued before the previous cutoff. func ClearUserRevokeCutoff(ctx context.Context, userID string) error { - if userID == "" { - return errors.New("user_id is required") + if err := validateKillID("user_id", userID); err != nil { + return err } rdb := redisclient.Client() if rdb == nil { @@ -120,15 +146,19 @@ func ClearUserRevokeCutoff(ctx context.Context, userID string) error { } octx, cancel := context.WithTimeout(ctx, adminTimeout) defer cancel() - return rdb.Del(octx, redisclient.Key(revokeUserBeforePrefix+userID)).Err() + pipe := rdb.Pipeline() + pipe.Del(octx, redisclient.Key(revokeUserBeforePrefix+userID)) + pipe.ZRem(octx, redisclient.Key(revokeUsersIndexKey), userID) + _, err := pipe.Exec(octx) + return err } // GetUserRevokeCutoff returns the currently configured cutoff for // `userID`, or zero time + ok=false if none is set. Useful for the // admin dashboard and for reconciler verification. func GetUserRevokeCutoff(ctx context.Context, userID string) (time.Time, bool, error) { - if userID == "" { - return time.Time{}, false, errors.New("user_id is required") + if err := validateKillID("user_id", userID); err != nil { + return time.Time{}, false, err } rdb := redisclient.Client() if rdb == nil { @@ -150,6 +180,80 @@ func GetUserRevokeCutoff(ctx context.Context, userID string) (time.Time, bool, e return t, true, nil } +// UserRevokeCutoff is one entry of the revoke_users index, re-read +// from its authoritative string key. +type UserRevokeCutoff struct { + UserID string + Before time.Time +} + +// maxUserRevokeList caps ListUserRevokeCutoffs. A swarm with more +// revoked users than this has an offboarding problem the dashboard +// isn't the tool for; Hive can page by user instead. +const maxUserRevokeList = 500 + +// ListUserRevokeCutoffs returns every user with a cutoff set on this +// swarm, newest cutoff first, capped at `limit` (≤ 0 ⇒ the default +// cap). Reads the revoke_users index for the members, then the +// per-user string keys for the values: the string key is what the +// hot path enforces, so it wins. Members whose string key is gone +// are dropped from the result and pruned from the index in the same +// call, which keeps the index honest against direct Redis writes +// without a separate sweeper. +// +// Absent index ⇒ empty list, not an error. Users whose cutoff was +// written before the index existed are not listed until re-set. +func ListUserRevokeCutoffs(ctx context.Context, limit int64) ([]UserRevokeCutoff, error) { + rdb := redisclient.Client() + if rdb == nil { + return nil, ErrRedisUnavailable + } + if limit <= 0 || limit > maxUserRevokeList { + limit = maxUserRevokeList + } + octx, cancel := context.WithTimeout(ctx, adminTimeout) + defer cancel() + + indexKey := redisclient.Key(revokeUsersIndexKey) + ids, err := rdb.ZRevRange(octx, indexKey, 0, limit-1).Result() + if err != nil && !errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("revoke_users index: %w", err) + } + out := make([]UserRevokeCutoff, 0, len(ids)) + if len(ids) == 0 { + return out, nil + } + + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = redisclient.Key(revokeUserBeforePrefix + id) + } + vals, err := rdb.MGet(octx, keys...).Result() + if err != nil { + return nil, fmt.Errorf("revoke_user_before mget: %w", err) + } + + var stale []any + for i, id := range ids { + raw, ok := vals[i].(string) + if !ok { + stale = append(stale, id) + continue + } + // The hot path fails open on a malformed cutoff; the list is + // a diagnostic surface, so a key that exists but won't parse + // is still listed, with a zero Before, rather than hidden. + t, _ := time.Parse(time.RFC3339, raw) + out = append(out, UserRevokeCutoff{UserID: id, Before: t}) + } + if len(stale) > 0 { + // Best effort: a failed prune leaves the index slightly + // over-full, which the next list call retries. + _ = rdb.ZRem(octx, indexKey, stale...).Err() + } + return out, nil +} + // validateNonce rejects obviously-malformed nonces before they reach // Redis. Phase-4 nonces are 16 random bytes hex-encoded to 32 chars // (lowercase). Anything else is operator typo territory. diff --git a/gateway/internal/auth/admin_test.go b/gateway/internal/auth/admin_test.go index 70341f84d..d27ee72a3 100644 --- a/gateway/internal/auth/admin_test.go +++ b/gateway/internal/auth/admin_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/alicebob/miniredis/v2" + "github.com/stakwork/stakgraph/gateway/internal/redisclient" ) @@ -111,3 +113,112 @@ func TestSetUserRevokeCutoff_RequiresUserID(t *testing.T) { t.Fatal("empty user_id should error") } } + +func TestSetUserRevokeCutoff_MaintainsIndex(t *testing.T) { + mr := newMiniRedis(t) + cutoff := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + if err := SetUserRevokeCutoff(context.Background(), "u_alice", cutoff); err != nil { + t.Fatalf("Set: %v", err) + } + score, err := mr.ZScore("bifrost:revoke_users", "u_alice") + if err != nil { + t.Fatalf("index member missing: %v", err) + } + if int64(score) != cutoff.Unix() { + t.Fatalf("index score %v, want %d", score, cutoff.Unix()) + } + if err := ClearUserRevokeCutoff(context.Background(), "u_alice"); err != nil { + t.Fatalf("Clear: %v", err) + } + if indexHas(mr, "u_alice") { + t.Fatal("index member should be removed on clear") + } +} + +// indexHas reports membership in the revoke_users ZSET. miniredis's +// ZScore returns (0, nil) for a missing member of an existing key, so +// it can't answer this on its own. +func indexHas(mr *miniredis.Miniredis, member string) bool { + members, err := mr.ZMembers("bifrost:revoke_users") + if err != nil { + return false + } + for _, m := range members { + if m == member { + return true + } + } + return false +} + +func TestListUserRevokeCutoffs_NewestFirst_And_SelfHeals(t *testing.T) { + mr := newMiniRedis(t) + ctx := context.Background() + older := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC) + if err := SetUserRevokeCutoff(ctx, "u_older", older); err != nil { + t.Fatal(err) + } + if err := SetUserRevokeCutoff(ctx, "u_newer", newer); err != nil { + t.Fatal(err) + } + + got, err := ListUserRevokeCutoffs(ctx, 0) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 2 || got[0].UserID != "u_newer" || got[1].UserID != "u_older" { + t.Fatalf("order: %+v", got) + } + if !got[0].Before.Equal(newer) || !got[1].Before.Equal(older) { + t.Fatalf("cutoffs: %+v", got) + } + + // Limit is honoured, newest kept. + got, err = ListUserRevokeCutoffs(ctx, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].UserID != "u_newer" { + t.Fatalf("limit=1: %+v", got) + } + + // A direct DEL of the string key (Hive / an operator in redis-cli) + // must drop the entry from the list and prune the index. + mr.Del("bifrost:revoke_user_before:u_newer") + got, err = ListUserRevokeCutoffs(ctx, 0) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].UserID != "u_older" { + t.Fatalf("after direct del: %+v", got) + } + if indexHas(mr, "u_newer") { + t.Fatal("stale index member not pruned") + } + if !indexHas(mr, "u_older") { + t.Fatal("live index member should survive the prune") + } +} + +func TestListUserRevokeCutoffs_EmptyAndUnavailable(t *testing.T) { + _ = newMiniRedis(t) + got, err := ListUserRevokeCutoffs(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("empty index: %+v", got) + } + redisclient.SetClientForTest(nil) + if _, err := ListUserRevokeCutoffs(context.Background(), 0); !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("no redis: want ErrRedisUnavailable, got %v", err) + } +} + +func TestSetUserRevokeCutoff_RejectsMalformedUserID(t *testing.T) { + _ = newMiniRedis(t) + if err := SetUserRevokeCutoff(context.Background(), "has space", time.Now()); err == nil { + t.Fatal("user_id with whitespace should error") + } +} diff --git a/gateway/plans/phases/phase-6-plugin-enforcement.md b/gateway/plans/phases/phase-6-plugin-enforcement.md index a973b8ca2..2db7e88ab 100644 --- a/gateway/plans/phases/phase-6-plugin-enforcement.md +++ b/gateway/plans/phases/phase-6-plugin-enforcement.md @@ -62,6 +62,19 @@ > tool-loop detection, `hard_ceiling`, the `user_id == customer_id` > cross-check, and the `/_plugin/config/*` override layer. > +> **Status (user revocation in the dashboard):** `revoke_user_before` +> is now the operator UI's third kill axis next to run and agent. +> `PUT|GET|DELETE /_plugin/revoke/user/:id` moved from bearer-only to +> cookie-or-bearer (CSRF on cookie mutations), and +> `GET /_plugin/revoke/users` lists every cutoff on the swarm from a +> `revoke_users` ZSET index the admin helpers maintain (member = +> user_id, score = cutoff unix; the string key stays authoritative and +> the list prunes members whose key is gone). Nonce revocation stays +> bearer-only. Per swarm, like the other switches: Hive's fan-out and +> reconcile sweep read and write the same keys through the same routes. +> The UI is `UserDetail` (Authorization card + Revoke user switch, +> typed confirmation) and the People list's "Revoked" column. +> > **Status (phase 11 cutover):** Redis bucket keys and hot-path > flow are unchanged from the description below. Phase 11 > (`phase-11-symmetric-recursive-authorization.md`) adds one @@ -182,6 +195,7 @@ bifrost:kill: STRING "1" bifrost:kill:agent: STRING "1" bifrost:revoke: STRING "1" TTL = nonce-bearing layer.exp bifrost:revoke_user_before: STRING +bifrost:revoke_users ZSET { : cutoff unix } index for GET /_plugin/revoke/users bifrost:cost:agent:: HASH { total: float } # Config overrides (UI-editable; layer on top of plugin.yaml baseline). @@ -337,10 +351,18 @@ is rejected. invocations. An invocation signed after the revocation timestamp (against a re-issued user_authorization) is fine. - **Updated by:** Hive's offboarding flow (`PUT /customers/:id - {is_active: false}` is paired with this Redis write). -- **Read by:** PreLLMHook after macaroon verification. + {is_active: false}` is paired with this Redis write), and the + operator dashboard's Revoke user switch on UserDetail + (`PUT /_plugin/revoke/user/:id`, cookie-or-bearer, default + `before = now`). Both go through `auth.SetUserRevokeCutoff`, which + also maintains the `revoke_users` index. +- **Read by:** PreLLMHook after macaroon verification; the dashboard + (`GET /_plugin/revoke/user/:id`, `GET /_plugin/revoke/users`) and + Hive's reconcile sweep through the same GETs. - **TTL:** none. User revocation is a permanent state until - explicitly cleared. + explicitly cleared (`DELETE /_plugin/revoke/user/:id`). +- **Scope:** this swarm. The dashboard is per swarm like the run and + agent kills; "revoke everywhere" is Hive's fan-out. ### `cost:agent::` diff --git a/gateway/plans/phases/phase-9-operator-ui.md b/gateway/plans/phases/phase-9-operator-ui.md index e6bd2a817..9ffbc5632 100644 --- a/gateway/plans/phases/phase-9-operator-ui.md +++ b/gateway/plans/phases/phase-9-operator-ui.md @@ -412,8 +412,8 @@ The full operator-relevant surface, by page: | Run detail | `/runs/:id`, `/runs/:id/state` | `/runs/:id/kill`, `/runs/:id/kill` (DELETE) | | Agents list | `/spend/by-agent`, `/agents/:name/state` per row | — | | Agent detail | `/agents/:name/spend`, `/histogram/cost?dimension=agent-name`, `/agents/:name/state` | `/agents/:name/kill`, `/agents/:name/kill` (DELETE), `/config/agent_budgets/:name` (PUT — *new endpoint, see below*) | -| Users | `/spend/by-user` | — | -| User detail | `/users/:id/spend`, `/users/:id/quota` | — | +| Users | `/spend/by-user`, `/revoke/users` | — | +| User detail | `/users/:id/spend`, `/users/:id/quota`, `/revoke/user/:id` | `/revoke/user/:id` (PUT), `/revoke/user/:id` (DELETE) | | Sessions | `/spend/by-session` | — | | Session detail | `/sessions/:id`, `/sessions/:id/summary` | — | | Config | `/config` (new GET — see below) | `/config/agent_budgets/:name` (PUT), `/config/hard_ceiling` (PUT), `/config/tool_loop` (PUT) | @@ -561,15 +561,32 @@ Refresh: 60s for histograms, 10s for current bucket state. ### Users (`/users`) -Same shape as Agents but keyed on `user-id`. Less interactive -because there's nothing to kill or edit at the user level — quotas -are governed by Bifrost's customer caps, not the plugin. +Same shape as Agents but keyed on `user-id`. Quotas are governed by +Bifrost's customer caps, not the plugin, so there is nothing to edit +at the user level — but there is something to kill: a **Revoked** +column (the user-axis twin of the Agents list's kill-state column) +reads every cutoff on the swarm in one call from +`GET /_plugin/revoke/users`. ### User detail (`/users/:id`) +- **Authorization card + Revoke user switch.** The third kill axis + after run and agent. `PUT /_plugin/revoke/user/:id` sets + `revoke_user_before:` = now; the hot path then rejects every + macaroon whose user authorization was issued before it — all of + the user's in-flight runs, whatever agent carries them, and new + spawns under the current authorization — until Hive re-issues. + Per swarm, no TTL, `DELETE` clears. Typed confirmation (the user + id) because the blast radius is every run of one principal, same + class as an agent kill; the modal spells out the in-flight runs + from `/_plugin/users/:id/quota`. The card says "issued before + are rejected" rather than "revoked": after Hive re-issues, the + cutoff is still set and still true. Cookie-or-bearer like the kill + routes; nonce revocation stays bearer-only with no UI. - **Quota panel:** daily budget, spent today, remaining today, list of in-flight runs the user owns. Hits - `/_plugin/users/:id/quota`. + `/_plugin/users/:id/quota`. (Not built; the in-flight half feeds + the revoke modal.) - **Recent runs:** small table. - **Per-user histograms:** cost over time. @@ -646,8 +663,9 @@ Query mutations, but the UX is different. 1. User clicks Kill. 2. KillConfirmModal opens. For run kills: "Type 'kill' to confirm." - For agent kills: "Type the agent name to confirm." (Higher friction - for higher blast radius.) + For agent kills: "Type the agent name to confirm." For user + revokes: "Type the user id to confirm." (Higher friction for + higher blast radius.) 3. On confirm, mutation fires. The button shows a spinner. 4. On 200: close modal, invalidate the relevant queries (run state, agents list), bump the run-state polling cadence to 500ms for 30s