2026-07-05 22:59:58 +00:00
|
|
|
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"
|
2026-07-30 12:27:05 +00:00
|
|
|
// (a permanent kill), but the DB column is NOT NULL and Prune/List compare
|
2026-07-05 22:59:58 +00:00
|
|
|
// expires_at <= now(): a literal zero time (0001-01-01) would be excluded by
|
2026-07-30 12:27:05 +00:00
|
|
|
// List and deleted by the very next Prune, silently evaporating a
|
2026-07-05 22:59:58 +00:00
|
|
|
// 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
|
2026-07-30 12:27:05 +00:00
|
|
|
// warm/reconcile (List), and on trim (Prune).
|
2026-07-05 22:59:58 +00:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 12:27:05 +00:00
|
|
|
const durableUpsertSQL = `
|
|
|
|
|
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.revoked_at > stream_revocations.revoked_at
|
|
|
|
|
THEN EXCLUDED.reason ELSE stream_revocations.reason END,
|
|
|
|
|
revoked_at = GREATEST(stream_revocations.revoked_at, EXCLUDED.revoked_at),
|
|
|
|
|
expires_at = CASE WHEN stream_revocations.unrevoked_at IS NOT NULL
|
|
|
|
|
THEN EXCLUDED.expires_at
|
|
|
|
|
ELSE GREATEST(stream_revocations.expires_at, EXCLUDED.expires_at) END,
|
|
|
|
|
unrevoked_at = NULL,
|
|
|
|
|
tombstone_expires_at = NULL
|
|
|
|
|
WHERE stream_revocations.unrevoked_at IS NULL
|
|
|
|
|
OR stream_revocations.tombstone_expires_at <= now()
|
|
|
|
|
OR EXCLUDED.revoked_at > stream_revocations.unrevoked_at`
|
|
|
|
|
|
|
|
|
|
const durableTombstoneUpsertSQL = `
|
|
|
|
|
INSERT INTO stream_revocations (
|
|
|
|
|
kind, id, reason, revoked_at, expires_at, unrevoked_at, tombstone_expires_at
|
|
|
|
|
)
|
|
|
|
|
VALUES ($1, $2, '', $3, $4, $3, $4)
|
|
|
|
|
ON CONFLICT (kind, id) DO UPDATE SET
|
|
|
|
|
unrevoked_at = EXCLUDED.unrevoked_at,
|
|
|
|
|
tombstone_expires_at = EXCLUDED.tombstone_expires_at
|
|
|
|
|
WHERE EXCLUDED.unrevoked_at >= stream_revocations.revoked_at
|
|
|
|
|
AND (
|
|
|
|
|
stream_revocations.unrevoked_at IS NULL
|
|
|
|
|
OR EXCLUDED.unrevoked_at >= stream_revocations.unrevoked_at
|
|
|
|
|
)`
|
|
|
|
|
|
2026-07-05 22:59:58 +00:00
|
|
|
// 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
|
2026-07-30 12:27:05 +00:00
|
|
|
// far-future sentinel so Prune/List don't immediately reap the row.
|
2026-07-05 22:59:58 +00:00
|
|
|
expiresAt := r.ExpiresAt
|
|
|
|
|
if expiresAt.IsZero() {
|
|
|
|
|
expiresAt = permanentExpiry
|
|
|
|
|
}
|
2026-07-30 12:27:05 +00:00
|
|
|
// Merge cutoff and expiry independently, matching applyLocal: the later
|
|
|
|
|
// RevokedAt (and its reason) wins while expiry remains monotonic. A live
|
|
|
|
|
// tombstone rejects stale revocations; a genuinely newer revocation replaces
|
|
|
|
|
// the tombstone and starts a fresh expiry horizon.
|
|
|
|
|
_, err := s.pool.Exec(ctx, durableUpsertSQL,
|
2026-07-05 22:59:58 +00:00
|
|
|
string(r.Kind), r.ID, r.Reason, r.RevokedAt, expiresAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("streamrevoke upsert: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 12:27:05 +00:00
|
|
|
// UpsertTombstone durably records an explicit unrevoke. A stale tombstone
|
|
|
|
|
// cannot erase a newer revocation, and a stale replica cannot overwrite a live
|
|
|
|
|
// tombstone through Upsert.
|
|
|
|
|
func (s *PostgresDurableStore) UpsertTombstone(ctx context.Context, t Tombstone) error {
|
|
|
|
|
if _, err := s.pool.Exec(ctx, durableTombstoneUpsertSQL,
|
|
|
|
|
string(t.Kind), t.ID, t.UnrevokedAt, t.ExpiresAt); err != nil {
|
|
|
|
|
return fmt.Errorf("streamrevoke tombstone upsert: %w", err)
|
2026-07-29 15:17:15 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 12:27:05 +00:00
|
|
|
// List returns one snapshot of every active revocation and live tombstone.
|
|
|
|
|
// Tombstoned rows are never surfaced as revocations.
|
|
|
|
|
func (s *PostgresDurableStore) List(ctx context.Context) (DurableState, error) {
|
2026-07-05 22:59:58 +00:00
|
|
|
rows, err := s.pool.Query(ctx, `
|
2026-07-30 12:27:05 +00:00
|
|
|
SELECT kind, id, reason, revoked_at, expires_at,
|
|
|
|
|
unrevoked_at, tombstone_expires_at
|
2026-07-05 22:59:58 +00:00
|
|
|
FROM stream_revocations
|
2026-07-30 12:27:05 +00:00
|
|
|
WHERE (unrevoked_at IS NULL AND expires_at > now())
|
|
|
|
|
OR (unrevoked_at IS NOT NULL AND tombstone_expires_at > now())`)
|
2026-07-05 22:59:58 +00:00
|
|
|
if err != nil {
|
2026-07-30 12:27:05 +00:00
|
|
|
return DurableState{}, fmt.Errorf("streamrevoke list: %w", err)
|
2026-07-05 22:59:58 +00:00
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
2026-07-30 12:27:05 +00:00
|
|
|
var state DurableState
|
2026-07-05 22:59:58 +00:00
|
|
|
for rows.Next() {
|
|
|
|
|
var r Revocation
|
|
|
|
|
var kind string
|
2026-07-30 12:27:05 +00:00
|
|
|
var unrevokedAt, tombstoneExpiresAt *time.Time
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
&kind, &r.ID, &r.Reason, &r.RevokedAt, &r.ExpiresAt,
|
|
|
|
|
&unrevokedAt, &tombstoneExpiresAt,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return DurableState{}, fmt.Errorf("streamrevoke scan: %w", err)
|
2026-07-05 22:59:58 +00:00
|
|
|
}
|
|
|
|
|
r.Kind = Kind(kind)
|
2026-07-30 12:27:05 +00:00
|
|
|
if unrevokedAt != nil {
|
|
|
|
|
if tombstoneExpiresAt != nil {
|
|
|
|
|
state.Tombstones = append(state.Tombstones, Tombstone{
|
|
|
|
|
Kind: r.Kind, ID: r.ID, UnrevokedAt: *unrevokedAt, ExpiresAt: *tombstoneExpiresAt,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
state.Revocations = append(state.Revocations, r)
|
2026-07-05 22:59:58 +00:00
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
2026-07-30 12:27:05 +00:00
|
|
|
return DurableState{}, fmt.Errorf("streamrevoke list rows: %w", err)
|
2026-07-05 22:59:58 +00:00
|
|
|
}
|
2026-07-30 12:27:05 +00:00
|
|
|
return state, nil
|
2026-07-05 22:59:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-30 12:27:05 +00:00
|
|
|
// Prune physically deletes expired revocations and expired tombstones so
|
|
|
|
|
// neither state grows without bound.
|
2026-07-05 22:59:58 +00:00
|
|
|
func (s *PostgresDurableStore) Prune(ctx context.Context) error {
|
2026-07-30 12:27:05 +00:00
|
|
|
if _, err := s.pool.Exec(ctx, `
|
|
|
|
|
DELETE FROM stream_revocations
|
|
|
|
|
WHERE (unrevoked_at IS NULL AND expires_at <= now())
|
|
|
|
|
OR (unrevoked_at IS NOT NULL AND tombstone_expires_at <= now())`); err != nil {
|
2026-07-05 22:59:58 +00:00
|
|
|
return fmt.Errorf("streamrevoke prune: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|