Files
silo-server/internal/streamrevoke/durable_postgres.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

116 lines
4.6 KiB
Go

package streamrevoke
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// permanentExpiry is the far-future sentinel written when a Revocation has a
// zero-value ExpiresAt. The hot path treats a zero ExpiresAt as "never expires"
// (a permanent kill), but the DB column is NOT NULL and Prune/ListActive compare
// expires_at <= now(): a literal zero time (0001-01-01) would be excluded by
// ListActive and deleted by the very next Prune, silently evaporating a
// permanent kill. Writing a year-2999 sentinel preserves the intent durably.
var permanentExpiry = time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC)
// PostgresDurableStore is the Postgres-backed DurableStore: a durable mirror of
// the kill list so revocations survive a Redis flush or a server restart. It is
// never on the hot path — Store consults it only on write (Upsert), on
// warm/reconcile (ListActive), and on trim (Prune).
//
// Rows are keyed by (kind, id) so re-revoking the same session/user (the async
// over-cap enforcer does this every pass) UPSERTs the same row rather than
// accumulating duplicates; physical growth is reclaimed by Prune.
type PostgresDurableStore struct {
pool *pgxpool.Pool
}
// NewPostgresDurableStore builds a DurableStore from a pgx pool. It returns a
// nil DurableStore interface when pool is nil so callers can pass the result
// straight into Options.Durable and a Redis-less/DB-less mode degrades to a
// true nil interface (avoiding the "non-nil interface wrapping a nil pointer"
// trap that would make Store.durable != nil erroneously true).
func NewPostgresDurableStore(pool *pgxpool.Pool) DurableStore {
if pool == nil {
return nil
}
return &PostgresDurableStore{pool: pool}
}
// Upsert writes or refreshes a revocation, keyed by (kind, id).
func (s *PostgresDurableStore) Upsert(ctx context.Context, r Revocation) error {
// A zero ExpiresAt means "permanent" on the hot path; persist it as a
// far-future sentinel so Prune/ListActive don't immediately reap the row.
expiresAt := r.ExpiresAt
if expiresAt.IsZero() {
expiresAt = permanentExpiry
}
// Expiry is monotonic: a re-revoke never shortens an existing longer kill
// (GREATEST). The async over-cap enforcer re-revokes with a short 5m TTL, and
// without this it would shrink an admin's 24h kill on the same session key and
// reopen the restart-resurrection window. reason/revoked_at follow whichever
// expiry wins so the persisted row stays coherent. Mirrors applyLocal.
_, err := s.pool.Exec(ctx, `
INSERT INTO stream_revocations (kind, id, reason, revoked_at, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (kind, id) DO UPDATE SET
reason = CASE WHEN EXCLUDED.expires_at >= stream_revocations.expires_at
THEN EXCLUDED.reason ELSE stream_revocations.reason END,
revoked_at = CASE WHEN EXCLUDED.expires_at >= stream_revocations.expires_at
THEN EXCLUDED.revoked_at ELSE stream_revocations.revoked_at END,
expires_at = GREATEST(stream_revocations.expires_at, EXCLUDED.expires_at)`,
string(r.Kind), r.ID, r.Reason, r.RevokedAt, expiresAt)
if err != nil {
return fmt.Errorf("streamrevoke upsert: %w", err)
}
return nil
}
// Delete removes a revocation from the durable mirror.
func (s *PostgresDurableStore) Delete(ctx context.Context, kind Kind, id string) error {
if _, err := s.pool.Exec(ctx, `DELETE FROM stream_revocations WHERE kind = $1 AND id = $2`, string(kind), id); err != nil {
return fmt.Errorf("streamrevoke delete: %w", err)
}
return nil
}
// ListActive returns every revocation not yet expired, for warming the hot-path
// cache on startup and re-warming after a Redis flush.
func (s *PostgresDurableStore) ListActive(ctx context.Context) ([]Revocation, error) {
rows, err := s.pool.Query(ctx, `
SELECT kind, id, reason, revoked_at, expires_at
FROM stream_revocations
WHERE expires_at > now()`)
if err != nil {
return nil, fmt.Errorf("streamrevoke list active: %w", err)
}
defer rows.Close()
var out []Revocation
for rows.Next() {
var r Revocation
var kind string
if err := rows.Scan(&kind, &r.ID, &r.Reason, &r.RevokedAt, &r.ExpiresAt); err != nil {
return nil, fmt.Errorf("streamrevoke scan: %w", err)
}
r.Kind = Kind(kind)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("streamrevoke list active rows: %w", err)
}
return out, nil
}
// Prune physically deletes expired rows so the table does not grow unbounded as
// the async enforcer re-revokes across passes.
func (s *PostgresDurableStore) Prune(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `DELETE FROM stream_revocations WHERE expires_at <= now()`); err != nil {
return fmt.Errorf("streamrevoke prune: %w", err)
}
return nil
}