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.
179 lines
5.6 KiB
Go
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}
|
|
}
|