packages feed

shomei-postgres-0.2.0.0: src/Shomei/Persistence/Maintenance/Postgres.hs

-- | Data hygiene: the batched sweep of expired and dead rows.
--
-- Nothing in Shōmei's domain layer ever deletes anything, so six tables would otherwise grow
-- without bound — most sharply @shomei_refresh_tokens@, which gains a row on every token
-- refresh, forever. This module is the counterweight: 'sweepOnce' performs one full pass,
-- deleting rows that are past their expiry plus a configured grace period.
--
-- Sweeping is an infrastructure maintenance concern rather than a domain operation — no
-- workflow will ever call it — so the statements live here as plain @hasql@ statements
-- instead of widening the seven core store ports (and every in-memory test interpreter) with
-- operations nothing else uses. The sweeper is the sole bulk-delete path.
--
-- Definitions used throughout:
--
-- * A /batched delete/ deletes at most @batchSize@ rows (or, for refresh tokens, at most
--   @batchSize@ sessions' worth of rows) per statement, so row locks and the enclosing
--   transaction stay short-lived. Most statements here bound themselves with PostgreSQL's
--   physical row address: @DELETE FROM t WHERE ctid IN (SELECT ctid FROM t WHERE .. LIMIT n)@.
--   @ctid@ is a system column identifying a row version.
--
-- * A /grace period/ is extra time past logical expiry before a row becomes sweepable, kept
--   for forensics and — for refresh tokens — to protect reuse detection.
--
-- * A /retention window/ is the maximum age of rows in an append-only table
--   (@shomei_login_attempts@, @shomei_auth_events@).
--
-- Each batch is its own @Pool.use@ session, deliberately /not/ one big transaction: locks
-- stay short and a crash mid-sweep loses nothing, because every delete here is idempotent.
-- The background thread and @shomei-admin sweep@ may therefore run concurrently without
-- coordination; a batch simply finds fewer rows.
module Shomei.Persistence.Maintenance.Postgres
  ( SweepConfig (..),
    defaultSweepConfig,
    SweepReport (..),
    emptySweepReport,
    sweepReportCounts,
    sweepReportTotal,
    sweepOnce,
  )
where

import Contravariant.Extras (contrazip2)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import Data.Int (Int64)
import Data.Time (addUTCTime)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool (Pool, UsageError)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Error (AuthError)
import Shomei.Persistence.Database.Postgres (postgresUnavailable)
import Shomei.Prelude

-- | How much history to keep, and how large each delete batch may be.
--
-- The defaults are deliberately conservative where data is forensic and aggressive where it
-- is worthless: a one-time token is useless minutes after it expires, whereas an audit event
-- may be a compliance record.
data SweepConfig = SweepConfig
  { -- | Rows per @DELETE@ statement (sessions per statement, for refresh tokens).
    batchSize :: !Int,
    -- | Grace period before an expired or revoked session — and the whole rotation family of
    -- refresh tokens hanging off it — becomes sweepable. This must stay generous: reuse
    -- detection recognizes a replayed token by finding its @used@ row still present, so
    -- deleting those rows early would silently downgrade "token reuse" (which revokes the
    -- family) to "invalid token". After the grace period every token in the family is
    -- unusable anyway.
    deadSessionGraceDays :: !Int,
    -- | Grace period past expiry for email-verification tokens, password-reset tokens, and
    -- elapsed account lockouts. Pure debugging slack.
    oneTimeTokenGraceDays :: !Int,
    -- | Grace period past expiry for abandoned WebAuthn ceremonies, which are worthless
    -- seconds after expiry. An hour keeps live debugging possible.
    ceremonyGraceMinutes :: !Int,
    -- | Retention window for @shomei_login_attempts@. Brute-force counting reads a
    -- 15-minute window, so this is forensic slack over the biggest write-rate table.
    loginAttemptRetentionDays :: !Int,
    -- | Retention window for @shomei_auth_events@. 'Nothing' means retain forever, which is
    -- the default: the audit trail is the compliance record, and deleting it must be an
    -- explicit operator decision rather than something a default quietly does.
    authEventRetentionDays :: !(Maybe Int)
  }
  deriving stock (Show, Eq, Generic)

-- | See each field's documentation in 'SweepConfig' for why these values.
defaultSweepConfig :: SweepConfig
defaultSweepConfig =
  SweepConfig
    { batchSize = 1000,
      deadSessionGraceDays = 30,
      oneTimeTokenGraceDays = 7,
      ceremonyGraceMinutes = 60,
      loginAttemptRetentionDays = 90,
      authEventRetentionDays = Nothing
    }

-- | How many rows one 'sweepOnce' pass deleted, per table.
data SweepReport = SweepReport
  { refreshTokensDeleted :: !Int,
    sessionsDeleted :: !Int,
    verificationTokensDeleted :: !Int,
    resetTokensDeleted :: !Int,
    ceremoniesDeleted :: !Int,
    authorizationCodesDeleted :: !Int,
    lockoutsDeleted :: !Int,
    loginAttemptsDeleted :: !Int,
    -- | EP-9 time-bound role grants whose expiry has passed (past a grace period). Purely
    -- hygiene: an expired grant is already inert at the next token mint (the mint filters on
    -- @expires_at@), and the @role_granted@ audit payload records the window regardless.
    roleGrantsDeleted :: !Int,
    authEventsDeleted :: !Int
  }
  deriving stock (Show, Eq, Generic)

-- | The report of a sweep that deleted nothing.
emptySweepReport :: SweepReport
emptySweepReport =
  SweepReport
    { refreshTokensDeleted = 0,
      sessionsDeleted = 0,
      verificationTokensDeleted = 0,
      resetTokensDeleted = 0,
      ceremoniesDeleted = 0,
      authorizationCodesDeleted = 0,
      lockoutsDeleted = 0,
      loginAttemptsDeleted = 0,
      roleGrantsDeleted = 0,
      authEventsDeleted = 0
    }

-- | The report as @(table_name, rows_deleted)@ pairs in sweep order. The names are the
-- database table names minus the @shomei_@ prefix; log lines and @shomei-admin sweep@ both
-- render this, so operators see one vocabulary.
sweepReportCounts :: SweepReport -> [(Text, Int)]
sweepReportCounts r =
  [ ("refresh_tokens", r.refreshTokensDeleted),
    ("sessions", r.sessionsDeleted),
    ("verification_tokens", r.verificationTokensDeleted),
    ("reset_tokens", r.resetTokensDeleted),
    ("ceremonies", r.ceremoniesDeleted),
    ("authorization_codes", r.authorizationCodesDeleted),
    ("lockouts", r.lockoutsDeleted),
    ("login_attempts", r.loginAttemptsDeleted),
    ("role_grants", r.roleGrantsDeleted),
    ("auth_events", r.authEventsDeleted)
  ]

-- | Total rows deleted across every table.
sweepReportTotal :: SweepReport -> Int
sweepReportTotal = sum . map snd . sweepReportCounts

-- | Run one full sweep pass against @pool@, treating @now@ as the current time (injected so
-- tests can seed rows at fixed offsets). Returns the per-table deletion counts, or the first
-- 'DependencyUnavailable PostgreSQL' if the database was unreachable or a statement failed — an unreachable
-- database is an ordinary, expected outcome for a periodic maintenance task, not a crash.
--
-- Statement order is load-bearing. @shomei_refresh_tokens.session_id@ references
-- @shomei_sessions@ with no @ON DELETE@ action, so every dead session's tokens must be gone
-- before the session itself can be deleted.
sweepOnce :: Pool -> SweepConfig -> UTCTime -> IO (Either AuthError SweepReport)
sweepOnce pool cfg now = runExceptT do
  refreshTokensDeleted <- drain deadSessionTokensStmt deadSessionCutoff
  sessionsDeleted <- drain deadSessionsStmt deadSessionCutoff
  verificationTokensDeleted <- drain expiredVerificationTokensStmt oneTimeTokenCutoff
  resetTokensDeleted <- drain expiredResetTokensStmt oneTimeTokenCutoff
  ceremoniesDeleted <- drain expiredCeremoniesStmt ceremonyCutoff
  -- EP-5's authorization codes live 60 seconds and are consumed once. They need no grace period
  -- of their own: a code past `expires_at` can never be exchanged, consumed or not, so the
  -- ceremony grace window (which exists for exactly the same "short-lived, already useless"
  -- shape) is the right one to reuse.
  authorizationCodesDeleted <- drain expiredAuthorizationCodesStmt ceremonyCutoff
  lockoutsDeleted <- drain elapsedLockoutsStmt oneTimeTokenCutoff
  loginAttemptsDeleted <- drain oldLoginAttemptsStmt loginAttemptCutoff
  -- EP-9 time-bound grants past expiry. They reuse the one-time-token grace: an expired grant is
  -- already inert (the mint filters it), so the grace is pure forensic slack, like a spent
  -- verification token.
  roleGrantsDeleted <- drain expiredRoleGrantsStmt oneTimeTokenCutoff
  -- Retaining the audit trail forever is the default; deleting it is opt-in.
  authEventsDeleted <- case cfg.authEventRetentionDays of
    Nothing -> pure 0
    Just days -> drain oldAuthEventsStmt (daysAgo days)
  pure SweepReport {..}
  where
    drain stmt cutoff = ExceptT (drainTable pool stmt cutoff limit)

    -- A non-positive batch size would compile to LIMIT 0, deleting nothing forever. Clamp
    -- rather than fail: a misconfigured sweeper that still works is better than one that
    -- silently no-ops.
    limit = fromIntegral (max 1 cfg.batchSize) :: Int64

    daysAgo d = addUTCTime (negate (fromIntegral d * 86400)) now
    minutesAgo m = addUTCTime (negate (fromIntegral m * 60)) now

    deadSessionCutoff = daysAgo cfg.deadSessionGraceDays
    oneTimeTokenCutoff = daysAgo cfg.oneTimeTokenGraceDays
    ceremonyCutoff = minutesAgo cfg.ceremonyGraceMinutes
    loginAttemptCutoff = daysAgo cfg.loginAttemptRetentionDays

-- | Run one statement repeatedly until it deletes nothing, summing the rows it removed.
--
-- The terminator is "this batch deleted zero rows", not "this batch deleted fewer than
-- @limit@ rows": 'deadSessionTokensStmt' bounds itself by /sessions/, so a batch of one
-- session can legitimately delete a whole rotation family's worth of tokens. Every statement
-- here is guaranteed to make progress while rows match — in particular
-- 'deadSessionTokensStmt' only selects sessions that still have at least one token — so a
-- zero result means the predicate is drained.
drainTable :: Pool -> Statement (UTCTime, Int64) Int64 -> UTCTime -> Int64 -> IO (Either AuthError Int)
drainTable pool stmt cutoff limit = fmap (either (Left . postgresUnavailable) Right) (go 0)
  where
    go :: Int64 -> IO (Either UsageError Int)
    go !acc = do
      res <- Pool.use pool (Session.statement (cutoff, limit) stmt)
      case res of
        Left err -> pure (Left err)
        Right deleted
          | deleted <= 0 -> pure (Right (fromIntegral acc))
          | otherwise -> go (acc + deleted)

-- Statements -----------------------------------------------------------------

-- | @$1@ is the cutoff timestamp, @$2@ the batch limit.
cutoffAndLimit :: E.Params (UTCTime, Int64)
cutoffAndLimit =
  contrazip2
    (E.param (E.nonNullable E.timestamptz))
    (E.param (E.nonNullable E.int8))

-- | A bounded delete of the rows a predicate selects, counted.
batchedDelete :: Text -> Statement (UTCTime, Int64) Int64
batchedDelete sql = preparable sql cutoffAndLimit D.rowsAffected

-- | Every refresh token belonging to a session that expired, or was revoked, before the
-- cutoff.
--
-- This batches by /session/ rather than by row, which the @ctid IN (SELECT ctid .. LIMIT n)@
-- shape used elsewhere cannot do safely here: @parent_token_id@ is a self-referencing foreign
-- key with no @ON DELETE@ action, checked at end of statement, so a row-bounded batch that
-- happened to split a rotation family — deleting a parent while its child survives into the
-- next batch — raises
-- @violates foreign key constraint "shomei_refresh_tokens_parent_token_id_fkey"@. Every
-- member of a rotation family shares one @session_id@, so deleting a whole session's tokens
-- in one statement is always internally consistent.
--
-- The @EXISTS@ guard keeps the drain loop honest: without it, a batch could select only
-- already-tokenless sessions, delete zero rows, and stop while other dead sessions still hold
-- tokens.
deadSessionTokensStmt :: Statement (UTCTime, Int64) Int64
deadSessionTokensStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_refresh_tokens rt
    WHERE rt.session_id IN (
      SELECT s.session_id
      FROM shomei.shomei_sessions s
      WHERE (s.expires_at <= $1 OR (s.status = 'revoked' AND s.revoked_at <= $1))
        AND EXISTS (
          SELECT 1 FROM shomei.shomei_refresh_tokens rt2
          WHERE rt2.session_id = s.session_id)
      LIMIT $2)
    """

-- | Sessions dead past the cutoff that no longer have any refresh tokens. The @NOT EXISTS@
-- guard means a partially swept family never strands a token whose session is gone; the
-- leftovers are collected by the next cycle, after 'deadSessionTokensStmt' drains them.
deadSessionsStmt :: Statement (UTCTime, Int64) Int64
deadSessionsStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_sessions
    WHERE ctid IN (
      SELECT s2.ctid
      FROM shomei.shomei_sessions s2
      WHERE (s2.expires_at <= $1 OR (s2.status = 'revoked' AND s2.revoked_at <= $1))
        AND NOT EXISTS (
          SELECT 1 FROM shomei.shomei_refresh_tokens rt
          WHERE rt.session_id = s2.session_id)
      LIMIT $2)
    """

expiredVerificationTokensStmt :: Statement (UTCTime, Int64) Int64
expiredVerificationTokensStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_email_verification_tokens
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_email_verification_tokens
      WHERE expires_at <= $1
      LIMIT $2)
    """

expiredResetTokensStmt :: Statement (UTCTime, Int64) Int64
expiredResetTokensStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_password_reset_tokens
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_password_reset_tokens
      WHERE expires_at <= $1
      LIMIT $2)
    """

