Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gateway/internal/adminapi/hotstate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
66 changes: 59 additions & 7 deletions gateway/internal/adminapi/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}/<id>.
// dispatch routes /_plugin/revoke/{nonce,user}/<id> 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
Expand Down Expand Up @@ -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)
}
170 changes: 170 additions & 0 deletions gateway/internal/adminapi/revoke_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
28 changes: 20 additions & 8 deletions gateway/internal/adminapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading