Files
silo-server/internal/api/handlers/admin_stream_revocations.go
T
CoffeeKnyte fca9f38a33 feat(api): operator-visible stream kill list with unrevoke
The kill switch had no operator surface. streamrevoke.Store.List() existed and
was called from nowhere, so an admin could only terminate one session by id or
revoke a user's streams as a side effect of editing their account — with no way
to see what was revoked, choose a TTL, or undo a mistake. Because expiry is
deliberately monotonic, a wrong 24h kill was irreversible.

- GET/POST/DELETE /api/v1/admin/streams/revocations, admin-only, additive.
- Explicit wire-to-internal kind mapping: the wire accepts "session" (and
  "sess"), the store key is "sess". Passing the wire string straight into a Key
  would create a revocation IsRevoked never consults.
- Validation: non-empty bounded session ids; canonical positive user ids
  (strconv.Itoa round-trip, so "01" is rejected — the cache key is the
  canonical form); ttl_seconds bounded to 30d so the duration cannot overflow;
  bounded reason and request body. DELETE of an absent key is idempotent.
- Store.Unrevoke, guarded by a bounded in-memory tombstone: the tombstone is
  installed and the local entry dropped BEFORE the slow durable/Redis deletes,
  so a concurrent poll reconcile cannot re-apply the row it just read. A newer
  Revoke on the same key clears the tombstone, so an unrevoke never suppresses
  a later legitimate kill. Tombstones age out with the kill they replaced.
- maintain() takes the same operation lock as Revoke/Unrevoke around its
  durable block, closing the window where a poll tick could re-Upsert a row
  Unrevoke had just deleted — invisible until a restart resurrected the kill.
- Durable self-heal now compares expiry, not mere presence, so a failed Revoke
  mirror leaves a stale shorter row that the next tick repairs.
- A failed unrevoke publish fails safe: other processes keep the kill until it
  expires. Propagation failures surface as warnings rather than weakening the
  local result.

Unrevoking an over-cap victim is legal but the async enforcer will re-revoke it
on its next pass while the user is still over cap; that is documented at the
endpoint.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:17:15 +00:00

179 lines
5.6 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
)
const (
maxStreamRevocationBodyBytes = 16 << 10
maxStreamRevocationIDLength = 256
maxStreamRevocationReason = 512
maxStreamRevocationTTL = 30 * 24 * 60 * 60
)
// AdminStreamRevocationHandler exposes the operator-visible stream kill list.
// Unrevoking an over-cap victim is legal, but the async enforcer may revoke it
// again on its next pass while the owning user remains over cap.
type AdminStreamRevocationHandler struct {
store *streamrevoke.Store
}
func NewAdminStreamRevocationHandler(store *streamrevoke.Store) *AdminStreamRevocationHandler {
return &AdminStreamRevocationHandler{store: store}
}
type streamRevocationRequest struct {
Kind string `json:"kind"`
ID string `json:"id"`
Reason string `json:"reason"`
TTLSeconds *int64 `json:"ttl_seconds"`
}
type streamRevocationResponse struct {
streamrevoke.Revocation
Warnings []string `json:"warnings,omitempty"`
}
func (h *AdminStreamRevocationHandler) HandleList(w http.ResponseWriter, _ *http.Request) {
revocations := h.store.List()
sort.Slice(revocations, func(i, j int) bool {
return revocations[i].RevokedAt.After(revocations[j].RevokedAt)
})
writeJSON(w, http.StatusOK, map[string]any{"revocations": revocations})
}
func (h *AdminStreamRevocationHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req streamRevocationRequest
r.Body = http.MaxBytesReader(w, r.Body, maxStreamRevocationBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := ensureJSONEOF(decoder); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
key, err := revocationKeyFromWire(req.Kind, req.ID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
reason := strings.TrimSpace(req.Reason)
if len(reason) > maxStreamRevocationReason {
writeError(w, http.StatusBadRequest, "bad_request", "Reason is too long")
return
}
var until time.Time
if req.TTLSeconds != nil {
if *req.TTLSeconds <= 0 || *req.TTLSeconds > maxStreamRevocationTTL {
writeError(w, http.StatusBadRequest, "bad_request", "ttl_seconds must be between 1 and 2592000")
return
}
until = time.Now().Add(time.Duration(*req.TTLSeconds) * time.Second)
} else {
// A zero expiry asks the store to use its default via the public helper.
var warnings []string
switch key.Kind {
case streamrevoke.KindSession:
warnings, err = h.store.RevokeSessionWithWarnings(r.Context(), key.ID, reason)
case streamrevoke.KindUser:
userID, _ := strconv.Atoi(key.ID)
warnings, err = h.store.RevokeUserWithWarnings(r.Context(), userID, reason)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create revocation")
return
}
writeJSON(w, http.StatusCreated, streamRevocationResponse{Revocation: findRevocation(h.store, key), Warnings: warnings})
return
}
warnings, err := h.store.RevokeWithWarnings(r.Context(), key, reason, until)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create revocation")
return
}
writeJSON(w, http.StatusCreated, streamRevocationResponse{Revocation: findRevocation(h.store, key), Warnings: warnings})
}
func (h *AdminStreamRevocationHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
key, err := revocationKeyFromWire(chi.URLParam(r, "kind"), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
warnings, err := h.store.UnrevokeWithWarnings(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove revocation")
return
}
if len(warnings) > 0 {
writeJSON(w, http.StatusOK, map[string]any{"warnings": warnings})
return
}
w.WriteHeader(http.StatusNoContent)
}
func revocationKeyFromWire(kind, id string) (streamrevoke.Key, error) {
var internalKind streamrevoke.Kind
switch kind {
case "session", string(streamrevoke.KindSession):
internalKind = streamrevoke.KindSession
case string(streamrevoke.KindUser):
internalKind = streamrevoke.KindUser
default:
return streamrevoke.Key{}, errors.New("kind must be session, sess, or user")
}
if internalKind == streamrevoke.KindSession {
id = strings.TrimSpace(id)
if id == "" {
return streamrevoke.Key{}, errors.New("session id is required")
}
if len(id) > maxStreamRevocationIDLength {
return streamrevoke.Key{}, errors.New("session id is too long")
}
return streamrevoke.Key{Kind: internalKind, ID: id}, nil
}
userID, err := strconv.Atoi(id)
if err != nil || userID <= 0 || strconv.Itoa(userID) != id {
return streamrevoke.Key{}, errors.New("user id must be a canonical positive integer")
}
return streamrevoke.Key{Kind: internalKind, ID: id}, nil
}
func ensureJSONEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values")
}
return err
}
return nil
}
func findRevocation(store *streamrevoke.Store, key streamrevoke.Key) streamrevoke.Revocation {
for _, revocation := range store.List() {
if revocation.Kind == key.Kind && revocation.ID == key.ID {
return revocation
}
}
return streamrevoke.Revocation{Kind: key.Kind, ID: key.ID}
}