expiredCeremoniesStmt :: Statement (UTCTime, Int64) Int64
expiredCeremoniesStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_webauthn_pending_ceremonies
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_webauthn_pending_ceremonies
      WHERE expires_at <= $1
      LIMIT $2)
    """

-- | Authorization codes past their expiry (EP-5). Consumed rows are swept the same way: a
-- consumed code is refused by `expires_at > now` in the consume statement anyway, so keeping it
-- past expiry buys nothing.
expiredAuthorizationCodesStmt :: Statement (UTCTime, Int64) Int64
expiredAuthorizationCodesStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_oauth_authorization_codes
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_oauth_authorization_codes
      WHERE expires_at <= $1
      LIMIT $2)
    """

-- | Lockout rows whose lock has elapsed. Rows with a NULL @locked_until@ are accumulating
-- failure counts for an account that is not currently locked; they are one row per account
-- that has ever failed a login and are left alone.
elapsedLockoutsStmt :: Statement (UTCTime, Int64) Int64
elapsedLockoutsStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_account_lockouts
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_account_lockouts
      WHERE locked_until IS NOT NULL AND locked_until <= $1
      LIMIT $2)
    """

-- | EP-9 role grants whose @expires_at@ has passed the cutoff. Forever grants (@expires_at IS
-- NULL@) and grants still within the grace window are spared; the partial index
-- @shomei_role_grants_expires_at_idx@ keeps the scan cheap.
expiredRoleGrantsStmt :: Statement (UTCTime, Int64) Int64
expiredRoleGrantsStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_role_grants
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_role_grants
      WHERE expires_at IS NOT NULL AND expires_at <= $1
      LIMIT $2)
    """

oldLoginAttemptsStmt :: Statement (UTCTime, Int64) Int64
oldLoginAttemptsStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_login_attempts
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_login_attempts
      WHERE occurred_at <= $1
      LIMIT $2)
    """

oldAuthEventsStmt :: Statement (UTCTime, Int64) Int64
oldAuthEventsStmt =
  batchedDelete
    """
    DELETE FROM shomei.shomei_auth_events
    WHERE ctid IN (
      SELECT ctid FROM shomei.shomei_auth_events
      WHERE created_at <= $1
      LIMIT $2)
    """