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.
116 lines
4.6 KiB
Go
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
|
|
}
|