shomei-server-0.3.0.0: app/Shomei/Admin/Sweep.hs
-- | @shomei-admin sweep@: delete expired and dead rows once, then exit.
--
-- The standalone server already runs this sweep on a background thread (see
-- @Shomei.Server.Boot.installSweeper@), which is the turnkey default. This subcommand exists
-- for operators who would rather schedule maintenance externally — a cron entry, a Kubernetes
-- CronJob — and who then set @SHOMEI_SWEEP_ENABLED=false@ on the server. Both triggers call
-- the same 'sweepOnce', so there is one implementation and two ways to fire it. Running both
-- concurrently is harmless: every delete is idempotent and a batch simply finds fewer rows.
--
-- @
-- shomei-admin sweep [--batch-size N] [--dead-session-grace-days N] [--one-time-token-grace-days N]
-- [--ceremony-grace-minutes N] [--login-attempt-retention-days N]
-- [--auth-event-retention-days N]
-- @
--
-- Exits 0 with one @table: count@ line per swept table, or 1 with the database error.
module Shomei.Admin.Sweep
( SweepOptions (..),
defaultSweepOptions,
sweepParser,
sweepOptionsToConfig,
runSweep,
runSweepReport,
)
where
import Data.Text qualified as Text
import Data.Time (getCurrentTime)
import Options.Applicative
import Shomei.Admin.Env (AdminEnv (..))
import Shomei.Error (AuthError)
import Shomei.Persistence.Maintenance.Postgres
( SweepConfig (..),
SweepReport,
defaultSweepConfig,
sweepOnce,
sweepReportCounts,
)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
-- | The sweep knobs as they arrive from the command line. Defaults match
-- 'defaultSweepConfig', so a bare @shomei-admin sweep@ behaves exactly like one cycle of the
-- server's background sweeper.
data SweepOptions = SweepOptions
{ optBatchSize :: !Int,
optDeadSessionGraceDays :: !Int,
optOneTimeTokenGraceDays :: !Int,
optCeremonyGraceMinutes :: !Int,
optLoginAttemptRetentionDays :: !Int,
-- | Absent retains the audit trail forever, which is the default.
optAuthEventRetentionDays :: !(Maybe Int)
}
-- | A bare @shomei-admin sweep@ with no flags, i.e. one cycle of the server's background
-- sweeper. The parser's flag defaults are taken from here.
defaultSweepOptions :: SweepOptions
defaultSweepOptions =
SweepOptions
{ optBatchSize = defaultSweepConfig.batchSize,
optDeadSessionGraceDays = defaultSweepConfig.deadSessionGraceDays,
optOneTimeTokenGraceDays = defaultSweepConfig.oneTimeTokenGraceDays,
optCeremonyGraceMinutes = defaultSweepConfig.ceremonyGraceMinutes,
optLoginAttemptRetentionDays = defaultSweepConfig.loginAttemptRetentionDays,
optAuthEventRetentionDays = defaultSweepConfig.authEventRetentionDays
}
sweepParser :: Parser SweepOptions
sweepParser =
SweepOptions
<$> intOpt "batch-size" defaultSweepOptions.optBatchSize "Rows per DELETE (sessions per DELETE, for refresh tokens)"
<*> intOpt "dead-session-grace-days" defaultSweepOptions.optDeadSessionGraceDays "Grace before an expired or revoked session and its refresh-token family are deleted"
<*> intOpt "one-time-token-grace-days" defaultSweepOptions.optOneTimeTokenGraceDays "Grace before expired verification/reset tokens and elapsed lockouts are deleted"
<*> intOpt "ceremony-grace-minutes" defaultSweepOptions.optCeremonyGraceMinutes "Grace before expired WebAuthn ceremonies are deleted"
<*> intOpt "login-attempt-retention-days" defaultSweepOptions.optLoginAttemptRetentionDays "Maximum age of login-attempt rows"
<*> optional
( option
auto
( long "auth-event-retention-days"
<> metavar "N"
<> help "Maximum age of audit events. Omit to retain the audit trail forever (the default)."
)
)
where
intOpt name def helpText =
option
auto
(long name <> metavar "N" <> value def <> showDefault <> help helpText)
sweepOptionsToConfig :: SweepOptions -> SweepConfig
sweepOptionsToConfig o =
SweepConfig
{ batchSize = o.optBatchSize,
deadSessionGraceDays = o.optDeadSessionGraceDays,
oneTimeTokenGraceDays = o.optOneTimeTokenGraceDays,
ceremonyGraceMinutes = o.optCeremonyGraceMinutes,
loginAttemptRetentionDays = o.optLoginAttemptRetentionDays,
authEventRetentionDays = o.optAuthEventRetentionDays
}
-- | Run one sweep against the admin pool and hand back the report. Separated from 'runSweep'
-- so tests can assert on the counts without capturing stdout or catching @exitFailure@.
runSweepReport :: AdminEnv -> SweepOptions -> IO (Either AuthError SweepReport)
runSweepReport env opts = do
now <- getCurrentTime
sweepOnce env.pool (sweepOptionsToConfig opts) now
-- | Sweep once, print the per-table counts, and exit non-zero if the database was unreachable.
runSweep :: AdminEnv -> SweepOptions -> IO ()
runSweep env opts =
runSweepReport env opts >>= \case
Left err -> do
hPutStrLn stderr ("shomei-admin: sweep failed: " <> show err)
exitFailure
Right report -> putStr (renderReport opts.optAuthEventRetentionDays report)
-- | Aligned @table: count@ lines. The audit row is annotated when retention is off, so an
-- operator reading @auth_events: 0@ is never left wondering whether the sweep tried and found
-- nothing or was never asked to look.
renderReport :: Maybe Int -> SweepReport -> String
renderReport authEventRetention report =
unlines [line table deleted | (table, deleted) <- counts]
where
counts = sweepReportCounts report
width = maximum [Text.length table | (table, _) <- counts] + 2
line table deleted =
let label = Text.unpack table <> ":"
padded = label <> replicate (width - length label) ' '
suffix
| table == "auth_events", Nothing <- authEventRetention = " (retention disabled)"
| otherwise = ""
in padded <> show deleted <> suffix