shomei-server (empty) → 0.3.0.0
raw patch · 40 files changed
+11642/−0 lines, 40 filesdep +aesondep +basedep +base64
Dependencies added: aeson, base, base64, bytestring, containers, contravariant-extras, crypton, directory, effectful, effectful-core, hasql, http-client, http-client-tls, http-types, iproute, jose, mime-mail, network, optparse-applicative, pg-migrate, process, ram, servant-health, servant-server, shomei-core, shomei-jwt, shomei-migrations, shomei-postgres, shomei-servant, shomei-server, shomei-webauthn, smtp-mail, stm, tasty, tasty-hunit, text, time, unix, unordered-containers, uuid, wai, warp
Files
- CHANGELOG.md +96/−0
- LICENSE +21/−0
- app/Admin.hs +238/−0
- app/Main.hs +8/−0
- app/Shomei/Admin/Audit.hs +192/−0
- app/Shomei/Admin/Env.hs +73/−0
- app/Shomei/Admin/Keys.hs +349/−0
- app/Shomei/Admin/OAuthClients.hs +346/−0
- app/Shomei/Admin/Roles.hs +349/−0
- app/Shomei/Admin/ServiceAccounts.hs +313/−0
- app/Shomei/Admin/Sweep.hs +132/−0
- app/Shomei/Admin/Users.hs +184/−0
- review-load/Main.hs +315/−0
- shomei-server.cabal +353/−0
- src/Shomei/Health/Server.hs +85/−0
- src/Shomei/Notify.hs +649/−0
- src/Shomei/Notify/Queue.hs +96/−0
- src/Shomei/Server/App.hs +215/−0
- src/Shomei/Server/Boot.hs +550/−0
- src/Shomei/Server/BreachChecker.hs +72/−0
- src/Shomei/Server/Config.hs +1471/−0
- src/Shomei/Server/DatabaseError.hs +27/−0
- src/Shomei/Server/ExceptionResponse.hs +22/−0
- src/Shomei/Server/Keys.hs +220/−0
- src/Shomei/Server/Middleware/BodyLimit.hs +58/−0
- src/Shomei/Server/Middleware/RateLimit.hs +180/−0
- src/Shomei/Server/Middleware/TrustedProxy.hs +86/−0
- src/Shomei/Server/Observability/Logging.hs +165/−0
- src/Shomei/Server/Observability/Metrics.hs +220/−0
- src/Shomei/Server/Runtime.hs +53/−0
- src/Shomei/Server/Supervisor.hs +130/−0
- test-health/Main.hs +143/−0
- test/Admin/Main.hs +1077/−0
- test/Main.hs +16/−0
- test/Shomei/Server/ConfigSpec.hs +868/−0
- test/Shomei/Server/E2ESpec.hs +997/−0
- test/Shomei/Server/MiddlewareSpec.hs +546/−0
- test/Shomei/Server/NotifySpec.hs +487/−0
- test/Shomei/Server/RuntimeSpec.hs +126/−0
- test/Shomei/Server/SupervisorSpec.hs +114/−0
+ CHANGELOG.md view
@@ -0,0 +1,96 @@+# Changelog for shomei-server++All notable changes to `shomei-server` are documented here. This package adheres to the+[PVP](https://pvp.haskell.org/) and is versioned independently of the other+Shōmei packages.++## 0.3.0.0 — 2026-11-05++- **Breaking:** internal dependency bounds follow the 0.3 API series; migration dependencies require `shomei-migrations ^>=0.2.0.1`.+- First Hackage publication; earlier version sections describe repository development milestones.++- Explicit lab overrides for HTTP webhooks and plaintext SMTP credentials now emit fixed secret-free boot warnings. The notification guide documents inherited outbound HTTP proxy settings.++- Signing-key CLI transactions use finite database retry/deadline defaults. Activation revalidates and locks the pending source row before retiring the old key; injected activation/rewrap write failures roll back wholly. Key assembly fails closed on multiple active rows even after index bypass.++- Audit CLI accepts canonical user/session TypeIDs and legacy UUID filters and emits canonical user/session IDs. HIBP failures have an injectable query/report boundary and a tested bounded, secret-free diagnostic.++- **Breaking:** ServerSettings gains bind host, finite connection limit (1024) and slow-client timeout (30 seconds). Standalone and embedded Warp hosts use shared `hostWarpSettings`; excess connections close immediately and all close paths restore capacity. Dhall/environment settings are synchronized.+- Container entrypoint delegates migration and active-key bootstrap to shared server boot. Request severity is error for 5xx, warn for 429 and info otherwise.++- **Breaking:** PostgreSQL assembly uses the managed `Shomei.Persistence.Pool.Postgres.Pool` and its `UsageError`, `use` and `release` operations. Interrupted/failed sessions discard their connection and restore capacity; the external pool dependency is removed.++- Machine and delegated mints atomically enforce the configurable active principal ceiling (default 1000), preserving existing sessions after lowering the cap. Expiry frees capacity; finite sweeper retention bounds ordinary history by issuance rate and retention.++- **Breaking:** database transactions use the bounded statement wrapper; session bulk revocation returns winning IDs and the audited unit of work returns a count. Administrative session HTTP listing returns a keyset page (default 50, maximum 200). Server transaction limits default to three attempts/five seconds.++- Enforce finite new-write identity, display-name, passkey-label and role/scope budgets; preserve legacy identity authentication and snapshots. Login passwords over 256 code points fail before credential proof accounting. PostgreSQL adds migration 0039; see `docs/user/input-limits.md`.++- Expose finite actor migration settings through `SHOMEI_LEGACY_ACTOR_UNTIL_UNIX` and+ `SHOMEI_EMIT_LEGACY_ACTOR` with matching Dhall fields. Follow the coordinated token upgrade guide.++- Expose the passkey enrollment ceiling through `SHOMEI_MAX_PASSKEYS_PER_USER` and Dhall+ `maxPasskeysPerUser` (default 20, accepted range 1–100).++- Add `shomei-admin totp migrate-envelopes --check|--offline`; shared server startup refuses+ legacy TOTP envelopes. See the MFA guide for the required offline upgrade and rollback steps.+- Require released `smtp-mail >=0.5.0.2`, which fixes the `getHostName` import conflict with+ `network >=3.2.10`.++- `SHOMEI_REMEMBERED_SESSION_TTL` and the Dhall field `rememberedSessionTtlSeconds` set the+ lifetime of a remembered session.++## 0.2.0.0 — 2026-08-27++- **Breaking:** secure cookie transport now uses `__Host-shomei_session` and+ `__Secure-shomei_refresh`. Existing browser sessions are logged out once; development deployments+ with `cookieSecure = false` retain the bare names.+- Unknown-length request bodies are metered while consumed and receive a Problem Details 413 when+ they cross the cap. Warp parser failures and escaped synchronous exceptions now use the same+ public 400/413/500 envelope, and hostile authentication-header bytes cannot escape UTF-8 decoding.+- Prometheus method labels now use a fixed eight-value vocabulary with correctly escaped values,+ elapsed-time accounting uses monotonic clocks, and readiness is bounded to two seconds with a+ one-second single-flight cache.+- `shomei-admin users create` reads passwords from stdin or `--password-file`, loads the+ deployment's Dhall password and HIBP breach policy, and can explicitly mark a bootstrap email+ verified. PostgreSQL diagnostics retain only a safe category and optional SQLSTATE; breach-check+ outages log one classified reason.+- **Breaking:** `SmtpConfig` and `WebhookConfig` no longer carry credentials. The server loads+ stripped secrets into non-printable runtime types, requires HTTPS webhooks and encrypted SMTP+ authentication unless env-only lab flags opt out, and signs webhooks over+ `<X-Shomei-Timestamp>.<raw body>` for receiver replay windows.+- Notification delivery now runs on a supervised bounded in-memory queue, keeping SMTP/webhook+ latency out of request handling. Queue capacity is configurable, overflow is audited without+ blocking, and graceful shutdown drains queued work before releasing the database pool.+- Notification delivery failures persist only stable reason codes; transport exceptions,+ rendered messages, webhook response bodies, and request URLs never reach logs or audit rows.+- Configuration adds `dbStatementTimeoutMs` / `SHOMEI_DB_STATEMENT_TIMEOUT_MS` (30 seconds by+ default), and the in-process sweeper uses a dedicated one-connection pool.+- **Breaking:** the Dhall schema now exports `{ Type, default }` with every loader key optional;+ annotated files use `Shomei::{ field = Some value }`. A test keeps schema and loader fields equal.+- **Breaking:** Dhall configuration rejects unknown fields and invalid WebAuthn policy names, and+ startup refuses empty WebAuthn origin sets. `SHOMEI_EMAIL_VERIFICATION_REQUIRED` now overlays the+ corresponding file setting.+- The server and `shomei-admin` refuse Argon2 costs that the implementation cannot represent or+ accept; server startup also performs one real derivation before acquiring a database pool.+- **Breaking:** configuration adds `allowedClockSkewSeconds` and validates issuer and audience as+ RFC 7519 StringOrURI values at boot.+- The admin CLI activates and rewraps signing keys transactionally, stamps `revoked_at`, and lists+ every lifecycle timestamp. The server refuses ambiguous multi-active key state.++Not yet published to Hackage: depends on `shomei-webauthn`, which is blocked+on a Hackage release of `webauthn` compatible with GHC 9.12.4 and `jose 0.13`.++Initial release contents:++- The `shomei-server` warp executable serving `ShomeiAPI` against PostgreSQL,+ with signing-key bootstrap, publication, and hot reload.+- WAI middleware stack: structured logging, Prometheus metrics, per-IP and+ per-account rate limiting, and request body limits.+- Health and readiness probes and graceful shutdown under a supervisor.+- Environment-driven configuration loader.+- HIBP breach checking enforced in the password workflows.+- The `shomei-admin` operations CLI: user and session administration,+ service-account and OAuth-client lifecycle, and zero-downtime signing-key+ rotation including encrypt-at-rest and rewrap.+- Password-reset and email-verification tokens are never logged.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Nadeem Bitar++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ app/Admin.hs view
@@ -0,0 +1,238 @@+-- | @shomei-admin@: the operational CLI (EP-4). Subcommands manage migrations, the signing-key+-- rotation lifecycle, and bootstrap user creation against a deployed Shōmei database, without the+-- HTTP server running. See @shomei-admin --help@.+module Main (main) where++import Control.Exception (finally)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.IO qualified as TextIO+import Database.PostgreSQL.Migrate (MigrationReport (..))+import Options.Applicative+import Shomei.Account.Password.Domain (PlainPassword (..))+import Shomei.Admin.Audit (AuditCommand, auditParser, runAudit)+import Shomei.Admin.Env (AdminEnv (..), loadAdminEnv)+import Shomei.Admin.Keys (keysActivate, keysGenerate, keysList, keysRetire, keysRevoke, keysRewrap)+import Shomei.Admin.OAuthClients (OAuthClientsCommand, oauthClientsParser, runOAuthClients)+import Shomei.Admin.Roles (RolesCommand, rolesParser, runRoles)+import Shomei.Admin.ServiceAccounts (ServiceAccountsCommand, runServiceAccounts, serviceAccountsParser)+import Shomei.Admin.Sweep (SweepOptions, runSweep, sweepParser)+import Shomei.Admin.Users (createUserAction)+import Shomei.Mfa.Totp.Postgres (checkTotpEnvelopeVersions, migrateTotpEnvelopeBatch, totpEncryptionKeyFromBase64)+import Shomei.Migrations (applyShomeiMigrations)+import Shomei.Server.Keys (loadKekFromEnv, loadNamedKekFromEnv)+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256), signingAlgorithmFromText)+import System.Environment (lookupEnv)+import System.Exit (exitFailure)+import System.IO (hFlush, hIsTerminalDevice, hPutStr, hPutStrLn, hSetEcho, stderr, stdin)++-- The command tree -----------------------------------------------------------++data Command+ = Migrate+ | Keys KeysCommand+ | Users UsersCommand+ | Roles RolesCommand+ | ServiceAccounts ServiceAccountsCommand+ | OAuthClients OAuthClientsCommand+ | Audit AuditCommand+ | Sweep SweepOptions+ | TotpMigrateEnvelopes Bool Bool++data KeysCommand+ = KeysGenerate SigningAlgorithm+ | KeysActivate Text+ | KeysRetire Text+ | KeysRevoke Text+ | KeysList+ | KeysRewrap++data UsersCommand = UsersCreate+ { email :: Text,+ passwordFile :: Maybe FilePath,+ displayName :: Maybe Text,+ emailVerified :: Bool+ }++commandParser :: Parser Command+commandParser =+ hsubparser+ ( command "migrate" (info (pure Migrate) (progDesc "Apply pending database migrations"))+ <> command "keys" (info (Keys <$> keysParser) (progDesc "Manage signing keys"))+ <> command "users" (info (Users <$> usersParser) (progDesc "Manage user accounts"))+ <> command "roles" (info (Roles <$> rolesParser) (progDesc "Declare roles and grant them to users"))+ <> command "service-accounts" (info (ServiceAccounts <$> serviceAccountsParser) (progDesc "Manage OAuth2 client_credentials machine accounts"))+ <> command "oauth-clients" (info (OAuthClients <$> oauthClientsParser) (progDesc "Manage OAuth2/OIDC clients for the authorization-code flow"))+ <> command "audit" (info (Audit <$> auditParser) (progDesc "Query the audit log / security events"))+ <> command "sweep" (info (Sweep <$> sweepParser) (progDesc "Delete expired and dead rows once, then exit"))+ <> command "totp" (info totpParser (progDesc "Maintain encrypted TOTP envelopes"))+ )++totpParser :: Parser Command+totpParser =+ hsubparser+ ( command+ "migrate-envelopes"+ ( info+ ( TotpMigrateEnvelopes+ <$> switch (long "check" <> help "Read version counts only; requires no TOTP key")+ <*> switch (long "offline" <> help "Confirm all Shomei writers are stopped and a matching backup exists")+ )+ (progDesc "Convert legacy TOTP envelopes in resumable batches of at most 100 rows")+ )+ )++keysParser :: Parser KeysCommand+keysParser =+ hsubparser+ ( command "generate" (info (KeysGenerate <$> algOpt) (progDesc "Mint a new signing key in pending status"))+ <> command "activate" (info (KeysActivate <$> kidArg) (progDesc "Promote a pending key to active (old one auto-retires)"))+ <> command "retire" (info (KeysRetire <$> kidArg) (progDesc "Demote an active key to retired (still trusted)"))+ <> command "revoke" (info (KeysRevoke <$> kidArg) (progDesc "Mark a key revoked (immediately untrusted)"))+ <> command "list" (info (pure KeysList) (progDesc "Show every key with kid / status / timestamps"))+ <> command+ "rewrap"+ (info (pure KeysRewrap) (progDesc "Re-encrypt every private key from SHOMEI_KEY_ENCRYPTION_KEY_OLD to SHOMEI_KEY_ENCRYPTION_KEY"))+ )+ where+ kidArg = Text.pack <$> argument str (metavar "KID")+ algOpt =+ option+ (eitherReader (either (Left . Text.unpack) Right . signingAlgorithmFromText . Text.pack))+ (long "alg" <> metavar "ES256|RS256" <> value ES256 <> showDefaultWith (const "ES256") <> help "Signing algorithm for the new key")++usersParser :: Parser UsersCommand+usersParser =+ hsubparser+ (command "create" (info createOpts (progDesc "Create a user account")))+ where+ createOpts =+ UsersCreate+ <$> (Text.pack <$> strOption (long "email" <> metavar "EMAIL" <> help "User email address"))+ <*> optional (strOption (long "password-file" <> metavar "PATH" <> help "Read the password from PATH instead of stdin"))+ <*> optional (Text.pack <$> strOption (long "display-name" <> metavar "NAME" <> help "Optional display name"))+ <*> switch (long "email-verified" <> help "Mark the bootstrap user's email as already verified")++main :: IO ()+main = do+ cmd <- execParser opts+ run cmd+ where+ opts =+ info+ (commandParser <**> helper)+ (fullDesc <> progDesc "Operational CLI for a Shōmei deployment" <> header "shomei-admin")++run :: Command -> IO ()+run = \case+ Migrate -> do+ env <- loadAdminEnv+ applyShomeiMigrations env.connStr >>= \case+ Left migrationError -> do+ hPutStrLn stderr ("shomei-admin: schema migration failed: " <> show migrationError)+ exitFailure+ Right report ->+ putStrLn+ ( "migrations applied: "+ <> show (NonEmpty.length report.results)+ <> " in plan (started "+ <> show report.startedAt+ <> ", finished "+ <> show report.finishedAt+ <> "); run `shomei-migrate status` for per-migration detail"+ )+ Keys kc -> do+ env <- loadAdminEnv+ case kc of+ KeysGenerate alg -> do+ kek <- loadKekFromEnv+ keysGenerate kek alg env.pool+ KeysActivate kid -> keysActivate env.pool kid+ KeysRetire kid -> keysRetire env.pool kid+ KeysRevoke kid -> keysRevoke env.pool kid+ KeysList -> keysList env.pool+ KeysRewrap -> do+ newKek <- loadKekFromEnv+ oldKek <- loadNamedKekFromEnv "SHOMEI_KEY_ENCRYPTION_KEY_OLD"+ keysRewrap oldKek newKek env.pool+ Users (UsersCreate {email, passwordFile, displayName, emailVerified}) -> do+ password <- readPasswordSecret passwordFile+ env <- loadAdminEnv+ createUserAction env email password displayName emailVerified+ Roles rc -> do+ env <- loadAdminEnv+ runRoles env rc+ ServiceAccounts sc -> do+ env <- loadAdminEnv+ runServiceAccounts env sc+ OAuthClients oc -> do+ env <- loadAdminEnv+ runOAuthClients env oc+ Audit ac -> do+ env <- loadAdminEnv+ runAudit env ac+ Sweep opts -> do+ env <- loadAdminEnv+ runSweep env opts+ TotpMigrateEnvelopes check offline -> do+ env <- loadAdminEnv+ let die message = hPutStrLn stderr message >> exitFailure+ counts = checkTotpEnvelopeVersions env.pool >>= either (const (die "shomei-admin: TOTP version/schema check failed")) pure+ (legacy, invalid) <- counts+ putStrLn ("legacy=" <> show legacy <> " invalid_versions=" <> show invalid)+ if check+ then pure ()+ else do+ if not offline then die "shomei-admin: stop all Shomei writers, retain database/key backup, then pass --offline" else pure ()+ if invalid /= 0 then die "shomei-admin: unknown TOTP envelope version; conversion refused" else pure ()+ raw <- lookupEnv "SHOMEI_TOTP_ENCRYPTION_KEY"+ key <-+ either+ (const (die "shomei-admin: valid SHOMEI_TOTP_ENCRYPTION_KEY required"))+ pure+ (maybe (Left "missing") (totpEncryptionKeyFromBase64 . Text.pack) raw)+ let loop total conflicts = do+ (remaining, unknown) <- counts+ if unknown /= 0+ then die "shomei-admin: unknown TOTP envelope version"+ else+ if remaining == 0+ then putStrLn ("converted=" <> show total)+ else do+ converted <- migrateTotpEnvelopeBatch key env.pool >>= either (const (die "shomei-admin: TOTP batch failed; restore key/snapshot and retry; no unreadable row overwritten")) pure+ if converted == 0 && conflicts >= (2 :: Int)+ then die "shomei-admin: repeated conversion conflicts; verify all writers are stopped"+ else loop (total + converted) (if converted == 0 then conflicts + 1 else 0)+ loop (0 :: Int) 0++-- | Read a secret without placing it in argv. A terminal gets a no-echo prompt; redirected+-- stdin and files are consumed in full. Remove exactly one conventional line ending so+-- @printf 'secret\n'@ and a one-line secret file preserve every other byte of the password.+readPasswordSecret :: Maybe FilePath -> IO PlainPassword+readPasswordSecret source = do+ raw <- maybe readPasswordStdin TextIO.readFile source+ let password = dropOneTrailingNewline raw+ if Text.null password+ then do+ hPutStrLn stderr "shomei-admin: password must not be empty"+ exitFailure+ else pure (PlainPassword password)+ where+ readPasswordStdin = do+ interactive <- hIsTerminalDevice stdin+ if interactive+ then do+ hPutStr stderr "Password: "+ hFlush stderr+ hSetEcho stdin False+ secret <- TextIO.hGetLine stdin `finally` hSetEcho stdin True+ hPutStrLn stderr ""+ pure secret+ else TextIO.getContents++ dropOneTrailingNewline input =+ case Text.stripSuffix "\r\n" input of+ Just withoutCrLf -> withoutCrLf+ Nothing -> fromMaybe input (Text.stripSuffix "\n" input)
+ app/Main.hs view
@@ -0,0 +1,8 @@+-- | The single @shomei-server@ executable entry point; all logic lives in the+-- @shomei-server@ library (the boot sequence).+module Main (main) where++import Shomei.Server.Boot qualified as Boot++main :: IO ()+main = Boot.main
+ app/Shomei/Admin/Audit.hs view
@@ -0,0 +1,192 @@+-- | @shomei-admin audit@ (EP-7, M3): read the append-only security audit trail without a+-- @psql@ shell. The subcommands are thin wrappers over the shared+-- 'Shomei.Audit.Reader.Store' query layer (the same one the HTTP @GET \/v1\/admin\/audit\/events@+-- endpoint uses), run through the PostgreSQL interpreter over the admin pool:+--+-- @+-- shomei-admin audit events [--user USER_ID] [--session SESSION_ID] [--type T ...] [--since TS] [--until TS] [--limit N] [--json]+-- shomei-admin audit user \<TYPEID-or-UUID\> [filters] -- shortcut for --user+-- shomei-admin audit session \<TYPEID-or-UUID\> [filters] -- shortcut for --session+-- shomei-admin audit count [filters] -- how many events match+-- @+--+-- Default output is one tab-separated line per event+-- (@created_at \\t event_type \\t user_id \\t session_id \\t event_id@); @--json@ emits one JSON+-- object per line (NDJSON), including the raw event payload.+module Shomei.Admin.Audit+ ( AuditCommand (..),+ AuditFilters (..),+ auditParser,+ runAudit,+ runAuditReader,+ toQuery,+ toJsonObject,+ )+where++import Control.Monad (forM_)+import Data.Aeson (Value, encode, object, (.=))+import Data.ByteString.Lazy.Char8 qualified as BLC+import Data.List (intercalate)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Data.Time.Format.ISO8601 (iso8601ParseM, iso8601Show)+import Data.UUID (UUID)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, runEff)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Options.Applicative+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Audit.Reader.Postgres (runAuthEventReaderPostgres)+import Shomei.Audit.Reader.Store+ ( AuditEventQuery (..),+ AuthEventReader,+ StoredAuthEvent (..),+ countAuthEvents,+ emptyAuditQuery,+ queryAuthEvents,+ )+import Shomei.Error (AuthError)+import Shomei.Id (idText, parseId, sessionIdFromUUID, sessionIdToUUID, userIdFromUUID, userIdToUUID)+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.Persistence.Pool.Postgres (Pool)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- The command tree -----------------------------------------------------------++-- | Textual filters as they arrive from the command line (parsed/validated in 'toQuery').+data AuditFilters = AuditFilters+ { fUser :: !(Maybe Text),+ fSession :: !(Maybe Text),+ fTypes :: ![Text],+ fSince :: !(Maybe Text),+ fUntil :: !(Maybe Text),+ fLimit :: !Int,+ fJson :: !Bool+ }++data AuditCommand+ = AuditEvents AuditFilters+ | AuditUser Text AuditFilters+ | AuditSession Text AuditFilters+ | AuditCount AuditFilters++auditParser :: Parser AuditCommand+auditParser =+ hsubparser+ ( command "events" (info (AuditEvents <$> filtersParser) (progDesc "List audit events, most recent first"))+ <> command "user" (info (AuditUser <$> uuidArg "USER_ID" <*> filtersParser) (progDesc "Event timeline for one user"))+ <> command "session" (info (AuditSession <$> uuidArg "SESSION_ID" <*> filtersParser) (progDesc "Event timeline for one session"))+ <> command "count" (info (AuditCount <$> filtersParser) (progDesc "Count events matching the filters"))+ )+ where+ uuidArg mv = Text.pack <$> argument str (metavar mv)++filtersParser :: Parser AuditFilters+filtersParser =+ AuditFilters+ <$> optional (txt (long "user" <> metavar "USER_ID" <> help "Canonical user TypeID or legacy UUID"))+ <*> optional (txt (long "session" <> metavar "SESSION_ID" <> help "Canonical session TypeID or legacy UUID"))+ <*> many (txt (long "type" <> metavar "EVENT_TYPE" <> help "Filter by event type (repeatable)"))+ <*> optional (txt (long "since" <> metavar "ISO8601" <> help "Only events at or after this time (inclusive)"))+ <*> optional (txt (long "until" <> metavar "ISO8601" <> help "Only events strictly before this time"))+ <*> option auto (long "limit" <> metavar "N" <> value 50 <> showDefault <> help "Max rows (clamped to 1000)")+ <*> switch (long "json" <> help "Emit one JSON object per line (NDJSON)")+ where+ txt = fmap Text.pack . strOption++-- Execution ------------------------------------------------------------------++runAudit :: AdminEnv -> AuditCommand -> IO ()+runAudit env = \case+ AuditEvents f -> listEvents env f+ AuditUser uuid f -> listEvents env f {fUser = Just uuid}+ AuditSession uuid f -> listEvents env f {fSession = Just uuid}+ AuditCount f -> countEvents env f++listEvents :: AdminEnv -> AuditFilters -> IO ()+listEvents env f = do+ q <- toQuery f+ rows <- runReaderOrDie env.pool (queryAuthEvents q)+ if f.fJson+ then forM_ rows (BLC.putStrLn . encode . toJsonObject)+ else forM_ rows (putStrLn . tabLine)++countEvents :: AdminEnv -> AuditFilters -> IO ()+countEvents env f = do+ q <- toQuery f+ n <- runReaderOrDie env.pool (countAuthEvents q)+ print n++-- | Tab-separated line: @created_at \\t event_type \\t user_id \\t session_id \\t event_id@.+tabLine :: StoredAuthEvent -> String+tabLine s =+ intercalate+ "\t"+ [ iso8601Show s.storedCreatedAt,+ Text.unpack s.storedEventType,+ maybe "-" (Text.unpack . idText . userIdFromUUID) s.storedUserId,+ maybe "-" (Text.unpack . idText . sessionIdFromUUID) s.storedSessionId,+ UUID.toString s.storedEventId+ ]++toJsonObject :: StoredAuthEvent -> Value+toJsonObject s =+ object+ [ "eventId" .= UUID.toText s.storedEventId,+ "eventType" .= s.storedEventType,+ "userId" .= fmap (idText . userIdFromUUID) s.storedUserId,+ "sessionId" .= fmap (idText . sessionIdFromUUID) s.storedSessionId,+ "createdAt" .= iso8601Show s.storedCreatedAt,+ "payload" .= s.storedPayload+ ]++-- | Validate the textual filters into an 'AuditEventQuery' (a bad typed ID/UUID/timestamp aborts).+toQuery :: AuditFilters -> IO AuditEventQuery+toQuery f = do+ user <- parseIdentityOpt (fmap userIdToUUID . parseId) "--user" f.fUser+ session <- parseIdentityOpt (fmap sessionIdToUUID . parseId) "--session" f.fSession+ since <- parseTimeOpt "--since" f.fSince+ until_ <- parseTimeOpt "--until" f.fUntil+ pure+ emptyAuditQuery+ { queryUserId = user,+ querySessionId = session,+ queryEventTypes = f.fTypes,+ querySince = since,+ queryUntil = until_,+ queryLimit = f.fLimit+ }++parseIdentityOpt :: (Text -> Either Text UUID) -> String -> Maybe Text -> IO (Maybe UUID)+parseIdentityOpt _ _ Nothing = pure Nothing+parseIdentityOpt typed nm (Just raw) = case typed raw of+ Right uuid -> pure (Just uuid)+ Left _ -> maybe (die (nm <> ": expected a canonical typed id or legacy UUID")) (pure . Just) (UUID.fromText raw)++parseTimeOpt :: String -> Maybe Text -> IO (Maybe UTCTime)+parseTimeOpt _ Nothing = pure Nothing+parseTimeOpt nm (Just t) =+ maybe (die (nm <> ": invalid ISO-8601 timestamp: " <> Text.unpack t)) (pure . Just) (iso8601ParseM (Text.unpack t))++-- | Run a read over the 'AuthEventReader' PostgreSQL interpreter on the admin pool, aborting+-- on an infrastructure error. Mirrors @Shomei.Admin.Users.runSignup@'s minimal stack.+runAuditReader ::+ Pool ->+ Eff '[AuthEventReader, Database, Error AuthError, IOE] a ->+ IO (Either AuthError a)+runAuditReader pool =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runAuthEventReaderPostgres++runReaderOrDie :: Pool -> Eff '[AuthEventReader, Database, Error AuthError, IOE] a -> IO a+runReaderOrDie pool act = do+ res <- runAuditReader pool act+ either (\e -> die ("database error: " <> show e)) pure res++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure
+ app/Shomei/Admin/Env.hs view
@@ -0,0 +1,73 @@+-- | The configuration/assembly for @shomei-admin@.+--+-- A single stable entry point ('loadAdminEnv') reads @DATABASE_URL@, loads the same core Dhall+-- and environment policy as the server, and acquires a @hasql@ pool.+--+-- It deliberately skips server-only listen and pool validation. Every core field an admin+-- workflow consumes still comes from the deployment's real policy.+module Shomei.Admin.Env+ ( AdminEnv (..),+ loadAdminEnv,+ )+where++import Data.Foldable (for_)+import Data.Text (Text)+import Data.Text qualified as Text+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), argon2HardFloor, defaultArgon2Params)+import Shomei.Config (ShomeiConfig)+import Shomei.Persistence.Pool.Postgres (Pool, acquirePool)+import Shomei.Server.Config (defaultDbStatementTimeoutMs, loadCoreConfig)+import System.Environment (lookupEnv)+import Text.Read (readMaybe)++data AdminEnv = AdminEnv+ { config :: !ShomeiConfig,+ pool :: !Pool,+ connStr :: !Text,+ -- | Argon2id cost parameters for @users create@. Read from the same @SHOMEI_ARGON2_*@+ -- variables the server uses, so a password seeded by the CLI is hashed exactly as one+ -- created through @POST \/v1\/auth\/signup@ would be.+ argon2 :: !Argon2Params+ }++loadAdminEnv :: IO AdminEnv+loadAdminEnv = do+ cs <- requireEnv "DATABASE_URL"+ cfg <- loadCoreConfig+ params <- argon2FromEnv+ p <- acquirePool 4 10 defaultDbStatementTimeoutMs cs+ pure AdminEnv {config = cfg, pool = p, connStr = cs, argon2 = params}++argon2FromEnv :: IO Argon2Params+argon2FromEnv = do+ mem <- intEnvOr "SHOMEI_ARGON2_MEMORY_KIB" defaultArgon2Params.memoryKiB+ iters <- intEnvOr "SHOMEI_ARGON2_ITERATIONS" defaultArgon2Params.iterations+ lanes <- intEnvOr "SHOMEI_ARGON2_PARALLELISM" defaultArgon2Params.parallelism+ let params = Argon2Params {memoryKiB = mem, iterations = iters, parallelism = lanes}+ for_ (argon2HardFloor params) \why ->+ ioError+ ( userError+ ( "SHOMEI_ARGON2_MEMORY_KIB/SHOMEI_ARGON2_ITERATIONS/SHOMEI_ARGON2_PARALLELISM "+ <> "are rejected by the Argon2 implementation: "+ <> Text.unpack why+ )+ )+ pure params++intEnvOr :: Text -> Int -> IO Int+intEnvOr name def = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Just v | not (null v) -> case readMaybe v of+ Just n | n > 0 -> pure n+ _ -> ioError (userError (Text.unpack name <> " must be a positive integer"))+ _ -> pure def++requireEnv :: Text -> IO Text+requireEnv name = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Just v+ | let stripped = Text.strip (Text.pack v), not (Text.null stripped) -> pure stripped+ _ -> ioError (userError (Text.unpack name <> " is not set"))
+ app/Shomei/Admin/Keys.hs view
@@ -0,0 +1,349 @@+-- | The signing-key lifecycle for @shomei-admin@ (EP-4, M2): generate → activate (old key+-- auto-retires) → retire → revoke, plus the two read helpers @keys list@ and the+-- publishable-key read used to build a JWKS during rotation.+--+-- Key state is read and written with binary-local @hasql@ statements rather than the+-- 'Shomei.SigningKey.Store' effect, because (a) the effect's @ListActiveSigningKeys@ only+-- returns @active@ keys, but rotation needs to publish @active@ AND @retired@ keys, and (b) a CLI+-- benefits from one consistent, explicit SQL style with no effect-stack assembly. We deliberately+-- do NOT reuse @shomei-jwt@'s MasterPlan-1 @rotateSigningKey@ (which inserts new keys directly as+-- @active@); the CLI drives the fuller @pending → active → retired → revoked@ lifecycle, reusing+-- only key generation and the 'StoredSigningKey' conversion from @shomei-jwt@.+module Shomei.Admin.Keys+ ( keysGenerate,+ keysActivate,+ keysRetire,+ keysRevoke,+ keysList,+ keysRewrap,+ listPublishableSigningKeys,+ listAllKeys,+ summarizeUsageError,+ )+where++import Contravariant.Extras (contrazip2, contrazip9)+import Control.Monad (forM, forM_, unless)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime, getCurrentTime)+import Effectful (runEff)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Errors qualified as Hasql+import Hasql.Session (Session)+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Shomei.Persistence.Codec.Postgres (signingKeyStatusFromText, signingKeyStatusToText)+import Shomei.Persistence.Database.Postgres (runDatabasePool, runTransaction)+import Shomei.Persistence.Pool.Postgres (Pool)+import Shomei.Persistence.Pool.Postgres qualified as Pool+import Shomei.Persistence.Transaction.Postgres (Transaction)+import Shomei.Persistence.Transaction.Postgres qualified as Tx+import Shomei.Server.DatabaseError (summarizeUsageError)+import Shomei.SigningKey.Domain (SigningAlgorithm, SigningKeyStatus (..), StoredSigningKey (..), signingAlgorithmToText)+import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, toStoredSigningKeyFor)+import Shomei.SigningKey.Protection.Jwt+ ( KeyEncryptionKey,+ decryptPrivateJwk,+ encryptPrivateJwk,+ protectStoredSigningKey,+ )+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- Public actions -------------------------------------------------------------++-- | Mint a new key for @alg@ in @pending@ status and print its @kid@. When a+-- key-encryption key is configured, the private material is encrypted before it is written,+-- so a rotation never introduces a plaintext row into an encrypted table.+keysGenerate :: KeyEncryptionKey -> SigningAlgorithm -> Pool -> IO ()+keysGenerate kek alg pool = do+ now <- getCurrentTime+ jwk <- generateSigningKeyFor alg+ stored <- either (die . Text.unpack) pure (toStoredSigningKeyFor alg now jwk)+ pending <-+ protectStoredSigningKey+ kek+ stored+ { status = KeyPending,+ activatedAt = Nothing,+ retiredAt = Nothing,+ revokedAt = Nothing+ }+ runSess pool (Session.statement (keyRow pending) insertKeyStmt)+ putStrLn ("generated pending " <> Text.unpack (signingAlgorithmToText alg) <> " key: " <> Text.unpack pending.keyId)++-- | Promote a @pending@ key to @active@ and demote every prior @active@ key to @retired@,+-- atomically at one timestamp. Refuses if the key is not @pending@.+keysActivate :: Pool -> Text -> IO ()+keysActivate pool kid = do+ mk <- runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe+ key <- requireKey kid mk+ unless (key.status == KeyPending) (die ("key " <> Text.unpack kid <> " is " <> show key.status <> ", expected pending"))+ now <- getCurrentTime+ outcome <- runTx pool do+ pending <- Tx.statement kid lockPendingStmt+ case pending of+ Nothing -> pure Nothing+ Just _ -> do+ oldKids <- Tx.statement now retireActiveStmt+ Tx.statement (kid, now) setActiveStmt+ pure (Just oldKids)+ retired <- maybe (die "key is no longer pending; no activation was applied") pure outcome+ putStrLn ("activated " <> Text.unpack kid)+ forM_ retired \oldKid -> putStrLn ("retired (auto) " <> Text.unpack oldKid)++-- | Demote an @active@ key to @retired@ (still trusted in the JWKS), stamping @retired_at@.+keysRetire :: Pool -> Text -> IO ()+keysRetire pool kid = do+ key <- requireKey kid =<< (runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe)+ unless (key.status == KeyActive) (die ("key " <> Text.unpack kid <> " is " <> show key.status <> "; cannot retire"))+ now <- getCurrentTime+ runSess pool (Session.statement (kid, now) setRetiredStmt)+ putStrLn ("retired " <> Text.unpack kid)++-- | Mark a key @revoked@ (immediately untrusted, removed from the JWKS). Allowed from any+-- state except @revoked@.+keysRevoke :: Pool -> Text -> IO ()+keysRevoke pool kid = do+ key <- requireKey kid =<< (runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe)+ unless (key.status `elem` [KeyPending, KeyActive, KeyRetired]) (die ("key " <> Text.unpack kid <> " is already revoked"))+ now <- getCurrentTime+ runSess pool (Session.statement (kid, now) setRevokedStmt)+ putStrLn ("revoked " <> Text.unpack kid)++-- | Re-wrap every key under a new KEK: decrypt with @oldKek@, encrypt with @newKek@. Any+-- All-or-nothing: the full decrypt pass runs in memory /before/ the first write, so a wrong+-- @SHOMEI_KEY_ENCRYPTION_KEY_OLD@ aborts having modified nothing. (A half-rewrapped table+-- would be unreadable by either KEK.)+keysRewrap :: KeyEncryptionKey -> KeyEncryptionKey -> Pool -> IO ()+keysRewrap oldKek newKek pool = do+ keys <- listAllKeys pool+ -- Pass 1: decrypt everything, or die before touching a row.+ decrypted <- traverse decryptOne keys+ -- Pass 2: re-encrypt every row before opening the all-or-nothing write transaction.+ rewrapped <- forM decrypted \(k, plain) -> do+ enc <- encryptPrivateJwk newKek k.keyId plain+ pure (k.keyId, enc)+ runTx pool (forM_ rewrapped \row -> Tx.statement row setPrivateKeyStmt)+ putStrLn ("rewrapped " <> show (length decrypted) <> " key(s)")+ where+ decryptOne k =+ case decryptPrivateJwk oldKek k.keyId k.privateKeyJwk of+ Right plain -> pure (k, plain)+ Left err ->+ die+ ( "cannot decrypt key "+ <> Text.unpack k.keyId+ <> " with SHOMEI_KEY_ENCRYPTION_KEY_OLD ("+ <> show err+ <> "); no rows were modified"+ )++-- | Print every key with kid / status / timestamps.+keysList :: Pool -> IO ()+keysList pool = do+ keys <- listAllKeys pool+ if null keys+ then putStrLn "(no signing keys)"+ else forM_ keys \k ->+ putStrLn+ ( Text.unpack k.keyId+ <> "\t"+ <> show k.status+ <> "\tcreated="+ <> show k.createdAt+ <> "\tactivated="+ <> show k.activatedAt+ <> "\tretired="+ <> show k.retiredAt+ <> "\trevoked="+ <> show k.revokedAt+ )++-- Read helpers (also used by the integration tests) --------------------------++-- | Keys that belong in the published JWKS: @active@ and @retired@ (overlap during rotation).+listPublishableSigningKeys :: Pool -> IO [StoredSigningKey]+listPublishableSigningKeys pool = runSess pool (Session.statement () listPublishableStmt) >>= traverse rebuild++listAllKeys :: Pool -> IO [StoredSigningKey]+listAllKeys pool = runSess pool (Session.statement () listAllStmt) >>= traverse rebuild++-- Internals ------------------------------------------------------------------++requireKey :: Text -> Maybe StoredSigningKey -> IO StoredSigningKey+requireKey kid = maybe (die ("no such key: " <> Text.unpack kid)) pure++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure++runSess :: Pool -> Session a -> IO a+runSess pool s = do+ res <- Pool.use pool s+ either (\e -> die (Text.unpack (summarizeUsageError e))) pure res++runTx :: Pool -> Transaction a -> IO a+runTx pool transaction = do+ res <- runEff . runDatabasePool pool $ runTransaction transaction+ case res of+ Right value -> pure value+ Left err+ | uniqueViolation err ->+ die "another key became active concurrently; run keys list and retry"+ | otherwise -> die (Text.unpack (summarizeUsageError err))+ where+ uniqueViolation = \case+ Pool.SessionUsageError+ ( Hasql.StatementSessionError+ _+ _+ _+ _+ _+ (Hasql.ServerStatementError (Hasql.ServerError "23505" _ _ _ _))+ ) -> True+ _ -> False++rebuild :: KeyRow -> IO StoredSigningKey+rebuild r = either (\e -> die ("corrupt key row: " <> Text.unpack e)) pure (rebuildKey r)++rebuildMaybe :: Maybe KeyRow -> IO (Maybe StoredSigningKey)+rebuildMaybe = traverse rebuild++-- Row mapping and statements (mirror Shomei.SigningKey.Postgres) ---------++type KeyRow = (Text, Text, Text, Text, Text, UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime)++keyRow :: StoredSigningKey -> KeyRow+keyRow k =+ ( k.keyId,+ k.algorithm,+ k.publicKeyJwk,+ k.privateKeyJwk,+ signingKeyStatusToText k.status,+ k.createdAt,+ k.activatedAt,+ k.retiredAt,+ k.revokedAt+ )++rebuildKey :: KeyRow -> Either Text StoredSigningKey+rebuildKey (kid, alg, pub, priv, st, c, act, ret, rev) = do+ status <- signingKeyStatusFromText st+ pure StoredSigningKey {keyId = kid, algorithm = alg, publicKeyJwk = pub, privateKeyJwk = priv, status = status, createdAt = c, activatedAt = act, retiredAt = ret, revokedAt = rev}++keyRowDecoder :: D.Row KeyRow+keyRowDecoder =+ (,,,,,,,,)+ <$> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.text)+ <*> D.column (D.nonNullable D.timestamptz)+ <*> D.column (D.nullable D.timestamptz)+ <*> D.column (D.nullable D.timestamptz)+ <*> D.column (D.nullable D.timestamptz)++findByKidStmt :: Statement Text (Maybe KeyRow)+findByKidStmt =+ preparable+ """+ SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at+ FROM shomei.shomei_signing_keys WHERE key_id = $1+ """+ (E.param (E.nonNullable E.text))+ (D.rowMaybe keyRowDecoder)++listAllStmt :: Statement () [KeyRow]+listAllStmt =+ preparable+ """+ SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at+ FROM shomei.shomei_signing_keys ORDER BY created_at+ """+ E.noParams+ (D.rowList keyRowDecoder)++listPublishableStmt :: Statement () [KeyRow]+listPublishableStmt =+ preparable+ """+ SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at+ FROM shomei.shomei_signing_keys WHERE status IN ('active','retired') ORDER BY created_at+ """+ E.noParams+ (D.rowList keyRowDecoder)++insertKeyStmt :: Statement KeyRow ()+insertKeyStmt =+ preparable+ """+ INSERT INTO shomei.shomei_signing_keys+ (key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at)+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)+ """+ ( contrazip9+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.text))+ (E.param (E.nonNullable E.timestamptz))+ (E.param (E.nullable E.timestamptz))+ (E.param (E.nullable E.timestamptz))+ (E.param (E.nullable E.timestamptz))+ )+ D.noResult++-- | Replace a key's private material (encrypt-at-rest backfill and KEK rewrap). Never+-- touches @public_key_jwk@ or @status@.+setPrivateKeyStmt :: Statement (Text, Text) ()+setPrivateKeyStmt =+ preparable+ "UPDATE shomei.shomei_signing_keys SET private_key_jwk = $2 WHERE key_id = $1"+ (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))+ D.noResult++setActiveStmt :: Statement (Text, UTCTime) ()+setActiveStmt =+ preparable+ "UPDATE shomei.shomei_signing_keys SET status = 'active', activated_at = $2 WHERE key_id = $1"+ (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))+ D.noResult++setRetiredStmt :: Statement (Text, UTCTime) ()+setRetiredStmt =+ preparable+ "UPDATE shomei.shomei_signing_keys SET status = 'retired', retired_at = $2 WHERE key_id = $1"+ (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))+ D.noResult++retireActiveStmt :: Statement UTCTime [Text]+retireActiveStmt =+ preparable+ """+ UPDATE shomei.shomei_signing_keys+ SET status = 'retired', retired_at = $1+ WHERE status = 'active'+ RETURNING key_id+ """+ (E.param (E.nonNullable E.timestamptz))+ (D.rowList (D.column (D.nonNullable D.text)))++setRevokedStmt :: Statement (Text, UTCTime) ()+setRevokedStmt =+ preparable+ "UPDATE shomei.shomei_signing_keys SET status = 'revoked', revoked_at = $2 WHERE key_id = $1"+ (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))+ D.noResult++-- Revalidate and lock the source state inside the same transaction as retirement.+lockPendingStmt :: Statement Text (Maybe Text)+lockPendingStmt =+ preparable+ "SELECT key_id FROM shomei.shomei_signing_keys WHERE key_id=$1 AND status='pending' FOR UPDATE"+ (E.param (E.nonNullable E.text))+ (D.rowMaybe (D.column (D.nonNullable D.text)))
+ app/Shomei/Admin/OAuthClients.hs view
@@ -0,0 +1,346 @@+-- | @shomei-admin oauth-clients@ (MasterPlan 7 EP-5): register, list, and revoke the OAuth2 \/+-- OIDC relying parties that drive the authorization-code flow.+--+-- @+-- shomei-admin oauth-clients create --display-name TEXT --type confidential|public+-- --redirect-uri URI [--redirect-uri URI]...+-- [--scope S]...+-- shomei-admin oauth-clients list+-- shomei-admin oauth-clients revoke \<client_id\>+-- @+--+-- Registration is static — this CLI managing database rows — and there is no dynamic-registration+-- endpoint (RFC 7591). Nor are clients declarable in the config file: that would recreate exactly+-- the dual-source situation EP-4 is deprecating for service accounts.+--+-- __A confidential client's secret is generated here and printed exactly once.__ Only its SHA-256+-- digest is persisted. There is no @rotate-secret@ in this plan: unlike a service account, an+-- OAuth client's credential is held by an application an operator also controls, and a compromised+-- one is handled by revoking and re-registering. (Add rotation here if that proves wrong.)+--+-- A __public__ client (a browser SPA, a native or CLI app) has no secret at all. Its only binding+-- between the authorize and the token request is PKCE, which is therefore mandatory for it.+--+-- Unlike a service account, an OAuth client gets __no backing @shomei_users@ row__: it is never a+-- token subject. The token it exchanges a code for belongs to whichever user authenticated at+-- @\/oauth\/authorize@.+module Shomei.Admin.OAuthClients+ ( OAuthClientsCommand (..),+ oauthClientsParser,+ runOAuthClients,++ -- * Actions++ --++ -- | The subcommands as functions that /return/ what they did, with printing left to+ -- 'runOAuthClients'. Exported so the integration suite can assert on the generated secret+ -- without capturing the process's stdout — which is not safe to do in a test runner that+ -- executes cases in parallel (an EP-4 discovery).+ createAction,+ revokeAction,+ listAction,+ )+where++import Control.Monad (forM_, unless, when)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Effectful (Eff, IOE, runEff)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Options.Applicative+import Shomei.Account.Password.Hash.Postgres (generateOpaqueToken)+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Audit.Event.Domain qualified as Event+import Shomei.Persistence.Pool.Postgres (Pool)++-- Read by /record pattern/ only, never through @OverloadedRecordDot@: 'OAuthClient' shares+-- @clientId@ / @displayName@ / @createdAt@ / @status@ with 'Shomei.ServiceAccount.Domain'.++import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)+import Shomei.Authorization.Claims.Domain (Scope (..))+import Shomei.Error (AuthError)+import Shomei.Id (OAuthClientId, genOAuthClientId, idText)+import Shomei.OAuth.Client.Domain+ ( ClientType (..),+ NewOAuthClient (..),+ OAuthClient (..),+ OAuthClientStatus (..),+ )+import Shomei.OAuth.Client.Postgres (runOAuthClientStorePostgres)+import Shomei.OAuth.Client.Store+ ( OAuthClientStore,+ findOAuthClientByClientId,+ listOAuthClients,+ revokeOAuthClient,+ )+import Shomei.OAuth.Client.Workflow (ClientRegistrationError (..), registerOAuthClient)+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.ServiceAccount.Secret (sha256Hex)+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock, now)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- The command tree -----------------------------------------------------------++data OAuthClientsCommand+ = -- | display name, client type, redirect URIs, allowed scopes+ OAuthClientsCreate Text ClientType [Text] [Text]+ | OAuthClientsRevoke Text+ | OAuthClientsList++oauthClientsParser :: Parser OAuthClientsCommand+oauthClientsParser =+ hsubparser+ ( command "create" (info createOpts (progDesc "Register an OAuth2/OIDC client; prints a confidential client's secret once"))+ <> command "revoke" (info (OAuthClientsRevoke <$> clientIdArg) (progDesc "Refuse all future authorize and token requests for this client"))+ <> command "list" (info (pure OAuthClientsList) (progDesc "Show every OAuth client (never its secret hash)"))+ )+ where+ createOpts =+ OAuthClientsCreate+ <$> (Text.pack <$> strOption (long "display-name" <> metavar "TEXT" <> help "Human label, e.g. \"grafana\""))+ <*> option+ readClientType+ ( long "type"+ <> metavar "confidential|public"+ <> help "confidential: can keep a secret. public: cannot (SPA, native app); PKCE is then mandatory"+ )+ <*> many (Text.pack <$> strOption (long "redirect-uri" <> metavar "URI" <> help "An exact redirect target; repeatable. Matched by exact string equality"))+ <*> many (Text.pack <$> strOption (long "scope" <> metavar "SCOPE" <> help "A scope this client may request; repeatable"))+ clientIdArg = Text.pack <$> argument str (metavar "CLIENT_ID")++readClientType :: ReadM ClientType+readClientType = eitherReader \case+ "confidential" -> Right ConfidentialClient+ "public" -> Right PublicClient+ other -> Left ("must be confidential|public, got " <> other)++-- Field accessors ------------------------------------------------------------++ocId :: OAuthClient -> OAuthClientId+ocId OAuthClient {oauthClientId} = oauthClientId++ocClientId :: OAuthClient -> Text+ocClientId OAuthClient {clientId} = clientId++ocClientType :: OAuthClient -> ClientType+ocClientType OAuthClient {clientType} = clientType++ocDisplayName :: OAuthClient -> Text+ocDisplayName OAuthClient {displayName} = displayName++ocRedirectUris :: OAuthClient -> [Text]+ocRedirectUris OAuthClient {redirectUris} = redirectUris++ocAllowedScopes :: OAuthClient -> Set Scope+ocAllowedScopes OAuthClient {allowedScopes} = allowedScopes++ocStatus :: OAuthClient -> OAuthClientStatus+ocStatus OAuthClient {status} = status++ocCreatedAt :: OAuthClient -> UTCTime+ocCreatedAt OAuthClient {createdAt} = createdAt++-- Execution ------------------------------------------------------------------++-- | Register a client, returning it and — for a confidential client — the generated secret.+-- The secret is returned, never re-read: only its digest is persisted.+createAction :: AdminEnv -> Text -> ClientType -> [Text] -> [Text] -> IO (OAuthClient, Maybe Text)+createAction env displayName clientType rawUris rawScopes = do+ redirectUris <- parseRedirectUris rawUris+ when (Text.length displayName > 256) (die "display name exceeds 256 characters")+ scopes <- parseScopes rawScopes+ -- A public client is issued no secret at all, rather than one that is ignored: a credential+ -- that exists but is never checked is worse than none, because an operator will store it.+ mSecret <- case clientType of+ ConfidentialClient -> Just <$> generateOpaqueToken+ PublicClient -> pure Nothing+ registration <- runOrDie env.pool do+ ocid <- genOAuthClientId+ let cid = idText ocid+ ts <- now+ result <-+ registerOAuthClient+ env.config+ NewOAuthClient+ { oauthClientId = ocid,+ clientId = cid,+ secretHash = sha256Hex <$> mSecret,+ clientType,+ displayName,+ redirectUris,+ allowedScopes = scopes,+ createdAt = ts+ }+ case result of+ Left refused -> pure (Left refused)+ Right client -> do+ publishAuthEvent+ ( Event.OAuthClientCreated+ Event.OAuthClientCreatedData+ { oauthClientId = cid,+ clientId = cid,+ clientType = renderClientType clientType,+ displayName,+ redirectUris,+ allowedScopes = scopes,+ occurredAt = ts+ }+ )+ pure (Right client)+ client <- case registration of+ Left (PrivilegeScopesRefused refused) -> case Set.toList refused of+ [Scope scope] ->+ die+ ( "refused: "+ <> Text.unpack scope+ <> " is a privilege scope and cannot be registered on an OAuth client (it would make every user who authorizes through it hold it); grant it to a service account instead"+ )+ _ ->+ die+ ( "refused: "+ <> renderScopes refused+ <> " are privilege scopes and cannot be registered on an OAuth client (they would make every user who authorizes through it hold them); grant them to a service account instead"+ )+ Right registered -> pure registered+ pure (client, mSecret)++revokeAction :: AdminEnv -> Text -> IO OAuthClient+revokeAction env cid = do+ client <- requireClient env cid+ runOrDie env.pool do+ ts <- now+ revokeOAuthClient (ocId client) ts+ publishAuthEvent+ ( Event.OAuthClientRevoked+ Event.OAuthClientRevokedData+ { oauthClientId = idText (ocId client),+ clientId = ocClientId client,+ occurredAt = ts+ }+ )+ pure client++listAction :: AdminEnv -> IO [OAuthClient]+listAction env = runOrDie env.pool listOAuthClients++runOAuthClients :: AdminEnv -> OAuthClientsCommand -> IO ()+runOAuthClients env = \case+ OAuthClientsCreate displayName clientType rawUris rawScopes -> do+ (client, mSecret) <- createAction env displayName clientType rawUris rawScopes+ putStrLn ("client_id: " <> Text.unpack (ocClientId client))+ case mSecret of+ Just secret ->+ putStrLn ("client_secret: " <> Text.unpack secret <> " (shown once - store it now, it cannot be retrieved)")+ Nothing ->+ putStrLn "client_secret: (none - public client; PKCE with S256 is required at /oauth/authorize)"+ putStrLn ("redirect_uris: " <> Text.unpack (Text.unwords (ocRedirectUris client)))+ putStrLn ("scopes: " <> renderScopes (ocAllowedScopes client))+ OAuthClientsRevoke cid -> do+ client <- revokeAction env cid+ putStrLn ("revoked " <> Text.unpack (ocClientId client))+ -- Access tokens are stateless JWTs; revoking the client stops new grants, not old tokens.+ putStrLn "note: tokens already issued through this client remain valid until they expire"+ OAuthClientsList -> do+ clients <- listAction env+ forM_ clients \c ->+ putStrLn+ ( Text.unpack (ocClientId c)+ <> " "+ <> renderStatus (ocStatus c)+ <> " "+ <> renderClientTypePadded (ocClientType c)+ <> " "+ <> Text.unpack (ocDisplayName c)+ <> " redirect_uris=["+ <> Text.unpack (Text.unwords (ocRedirectUris c))+ <> "] scopes=["+ <> renderScopes (ocAllowedScopes c)+ <> "] created="+ <> show (ocCreatedAt c)+ )++-- | Resolve a @client_id@ or exit 1, so a typo fails loudly instead of updating zero rows.+requireClient :: AdminEnv -> Text -> IO OAuthClient+requireClient env cid = do+ found <- runOrDie env.pool (findOAuthClientByClientId cid)+ maybe (die ("no oauth client with client_id " <> Text.unpack cid)) pure found++renderStatus :: OAuthClientStatus -> String+renderStatus = \case+ OAuthClientActive -> "active "+ OAuthClientRevoked -> "revoked"++renderClientType :: ClientType -> Text+renderClientType = \case+ ConfidentialClient -> "confidential"+ PublicClient -> "public"++renderClientTypePadded :: ClientType -> String+renderClientTypePadded = \case+ ConfidentialClient -> "confidential"+ PublicClient -> "public "++renderScopes :: Set Scope -> String+renderScopes scopes = Text.unpack (Text.unwords [s | Scope s <- Set.toList scopes])++-- | Scopes are trimmed and must be non-blank; a scope containing whitespace could never be+-- expressed in the space-delimited OAuth2 @scope@ parameter.+parseScopes :: [Text] -> IO (Set Scope)+parseScopes raws = Set.fromList <$> traverse one raws+ where+ one raw =+ let trimmed = Text.strip raw+ in if Text.null trimmed || Text.any (== ' ') trimmed+ then die "invalid scope (must be non-blank, contain no spaces, and have at most 128 characters)"+ else pure (Scope trimmed)++-- | At least one redirect URI, each absolute and fragment-free.+--+-- A client with no registered URI could never complete a flow, and RFC 6749 §3.1.2 requires the+-- URI to be absolute and to carry no fragment (the fragment is where an implicit-flow response+-- would land, and the browser never sends it to the server anyway).+parseRedirectUris :: [Text] -> IO [Text]+parseRedirectUris raws = do+ uris <- traverse one raws+ unless (not (null uris)) (die "at least one --redirect-uri is required")+ pure uris+ where+ one raw =+ let trimmed = Text.strip raw+ in if not (any (`Text.isPrefixOf` trimmed) ["http://", "https://"])+ then die ("invalid redirect-uri: " <> show raw <> " (must be an absolute http(s) URL)")+ else+ if Text.any (== '#') trimmed+ then die ("invalid redirect-uri: " <> show raw <> " (must carry no fragment)")+ else pure trimmed++-- | The minimal chain these commands need. No @UserStore@: an OAuth client has no backing user.+runOAuthClientsEff ::+ Pool ->+ Eff '[OAuthClientStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO (Either AuthError a)+runOAuthClientsEff pool =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runAuthEventPublisherPostgres+ . runOAuthClientStorePostgres++runOrDie ::+ Pool ->+ Eff '[OAuthClientStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO a+runOrDie pool act = do+ res <- runOAuthClientsEff pool act+ either (\e -> die ("database error: " <> show e)) pure res++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure
+ app/Shomei/Admin/Roles.hs view
@@ -0,0 +1,349 @@+-- | @shomei-admin roles@ (MasterPlan 7 EP-1, extended by EP-9): declare roles, wire permissions+-- onto them, and grant them (optionally for a bounded window) to users — all without the HTTP+-- server running. This is the bootstrap path for the very first administrator: the @\/admin@+-- surface is gated on the @admin@ role, so something outside HTTP has to grant it.+--+-- @+-- shomei-admin roles define \<NAME\> [--description TEXT]+-- shomei-admin roles list-defined+-- shomei-admin roles allow \<ROLE\> \<PERMISSION\>+-- shomei-admin roles disallow \<ROLE\> \<PERMISSION\>+-- shomei-admin roles show \<ROLE\>+-- shomei-admin roles grant --user \<user_… | UUID\> --role NAME [--expires-in \<dur\> | --expires-at \<ISO8601\>]+-- shomei-admin roles revoke --user \<user_… | UUID\> --role NAME+-- shomei-admin roles list --user \<user_… | UUID\>+-- @+--+-- Granting a role that is not in the registry fails loudly (exit 1) rather than minting a role+-- no gate will ever check — that is what @roles define@ exists for. Likewise @allow@ refuses a+-- permission on an undefined role.+--+-- @define@, @list-defined@, @allow@, @disallow@, and @show@ talk to the+-- 'Shomei.Authorization.Role.Store.RoleStore' port directly: catalog metadata (including permission+-- wiring) is not a security event, so they publish no audit event. @grant@ and @revoke@ go+-- through 'Shomei.Authorization.Role.Workflow', which audits them as @role_granted@ / @role_revoked@ with a+-- @NULL@ actor (there is no authenticated admin on the box); a time-bound grant records its+-- expiry in the @role_granted@ payload.+module Shomei.Admin.Roles+ ( RolesCommand (..),+ GrantExpiry (..),+ rolesParser,+ runRoles,+ )+where++import Control.Monad (forM_)+import Data.Char (isDigit, isSpace)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)+import Data.Time.Format.ISO8601 (iso8601ParseM, iso8601Show)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Options.Applicative+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Account.User.Store (UserStore)+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher)+import Shomei.Authorization.Claims.Domain (Permission (..), Role (..))+import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)+import Shomei.Authorization.Role.Store+ ( RoleDefinition (..),+ RoleStore,+ allowPermission,+ defineRole,+ disallowPermission,+ listDefinedRoles,+ listPermissionsForRole,+ )+import Shomei.Authorization.Role.Workflow (grantRoleTo, revokeRoleFrom, rolesOf)+import Shomei.Error (AuthError (..))+import Shomei.Id (UserId, idText, parseId, userIdFromUUID)+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.Persistence.Pool.Postgres (Pool)+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock, now)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- The command tree -----------------------------------------------------------++-- | How a grant's expiry was specified on the command line, resolved to an absolute instant at+-- run time (see 'resolveExpiry'). The two forms are mutually exclusive.+data GrantExpiry+ = -- | @--expires-in \<n\>(s|m|h|d)@: relative to the CLI's clock at run time.+ ExpiresIn NominalDiffTime+ | -- | @--expires-at \<ISO8601 UTC\>@: an absolute instant.+ ExpiresAt UTCTime+ deriving stock (Eq, Show)++data RolesCommand+ = RolesDefine Text (Maybe Text)+ | RolesListDefined+ | RolesAllow Text Text+ | RolesDisallow Text Text+ | RolesShow Text+ | RolesGrant Text Text (Maybe GrantExpiry)+ | RolesRevoke Text Text+ | RolesList Text++rolesParser :: Parser RolesCommand+rolesParser =+ hsubparser+ ( command "define" (info defineOpts (progDesc "Declare a role so it can be granted"))+ <> command "list-defined" (info (pure RolesListDefined) (progDesc "Show the role registry"))+ <> command "allow" (info allowOpts (progDesc "Attach a permission to a role"))+ <> command "disallow" (info disallowOpts (progDesc "Detach a permission from a role"))+ <> command "show" (info showOpts (progDesc "Show a role and the permissions it implies"))+ <> command "grant" (info (RolesGrant <$> userOpt <*> roleOpt <*> optional expiryOpt) (progDesc "Grant a defined role to a user"))+ <> command "revoke" (info (RolesRevoke <$> userOpt <*> roleOpt) (progDesc "Revoke a role from a user"))+ <> command "list" (info (RolesList <$> userOpt) (progDesc "Show the roles granted to a user"))+ )+ where+ defineOpts =+ RolesDefine+ <$> (Text.pack <$> argument str (metavar "NAME"))+ <*> optional (Text.pack <$> strOption (long "description" <> metavar "TEXT" <> help "What the role is for"))+ allowOpts = RolesAllow <$> roleArg <*> permissionArg+ disallowOpts = RolesDisallow <$> roleArg <*> permissionArg+ showOpts = RolesShow <$> roleArg+ roleArg = Text.pack <$> argument str (metavar "ROLE")+ permissionArg = Text.pack <$> argument str (metavar "PERMISSION")+ userOpt =+ Text.pack+ <$> strOption (long "user" <> metavar "USER_ID" <> help "Typed id (user_…) or bare UUID")+ roleOpt =+ Text.pack <$> strOption (long "role" <> metavar "NAME" <> help "Role name")+ -- '--expires-in' and '--expires-at' are alternatives: supplying both leaves the second flag+ -- unconsumed and optparse reports an error, which is the mutually-exclusive behaviour we want.+ expiryOpt =+ (ExpiresIn <$> option durationReader (long "expires-in" <> metavar "DURATION" <> help "Grant expiry as <n>(s|m|h|d), e.g. 4h"))+ <|> (ExpiresAt <$> option iso8601Reader (long "expires-at" <> metavar "ISO8601" <> help "Grant expiry as an ISO8601 UTC instant"))++-- | @<n>(s|m|h|d)@ → 'NominalDiffTime'. A bare number, a bad unit, or trailing junk is rejected.+durationReader :: ReadM NominalDiffTime+durationReader =+ eitherReader \s ->+ maybe (Left ("expected <n>(s|m|h|d), e.g. 4h; got " <> show s)) Right (parseDuration s)++parseDuration :: String -> Maybe NominalDiffTime+parseDuration s = case span isDigit s of+ (digits@(_ : _), [unit]) -> do+ n <- readInteger digits+ mult <- case unit of+ 's' -> Just 1+ 'm' -> Just 60+ 'h' -> Just 3600+ 'd' -> Just 86400+ _ -> Nothing+ pure (fromInteger (n * mult))+ _ -> Nothing+ where+ readInteger ds = case reads ds of+ [(n, "")] -> Just (n :: Integer)+ _ -> Nothing++-- | An ISO8601 UTC timestamp → 'UTCTime' (e.g. @2026-07-11T21:00:00Z@).+iso8601Reader :: ReadM UTCTime+iso8601Reader =+ eitherReader \s ->+ maybe (Left ("expected an ISO8601 UTC instant, e.g. 2026-07-11T21:00:00Z; got " <> show s)) Right (iso8601ParseM s)++-- Execution ------------------------------------------------------------------++-- | Resolve a parsed 'GrantExpiry' to an absolute instant. @--expires-in@ is relative to the CLI's+-- clock now; @--expires-at@ is already absolute. 'Nothing' (no flag) means the grant never expires.+resolveExpiry :: Maybe GrantExpiry -> IO (Maybe UTCTime)+resolveExpiry Nothing = pure Nothing+resolveExpiry (Just (ExpiresAt t)) = pure (Just t)+resolveExpiry (Just (ExpiresIn d)) = Just . addUTCTime d <$> getCurrentTime++-- | The outcome of a permission-wiring command, so the boundary (IO) prints or dies without the+-- eff needing to.+data AllowOutcome = AllowRoleUndefined | NewlyAllowed | AlreadyAllowed++data DisallowOutcome = DisallowRoleUndefined | Detached | NotAttached++data ShowOutcome = ShowRoleUndefined | ShowRole RoleDefinition (Set.Set Permission)++runRoles :: AdminEnv -> RolesCommand -> IO ()+runRoles env = \case+ RolesDefine rawRole mDesc -> do+ role <- parseRole rawRole+ fresh <- runOrDie env.pool do+ ts <- now+ defineRole role mDesc ts+ putStrLn+ if fresh+ then "defined role " <> roleString role+ else "role " <> roleString role <> " was already defined"+ RolesListDefined -> do+ defs <- runOrDie env.pool listDefinedRoles+ forM_ defs \d ->+ putStrLn (roleString d.role <> maybe "" (\t -> " — " <> Text.unpack t) d.description)+ RolesAllow rawRole rawPerm -> do+ role <- parseRole rawRole+ perm <- parsePermission rawPerm+ outcome <- runOrDie env.pool (allowEff role perm)+ case outcome of+ AllowRoleUndefined -> dieAuthError (RoleNotDefined role)+ NewlyAllowed -> putStrLn ("allowed " <> permString perm <> " for role " <> roleString role)+ AlreadyAllowed -> putStrLn ("role " <> roleString role <> " already allowed " <> permString perm)+ RolesDisallow rawRole rawPerm -> do+ role <- parseRole rawRole+ perm <- parsePermission rawPerm+ outcome <- runOrDie env.pool (disallowEff role perm)+ case outcome of+ DisallowRoleUndefined -> dieAuthError (RoleNotDefined role)+ Detached -> putStrLn ("disallowed " <> permString perm <> " for role " <> roleString role)+ NotAttached -> putStrLn ("role " <> roleString role <> " did not allow " <> permString perm)+ RolesShow rawRole -> do+ role <- parseRole rawRole+ outcome <- runOrDie env.pool (showEff role)+ case outcome of+ ShowRoleUndefined -> dieAuthError (RoleNotDefined role)+ ShowRole d perms -> do+ putStrLn (roleString d.role <> maybe "" (\t -> " — " <> Text.unpack t) d.description)+ if Set.null perms+ then putStrLn " (no permissions)"+ else forM_ (Set.toList perms) \p -> putStrLn (" " <> permString p)+ RolesGrant rawUser rawRole mExpiry -> do+ uid <- parseUserRef rawUser+ role <- parseRole rawRole+ expiry <- resolveExpiry mExpiry+ outcome <- runOrDie env.pool (grantRoleTo Nothing expiry uid role)+ case outcome of+ Left e -> dieAuthError e+ Right True ->+ putStrLn+ ( "granted "+ <> roleString role+ <> " to "+ <> Text.unpack (idText uid)+ <> maybe "" (\t -> " (expires " <> iso8601Show t <> ")") expiry+ )+ Right False -> putStrLn ("user already had role " <> roleString role)+ RolesRevoke rawUser rawRole -> do+ uid <- parseUserRef rawUser+ role <- parseRole rawRole+ outcome <- runOrDie env.pool (revokeRoleFrom Nothing uid role)+ case outcome of+ Left e -> dieAuthError e+ Right True -> putStrLn ("revoked " <> roleString role <> " from " <> Text.unpack (idText uid))+ Right False -> putStrLn "no such grant"+ RolesList rawUser -> do+ uid <- parseUserRef rawUser+ outcome <- runOrDie env.pool (rolesOf uid)+ case outcome of+ Left e -> dieAuthError e+ Right roles -> forM_ (Set.toList roles) (putStrLn . roleString)++-- | Attach a permission to a role, refusing an undefined role first (the FK backstops it, but a+-- typed pre-check gives a clear message and matches the grant path's behaviour).+allowEff :: (RoleStore :> es, Clock :> es) => Role -> Permission -> Eff es AllowOutcome+allowEff role perm = do+ defined <- definedRoleNames+ if not (role `Set.member` defined)+ then pure AllowRoleUndefined+ else do+ ts <- now+ changed <- allowPermission role perm ts+ pure (if changed then NewlyAllowed else AlreadyAllowed)++disallowEff :: (RoleStore :> es) => Role -> Permission -> Eff es DisallowOutcome+disallowEff role perm = do+ defined <- definedRoleNames+ if not (role `Set.member` defined)+ then pure DisallowRoleUndefined+ else do+ changed <- disallowPermission role perm+ pure (if changed then Detached else NotAttached)++showEff :: (RoleStore :> es) => Role -> Eff es ShowOutcome+showEff role = do+ defs <- listDefinedRoles+ case [d | d <- defs, d.role == role] of+ [] -> pure ShowRoleUndefined+ (d : _) -> ShowRole d <$> listPermissionsForRole role++definedRoleNames :: (RoleStore :> es) => Eff es (Set.Set Role)+definedRoleNames = Set.fromList . map (.role) <$> listDefinedRoles++-- | Render the typed errors the role workflows can return, each with the fix.+dieAuthError :: AuthError -> IO a+dieAuthError = \case+ UserNotFound -> die "user not found"+ RoleNotDefined (Role r) ->+ die+ ( "role not defined: "+ <> Text.unpack r+ <> " (define it first: shomei-admin roles define "+ <> Text.unpack r+ <> ")"+ )+ other -> die ("unexpected error: " <> show other)++-- | A user reference is either the typed id rendered by the API (@user_01ABC…@) or the bare+-- UUID the audit columns speak. Operators paste whichever they have in front of them.+parseUserRef :: Text -> IO UserId+parseUserRef raw =+ case parseId raw of+ Right uid -> pure uid+ Left _ ->+ case UUID.fromText raw of+ Just uuid -> pure (userIdFromUUID uuid)+ Nothing -> die ("--user must be a typed id (user_…) or a UUID, got " <> Text.unpack raw)++-- | Role names are trimmed and must be non-blank. Validating at the boundary keeps the+-- workflow free of shape checks (it treats a 'Role' as opaque text).+parseRole :: Text -> IO Role+parseRole raw =+ let trimmed = Text.strip raw+ in if Text.length raw > 128 || Text.null trimmed+ then die "role name must be non-blank and at most 128 characters"+ else pure (Role trimmed)++-- | Permission strings are trimmed and must be non-blank and contain no internal whitespace. The+-- @resource:verb@ shape is a documented convention, not enforced grammar — only blank/whitespace+-- strings are rejected.+parsePermission :: Text -> IO Permission+parsePermission raw =+ let trimmed = Text.strip raw+ in if Text.null trimmed || Text.any isSpace trimmed+ then die ("invalid permission: " <> show raw <> " (must be non-blank with no whitespace)")+ else pure (Permission trimmed)++roleString :: Role -> String+roleString (Role r) = Text.unpack r++permString :: Permission -> String+permString (Permission p) = Text.unpack p++-- | The minimal chain the role commands need, mirroring @Shomei.Admin.Users.runSignup@.+-- @UserStore@ is present because 'grantRoleTo' resolves the subject before granting.+runRolesEff ::+ Pool ->+ Eff '[UserStore, RoleStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO (Either AuthError a)+runRolesEff pool =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runAuthEventPublisherPostgres+ . runRoleStorePostgres+ . runUserStorePostgres++runOrDie ::+ Pool ->+ Eff '[UserStore, RoleStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO a+runOrDie pool act = do+ res <- runRolesEff pool act+ either (\e -> die ("database error: " <> show e)) pure res++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure
+ app/Shomei/Admin/ServiceAccounts.hs view
@@ -0,0 +1,313 @@+-- | @shomei-admin service-accounts@ (MasterPlan 7 EP-4): create, rotate, revoke, and list the+-- database-backed machine credentials that authenticate at @POST \/oauth\/token@ with the OAuth2+-- @client_credentials@ grant.+--+-- @+-- shomei-admin service-accounts create --display-name TEXT [--scope S]...+-- shomei-admin service-accounts rotate-secret \<client_id\>+-- shomei-admin service-accounts revoke \<client_id\>+-- shomei-admin service-accounts list+-- @+--+-- This is the runtime lifecycle the config-defined service accounts never had: creating,+-- rotating, or revoking one of those meant editing configuration and redeploying.+--+-- __The secret is generated here and printed exactly once.__ Only its SHA-256 digest is+-- persisted, so a lost secret cannot be recovered — it can only be replaced with+-- @rotate-secret@. The model is single-secret: rotation invalidates the old secret immediately,+-- with no overlap window. An operator who needs a zero-downtime handover creates a /second/+-- account, migrates consumers to it, then revokes the first.+--+-- Every service account owns a dedicated row in @shomei_users@, created here with @login_id@ set+-- to the account's @client_id@ and no password credential. That row is not a convenience: an+-- access token's @sub@ is a 'Shomei.Id.UserId' and @shomei_sessions.user_id@ has a foreign key+-- into @shomei_users@, so a token cannot be minted without one.+--+-- @create@, @rotate-secret@ and @revoke@ publish audit events; @list@ reads only, and never+-- prints a hash.+module Shomei.Admin.ServiceAccounts+ ( ServiceAccountsCommand (..),+ serviceAccountsParser,+ runServiceAccounts,++ -- * Actions++ --++ -- | The subcommands as functions that /return/ what they did, with printing left to+ -- 'runServiceAccounts'. Exported so the integration suite can assert on the generated+ -- secret without capturing the process's stdout — which is not safe to do in a test+ -- runner that executes cases in parallel.+ createAction,+ rotateSecretAction,+ revokeAction,+ listAction,+ )+where++import Control.Monad (forM_, when)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Effectful (Eff, IOE, runEff)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Options.Applicative+import Shomei.Account.LoginId.Domain (mkLoginId)+import Shomei.Account.Password.Hash.Postgres (generateOpaqueToken)+import Shomei.Persistence.Pool.Postgres (Pool)++-- 'ServiceAccount' shares the field names @userId@ / @createdAt@ / @displayName@ with+-- 'Shomei.Account.User.Domain.User'. Both are imported with @(..)@ and read by /record pattern/ only,+-- never through @OverloadedRecordDot@: a record pattern names its constructor and so is+-- unambiguous, while @user.userId@ would not be.++import Shomei.Account.User.Domain (NewUser (..), User (..))+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Account.User.Store (UserStore, createUser)+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Audit.Event.Domain qualified as Event+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)+import Shomei.Authorization.Claims.Domain (Scope (..))+import Shomei.Authorization.Scope.Domain (privilegeScopesIn)+import Shomei.Error (AuthError)+import Shomei.Id (ServiceAccountDbId, UserId, genServiceAccountDbId, idText)+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.ServiceAccount.Domain (NewServiceAccount (..), ServiceAccount (..), ServiceAccountStatus (..))+import Shomei.ServiceAccount.Postgres (runServiceAccountStorePostgres)+import Shomei.ServiceAccount.Secret (sha256Hex)+import Shomei.ServiceAccount.Store+ ( ServiceAccountStore,+ createServiceAccount,+ findServiceAccountByClientId,+ listServiceAccounts,+ revokeServiceAccount,+ rotateServiceAccountSecret,+ )+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock, now)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- The command tree -----------------------------------------------------------++data ServiceAccountsCommand+ = ServiceAccountsCreate Text [Text]+ | ServiceAccountsRotateSecret Text+ | ServiceAccountsRevoke Text+ | ServiceAccountsList++serviceAccountsParser :: Parser ServiceAccountsCommand+serviceAccountsParser =+ hsubparser+ ( command "create" (info createOpts (progDesc "Create a service account; prints its secret once"))+ <> command "rotate-secret" (info (ServiceAccountsRotateSecret <$> clientIdArg) (progDesc "Replace the secret; the old one stops working immediately"))+ <> command "revoke" (info (ServiceAccountsRevoke <$> clientIdArg) (progDesc "Refuse all future tokens for this account"))+ <> command "list" (info (pure ServiceAccountsList) (progDesc "Show every service account (never its secret hash)"))+ )+ where+ createOpts =+ ServiceAccountsCreate+ <$> (Text.pack <$> strOption (long "display-name" <> metavar "TEXT" <> help "Human label, e.g. \"rei connector\""))+ <*> many (Text.pack <$> strOption (long "scope" <> metavar "SCOPE" <> help "A scope this account may request; repeatable"))+ clientIdArg = Text.pack <$> argument str (metavar "CLIENT_ID")++-- Field accessors ------------------------------------------------------------++saId :: ServiceAccount -> ServiceAccountDbId+saId ServiceAccount {serviceAccountId} = serviceAccountId++saClientId :: ServiceAccount -> Text+saClientId ServiceAccount {clientId} = clientId++saUserId :: ServiceAccount -> UserId+saUserId ServiceAccount {userId} = userId++saDisplayName :: ServiceAccount -> Text+saDisplayName ServiceAccount {displayName} = displayName++saAllowedScopes :: ServiceAccount -> Set Scope+saAllowedScopes ServiceAccount {allowedScopes} = allowedScopes++saStatus :: ServiceAccount -> ServiceAccountStatus+saStatus ServiceAccount {status} = status++saCreatedAt :: ServiceAccount -> UTCTime+saCreatedAt ServiceAccount {createdAt} = createdAt++saRotatedAt :: ServiceAccount -> Maybe UTCTime+saRotatedAt ServiceAccount {rotatedAt} = rotatedAt++-- Execution ------------------------------------------------------------------++-- | Create an account and its backing user, returning the account and the generated secret.+-- The secret is returned, never re-read: only its digest is persisted.+createAction :: AdminEnv -> Text -> [Text] -> IO (ServiceAccount, Text)+createAction env displayName rawScopes = do+ when (Text.length displayName > 256) (die "display name exceeds 256 characters")+ scopes <- parseScopes rawScopes+ forM_ (Set.toList (privilegeScopesIn env.config scopes)) \(Scope scope) ->+ hPutStrLn+ stderr+ ( "shomei-admin: warning: "+ <> Text.unpack scope+ <> " is a privilege scope; this account holds it as its own authority (see docs/user/security.md, \"Scopes are principal privilege\")"+ )+ secret <- generateOpaqueToken+ account <- runOrDie env.pool do+ said <- genServiceAccountDbId+ let cid = idText said+ ts <- now+ -- The backing principal. No password credential is created, so this row can never be+ -- logged into; it exists to satisfy the session/claims foreign keys.+ loginId <- either (const (error ("impossible: client id is not a valid login id: " <> Text.unpack cid))) pure (mkLoginId cid)+ User {userId = backingUserId} <- createUser NewUser {loginId, email = Nothing, displayName = Just displayName}+ account <-+ createServiceAccount+ NewServiceAccount+ { serviceAccountId = said,+ clientId = cid,+ userId = backingUserId,+ secretHash = sha256Hex secret,+ displayName,+ allowedScopes = scopes,+ createdAt = ts+ }+ publishAuthEvent+ ( Event.ServiceAccountCreated+ Event.ServiceAccountCreatedData+ { serviceAccountId = idText said,+ clientId = cid,+ userId = backingUserId,+ displayName,+ allowedScopes = scopes,+ occurredAt = ts+ }+ )+ pure account+ pure (account, secret)++-- | Replace the account's secret, returning it. The previous secret stops working immediately.+rotateSecretAction :: AdminEnv -> Text -> IO (ServiceAccount, Text)+rotateSecretAction env cid = do+ secret <- generateOpaqueToken+ account <- requireAccount env cid+ runOrDie env.pool do+ ts <- now+ rotateServiceAccountSecret (saId account) (sha256Hex secret) ts+ publishAuthEvent+ ( Event.ServiceAccountSecretRotated+ Event.ServiceAccountSecretRotatedData+ { serviceAccountId = idText (saId account),+ clientId = saClientId account,+ userId = saUserId account,+ occurredAt = ts+ }+ )+ pure (account, secret)++revokeAction :: AdminEnv -> Text -> IO ServiceAccount+revokeAction env cid = do+ account <- requireAccount env cid+ runOrDie env.pool do+ ts <- now+ revokeServiceAccount (saId account) ts+ publishAuthEvent+ ( Event.ServiceAccountRevoked+ Event.ServiceAccountRevokedData+ { serviceAccountId = idText (saId account),+ clientId = saClientId account,+ userId = saUserId account,+ occurredAt = ts+ }+ )+ pure account++listAction :: AdminEnv -> IO [ServiceAccount]+listAction env = runOrDie env.pool listServiceAccounts++runServiceAccounts :: AdminEnv -> ServiceAccountsCommand -> IO ()+runServiceAccounts env = \case+ ServiceAccountsCreate displayName rawScopes -> do+ (account, secret) <- createAction env displayName rawScopes+ putStrLn ("client_id: " <> Text.unpack (saClientId account))+ putStrLn ("client_secret: " <> Text.unpack secret <> " (shown once - store it now, it cannot be retrieved)")+ putStrLn ("scopes: " <> renderScopes (saAllowedScopes account))+ ServiceAccountsRotateSecret cid -> do+ (account, secret) <- rotateSecretAction env cid+ putStrLn ("client_id: " <> Text.unpack (saClientId account))+ putStrLn ("client_secret: " <> Text.unpack secret <> " (shown once; the previous secret no longer works)")+ ServiceAccountsRevoke cid -> do+ account <- revokeAction env cid+ putStrLn ("revoked " <> Text.unpack (saClientId account))+ -- Access tokens are stateless JWTs: one minted a moment ago stays valid until it expires.+ putStrLn "note: tokens already issued to this account remain valid until they expire (default 5 minutes)"+ ServiceAccountsList -> do+ accounts <- listAction env+ forM_ accounts \a ->+ putStrLn+ ( Text.unpack (saClientId a)+ <> " "+ <> renderStatus (saStatus a)+ <> " "+ <> Text.unpack (saDisplayName a)+ <> " scopes=["+ <> renderScopes (saAllowedScopes a)+ <> "] created="+ <> show (saCreatedAt a)+ <> maybe "" (\t -> " rotated=" <> show t) (saRotatedAt a)+ )++-- | Resolve a @client_id@ or exit 1. Every mutating subcommand names an account this way, so a+-- typo fails loudly instead of silently updating zero rows.+requireAccount :: AdminEnv -> Text -> IO ServiceAccount+requireAccount env cid = do+ found <- runOrDie env.pool (findServiceAccountByClientId cid)+ maybe (die ("no service account with client_id " <> Text.unpack cid)) pure found++renderStatus :: ServiceAccountStatus -> String+renderStatus = \case+ ServiceAccountActive -> "active "+ ServiceAccountRevoked -> "revoked"++renderScopes :: Set Scope -> String+renderScopes scopes = Text.unpack (Text.unwords [s | Scope s <- Set.toList scopes])++-- | Scopes are trimmed and must be non-blank; a scope containing whitespace could never be+-- expressed in the space-delimited OAuth2 @scope@ parameter.+parseScopes :: [Text] -> IO (Set Scope)+parseScopes raws = Set.fromList <$> traverse one raws+ where+ one raw =+ let trimmed = Text.strip raw+ in if Text.null trimmed || Text.any (== ' ') trimmed+ then die "invalid scope (must be non-blank, contain no spaces, and have at most 128 characters)"+ else pure (Scope trimmed)++-- | The minimal chain these commands need, mirroring @Shomei.Admin.Roles.runRolesEff@.+-- @UserStore@ is present because @create@ provisions the account's backing user row.+runServiceAccountsEff ::+ Pool ->+ Eff '[UserStore, ServiceAccountStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO (Either AuthError a)+runServiceAccountsEff pool =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runAuthEventPublisherPostgres+ . runServiceAccountStorePostgres+ . runUserStorePostgres++runOrDie ::+ Pool ->+ Eff '[UserStore, ServiceAccountStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->+ IO a+runOrDie pool act = do+ res <- runServiceAccountsEff pool act+ either (\e -> die ("database error: " <> show e)) pure res++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure
+ app/Shomei/Admin/Sweep.hs view
@@ -0,0 +1,132 @@+-- | @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
+ app/Shomei/Admin/Users.hs view
@@ -0,0 +1,184 @@+-- | @shomei-admin users create@ (EP-4, M3): seed a user account without the HTTP API by+-- driving the existing 'Shomei.Session.Authentication.Workflow.signup' through the full PostgreSQL interpreter stack+-- (with a trivial 'TokenSigner' fake — the CLI does not need a real access token).+module Shomei.Admin.Users+ ( createUserAction,+ )+where++import Control.Monad (when)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Effectful (Eff, IOE, runEff)+import Effectful.Dispatch.Dynamic (interpret_)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Network.HTTP.Client (Manager)+import Network.HTTP.Client.TLS (newTlsManager)+import Shomei.Account.Credential.Postgres (runCredentialStorePostgres)+import Shomei.Account.Credential.Store (CredentialStore)+import Shomei.Account.Email.Domain (mkEmail)+import Shomei.Account.LoginId.Domain (loginIdText, mkLoginId)+import Shomei.Account.Password.Breach.Store (PasswordBreachChecker)+import Shomei.Account.Password.Domain (PasswordPolicy (..), PlainPassword (..))+import Shomei.Account.Password.Hash.Postgres (Argon2Params, HashingLimiter, newHashingLimiter, runPasswordHasherCrypto, runTokenGenCrypto)+import Shomei.Account.Password.Hash.Store (PasswordHasher)+import Shomei.Account.User.Domain (User (..))+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Account.User.Store (UserStore, markUserEmailVerified)+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher)+import Shomei.Authorization.Claims.Domain (Role (..))+import Shomei.Authorization.Claims.Store (ClaimsEnricher, runClaimsEnricherNull)+import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)+import Shomei.Authorization.Role.Store (RoleStore)+import Shomei.Authorization.Role.Workflow (undefinedDefaultRoles)+import Shomei.Config (ShomeiConfig (..))+import Shomei.Error (AuthError)+import Shomei.Id (UserId)+import Shomei.OAuth.IdToken.Domain (IdToken (..))+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.Persistence.Pool.Postgres (Pool)+import Shomei.Server.BreachChecker (runPasswordBreachCheckerHibp)+import Shomei.Session.Authentication.Workflow (signup)+import Shomei.Session.Command (SignupCommand (..))+import Shomei.Session.Token.Domain (AccessToken (..))+import Shomei.Session.Token.Generator (TokenGen)+import Shomei.Session.UnitOfWork.Postgres (runAuthUnitOfWorkPostgres)+import Shomei.Session.UnitOfWork.Store (AuthUnitOfWork)+import Shomei.SigningKey.Signer (TokenSigner (..))+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock, now)+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++createUserAction :: AdminEnv -> Text -> PlainPassword -> Maybe Text -> Bool -> IO ()+createUserAction env emailArg password mDisplay verified = do+ email <- either (\e -> die ("invalid email: " <> show e)) pure (mkEmail emailArg)+ loginId <- either (\e -> die ("invalid login id: " <> show e)) pure (mkLoginId emailArg)+ -- The server validates 'defaultRoles' against the registry at boot; the CLI has no boot, so it+ -- checks here. Without this, a typo in SHOMEI_DEFAULT_ROLES surfaces as a raw foreign-key+ -- violation from deep inside 'signup', after the user row has already been written.+ checkDefaultRoles env+ let cmd =+ SignupCommand+ { loginId = loginId,+ email = Just email,+ password = password,+ displayName = mDisplay+ }+ -- The CLI hashes exactly one password, so a limiter of one is right and costs nothing.+ limiter <- newHashingLimiter 1+ manager <- newTlsManager+ outcome <-+ runSignup+ env.pool+ limiter+ env.argon2+ manager+ env.config.passwordPolicy.breachCheckTimeoutMs+ (signup env.config cmd)+ case outcome of+ Left infra -> die ("infrastructure error: " <> show infra)+ Right (Left rejected) -> die ("signup rejected: " <> show rejected)+ Right (Right (user, _)) -> do+ when verified do+ verifiedResult <- runMarkEmailVerified env.pool user.userId+ either (die . ("could not mark email verified: " <>) . show) pure verifiedResult+ putStrLn+ ( "created user "+ <> show user.userId+ <> " <"+ <> Text.unpack (loginIdText user.loginId)+ <> ">"+ <> if verified then " (email verified)" else ""+ )++-- | Run a 'signup' over the PostgreSQL interpreters, with a fake signer.+--+-- 'signup' applies @config.defaultRoles@ (reading the real 'RoleStore' and auditing each grant+-- through the real publisher), so a user created here receives exactly the roles an HTTP signup+-- would — no special-casing. 'ClaimsEnricher' is the null interpreter because the token this+-- path mints is the discarded fake one.+runSignup ::+ Pool ->+ HashingLimiter ->+ Argon2Params ->+ Manager ->+ Int ->+ Eff+ [ UserStore,+ RoleStore,+ CredentialStore,+ AuthUnitOfWork,+ PasswordBreachChecker,+ PasswordHasher,+ TokenSigner,+ ClaimsEnricher,+ AuthEventPublisher,+ Clock,+ TokenGen,+ Database,+ Error AuthError,+ IOE+ ]+ a ->+ IO (Either AuthError a)+runSignup pool limiter argon2 manager breachTimeoutMs =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runTokenGenCrypto+ . runClockIO+ . runAuthEventPublisherPostgres+ . runClaimsEnricherNull+ . runTokenSignerFake+ . runPasswordHasherCrypto limiter argon2+ . runPasswordBreachCheckerHibp manager breachTimeoutMs+ . runAuthUnitOfWorkPostgres+ . runCredentialStorePostgres+ . runRoleStorePostgres+ . runUserStorePostgres++-- | Refuse to create a user when @SHOMEI_DEFAULT_ROLES@ names a role missing from the registry,+-- with the same message the server prints at boot.+checkDefaultRoles :: AdminEnv -> IO ()+checkDefaultRoles env+ | Set.null env.config.defaultRoles = pure ()+ | otherwise = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePool env.pool+ . runRoleStorePostgres+ $ undefinedDefaultRoles env.config+ case outcome of+ Left e -> die ("could not validate defaultRoles: " <> show e)+ Right missing+ | Set.null missing -> pure ()+ | otherwise ->+ die+ ( "SHOMEI_DEFAULT_ROLES names undefined roles: "+ <> Text.unpack (Text.intercalate ", " [r | Role r <- Set.toList missing])+ <> " (define them first: shomei-admin roles define <name>)"+ )++runTokenSignerFake :: Eff (TokenSigner : es) a -> Eff es a+runTokenSignerFake = interpret_ \case+ SignAccessToken _ -> pure (AccessToken "admin-cli-token")+ SignIdToken _ -> pure (IdToken "admin-cli-id-token")++runMarkEmailVerified :: Pool -> UserId -> IO (Either AuthError ())+runMarkEmailVerified pool uid =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runUserStorePostgres+ $ do+ ts <- now+ markUserEmailVerified uid ts++die :: String -> IO a+die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure
+ review-load/Main.hs view
@@ -0,0 +1,315 @@+{-# LANGUAGE ScopedTypeVariables #-}++-- | Isolated MasterPlan 9 workload. No caller-supplied database or HTTP target is accepted.+module Main (main) where++import Control.Concurrent (forkIO, killThread)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (SomeException, bracket, throwIO, try)+import Control.Monad (forM, forM_, replicateM_, unless, void)+import Data.Aeson (Value (..), eitherDecode, encode, object, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.IORef (newIORef)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.UUID qualified as UUID+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Session qualified as Session+import Hasql.Statement (preparable)+import Network.HTTP.Client qualified as HTTP+import Network.HTTP.Types (Header, statusCode, urlEncode)+import Network.Wai.Handler.Warp qualified as Warp+import Servant.Health (ProbeVerdict (Healthy))+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), defaultArgon2Params, newHashingLimiter)+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))+import Shomei.Config (ObservabilityConfig (..), RateLimitConfig (..), ShomeiConfig (..), defaultShomeiConfig)+import Shomei.Id (UserId, genUserId, idText, parseId, userIdToUUID)+import Shomei.Mfa.Totp.Postgres (totpEncryptionKeyFromBytes)+import Shomei.Migrations.TestSupport (withShomeiMigratedDatabase)+import Shomei.Notify (noNotifierSecrets)+import Shomei.Notify.Queue (newNotifierQueue)+import Shomei.Persistence.Database.Postgres (defaultTransactionRetryPolicy)+import Shomei.Persistence.Pool.Postgres (Pool, acquirePool)+import Shomei.Persistence.Pool.Postgres qualified as Pool+import Shomei.Servant.Api (shomeiThrottledRoutes)+import Shomei.Servant.CachedJson (cachedJsonBytes)+import Shomei.Servant.OpenApi (openApiDocument)+import Shomei.Server.App (Env (..))+import Shomei.Server.Boot (application, hostMiddleware)+import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), defaultSweepSettings)+import Shomei.Server.Keys (bootstrapKeys)+import Shomei.Server.Middleware.RateLimit (newRateLimiterFor)+import Shomei.Server.Middleware.TrustedProxy (emptyTrustedProxies)+import Shomei.Server.Observability.Metrics (newMetrics)+import Shomei.Server.Runtime (hostWarpSettings)+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256))+import Shomei.SigningKey.Protection.Jwt (keyEncryptionKeyFromBase64)+import System.Directory (getTemporaryDirectory, removeFile)+import System.Environment (getArgs, setEnv)+import System.Exit (ExitCode (ExitSuccess), exitFailure)+import System.IO (hClose, hPutStrLn, openBinaryTempFile, stderr)+import System.Mem (performGC)+import System.Posix.Process (getProcessID)+import System.Process (readProcess, readProcessWithExitCode)+import System.Timeout (timeout)+import Text.Read (readMaybe)++main :: IO ()+main = do+ args <- getArgs+ (scenario, output) <- case args of+ ["--scenario", s, "--output", out] | s `elem` ["all", "sessions", "openapi", "argon2"] -> pure (s, out)+ _ -> hPutStrLn stderr "expected --scenario all|sessions|openapi|argon2 --output PATH; external targets are refused" >> exitFailure+ result <- timeout 180000000 (try @SomeException (workload scenario))+ case result of+ Just (Right report) -> LBS.writeFile output (encode report <> "\n")+ _ -> do+ LBS.writeFile output (encode (object ["scenario" .= scenario, "error_count" .= (1 :: Int), "result" .= ("failed_or_timed_out" :: Text)]) <> "\n")+ hPutStrLn stderr "isolated review workload failed; no credential-bearing exception is emitted"+ exitFailure++settings :: ServerSettings+settings =+ ServerSettings+ { serverPort = 0,+ serverBindHost = "127.0.0.1",+ serverMaxConnections = 1024,+ serverSlowClientTimeoutSeconds = 30,+ serverConnStr = "",+ serverTrustedProxies = emptyTrustedProxies,+ serverProxyProtocol = ProxyProtocolOff,+ serverDbPoolSize = 8,+ serverDbPoolAcquisitionTimeoutMs = 10000,+ serverDbStatementTimeoutMs = 30000,+ serverDbTransactionMaxAttempts = 3,+ serverDbTransactionDeadlineMs = 5000,+ serverNotifierQueueSize = 1024,+ serverWarnings = [],+ serverSweep = defaultSweepSettings,+ serverArgon2 = defaultArgon2Params,+ serverHashingMaxConcurrency = 2+ }++workload :: String -> IO Value+workload scenario = withShomeiMigratedDatabase \conn -> bracket (acquirePool 8 10 30000 conn) Pool.release \pool -> do+ started <- getMonotonicTimeNSec+ let base = defaultShomeiConfig (Issuer "review-fixture") (Audience "review-fixture")+ cfg = base {rateLimitConfig = base.rateLimitConfig {rateLimitEnabled = False}, observabilityConfig = base.observabilityConfig {requestLoggingEnabled = False, metricsEnabled = False}}+ kek = either (error . Text.unpack) id (keyEncryptionKeyFromBase64 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")+ totpKey = either (error . Text.unpack) id (totpEncryptionKeyFromBytes (BS.replicate 32 0))+ keys <- newIORef =<< bootstrapKeys kek ES256 pool+ manager <- HTTP.newManager (HTTP.managerSetProxy HTTP.noProxy HTTP.defaultManagerSettings {HTTP.managerResponseTimeout = HTTP.responseTimeoutMicro 5000000})+ hashing <- newHashingLimiter 2+ queue <- newNotifierQueue 1024+ limiter <- newRateLimiterFor shomeiThrottledRoutes cfg.rateLimitConfig+ metrics <- newMetrics+ runtime <- hostWarpSettings cfg settings Warp.defaultSettings+ let env =+ Env+ { envPool = pool,+ envDbRetryPolicy = defaultTransactionRetryPolicy,+ envConfig = cfg,+ envKeys = keys,+ envKek = kek,+ envHttpManager = manager,+ envNotifierSecrets = noNotifierSecrets,+ envNotifierQueue = queue,+ envArgon2Params = defaultArgon2Params,+ envHashingLimiter = hashing,+ envTotpKey = totpKey+ }+ app = hostMiddleware cfg settings limiter metrics (application env (pure Healthy) (pure Healthy))+ Warp.testWithApplicationSettings runtime (pure app) \port -> do+ let origin = "http://127.0.0.1:" <> show port+ hPutStrLn stderr "[review-load] session fixture and workload"+ sessions <- if scenario `elem` ["all", "sessions"] then sessionWork pool manager origin else pure Null+ hPutStrLn stderr "[review-load] repeated OpenAPI workload"+ openapi <- if scenario `elem` ["all", "openapi"] then openApiWork manager origin else pure Null+ hPutStrLn stderr "[review-load] bounded Argon2 workload"+ argon2 <- if scenario `elem` ["all", "argon2"] then argonWork origin else pure Null+ ended <- getMonotonicTimeNSec+ pure+ ( object+ [ "scenario" .= scenario,+ "target" .= ("isolated_loopback" :: Text),+ "error_count" .= (0 :: Int),+ "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double),+ "configuration"+ .= object+ [ "argon2_memory_kib" .= defaultArgon2Params.memoryKiB,+ "argon2_iterations" .= defaultArgon2Params.iterations,+ "argon2_parallelism" .= defaultArgon2Params.parallelism,+ "hashing_limit" .= (2 :: Int),+ "rts_requested_capabilities" .= (2 :: Int),+ "http_workers" .= (4 :: Int),+ "rate_limit_enabled" .= False,+ "max_connections" .= (1024 :: Int),+ "total_deadline_seconds" .= (180 :: Int)+ ],+ "sessions" .= sessions,+ "openapi" .= openapi,+ "argon2" .= argon2+ ]+ )++failCheck :: Bool -> IO ()+failCheck condition = unless condition (ioError (userError "fixed workload acceptance failed"))++checked :: Either e a -> IO a+checked = either (const (ioError (userError "isolated fixture operation failed"))) pure++exec :: Pool -> Text -> IO ()+exec pool sql = checked =<< Pool.use pool (Session.script sql)++scalar :: Pool -> Text -> IO Int+scalar pool sql = fromIntegral <$> (checked =<< Pool.use pool (Session.statement () (preparable sql E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))))++request :: HTTP.Manager -> String -> BS.ByteString -> [Header] -> Maybe Value -> IO (HTTP.Response LBS.ByteString)+request manager url method headers body = do+ req <- HTTP.parseRequest url+ HTTP.httpLbs+ req+ { HTTP.method = method,+ HTTP.requestHeaders = headers,+ HTTP.redirectCount = 0,+ HTTP.requestBody = maybe (HTTP.RequestBodyBS BS.empty) (HTTP.RequestBodyLBS . encode) body+ }+ manager++textAt :: [Text] -> Value -> IO Text+textAt [] (String text) = pure text+textAt (field : rest) (Object fields) = maybe (checked (Left ())) (textAt rest) (KM.lookup (Key.fromText field) fields)+textAt _ _ = checked (Left ())++sessionWork :: Pool -> HTTP.Manager -> String -> IO Value+sessionWork pool manager origin = do+ let password = "review-fixture-correct-horse-42" :: Text+ json = [("Content-Type", "application/json")]+ signup <- request manager (origin <> "/v1/auth/signup") "POST" json (Just (object ["loginId" .= ("review-admin" :: Text), "password" .= password, "displayName" .= ("Review" :: Text)]))+ failCheck (statusCode (HTTP.responseStatus signup) == 201)+ doc <- checked (eitherDecode (HTTP.responseBody signup))+ uidText <- textAt ["user", "userId"] doc+ uid :: UserId <- checked (parseId uidText)+ let uidSql = "'" <> UUID.toText (userIdToUUID uid) <> "'"+ exec pool ("INSERT INTO shomei.shomei_role_grants(user_id,role,granted_at) VALUES(" <> uidSql <> ",'admin',now())")+ login <- request manager (origin <> "/v1/auth/login") "POST" json (Just (object ["loginId" .= ("review-admin" :: Text), "password" .= password]))+ failCheck (statusCode (HTTP.responseStatus login) == 200)+ loginDoc <- checked (eitherDecode (HTTP.responseBody login))+ token <- textAt ["token", "accessToken"] loginDoc+ target <- genUserId+ let targetSql = "'" <> UUID.toText (userIdToUUID target) <> "'"+ headers = [("Authorization", "Bearer " <> Text.encodeUtf8 token)]+ path = origin <> "/v1/admin/users/" <> Text.unpack (idText target) <> "/sessions"+ exec pool ("INSERT INTO shomei.shomei_users(user_id,login_id,status,created_at,updated_at) VALUES(" <> targetSql <> ",'review-target','active',now(),now()); INSERT INTO shomei.shomei_sessions(session_id,user_id,status,kind,created_at,expires_at) SELECT gen_random_uuid()," <> targetSql <> ",'active','interactive',now(),now()+interval '1 day' FROM generate_series(1,10000)")+ started <- getMonotonicTimeNSec+ let walk cursor ids pages = do+ failCheck (pages < 64)+ let suffix = maybe "" (\value -> "&before=" <> BS8.unpack (urlEncode True (Text.encodeUtf8 value))) cursor+ response <- request manager (path <> "?limit=200" <> suffix) "GET" headers Nothing+ failCheck (statusCode (HTTP.responseStatus response) == 200)+ page <- checked (eitherDecode (HTTP.responseBody response))+ values <- case page of+ Object fields | Just (Array rows) <- KM.lookup "sessions" fields -> traverse (textAt ["sessionId"]) (toList rows)+ _ -> checked (Left ())+ failCheck (length values <= 200 && Set.size (Set.fromList values) == length values && Set.null (Set.intersection ids (Set.fromList values)))+ let nextIds = Set.union ids (Set.fromList values)+ next = case page of+ Object fields -> case KM.lookup "nextCursor" fields of+ Just (String c) -> Just c+ _ -> Nothing+ _ -> Nothing+ case next of+ Nothing -> pure (Set.size nextIds, pages + 1)+ Just c -> walk (Just c) nextIds (pages + 1)+ (total, pages) <- walk Nothing Set.empty (0 :: Int)+ failCheck (total == 10000 && pages == 51)+ revoked <- request manager path "DELETE" headers Nothing+ failCheck (statusCode (HTTP.responseStatus revoked) == 204)+ count <- scalar pool ("SELECT count(*) FROM shomei.shomei_sessions WHERE user_id=" <> targetSql <> " AND status='revoked'")+ audits <- scalar pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type='session_revoked'"+ failCheck (count == 10000 && audits == 10000)+ ended <- getMonotonicTimeNSec+ pure+ ( object+ [ "fixture_sessions" .= total,+ "page_size" .= (200 :: Int),+ "pages" .= pages,+ "revoked" .= count,+ "audit_events" .= audits,+ "old_unbounded_page_rows" .= (10000 :: Int),+ "current_max_page_rows" .= (200 :: Int),+ "bulk_unit_of_work_contract_calls" .= (1 :: Int),+ "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double)+ ]+ )++parallel :: [IO a] -> IO [a]+parallel actions =+ bracket+ ( forM actions \action -> do+ done <- newEmptyMVar+ worker <- forkIO (try @SomeException action >>= putMVar done)+ pure (worker, done)+ )+ (mapM_ (killThread . fst))+ (traverse (\(_, done) -> takeMVar done >>= either throwIO pure))++rssKiB :: IO Int+rssKiB = do+ performGC+ pid <- getProcessID+ raw <- readProcess "ps" ["-o", "rss=", "-p", show pid] ""+ maybe (checked (Left ())) pure (readMaybe raw)++openApiWork :: HTTP.Manager -> String -> IO Value+openApiWork manager origin = do+ let expected = cachedJsonBytes openApiDocument+ one = do+ response <- request manager (origin <> "/openapi.json") "GET" [] Nothing+ failCheck (statusCode (HTTP.responseStatus response) == 200 && HTTP.responseBody response == expected)+ one+ warm <- rssKiB+ started <- getMonotonicTimeNSec+ samples <- forM [1 .. 4 :: Int] \_ -> do+ void (parallel [replicateM_ count one | count <- [63, 63, 62, 62]])+ rssKiB+ ended <- getMonotonicTimeNSec+ -- Same process includes the bounded load generator. A fixed generous noise allowance+ -- catches retention proportional to 1000 document copies without treating RSS as exact.+ failCheck (maximum samples - warm < 32768)+ pure+ ( object+ [ "requests" .= (1000 :: Int),+ "workers" .= (4 :: Int),+ "error_count" .= (0 :: Int),+ "wire_bytes_per_response" .= LBS.length expected,+ "warm_rss_kib" .= warm,+ "batch_rss_kib" .= samples,+ "memory_growth_allowance_kib" .= (32768 :: Int),+ "observer_shares_process" .= True,+ "wall_ms" .= (fromIntegral (ended - started) / 1000000 :: Double)+ ]+ )++argonWork :: String -> IO Value+argonWork origin = bracket create removeFile \path -> do+ pid <- getProcessID+ forM_ [("LOAD_USERS", "4"), ("PROBE_COUNT", "100"), ("LOAD_SECONDS", "10"), ("SIGNUP_LOOPS", "0"), ("SERVER_PID", show pid), ("LOAD_JSON_OUTPUT", path)] (uncurry setEnv)+ (code, _, _) <- readProcessWithExitCode "sh" ["scripts/argon2-load-test.sh", origin] ""+ failCheck (code == ExitSuccess)+ checked . eitherDecode =<< LBS.readFile path+ where+ create = do+ directory <- getTemporaryDirectory+ (path, handle) <- openBinaryTempFile directory "shomei-review-argon2.json"+ hClose handle+ pure path
+ shomei-server.cabal view
@@ -0,0 +1,353 @@+cabal-version: 3.0+name: shomei-server+version: 0.3.0.0+synopsis: Standalone Shōmei authentication service+description:+ The standalone deployment of Shōmei: a warp executable that serves ShomeiAPI+ against PostgreSQL, the WAI middleware stack (structured logging, Prometheus+ metrics, rate limiting, body limits), health and readiness probes, the+ environment-driven config loader, signing-key bootstrap and rotation, and+ breach-password checking. Also ships shomei-admin, an operations CLI for+ user administration and zero-downtime signing-key rotation. Other services+ verify its tokens by fetching its published JWKS.++homepage: https://github.com/shinzui/shomei+bug-reports: https://github.com/shinzui/shomei/issues+license: MIT+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Web, Security+tested-with: ghc ==9.12.4+extra-doc-files: CHANGELOG.md++source-repository head+ type: git+ location: https://github.com/shinzui/shomei.git++flag review-load+ description: Build the isolated remediation workload tool (excluded from production builds).+ default: False+ manual: True++common warnings+ ghc-options:+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns+ -Wpartial-fields+ -Wredundant-constraints++common shared+ default-language: GHC2024+ default-extensions:+ BlockArguments+ DataKinds+ DeriveAnyClass+ DuplicateRecordFields+ LambdaCase+ MultilineStrings+ OverloadedLabels+ OverloadedRecordDot+ OverloadedStrings+ QualifiedDo+ TemplateHaskell+ TypeApplications+ TypeFamilies+ TypeOperators++library+ import: warnings, shared+ hs-source-dirs: src+ exposed-modules:+ Shomei.Health.Server+ Shomei.Notify+ Shomei.Notify.Queue+ Shomei.Server.App+ Shomei.Server.Boot+ Shomei.Server.BreachChecker+ Shomei.Server.Config+ Shomei.Server.DatabaseError+ Shomei.Server.ExceptionResponse+ Shomei.Server.Keys+ Shomei.Server.Middleware.BodyLimit+ Shomei.Server.Middleware.RateLimit+ Shomei.Server.Middleware.TrustedProxy+ Shomei.Server.Observability.Logging+ Shomei.Server.Observability.Metrics+ Shomei.Server.Runtime+ Shomei.Server.Supervisor++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ crypton >=1.1 && <1.2,+ effectful >=2.5 && <2.8,+ effectful-core >=2.5 && <2.8,+ hasql >=1.10 && <1.11,+ http-client >=0.7 && <0.8,+ http-client-tls >=0.3 && <0.5,+ http-types >=0.12 && <0.13,+ iproute >=1.7.8 && <1.8,+ jose >=0.13 && <0.14,+ mime-mail >=0.5 && <0.6,+ network >=3.1 && <3.3,+ pg-migrate >=1.2 && <1.3,+ process >=1.6 && <1.7,+ ram >=0.22 && <0.23,+ servant-health >=0.1 && <0.2,+ servant-server >=0.20.3 && <0.21,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-servant ^>=0.3.0.0,+ shomei-webauthn ^>=0.3.0.0,+ smtp-mail >=0.5.0.2 && <0.6,+ stm >=2.5 && <2.6,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15,+ unix >=2.8 && <2.9,+ unordered-containers >=0.2 && <0.3,+ uuid >=1.3 && <1.4,+ wai >=3.2 && <3.3,+ warp >=3.3 && <3.5,++executable shomei-server+ import: warnings, shared+ hs-source-dirs: app+ main-is: Main.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ build-depends:+ base >=4.18 && <5,+ shomei-server ^>=0.3.0.0,++-- EP-4: the operational CLI. A second executable in this package (MasterPlan 2 default;+-- avoids registering a new mori.dhall package). It reuses the same pool/config/key/interpreter+-- assembly the server uses, but needs no servant/wai.+executable shomei-admin+ import: warnings, shared+ hs-source-dirs: app+ main-is: Admin.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ other-modules:+ Shomei.Admin.Audit+ Shomei.Admin.Env+ Shomei.Admin.Keys+ Shomei.Admin.OAuthClients+ Shomei.Admin.Roles+ Shomei.Admin.ServiceAccounts+ Shomei.Admin.Sweep+ Shomei.Admin.Users++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ contravariant-extras >=0.3 && <0.4,+ effectful >=2.5 && <2.8,+ effectful-core >=2.5 && <2.8,+ hasql >=1.10 && <1.11,+ http-client >=0.7 && <0.8,+ http-client-tls >=0.3 && <0.5,+ optparse-applicative >=0.19 && <0.20,+ pg-migrate >=1.2 && <1.3,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15,+ uuid >=1.3 && <1.4,++test-suite shomei-server-config-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Shomei/Server/ConfigSpec.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ directory >=1.3 && <1.4,+ process >=1.6 && <1.7,+ shomei-core ^>=0.3.0.0,+ shomei-postgres ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ tasty >=1.4 && <1.6,+ tasty-hunit >=0.10 && <0.11,+ text >=2.0 && <2.2,++test-suite shomei-admin-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs:+ app+ test++ main-is: Admin/Main.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ other-modules:+ Shomei.Admin.Audit+ Shomei.Admin.Env+ Shomei.Admin.Keys+ Shomei.Admin.OAuthClients+ Shomei.Admin.Roles+ Shomei.Admin.ServiceAccounts+ Shomei.Admin.Sweep+ Shomei.Admin.Users++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ contravariant-extras >=0.3 && <0.4,+ effectful >=2.5 && <2.8,+ effectful-core >=2.5 && <2.8,+ hasql >=1.10 && <1.11,+ http-client >=0.7 && <0.8,+ http-client-tls >=0.3 && <0.5,+ jose >=0.13 && <0.14,+ optparse-applicative >=0.19 && <0.20,+ ram >=0.22 && <0.23,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations ^>=0.2.0.1,+ shomei-migrations:test-support ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ tasty >=1.4 && <1.6,+ tasty-hunit >=0.10 && <0.11,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15,+ uuid >=1.3 && <1.4,++test-suite shomei-server-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ other-modules:+ Shomei.Server.E2ESpec+ Shomei.Server.MiddlewareSpec+ Shomei.Server.NotifySpec+ Shomei.Server.RuntimeSpec+ Shomei.Server.SupervisorSpec++ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ base64 >=1.0 && <1.1,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ directory >=1.3 && <1.4,+ effectful >=2.5 && <2.8,+ effectful-core >=2.5 && <2.8,+ hasql >=1.10 && <1.11,+ http-client >=0.7 && <0.8,+ http-types >=0.12 && <0.13,+ network >=3.1 && <3.3,+ servant-health >=0.1 && <0.2,+ servant-health:testkit,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations:test-support ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-servant ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ tasty >=1.4 && <1.6,+ tasty-hunit >=0.10 && <0.11,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15,+ wai >=3.2 && <3.3,+ warp >=3.3 && <3.5,++test-suite shomei-health-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test-health+ main-is: Main.hs+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N++ build-depends:+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ http-client >=0.7 && <0.8,+ servant-health >=0.1 && <0.2,+ servant-health:testkit,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations:test-support ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ tasty >=1.4 && <1.6,+ tasty-hunit >=0.10 && <0.11,++-- Isolated acceptance driver; never accepts a deployment database or external HTTP target.+executable shomei-review-load+ import: warnings, shared+ hs-source-dirs: review-load+ main-is: Main.hs++ if !flag(review-load)+ buildable: False+ ghc-options:+ -threaded+ -rtsopts+ -with-rtsopts=-N2++ build-depends:+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ directory >=1.3 && <1.4,+ hasql >=1.10 && <1.11,+ http-client >=0.7 && <0.8,+ http-types >=0.12 && <0.13,+ process >=1.6 && <1.7,+ servant-health >=0.1 && <0.2,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ shomei-migrations:test-support ^>=0.2.0.1,+ shomei-postgres ^>=0.3.0.0,+ shomei-servant ^>=0.3.0.0,+ shomei-server ^>=0.3.0.0,+ text >=2.0 && <2.2,+ unix >=2.8 && <2.9,+ uuid >=1.3 && <1.4,+ warp >=3.3 && <3.5,
+ src/Shomei/Health/Server.hs view
@@ -0,0 +1,85 @@+-- | Production liveness and readiness policy for the standalone service.+module Shomei.Health.Server+ ( HealthPolicy (..),+ defaultHealthPolicy,+ buildHealthChecks,+ buildHealthChecksWith,+ )+where++import Control.Concurrent.MVar (modifyMVar, newMVar)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import Servant.Health (ProbeCheck)+import Servant.Health.Check+ ( boolCheck,+ newFailureTracker,+ safeCheck,+ sequenceChecks,+ withProbeTimeout,+ )+import Shomei.Error (AuthError)+import Shomei.Server.App (Env, runAppIO)+import Shomei.SigningKey.Domain (StoredSigningKey)+import Shomei.SigningKey.Store (listActiveSigningKeys)++data HealthPolicy = HealthPolicy+ { readinessTimeoutMicros :: !Int,+ readinessCacheMicros :: !Int+ }+ deriving stock (Eq, Show)++defaultHealthPolicy :: HealthPolicy+defaultHealthPolicy =+ HealthPolicy+ { readinessTimeoutMicros = 2_000_000,+ readinessCacheMicros = 1_000_000+ }++-- | Production checks backed by the standalone server's interpreter stack.+buildHealthChecks :: Env -> IO (ProbeCheck, ProbeCheck)+buildHealthChecks env = buildHealthChecksWith defaultHealthPolicy (runAppIO env listActiveSigningKeys)++-- | Construct the two tracked checks once at startup. The injected query is the service-owned+-- IO bridge for @listActiveSigningKeys@: a typed execution failure is reported as @postgres@,+-- while a successful empty result is reported as @signing-key@.+buildHealthChecksWith :: HealthPolicy -> IO (Either AuthError [StoredSigningKey]) -> IO (ProbeCheck, ProbeCheck)+buildHealthChecksWith policy runSigningKeyQuery = do+ trackLiveness <- newFailureTracker+ trackReadiness <- newFailureTracker+ cachedReadiness <-+ cachedFor policy.readinessCacheMicros+ . withProbeTimeout policy.readinessTimeoutMicros "postgres"+ . sequenceChecks+ $ [ safeCheck "postgres" do+ runSigningKeyQuery >>= \case+ Left _ -> boolCheck "postgres" (pure False)+ Right keys -> boolCheck "signing-key" (pure (not (null keys)))+ ]+ let liveness =+ trackLiveness+ . withProbeTimeout 2_000_000 "liveness"+ . safeCheck "liveness"+ $ boolCheck "liveness" (pure True)+ readiness = trackReadiness cachedReadiness+ pure (liveness, readiness)++-- | Cache one readiness verdict for a short monotonic-time window. Holding the MVar while the+-- check runs makes cache refresh single-flight: concurrent probes share one dependency query.+cachedFor :: Int -> ProbeCheck -> IO ProbeCheck+cachedFor micros check+ | micros <= 0 = pure check+ | otherwise = do+ cache <- newMVar Nothing+ let windowNanos = fromIntegral micros * 1_000 :: Word64+ pure $ modifyMVar cache \stored -> do+ now <- getMonotonicTimeNSec+ case stored of+ Just (observedAt, verdict)+ | now >= observedAt,+ now - observedAt < windowNanos ->+ pure (stored, verdict)+ _ -> do+ verdict <- check+ observedAt <- getMonotonicTimeNSec+ pure (Just (observedAt, verdict), verdict)
+ src/Shomei/Notify.hs view
@@ -0,0 +1,649 @@+-- | Notification interpreters for the standalone server.+--+-- The core defines the 'Notifier' effect and emits a 'Notification' (recipient, one-time+-- link/token, expiry); turning that into a delivered message is this module's job. It provides+-- three built-in interpreters, selected by 'runNotifierFromConfig' from the operator's+-- 'Shomei.Config.NotifierTransport':+--+-- * 'runNotifierLog' writes the link to the server log (the default; development / log-scraping).+--+-- * 'runNotifierSmtp' delivers a plain-text email through a __provider relay__ (SES, SendGrid,+-- Resend, Postmark) over implicit-TLS / STARTTLS / plaintext-lab modes. Not a mail server.+--+-- * 'runNotifierWebhook' POSTs the notification as HMAC-signed JSON to a configured URL.+--+-- Both delivering interpreters are __fire-and-forget and hardened__: every exception is caught+-- inside the interpreter, a failed delivery logs one redacted line and publishes a+-- 'Shomei.Audit.Event.Domain.NotificationDeliveryFailed' audit event, and the triggering HTTP request+-- still succeeds. Their operational log lines never contain the one-time token. Operators who+-- want a provider Shōmei does not ship supply their own 'Notifier' interpreter.+module Shomei.Notify+ ( SmtpPassword (..),+ WebhookSecret (..),+ NotifierSecrets (..),+ noNotifierSecrets,+ smtpPasswordText,+ webhookSecretBytes,+ DeliveryReason (..),+ SmtpStage (..),+ reasonText,+ classifySmtpFailure,+ classifyWebhookFailure,+ redactDeliveryText,+ runNotifierEnqueue,+ runNotifierFromConfig,+ runNotifierLog,+ runNotifierSmtp,+ runNotifierWebhook,+ renderNotification,+ notificationTypeText,+ transportChannel,+ deliverNotification,+ webhookSignature,+ )+where++import Control.Concurrent (threadDelay)+import Control.Exception (IOException, SomeException, fromException, try)+import Crypto.Hash.Algorithms (SHA256)+import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)+import Data.Aeson (encode)+import Data.ByteArray.Encoding (Base (Base16), convertToBase)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as BSL+import Data.Char (isSpace)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Text.Lazy qualified as TL+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)+import Data.Time.Format.ISO8601 (iso8601Show)+import Effectful (Eff, IOE, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Network.HTTP.Client+ ( HttpException (..),+ HttpExceptionContent (..),+ Manager,+ RequestBody (RequestBodyBS),+ httpLbs,+ method,+ parseRequest,+ requestBody,+ requestHeaders,+ responseStatus,+ responseTimeout,+ responseTimeoutMicro,+ )+import Network.HTTP.Types.Status (statusCode, statusIsSuccessful)+import Network.Mail.Mime (Address (..), Mail, simpleMail')+import Network.Mail.SMTP+ ( sendMail',+ sendMailSTARTTLS',+ sendMailTLS',+ sendMailWithLogin',+ sendMailWithLoginSTARTTLS',+ sendMailWithLoginTLS',+ )+import Shomei.Account.Email.Domain (emailText)+import Shomei.Account.Notification.Domain (Notification (..))+import Shomei.Account.Notification.Store (Notifier (..))+import Shomei.Account.OneTimeToken.Domain (OneTimeToken, oneTimeTokenText)+import Shomei.Account.Password.Hash.Postgres (sha256Hex)+import Shomei.Audit.Event.Domain (AuthEvent (NotificationDeliveryFailed), NotificationDeliveryFailedData (..))+import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)+import Shomei.Config (NotifierConfig (..), NotifierTransport (..), ShomeiConfig (..), SmtpConfig (..), SmtpTlsMode (..), WebhookConfig (..))+import Shomei.Notify.Queue (NotifierQueue)+import Shomei.Notify.Queue qualified as Queue+import Shomei.Prelude+import Shomei.Time.Store (Clock, now)+import System.IO (hPutStrLn, stderr)+import System.IO.Error (ioeGetErrorString, isUserError)+import System.Timeout (timeout)+import Text.Read (readMaybe)++-- | Stable, secret-free reasons that may be written to logs and persisted audit rows.+-- Third-party exception text is inspected only long enough to select one of these values.+data DeliveryReason+ = ConnectFailed+ | TlsFailed+ | AuthFailed+ | Timeout+ | RejectedAt SmtpStage Int+ | DataRefused+ | InvalidUrl+ | HttpStatus Int+ | RedirectLoop+ | TransportError+ | QueueFull+ | ShuttingDown+ | ExpiredInQueue+ | Unknown+ deriving stock (Eq, Show)++data SmtpStage = AtEhlo | AtStartTls | AtMail | AtRcpt | AtData+ deriving stock (Eq, Show)++-- | SMTP credential material. Deliberately has no 'Show', 'Eq', or JSON instances: placing it+-- in a printable configuration or diagnostic structure is a type error.+newtype SmtpPassword = SmtpPassword Text++-- | Webhook HMAC key. Deliberately has no 'Show', 'Eq', or JSON instances.+newtype WebhookSecret = WebhookSecret Text++-- | Runtime-only notifier credentials, carried beside (never inside) 'ShomeiConfig'.+data NotifierSecrets = NotifierSecrets+ { smtpPassword :: !(Maybe SmtpPassword),+ webhookSecret :: !(Maybe WebhookSecret)+ }++noNotifierSecrets :: NotifierSecrets+noNotifierSecrets = NotifierSecrets {smtpPassword = Nothing, webhookSecret = Nothing}++smtpPasswordText :: SmtpPassword -> Text+smtpPasswordText (SmtpPassword password) = password++webhookSecretBytes :: WebhookSecret -> ByteString+webhookSecretBytes (WebhookSecret secret) = TE.encodeUtf8 secret++reasonText :: DeliveryReason -> Text+reasonText = \case+ ConnectFailed -> "connect_failed"+ TlsFailed -> "tls_failed"+ AuthFailed -> "auth_failed"+ Timeout -> "timeout"+ RejectedAt stage code -> "rejected_at_" <> smtpStageText stage <> ":" <> Text.pack (show code)+ DataRefused -> "data_refused"+ InvalidUrl -> "invalid_url"+ HttpStatus code -> "http_status:" <> Text.pack (show code)+ RedirectLoop -> "redirect_loop"+ TransportError -> "transport_error"+ QueueFull -> "queue_full"+ ShuttingDown -> "shutting_down"+ ExpiredInQueue -> "expired_in_queue"+ Unknown -> "unknown"++smtpStageText :: SmtpStage -> Text+smtpStageText = \case+ AtEhlo -> "ehlo"+ AtStartTls -> "starttls"+ AtMail -> "mail"+ AtRcpt -> "rcpt"+ AtData -> "data"++-- | Collapse smtp-mail failures into a vocabulary that cannot contain the rendered message.+classifySmtpFailure :: SomeException -> DeliveryReason+classifySmtpFailure err =+ case fromException err :: Maybe IOException of+ Just ioe+ | not (isUserError ioe) -> ConnectFailed+ | otherwise -> classifyUserError (Text.pack (ioeGetErrorString ioe))+ Nothing+ | looksLikeTls (Text.pack (show err)) -> TlsFailed+ | looksLikeConnectFailure (Text.pack (show err)) -> ConnectFailed+ | otherwise -> Unknown+ where+ classifyUserError msg+ | "timed out" `Text.isInfixOf` lower = Timeout+ | "authentication failed" `Text.isInfixOf` lower = AuthFailed+ | "cannot connect to the server" `Text.isInfixOf` lower = ConnectFailed+ | "connection refused" `Text.isInfixOf` lower = ConnectFailed+ | "failed to connect" `Text.isInfixOf` lower = ConnectFailed+ | "cannot accept any data" `Text.isInfixOf` lower = DataRefused+ | Just rejected <- parseRejected msg = rejected+ | otherwise = Unknown+ where+ lower = Text.toLower msg++-- | Collapse http-client failures without retaining the request, URL, headers, or response body.+classifyWebhookFailure :: SomeException -> DeliveryReason+classifyWebhookFailure err =+ case fromException err of+ Just (InvalidUrlException _ _) -> InvalidUrl+ Just (HttpExceptionRequest _ content) -> classifyHttpContent content+ Nothing -> TransportError+ where+ classifyHttpContent = \case+ ResponseTimeout -> Timeout+ ConnectionTimeout -> Timeout+ ConnectionFailure _ -> ConnectFailed+ TooManyRedirects _ -> RedirectLoop+ StatusCodeException response _ -> HttpStatus (statusCode (responseStatus response))+ InternalException inner+ | looksLikeTls (Text.pack (show inner)) -> TlsFailed+ _ -> TransportError++looksLikeTls :: Text -> Bool+looksLikeTls rendered =+ any (`Text.isInfixOf` rendered) ["HandshakeFailed", "TLSException", "TlsException"]++looksLikeConnectFailure :: Text -> Bool+looksLikeConnectFailure rendered =+ any+ (`Text.isInfixOf` rendered)+ ["HostCannotConnect", "Network.Socket.connect", "Connection refused"]++parseRejected :: Text -> Maybe DeliveryReason+parseRejected msg = do+ commandAndRest <- Text.stripPrefix "Unexpected reply to: " msg+ stage <- parseStage commandAndRest+ let (_, reply) = Text.breakOn "Got this instead: " commandAndRest+ guard (not (Text.null reply))+ code <- readMaybe (Text.unpack (Text.take 3 (Text.drop (Text.length "Got this instead: ") reply)))+ pure (RejectedAt stage code)+ where+ parseStage command+ | "EHLO" `Text.isPrefixOf` command || "HELO" `Text.isPrefixOf` command = Just AtEhlo+ | "STARTTLS" `Text.isPrefixOf` command = Just AtStartTls+ | "MAIL" `Text.isPrefixOf` command = Just AtMail+ | "RCPT" `Text.isPrefixOf` command = Just AtRcpt+ | "DATA" `Text.isPrefixOf` command = Just AtData+ | otherwise = Nothing++-- | Select the notifier interpreter from configuration and run it, reusing the server's shared+-- TLS 'Manager' for the webhook transport. A single dispatching handler both implements the+-- @alsoLogNotifications@ tee (log first, then deliver — no double delivery) and guards against a+-- selected transport whose sub-config is somehow absent by falling back to the log sender with a+-- one-line warning (boot validation makes that unreachable in the standalone server).+--+-- This is written as one 'interpret_' rather than the plan's @interpose@-based tee: forwarding to+-- an underlying handler by re-'send'ing inside an 'interpose' handler would re-enter the tee+-- handler and loop. Dispatching per notification is unambiguous and runs each delivery once.+runNotifierFromConfig ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ Manager ->+ NotifierSecrets ->+ ShomeiConfig ->+ Eff (Notifier : es) a ->+ Eff es a+runNotifierFromConfig mgr secrets cfg = interpret_ \case+ SendNotification notification -> deliverNotification mgr secrets cfg notification++-- | The standalone request-path interpreter. Enqueueing is one bounded STM transaction and+-- never waits for a relay. Overflow and shutdown are still observable through the audit port.+runNotifierEnqueue ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ NotifierQueue ->+ Text ->+ Eff (Notifier : es) a ->+ Eff es a+runNotifierEnqueue notifierQueue channel = interpret_ \case+ SendNotification notification -> do+ outcome <- liftIO (Queue.enqueueNotification notifierQueue notification)+ case outcome of+ Queue.Enqueued -> pure ()+ Queue.QueueFull -> publishDeliveryFailed channel notification QueueFull+ Queue.QueueClosed -> publishDeliveryFailed channel notification ShuttingDown++-- | Deliver one dequeued notification. Expired work is audited and skipped before any network+-- operation; otherwise this is exactly the synchronous interpreter's former dispatch path.+deliverNotification ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ Manager ->+ NotifierSecrets ->+ ShomeiConfig ->+ Notification ->+ Eff es ()+deliverNotification mgr secrets cfg notification = do+ currentTime <- now+ if notificationExpiresAt notification <= currentTime+ then publishDeliveryFailed (transportChannel nc.notifierTransport) notification ExpiredInQueue+ else do+ when tee (logNotification nc notification)+ deliver notification+ where+ nc = cfg.notifierConfig+ tee = nc.alsoLogNotifications && nc.notifierTransport /= LogNotifier+ deliver = case nc.notifierTransport of+ LogNotifier -> logNotification nc+ SmtpNotifier -> maybe (logFallback "smtp" nc) (\sc -> deliverSmtp nc sc secrets.smtpPassword) nc.smtpConfig+ WebhookNotifier -> case (nc.webhookConfig, secrets.webhookSecret) of+ (Just wc, Just secret) -> deliverWebhook mgr wc secret+ _ -> \undeliverable -> publishDeliveryFailed "webhook" undeliverable AuthFailed++transportChannel :: NotifierTransport -> Text+transportChannel = \case+ LogNotifier -> "log"+ SmtpNotifier -> "smtp"+ WebhookNotifier -> "webhook"++notificationExpiresAt :: Notification -> UTCTime+notificationExpiresAt = \case+ EmailVerificationRequested _ _ expires -> expires+ PasswordResetRequested _ _ expires -> expires++-- | Fallback used only if a transport is selected with no sub-config (boot validation prevents+-- this): warn once and log the notification rather than silently dropping it.+logFallback :: (IOE :> es) => Text -> NotifierConfig -> Notification -> Eff es ()+logFallback which nc n = do+ liftIO (hPutStrLn stderr ("[shomei:" <> Text.unpack which <> "] no configuration; falling back to the log sender"))+ logNotification nc n++runNotifierLog :: (IOE :> es) => NotifierConfig -> Eff (Notifier : es) a -> Eff es a+runNotifierLog cfg = interpret_ \case+ SendNotification n -> logNotification cfg n++-- | Write one notification to stderr through 'renderNotification' (token redacted unless+-- 'NotifierConfig.logRawTokens'). The per-notification primitive shared by the log sender and the+-- @alsoLogNotifications@ tee.+logNotification :: (IOE :> es) => NotifierConfig -> Notification -> Eff es ()+logNotification cfg n = liftIO (hPutStrLn stderr (renderNotification cfg n))++-- | Render a notification as one log line.+--+-- By default the one-time token is __redacted__: the line carries only the first 8 hex+-- characters of its SHA-256, which is enough to correlate a log line with the token's+-- stored hash trail but useless for taking the account over (the token itself is 32 random+-- bytes). No link is printed either — a link without its token is noise.+--+-- Setting 'NotifierConfig.logRawTokens' (env @SHOMEI_NOTIFIER_LOG_SECRETS=true@) restores+-- the full clickable link. That is for local development, where the logged link is how you+-- complete the flow; in any shared environment it hands account takeover to whoever can+-- read the log.+renderNotification :: NotifierConfig -> Notification -> String+renderNotification cfg = \case+ EmailVerificationRequested email token expires ->+ line "email_verification" "/v1/auth/verify-email/confirm" email token expires+ PasswordResetRequested email token expires ->+ line "password_reset" "/v1/auth/password-reset/confirm" email token expires+ where+ line kind path email token expires =+ "[shomei:log] "+ <> kind+ <> " email="+ <> Text.unpack (emailText email)+ <> secretPart path token+ <> " expires_at="+ <> show expires+ <> hint+ secretPart path token+ | cfg.logRawTokens =+ " link="+ <> Text.unpack cfg.publicBaseUrl+ <> path+ <> "?token="+ <> Text.unpack (oneTimeTokenText token)+ | otherwise = " token_sha256=" <> Text.unpack (tokenPrefix token)+ hint+ | cfg.logRawTokens = ""+ | otherwise = " (set SHOMEI_NOTIFIER_LOG_SECRETS=true to log the full link in development)"++-- | The first 8 hex characters of the token's SHA-256 — a correlation handle, not a secret.+-- One-time tokens are stored as SHA-256 too (base64url rather than hex), so this prefix+-- ties a log line to its @token_hash@ row.+tokenPrefix :: OneTimeToken -> Text+tokenPrefix = Text.take 8 . sha256Hex . oneTimeTokenText++-- SMTP interpreter (EP-8) -----------------------------------------------------++-- | Deliver notifications as plain-text email through a __provider relay__ (SES, SendGrid,+-- Resend, Postmark). Fire-and-forget and hardened: every exception (including a timeout) is+-- caught, a failed send logs one redacted line and publishes a 'NotificationDeliveryFailed'+-- audit event, and the triggering workflow still returns success. This interpreter sits above+-- 'AuthEventPublisher'/'Clock' in the server stack, so it may publish and read the clock.+runNotifierSmtp ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ NotifierConfig ->+ SmtpConfig ->+ Maybe SmtpPassword ->+ Eff (Notifier : es) a ->+ Eff es a+runNotifierSmtp nc sc password = interpret_ \case+ SendNotification n -> deliverSmtp nc sc password n++-- | Deliver one notification over SMTP: build the message, send it under a timeout, and on any+-- failure publish the redacted 'NotificationDeliveryFailed' event. The per-notification primitive+-- shared by 'runNotifierSmtp' and the config dispatcher.+deliverSmtp ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ NotifierConfig ->+ SmtpConfig ->+ Maybe SmtpPassword ->+ Notification ->+ Eff es ()+deliverSmtp nc sc password n = do+ let (subject, body) = renderEmail nc n+ recipient = notificationRecipient n+ mail = simpleMail' (Address Nothing recipient) (Address Nothing sc.fromAddress) subject body+ outcome <- liftIO (try @SomeException (sendViaSmtp sc password mail))+ case outcome of+ Right () -> pure ()+ Left err -> publishDeliveryFailed "smtp" n (classifySmtpFailure err)++-- | Run the SMTP dialogue for one message under a timeout, choosing the connection mode from+-- 'SmtpTlsMode' and using the authenticated variant when credentials are present. Boot+-- validation guarantees username and password are both present or both absent. The explicit+-- mismatch branch keeps a hand-built embedding configuration from silently downgrading.+sendViaSmtp :: SmtpConfig -> Maybe SmtpPassword -> Mail -> IO ()+sendViaSmtp sc mp mail = do+ let SmtpConfig {host = h, port = p, tlsMode = tls, username = mu, timeoutSeconds = to} = sc+ host' = Text.unpack h+ port' = fromIntegral p+ creds = (,) <$> mu <*> fmap smtpPasswordText mp+ send = case (mu, mp, tls, creds) of+ (Just _, Nothing, _, _) -> ioError (userError "SMTP credentials are incomplete")+ (Nothing, Just _, _, _) -> ioError (userError "SMTP credentials are incomplete")+ (_, _, SmtpPlain, Just (u, pw)) -> sendMailWithLogin' host' port' (Text.unpack u) (Text.unpack pw) mail+ (_, _, SmtpPlain, Nothing) -> sendMail' host' port' mail+ (_, _, SmtpStartTls, Just (u, pw)) -> sendMailWithLoginSTARTTLS' host' port' (Text.unpack u) (Text.unpack pw) mail+ (_, _, SmtpStartTls, Nothing) -> sendMailSTARTTLS' host' port' mail+ (_, _, SmtpImplicitTls, Just (u, pw)) -> sendMailWithLoginTLS' host' port' (Text.unpack u) (Text.unpack pw) mail+ (_, _, SmtpImplicitTls, Nothing) -> sendMailTLS' host' port' mail+ result <- timeout (max 1 to * 1_000_000) send+ case result of+ Just () -> pure ()+ Nothing -> ioError (userError ("SMTP delivery to " <> host' <> " timed out after " <> show to <> "s"))++-- Shared rendering + failure reporting ----------------------------------------++-- | The event-style type string for a notification, used both in the failure audit event and as+-- the webhook @X-Shomei-Notification-Type@ header (M3). Kept identical to the audit @event_type@+-- vocabulary so a reader correlates the two.+notificationTypeText :: Notification -> Text+notificationTypeText = \case+ EmailVerificationRequested {} -> "email_verification_requested"+ PasswordResetRequested {} -> "password_reset_requested"++-- | The recipient address of a notification.+notificationRecipient :: Notification -> Text+notificationRecipient = \case+ EmailVerificationRequested e _ _ -> emailText e+ PasswordResetRequested e _ _ -> emailText e++-- | The fixed English subject and plain-text body for a notification. These are the only two+-- bodies Shōmei ever emails; there is no templating, i18n, or HTML part (operators who want+-- branded copy take the webhook or a custom interpreter). The confirm links reuse the exact+-- @\/v1@ routes 'renderNotification' logs, so host confirm pages keep working unchanged.+renderEmail :: NotifierConfig -> Notification -> (Text, TL.Text)+renderEmail nc = \case+ EmailVerificationRequested _ token expires ->+ ( "Verify your email address",+ TL.fromStrict+ ( body+ [ "Hello,",+ "",+ "Please confirm your email address by opening this link:",+ "",+ link "/v1/auth/verify-email/confirm" token,+ "",+ "This link expires at " <> isoUtc expires <> " (UTC). If you did not request this,",+ "you can ignore this message."+ ]+ )+ )+ PasswordResetRequested _ token expires ->+ ( "Reset your password",+ TL.fromStrict+ ( body+ [ "Hello,",+ "",+ "A password reset was requested for your account. Open this link to",+ "choose a new password:",+ "",+ link "/v1/auth/password-reset/confirm" token,+ "",+ "This link expires at " <> isoUtc expires <> " (UTC). If you did not request this,",+ "you can ignore this message and your password will remain unchanged."+ ]+ )+ )+ where+ body = Text.intercalate "\n"+ link path token = nc.publicBaseUrl <> path <> "?token=" <> oneTimeTokenText token+ isoUtc = Text.pack . iso8601Show++-- | Log one redacted line and publish a 'NotificationDeliveryFailed' audit event for a delivery+-- that failed after exhausting its attempts. Shared by the SMTP and webhook interpreters. The+-- token appears __nowhere__: only channel, notification type, recipient, and a closed reason+-- code are emitted. The defence-in-depth redactor makes a future free-text reason safe too.+publishDeliveryFailed ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ -- | channel: @"smtp"@ | @"webhook"@+ Text ->+ Notification ->+ DeliveryReason ->+ Eff es ()+publishDeliveryFailed channel n reason = do+ let recipient = notificationRecipient n+ kind = notificationTypeText n+ safeReason = redactDeliveryText n (reasonText reason)+ liftIO+ ( hPutStrLn+ stderr+ ( Text.unpack+ ( "[shomei:"+ <> channel+ <> "] delivery_failed type="+ <> kind+ <> " recipient="+ <> recipient+ <> " reason="+ <> safeReason+ )+ )+ )+ occ <- now+ publishAuthEvent+ ( NotificationDeliveryFailed+ NotificationDeliveryFailedData+ { channel = channel,+ notificationType = kind,+ recipient = recipient,+ errorText = safeReason,+ occurredAt = occ+ }+ )++-- | Defence in depth for text associated with a delivery. The public failure path uses only+-- 'DeliveryReason', but this also removes the notification's token and any @token=@ parameter+-- before bounding the text in case a future transport threads richer context through here.+redactDeliveryText :: Notification -> Text -> Text+redactDeliveryText notification =+ truncateText+ . redactTokenParameters+ . Text.replace (oneTimeTokenText (notificationToken notification)) "<redacted>"++notificationToken :: Notification -> OneTimeToken+notificationToken = \case+ EmailVerificationRequested _ token _ -> token+ PasswordResetRequested _ token _ -> token++redactTokenParameters :: Text -> Text+redactTokenParameters input =+ case Text.breakOn "token=" input of+ (_, rest) | Text.null rest -> input+ (prefix, rest) ->+ let valueAndSuffix = Text.drop (Text.length "token=") rest+ suffix = Text.dropWhile isTokenCharacter valueAndSuffix+ in prefix <> "token=<redacted>" <> redactTokenParameters suffix+ where+ isTokenCharacter c = not (isSpace c || c `elem` ['&', '\'', '"'])++-- | Collapse whitespace (including newlines) to single spaces and cap at 500 characters, so an+-- error string is one safe line for a log and an audit payload.+truncateText :: Text -> Text+truncateText = Text.take 500 . Text.unwords . Text.words++-- Webhook interpreter (EP-8) --------------------------------------------------++-- | Deliver notifications as a signed JSON POST to a configured URL, reusing the server's shared+-- TLS 'Manager'. Same fire-and-forget hardening as 'runNotifierSmtp': all exceptions caught, a+-- non-2xx response counts as a failure, bounded retries with backoff, then one redacted log line+-- plus a 'NotificationDeliveryFailed' audit event. The JSON body is the notification's derived+-- 'ToJSON' (so it carries the raw token — the receiver builds the link), signed over the exact+-- bytes sent with @X-Shomei-Signature: sha256=<hex HMAC-SHA256>@.+runNotifierWebhook ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ Manager ->+ WebhookConfig ->+ WebhookSecret ->+ Eff (Notifier : es) a ->+ Eff es a+runNotifierWebhook mgr wc secret = interpret_ \case+ SendNotification n -> deliverWebhook mgr wc secret n++-- | Deliver one notification over the webhook and, on ultimate failure, publish the redacted+-- 'NotificationDeliveryFailed' event. The per-notification primitive shared by+-- 'runNotifierWebhook' and the config dispatcher.+deliverWebhook ::+ (IOE :> es, AuthEventPublisher :> es, Clock :> es) =>+ Manager ->+ WebhookConfig ->+ WebhookSecret ->+ Notification ->+ Eff es ()+deliverWebhook mgr wc secret n = do+ result <- attemptWebhook mgr wc secret n+ case result of+ Nothing -> pure ()+ Just reason -> publishDeliveryFailed "webhook" n reason++-- | POST the notification, retrying up to 'WebhookConfig.maxAttempts' with @4^(k-1)@-second+-- backoff (1 s, 4 s, …) between attempts, each under the configured per-attempt timeout. Returns+-- 'Nothing' on the first 2xx, or a secret-free reason after the last attempt fails. All exceptions are+-- caught here; nothing escapes to the interpreter. Each retry gets a fresh timestamp and+-- signature so a receiver's replay window measures the actual attempt, not initial enqueue.+attemptWebhook :: (IOE :> es, Clock :> es) => Manager -> WebhookConfig -> WebhookSecret -> Notification -> Eff es (Maybe DeliveryReason)+attemptWebhook mgr wc secret n = do+ let WebhookConfig {url = u, timeoutSeconds = to, maxAttempts = maxA} = wc+ body = BSL.toStrict (encode n)+ kind = notificationTypeText n+ attempts = max 1 maxA+ reqE <- liftIO (try @SomeException (parseRequest (Text.unpack u)))+ case reqE of+ Left _ -> pure (Just InvalidUrl)+ Right req0 -> go req0 body kind attempts to 1+ where+ go req0 body kind attempts to k = do+ attemptTime <- now+ let timestamp = TE.encodeUtf8 (Text.pack (show (floor (utcTimeToPOSIXSeconds attemptTime) :: Integer)))+ sig = webhookSignature (webhookSecretBytes secret) timestamp body+ req =+ req0+ { method = "POST",+ requestBody = RequestBodyBS body,+ requestHeaders =+ [ ("Content-Type", "application/json"),+ ("X-Shomei-Signature", sig),+ ("X-Shomei-Timestamp", timestamp),+ ("X-Shomei-Notification-Type", TE.encodeUtf8 kind),+ ("User-Agent", "shomei")+ ],+ responseTimeout = responseTimeoutMicro (max 1 to * 1_000_000)+ }+ outcome <- liftIO (try @SomeException (httpLbs req mgr))+ let failed reason+ | k >= attempts = pure (Just reason)+ | otherwise = liftIO (threadDelay (4 ^ (k - 1) * 1_000_000)) >> go req0 body kind attempts to (k + 1)+ case outcome of+ Right resp+ | statusIsSuccessful (responseStatus resp) -> pure Nothing+ | otherwise -> failed (HttpStatus (statusCode (responseStatus resp)))+ Left err -> failed (classifyWebhookFailure err)++-- | The @X-Shomei-Signature@ header value: @sha256=@ followed by the lowercase-hex+-- HMAC-SHA256 of @<unix-seconds>.<exact raw body>@ under the shared secret.+webhookSignature :: ByteString -> ByteString -> ByteString -> ByteString+webhookSignature secret timestamp body =+ "sha256="+ <> convertToBase+ Base16+ (hmacGetDigest (hmac secret (timestamp <> "." <> body) :: HMAC SHA256))
+ src/Shomei/Notify/Queue.hs view
@@ -0,0 +1,96 @@+-- | A bounded, non-blocking notification queue shared by request handlers and the worker.+module Shomei.Notify.Queue+ ( NotifierQueue,+ EnqueueOutcome (..),+ newNotifierQueue,+ enqueueNotification,+ withDequeued,+ closeNotifierQueue,+ drainNotifierQueue,+ )+where++import Control.Concurrent (threadDelay)+import Control.Concurrent.STM+ ( TVar,+ atomically,+ modifyTVar',+ newTVarIO,+ readTVar,+ writeTVar,+ )+import Control.Concurrent.STM.TBQueue+ ( TBQueue,+ isFullTBQueue,+ lengthTBQueue,+ newTBQueueIO,+ readTBQueue,+ writeTBQueue,+ )+import Control.Exception (bracket)+import GHC.Clock (getMonotonicTimeNSec)+import Shomei.Account.Notification.Domain (Notification)++data NotifierQueue = NotifierQueue+ { queue :: !(TBQueue Notification),+ closed :: !(TVar Bool),+ inFlight :: !(TVar Int)+ }++data EnqueueOutcome = Enqueued | QueueFull | QueueClosed+ deriving stock (Eq, Show)++-- | Create a queue whose capacity is at least one item.+newNotifierQueue :: Int -> IO NotifierQueue+newNotifierQueue requestedCapacity = do+ q <- newTBQueueIO (fromIntegral (max 1 requestedCapacity))+ closed <- newTVarIO False+ inFlight <- newTVarIO 0+ pure NotifierQueue {queue = q, closed, inFlight}++-- | Attempt one enqueue without ever retrying or blocking.+enqueueNotification :: NotifierQueue -> Notification -> IO EnqueueOutcome+enqueueNotification notifierQueue notification = atomically do+ isClosed <- readTVar notifierQueue.closed+ if isClosed+ then pure QueueClosed+ else do+ full <- isFullTBQueue notifierQueue.queue+ if full+ then pure QueueFull+ else writeTBQueue notifierQueue.queue notification >> pure Enqueued++-- | Block for one item, mark it in flight while the action runs, and clear that mark even when+-- the delivery is cancelled or throws.+withDequeued :: NotifierQueue -> (Notification -> IO ()) -> IO ()+withDequeued notifierQueue action = bracket acquire release action+ where+ acquire = atomically do+ notification <- readTBQueue notifierQueue.queue+ modifyTVar' notifierQueue.inFlight (+ 1)+ pure notification+ release _ = atomically (modifyTVar' notifierQueue.inFlight (max 0 . subtract 1))++-- | Refuse all future enqueues. Existing queued items remain available to the worker.+closeNotifierQueue :: NotifierQueue -> IO ()+closeNotifierQueue notifierQueue = atomically (writeTVar notifierQueue.closed True)++-- | Wait until the queue is empty and no delivery is in flight, or until the monotonic deadline.+-- The returned count includes queued and in-flight items still present at the deadline.+drainNotifierQueue :: NotifierQueue -> Int -> IO Int+drainNotifierQueue notifierQueue timeoutSeconds = do+ started <- getMonotonicTimeNSec+ let deadline = started + fromIntegral (max 0 timeoutSeconds) * 1_000_000_000+ loop = do+ remaining <- atomically do+ queued <- lengthTBQueue notifierQueue.queue+ active <- readTVar notifierQueue.inFlight+ pure (fromIntegral queued + active)+ if remaining == 0+ then pure 0+ else do+ now <- getMonotonicTimeNSec+ if now >= deadline+ then pure remaining+ else threadDelay 50_000 >> loop+ loop
+ src/Shomei/Server/App.hs view
@@ -0,0 +1,215 @@+-- | The Shōmei server effect stack and its runner.+--+-- This single module fixes the one effect stack every server-side action runs in+-- ('AppEffects'), the environment needed to interpret it ('Env'), and the runner that+-- interprets it down to IO ('runAppIO'). It is servant-free: 'runAppIO' returns+-- @IO (Either AuthError a)@ with no HTTP types, so the same stack is reusable by the+-- automated test and (later) the embedded mode, not just the standalone warp boot.+--+-- The stack is the EP-5 servant port stack (@Shomei.Servant.Seam.AppEffects@) /extended/+-- with the two effects the PostgreSQL interpreters need beneath the ports: 'Database'+-- (the hasql layer the store interpreters issue SQL through) and @Error AuthError@ (the+-- channel the interpreters use to surface infrastructure failures). EP-5's handlers run in+-- the smaller stack and are bridged onto this one with @inject@ at assembly time+-- (see "Shomei.Server.Boot").+module Shomei.Server.App+ ( AppEffects,+ Env (..),+ runAppIO,+ )+where++import Data.IORef (IORef, readIORef)+import Effectful (Eff, IOE, runEff)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Network.HTTP.Client (Manager)+import Shomei.Account.Credential.Postgres (runCredentialStorePostgres)+import Shomei.Account.Credential.Store (CredentialStore)+import Shomei.Account.Notification.Store (Notifier)+import Shomei.Account.Password.Breach.Store (PasswordBreachChecker)+import Shomei.Account.Password.Domain (PasswordPolicy (breachCheckTimeoutMs))+import Shomei.Account.Password.Hash.Postgres (Argon2Params, HashingLimiter, runPasswordHasherCrypto, runTokenGenCrypto)+import Shomei.Account.Password.Hash.Store (PasswordHasher)+import Shomei.Account.PasswordReset.Postgres (runPasswordResetTokenStorePostgres)+import Shomei.Account.PasswordReset.Store (PasswordResetTokenStore)+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Account.User.Store (UserStore)+import Shomei.Account.Verification.Postgres (runVerificationTokenStorePostgres)+import Shomei.Account.Verification.Store (VerificationTokenStore)+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher)+import Shomei.Audit.Reader.Postgres (runAuthEventReaderPostgres)+import Shomei.Audit.Reader.Store (AuthEventReader)+import Shomei.Authorization.Claims.Store (ClaimsEnricher, runClaimsEnricherNull)+import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)+import Shomei.Authorization.Role.Store (RoleStore)+import Shomei.Config (NotifierConfig (notifierTransport), ShomeiConfig (notifierConfig, passwordPolicy), webauthnConfig)+import Shomei.Error (AuthError)+import Shomei.Mfa.RecoveryCode.Postgres (runRecoveryCodeStorePostgres)+import Shomei.Mfa.RecoveryCode.Store (RecoveryCodeStore)+import Shomei.Mfa.Totp.Postgres (TotpEncryptionKey, runTotpCredentialStorePostgres)+import Shomei.Mfa.Totp.Store (TotpCredentialStore)+import Shomei.Notify (NotifierSecrets, runNotifierEnqueue, transportChannel)+import Shomei.Notify.Queue (NotifierQueue)+import Shomei.OAuth.AuthorizationCode.Postgres (runOAuthCodeStorePostgres)+import Shomei.OAuth.AuthorizationCode.Store (OAuthCodeStore)+import Shomei.OAuth.Client.Postgres (runOAuthClientStorePostgres)+import Shomei.OAuth.Client.Store (OAuthClientStore)+import Shomei.Passkey.Ceremony.Port (WebAuthnCeremony)+import Shomei.Passkey.Ceremony.Postgres (runPendingCeremonyStorePostgres)+import Shomei.Passkey.Ceremony.Store (PendingCeremonyStore)+import Shomei.Passkey.Postgres (runPasskeyStorePostgres)+import Shomei.Passkey.Store (PasskeyStore)+import Shomei.Persistence.Database.Postgres (Database, TransactionRetryPolicy, runDatabasePoolWith)+import Shomei.Persistence.Pool.Postgres (Pool)+import Shomei.Prelude+import Shomei.Server.BreachChecker (runPasswordBreachCheckerHibp)+import Shomei.Server.Keys (LoadedKeys (..))+import Shomei.ServiceAccount.Postgres (runServiceAccountStorePostgres)+import Shomei.ServiceAccount.Store (ServiceAccountStore)+import Shomei.Session.LoginAttempt.Postgres (runLoginAttemptStorePostgres)+import Shomei.Session.LoginAttempt.Store (LoginAttemptStore)+import Shomei.Session.Postgres (runSessionStorePostgres)+import Shomei.Session.RefreshToken.Postgres (runRefreshTokenStorePostgres)+import Shomei.Session.RefreshToken.Store (RefreshTokenStore)+import Shomei.Session.Store (SessionStore)+import Shomei.Session.Token.Generator (TokenGen)+import Shomei.Session.UnitOfWork.Postgres (runAuthUnitOfWorkPostgres)+import Shomei.Session.UnitOfWork.Store (AuthUnitOfWork)+import Shomei.SigningKey.Postgres (runSigningKeyStorePostgres)+import Shomei.SigningKey.Protection.Jwt (KeyEncryptionKey)+import Shomei.SigningKey.Sign.Jwt (runTokenSignerJwt)+import Shomei.SigningKey.Signer (TokenSigner)+import Shomei.SigningKey.Store (SigningKeyStore)+import Shomei.SigningKey.Verifier (TokenVerifier)+import Shomei.SigningKey.Verify.Jwt (runTokenVerifierJwt)+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock)+import Shomei.WebAuthn.Ceremony (runWebAuthnCeremonyLibrary)++-- | The single effect stack the assembled server interprets. The high-level ports+-- come first (the handler's view); 'Database', @Error AuthError@, and 'IOE' sit beneath+-- them because the store/publisher/signing-key interpreters issue SQL through 'Database'+-- and surface failures through @Error AuthError@.+type AppEffects =+ '[ UserStore,+ RoleStore,+ CredentialStore,+ SessionStore,+ RefreshTokenStore,+ AuthUnitOfWork,+ VerificationTokenStore,+ PasswordResetTokenStore,+ LoginAttemptStore,+ PasskeyStore,+ PendingCeremonyStore,+ ServiceAccountStore,+ OAuthClientStore,+ OAuthCodeStore,+ TotpCredentialStore,+ RecoveryCodeStore,+ Notifier,+ ClaimsEnricher,+ WebAuthnCeremony,+ PasswordBreachChecker,+ PasswordHasher,+ TokenSigner,+ TokenVerifier,+ AuthEventPublisher,+ AuthEventReader,+ SigningKeyStore,+ Clock,+ TokenGen,+ Database,+ Error AuthError,+ IOE+ ]++-- | Everything the runtime needs to interpret 'AppEffects' down to IO: the live hasql+-- pool, the loaded config, and the current signing-key material (the private signing key,+-- the verifier's public key set, and the served JWKS document).+--+-- The key material is held in an 'IORef' rather than inlined, because+-- 'Shomei.Server.Keys.reloadKeys' swaps it while the server runs — that is what makes+-- @shomei-admin keys activate@ take effect without a restart.+data Env = Env+ { envPool :: !Pool,+ envDbRetryPolicy :: !TransactionRetryPolicy,+ envConfig :: !ShomeiConfig,+ envKeys :: !(IORef LoadedKeys),+ -- | the key-encryption key used to decrypt the signer during reloads. It is deliberately+ -- not part of 'ShomeiConfig', which is 'Show'able and serializable.+ envKek :: !KeyEncryptionKey,+ -- | the AES-256-GCM key that encrypts stored TOTP secrets (EP-7), loaded from+ -- @SHOMEI_TOTP_ENCRYPTION_KEY@. Deliberately not part of 'ShomeiConfig' (a secret). When+ -- TOTP is disabled this is a dummy key: enrollment is refused, so the store is+ -- unreachable, but the interpreter-stack shape stays fixed.+ envTotpKey :: !TotpEncryptionKey,+ -- | shared TLS manager for the HIBP breach-check interpreter (EP-3)+ envHttpManager :: !Manager,+ -- | runtime-only SMTP/webhook credentials; intentionally outside 'envConfig'+ envNotifierSecrets :: !NotifierSecrets,+ -- | bounded notification queue; request handlers only enqueue, while the supervised+ -- server worker performs delivery outside request latency.+ envNotifierQueue :: !NotifierQueue,+ -- | Argon2id cost parameters for hashing new passwords. Verification reads the parameters+ -- embedded in each stored hash, so this only affects hashes written from now on.+ envArgon2Params :: !Argon2Params,+ -- | bounds how many Argon2 derivations run at once, process-wide+ envHashingLimiter :: !HashingLimiter+ }++-- | Interpret the whole 'AppEffects' stack down to IO, surfacing an infrastructure+-- 'AuthError' as 'Left'. The composition is written outermost-last: read right-to-left it+-- peels 'AppEffects' head-to-tail. The ORDER is load-bearing — every SQL-issuing port is+-- interpreted ABOVE 'runDatabasePool' (so 'Database' is still in scope when they run), and+-- @Error AuthError@/'IOE' sit at the base. This is the same shape as @shomei-postgres@'s+-- own test harness, extended with EP-4's real signer/verifier interpreters.+--+-- The key material is re-read once per invocation (one invocation ≈ one request's port+-- batch), so a reload that lands between requests is picked up without rebuilding the WAI+-- application; a request already in flight finishes with the material it started with.+runAppIO :: Env -> Eff AppEffects a -> IO (Either AuthError a)+runAppIO env action = do+ keys <- readIORef env.envKeys+ runEff+ . runErrorNoCallStack+ . runDatabasePoolWith env.envDbRetryPolicy env.envPool+ . runTokenGenCrypto+ . runClockIO+ . runSigningKeyStorePostgres+ . runAuthEventReaderPostgres+ . runAuthEventPublisherPostgres+ . runTokenVerifierJwt keys.verifierJwks env.envConfig+ . runTokenSignerJwt keys.signingKey env.envConfig+ . runPasswordHasherCrypto env.envHashingLimiter env.envArgon2Params+ . runPasswordBreachCheckerHibp env.envHttpManager breachTimeoutMs+ . runWebAuthnCeremonyLibrary (webauthnConfig env.envConfig)+ -- The standalone server adds no claims of its own. An embedding host swaps this for its+ -- own 'ClaimsEnricher' interpreter where it builds 'Shomei.Servant.Seam.Env'.+ . runClaimsEnricherNull+ . runNotifierEnqueue+ env.envNotifierQueue+ (transportChannel env.envConfig.notifierConfig.notifierTransport)+ . runRecoveryCodeStorePostgres+ . runTotpCredentialStorePostgres env.envTotpKey+ . runOAuthCodeStorePostgres+ . runOAuthClientStorePostgres+ . runServiceAccountStorePostgres+ . runPendingCeremonyStorePostgres+ . runPasskeyStorePostgres+ . runLoginAttemptStorePostgres+ . runPasswordResetTokenStorePostgres+ . runVerificationTokenStorePostgres+ . runAuthUnitOfWorkPostgres+ . runRefreshTokenStorePostgres+ . runSessionStorePostgres+ . runCredentialStorePostgres+ . runRoleStorePostgres+ . runUserStorePostgres+ $ action+ where+ policy :: PasswordPolicy+ policy = env.envConfig.passwordPolicy+ breachTimeoutMs :: Int+ breachTimeoutMs = policy.breachCheckTimeoutMs
+ src/Shomei/Server/Boot.hs view
@@ -0,0 +1,550 @@+-- | Startup sequence and warp boot for the standalone Shōmei auth service.+--+-- 'main' runs the turnkey startup: load config → run migrations (idempotent) → acquire the+-- hasql pool → bootstrap the signing key → build the 'Env' → serve with warp. 'application'+-- builds the WAI app from the exact 'Shomei.Servant.Server.shomeiRoutes' tree and the+-- @AuthProtect "shomei-jwt"@ 'Context'. EP-5's handlers run in the smaller servant port+-- stack ('Shomei.Servant.Seam.AppEffects'); they are bridged onto this server's larger+-- PostgreSQL stack ('Shomei.Server.App.AppEffects') with @inject@ inside the seam env's+-- runner. Infrastructure failures remain typed through the seam; domain failures flow+-- through the same channel to the right status.+module Shomei.Server.Boot+ ( main,+ application,+ HostBackgroundTasks (..),+ installHostBackgroundTasks,+ hostMiddleware,+ edgeMiddleware,+ buildEnv,+ seamEnv,+ authContext,+ validateOidcIssuer,+ NotifierWorker (..),+ installNotifierWorker,+ )+where++-- 'Context' is hidden from the prelude (it re-exports lens's 'Context'); we mean+-- servant's 'Servant.Context' here.++import Control.Concurrent (forkIO, killThread)+import Data.Aeson ((.=))+import Data.Aeson.Key qualified as Key+import Data.ByteString qualified as BS+import Data.Foldable (traverse_)+import Data.IORef (newIORef, readIORef)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Time (DiffTime, picosecondsToDiffTime)+import Database.PostgreSQL.Migrate (MigrationReport (..))+import Effectful (Eff, inject, runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import GHC.Clock (getMonotonicTimeNSec)+import Network.HTTP.Client.TLS (newTlsManager)+import Network.Wai (Application, Middleware, Request)+import Network.Wai.Handler.Warp qualified as Warp+import Servant+ ( Context (EmptyContext, (:.)),+ ErrorFormatters,+ serveWithContext,+ )+import Servant.Health (ProbeCheck)+import Servant.Server.Experimental.Auth (AuthHandler)+import Shomei.Account.Password.Hash.Postgres (Argon2Failure (..), Argon2Params (..), argon2WarningFloor, hashingLimit, newHashingLimiter, sha256Hex, trialArgon2Derivation)+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Authorization.Claims.Domain (Issuer (..), Role (..))+import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)+import Shomei.Authorization.Role.Workflow (undefinedDefaultRoles)+import Shomei.Config (OAuthConfig (..), ObservabilityConfig (..), ShomeiConfig (..), SigningKeyConfig (..), TotpConfig (..), configSigningAlgorithm)+import Shomei.Error (AuthError)+import Shomei.Health.Server (buildHealthChecks)+import Shomei.Mfa.Totp.Postgres (TotpEncryptionKey, checkTotpEnvelopeVersions, totpEncryptionKeyFromBase64, totpEncryptionKeyFromBytes)+import Shomei.Migrations (applyShomeiMigrations)+import Shomei.Notify (deliverNotification)+import Shomei.Notify.Queue (closeNotifierQueue, drainNotifierQueue, newNotifierQueue, withDequeued)+import Shomei.Persistence.Database.Postgres (TransactionRetryPolicy (..), runDatabasePoolWith)+import Shomei.Persistence.Maintenance.Postgres (sweepOnce, sweepReportCounts)+import Shomei.Persistence.Pool.Postgres (acquirePool)+import Shomei.Persistence.Pool.Postgres qualified as Pool++-- '(.=)' is hidden from the prelude (it re-exports lens's state-setter of the same name);+-- we mean aeson's JSON pair constructor here.+import Shomei.Prelude hiding (Context, (.=))+import Shomei.Servant.Api (shomeiRoutesApi, shomeiThrottledRoutes)+import Shomei.Servant.Auth (AuthUser, authHandler)+import Shomei.Servant.Error (shomeiErrorFormatters)+import Shomei.Servant.Middleware (problemMiddleware)+import Shomei.Servant.Oidc (isAbsoluteHttpUrl)+import Shomei.Servant.Seam qualified as Seam+import Shomei.Servant.Server (shomeiRoutes)+import Shomei.Server.App (Env (..), runAppIO)+import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), SweepSettings (..), loadConfig, loadNotifierSecretsFromEnv, toSweepConfig)+import Shomei.Server.Keys (LoadedKeys (..), bootstrapKeys, loadKekFromEnv, reloadKeys)+import Shomei.Server.Middleware.BodyLimit (bodyLimitMiddleware, defaultBodyLimitBytes)+import Shomei.Server.Middleware.RateLimit (RateLimiter, newRateLimiterFor, rateLimitMiddleware)+import Shomei.Server.Middleware.TrustedProxy (TrustedProxies, trustedProxyMiddleware, trustedProxyTexts)+import Shomei.Server.Observability.Logging (requestLoggingMiddleware)+import Shomei.Server.Observability.Metrics (Metrics, metricsEndpointMiddleware, metricsMiddleware, newMetrics)+import Shomei.Server.Runtime (hostWarpSettings)+import Shomei.Server.Supervisor (logJsonLine, supervisedLoop, supervisedLoopMicros)+import Shomei.Session.LoginAttempt.Domain (AccountKey (..))+import Shomei.Time.Postgres (runClockIO)+import System.Environment (lookupEnv)+import System.Exit (exitFailure)+import System.IO (BufferMode (LineBuffering), hPutStrLn, hSetBuffering, stderr, stdout)+import System.Posix.Signals (installHandler, sigHUP, sigINT, sigTERM)+import System.Posix.Signals qualified as Signals++-- | The full turnkey startup sequence.+main :: IO ()+main = do+ -- Line-buffer stdout so each structured JSON log line is flushed immediately (when stdout+ -- is a pipe/file it would otherwise be block-buffered and logs would not appear promptly).+ hSetBuffering stdout LineBuffering+ hSetBuffering stderr LineBuffering+ (cfg, settings) <- loadConfig+ let trustedProxySummary =+ case trustedProxyTexts settings.serverTrustedProxies of+ [] -> "none"+ entries -> Text.intercalate "," entries+ proxyProtocolSummary = case settings.serverProxyProtocol of+ ProxyProtocolOff -> "none"+ ProxyProtocolRequired -> "required"+ hPutStrLn+ stderr+ ( "[shomei] trusted proxies: "+ <> Text.unpack trustedProxySummary+ <> "; proxy protocol: "+ <> proxyProtocolSummary+ )+ traverse_+ (\warning -> hPutStrLn stderr ("[shomei] WARNING: " <> Text.unpack warning))+ settings.serverWarnings+ -- Warn, never refuse: a resource-starved dev box legitimately wants cheap hashing, but an+ -- operator must not weaken password storage without seeing it said out loud.+ traverse_+ (\w -> hPutStrLn stderr ("[shomei] WARNING: " <> Text.unpack w))+ (argon2WarningFloor settings.serverArgon2)+ trialArgon2Derivation settings.serverArgon2 >>= \case+ Right () -> pure ()+ Left (Argon2Failure why) -> do+ hPutStrLn+ stderr+ ( "[shomei] FATAL: the Argon2 implementation rejected the configured parameters (m="+ <> show settings.serverArgon2.memoryKiB+ <> "KiB,t="+ <> show settings.serverArgon2.iterations+ <> ",p="+ <> show settings.serverArgon2.parallelism+ <> "): "+ <> Text.unpack why+ )+ exitFailure+ validateOidcIssuer cfg+ env <- buildEnv cfg settings+ (liveness, readiness) <- buildHealthChecks env+ backgroundTasks <- installHostBackgroundTasks cfg settings env+ rl <- newRateLimiterFor shomeiThrottledRoutes cfg.rateLimitConfig+ metrics <- newMetrics+ let obs = cfg.observabilityConfig+ -- Graceful shutdown: SIGTERM (orchestrator stop) and SIGINT (Ctrl-C) trigger warp's+ -- shutdown action, which stops accepting new connections and waits up to the+ -- configured timeout for in-flight requests to finish. After warp returns we close the+ -- pool and exit 0.+ installShutdown closeSocket = do+ let stop sig = hPutStrLn stderr ("[shomei] received " <> sig <> "; draining in-flight requests") >> closeSocket+ _ <- installHandler sigTERM (Signals.Catch (stop "SIGTERM")) Nothing+ _ <- installHandler sigINT (Signals.Catch (stop "SIGINT")) Nothing+ pure ()+ warpSettings <- hostWarpSettings cfg settings (Warp.setInstallShutdownHandler installShutdown Warp.defaultSettings)+ hPutStrLn stderr ("[shomei] listening on :" <> show settings.serverPort)+ Warp.runSettings warpSettings (hostMiddleware cfg settings rl metrics (application env liveness readiness))+ stopHostBackgroundTasks backgroundTasks obs.gracefulShutdownTimeoutSeconds+ hPutStrLn stderr "[shomei] drain complete; closing connection pools"+ Pool.release env.envPool+ hPutStrLn stderr "[shomei] shutdown complete"++-- | Background services required to give an embedded host the standalone server's runtime+-- guarantees. Call 'stopHostBackgroundTasks' after the host server stops accepting requests;+-- it drains queued notifications within the same bounded shutdown budget and releases the+-- sweeper's isolated pool.+newtype HostBackgroundTasks = HostBackgroundTasks+ { stopHostBackgroundTasks :: Int -> IO ()+ }++-- | Install every process-wide and background service used by the standalone server: validate+-- configured default roles, refresh signing keys periodically and on @SIGHUP@, sweep expired+-- state, and deliver queued notifications. An embedding host calls this once after 'buildEnv'.+installHostBackgroundTasks :: ShomeiConfig -> ServerSettings -> Env -> IO HostBackgroundTasks+installHostBackgroundTasks cfg settings env = do+ validateDefaultRoles cfg env+ installKeyReload cfg env+ releaseSweeper <- installSweeper settings env+ notifierWorker <- installNotifierWorker env+ pure+ HostBackgroundTasks+ { stopHostBackgroundTasks = \timeoutSeconds -> do+ drainNotifierWorker notifierWorker timeoutSeconds+ releaseSweeper+ }++-- | The WAI edge used by the standalone server. Wrap the host's whole application so its own+-- routes receive the same trusted-proxy attribution, request IDs, metrics, body cap, and+-- per-client rate limiting as Shōmei's routes.+hostMiddleware :: ShomeiConfig -> ServerSettings -> RateLimiter -> Metrics -> Middleware+hostMiddleware cfg settings =+ edgeMiddleware cfg.observabilityConfig settings.serverTrustedProxies++-- | The WAI edge every host must install. The trusted-proxy rewrite is outermost so logging,+-- metrics, the limiter, and Servant's 'RemoteHost' all observe the same client address.+edgeMiddleware :: ObservabilityConfig -> TrustedProxies -> RateLimiter -> Metrics -> Middleware+edgeMiddleware obs proxies limiter metrics =+ trustedProxyMiddleware proxies+ . requestLoggingMiddleware obs+ . withMetrics+ . bodyLimitMiddleware defaultBodyLimitBytes+ . rateLimitMiddleware limiter+ where+ withMetrics+ | obs.metricsEnabled = metricsMiddleware metrics . metricsEndpointMiddleware metrics+ | otherwise = id++-- | Refuse to start with the OIDC provider enabled and an issuer that is not an absolute+-- @http(s)@ URL.+--+-- The issuer doubles as the provider's public base URL: every endpoint in the discovery document+-- is derived from it, and ID tokens carry it as @iss@. With the default issuer @"shomei"@ the+-- document would advertise @shomei\/oauth\/token@ — a relative URL no client can fetch — and the+-- failure would surface as an inscrutable error inside someone else's OIDC library. Checked+-- before the pool is acquired, because it needs nothing but config.+validateOidcIssuer :: ShomeiConfig -> IO ()+validateOidcIssuer cfg =+ when (cfg.oauthConfig.oidcEnabled && not (isAbsoluteHttpUrl iss)) do+ hPutStrLn+ stderr+ ( "shomei-server: oidcEnabled is set but issuer is not an absolute http(s) URL: "+ <> show iss+ <> "\nThe OIDC issuer is also the base URL every published endpoint is derived from."+ <> "\nSet SHOMEI_ISSUER (or `issuer` in the config file) to this deployment's public"+ <> " base URL, e.g. https://auth.example.com"+ )+ exitFailure+ where+ iss = case cfg.issuer of Issuer t -> t++-- | Refuse to start when @defaultRoles@ names a role missing from the @shomei_roles@ registry.+--+-- Validating once here rather than on every signup keeps the hot path free of catalog reads and+-- turns a config typo into an immediate, obvious startup failure instead of a stream of 500s on+-- an endpoint nobody is watching. The registry is append-only, so a role validated here cannot+-- later disappear — which is why 'Shomei.Authorization.Role.Workflow.applyDefaultRoles' does not re-check.+--+-- An embedding host that sets @defaultRoles@ should call+-- 'Shomei.Authorization.Role.Workflow.undefinedDefaultRoles' the same way where it assembles its ports.+validateDefaultRoles :: ShomeiConfig -> Env -> IO ()+validateDefaultRoles cfg env = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePoolWith env.envDbRetryPolicy env.envPool+ . runRoleStorePostgres+ $ undefinedDefaultRoles cfg+ case outcome of+ Left e -> die ("could not validate defaultRoles against the role registry: " <> show e)+ Right missing+ | Set.null missing -> pure ()+ | otherwise -> do+ let names = Text.intercalate ", " [r | Role r <- Set.toList missing]+ first' = case Set.toList missing of+ Role r : _ -> r+ [] -> "<role>"+ die+ ( "defaultRoles names undefined roles: "+ <> Text.unpack names+ <> "\ndefine them first: shomei-admin roles define "+ <> Text.unpack first'+ )+ where+ die msg = hPutStrLn stderr ("shomei-server: " <> msg) >> exitFailure++-- | Install the two triggers that refresh signing-key material on a running server, so+-- @shomei-admin keys activate@ / @keys revoke@ take effect with no restart: a periodic+-- background reload every @refreshIntervalSeconds@ (0 disables it), and a @SIGHUP@ handler+-- for a deterministic "apply now". Both call 'reloadKeys', which keeps the last good+-- material if a reload fails.+--+-- The periodic reload runs on 'supervisedLoop', the shared supervised-background-thread+-- idiom: a crash is logged and retried with backoff rather than killing the loop, and the+-- thread dies with the process. Note that 'supervisedLoop' runs its first cycle immediately,+-- so a reload happens right after 'bootstrapKeys' — harmless, since 'reloadKeys' is idempotent+-- and keeps the last good material on failure.+installKeyReload :: ShomeiConfig -> Env -> IO ()+installKeyReload cfg env = do+ when (interval > 0) (void (forkIO (supervisedLoop "key-reload" interval reload)))+ void (installHandler sigHUP (Signals.Catch onHup) Nothing)+ where+ interval = cfg.signingKeyConfig.refreshIntervalSeconds+ reload = reloadKeys env.envKek env.envPool env.envKeys+ onHup = hPutStrLn stderr "[shomei] SIGHUP: reloading signing keys" >> reload++-- | Fork the background expired-data sweeper, unless the operator disabled it in favor of+-- scheduling @shomei-admin sweep@ externally. Its one-connection pool is deliberately separate+-- from the request pool, so a long drain never consumes request capacity. The returned cleanup+-- releases that pool during graceful shutdown.+--+-- A 'Left' from 'sweepOnce' means the database was unreachable or a statement failed. That is+-- an ordinary outcome for periodic maintenance, not a crash: it is logged and the loop sleeps+-- a normal interval rather than entering 'supervisedLoop''s backoff, which is reserved for+-- genuine exceptions.+installSweeper :: ServerSettings -> Env -> IO (IO ())+installSweeper settings _env+ | not sweep.sweepEnabled = pure (pure ())+ | otherwise = do+ sweepPool <-+ acquirePool+ 1+ (millisToDiffTime settings.serverDbPoolAcquisitionTimeoutMs)+ settings.serverDbStatementTimeoutMs+ settings.serverConnStr+ hPutStrLn+ stderr+ ( "[shomei] sweeper: every "+ <> show sweep.sweepIntervalSeconds+ <> "s, audit retention "+ <> maybe "disabled (retain forever)" (\d -> show d <> " days") sweep.sweepAuthEventRetentionDays+ <> ", on its own connection"+ )+ void (forkIO (supervisedLoop "sweeper" sweep.sweepIntervalSeconds (oneCycle sweepPool)))+ pure (Pool.release sweepPool)+ where+ sweep = settings.serverSweep+ oneCycle sweepPool = do+ -- Wall-clock time decides which rows are expired; a monotonic clock measures how long+ -- the sweep took, so an NTP step cannot produce a negative duration.+ cutoffNow <- getCurrentTime+ startTick <- getMonotonicTimeNSec+ result <- sweepOnce sweepPool (toSweepConfig sweep) cutoffNow+ endTick <- getMonotonicTimeNSec+ logSweepCycle (durationMs startTick endTick) result++ durationMs startTick endTick =+ fromIntegral (endTick - startTick) / 1_000_000 :: Double++ logSweepCycle elapsed = \case+ Left err ->+ logJsonLine+ [ "level" .= ("error" :: Text),+ "msg" .= ("sweep failed" :: Text),+ "task" .= ("sweeper" :: Text),+ "error" .= Text.pack (show err),+ "duration_ms" .= elapsed+ ]+ Right report ->+ logJsonLine+ ( [ "level" .= ("info" :: Text),+ "msg" .= ("sweep" :: Text)+ ]+ <> [Key.fromText table .= deleted | (table, deleted) <- sweepReportCounts report]+ <> ["duration_ms" .= elapsed]+ )++-- | Handle returned by 'installNotifierWorker'. EP-9's embedding assembly lifts the install+-- and drain calls verbatim so an embedding host receives the same delivery guarantees.+newtype NotifierWorker = NotifierWorker+ { drainNotifierWorker :: Int -> IO ()+ }++-- | Run one supervised delivery worker. Closing prevents new enqueue operations, then drain waits+-- within the server's graceful-shutdown budget before cancelling any remaining network call.+installNotifierWorker :: Env -> IO NotifierWorker+installNotifierWorker env = do+ workerThread <-+ forkIO+ ( supervisedLoopMicros+ "notifier"+ 0+ (5 * 1_000_000)+ (300 * 1_000_000)+ oneCycle+ )+ pure+ NotifierWorker+ { drainNotifierWorker = \timeoutSeconds -> do+ closeNotifierQueue env.envNotifierQueue+ dropped <- drainNotifierQueue env.envNotifierQueue timeoutSeconds+ logJsonLine+ [ "level" .= (if dropped == 0 then "info" else "warn" :: Text),+ "msg" .= ("notifier drained" :: Text),+ "dropped" .= dropped+ ]+ killThread workerThread+ }+ where+ oneCycle =+ withDequeued env.envNotifierQueue \notification -> do+ result <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePoolWith env.envDbRetryPolicy env.envPool+ . runClockIO+ . runAuthEventPublisherPostgres+ $ deliverNotification env.envHttpManager env.envNotifierSecrets env.envConfig notification+ case result of+ Right () -> pure ()+ Left err ->+ logJsonLine+ [ "level" .= ("error" :: Text),+ "msg" .= ("notifier delivery audit failed" :: Text),+ "task" .= ("notifier" :: Text),+ "error" .= Text.pack (show err)+ ]++-- | Run the schema migrations (idempotent), acquire the pool, and bootstrap the signing+-- key, yielding the assembled 'Env'. Shared by 'main' and by host applications that embed+-- the Shōmei API (the embedded demo builds its own 'Env' this way). Running+-- @shomei-migrate@ out-of-band is also supported for production.+buildEnv :: ShomeiConfig -> ServerSettings -> IO Env+buildEnv cfg settings = do+ alg <-+ either+ (\why -> hPutStrLn stderr ("[shomei] FATAL: signingKeyConfig.algorithm: " <> Text.unpack why) >> exitFailure)+ pure+ (configSigningAlgorithm cfg)+ applyShomeiMigrations settings.serverConnStr >>= \case+ Left migrationError -> do+ hPutStrLn stderr ("[shomei] FATAL: schema migration failed: " <> show migrationError)+ exitFailure+ Right report ->+ hPutStrLn+ stderr+ ("[shomei] schema migrations applied: " <> show (NonEmpty.length report.results) <> " migrations")+ hPutStrLn+ stderr+ ( "[shomei] db pool: size "+ <> show settings.serverDbPoolSize+ <> ", acquisition timeout "+ <> show settings.serverDbPoolAcquisitionTimeoutMs+ <> "ms, statement timeout "+ <> show settings.serverDbStatementTimeoutMs+ <> "ms"+ )+ pool <-+ acquirePool+ settings.serverDbPoolSize+ (millisToDiffTime settings.serverDbPoolAcquisitionTimeoutMs)+ settings.serverDbStatementTimeoutMs+ settings.serverConnStr+ checkTotpEnvelopeVersions pool >>= \case+ Right (0, 0) -> pure ()+ _ -> do+ Pool.release pool+ hPutStrLn stderr "[shomei] FATAL: TOTP envelope conversion required; stop writers and run shomei-admin totp migrate-envelopes --check, then --offline"+ exitFailure+ kek <- loadKekFromEnv+ totpKey <- loadTotpKeyFromEnv cfg+ notifierSecrets <- loadNotifierSecretsFromEnv cfg+ keys <- bootstrapKeys kek alg pool+ keysRef <- newIORef keys+ mgr <- newTlsManager+ limiter <- newHashingLimiter settings.serverHashingMaxConcurrency+ notifierQueue <- newNotifierQueue settings.serverNotifierQueueSize+ hPutStrLn+ stderr+ ( "[shomei] hashing concurrency "+ <> show (hashingLimit limiter)+ <> ", argon2 m="+ <> show settings.serverArgon2.memoryKiB+ <> "KiB,t="+ <> show settings.serverArgon2.iterations+ <> ",p="+ <> show settings.serverArgon2.parallelism+ )+ pure+ Env+ { envPool = pool,+ envDbRetryPolicy = TransactionRetryPolicy settings.serverDbTransactionMaxAttempts settings.serverDbTransactionDeadlineMs,+ envConfig = cfg,+ envKeys = keysRef,+ envKek = kek,+ envHttpManager = mgr,+ envNotifierSecrets = notifierSecrets,+ envNotifierQueue = notifierQueue,+ envArgon2Params = settings.serverArgon2,+ envHashingLimiter = limiter,+ envTotpKey = totpKey+ }++-- | The AES-256-GCM key that encrypts stored TOTP secrets (EP-7), loaded from+-- @SHOMEI_TOTP_ENCRYPTION_KEY@ (base64 of exactly 32 bytes; generate one with+-- @openssl rand -base64 32@).+--+-- When TOTP is enabled and the variable is absent or malformed, the server refuses to boot with+-- a loud message — an enabled factor whose secrets cannot be encrypted is a silent data-loss+-- trap. When TOTP is disabled the store is unreachable (enrollment is refused), so a valid key is+-- optional; a dummy all-zero key keeps the interpreter-stack shape fixed. A key supplied while+-- disabled is still validated, so a typo is caught before it is switched on.+loadTotpKeyFromEnv :: ShomeiConfig -> IO TotpEncryptionKey+loadTotpKeyFromEnv cfg = do+ raw <- lookupEnv "SHOMEI_TOTP_ENCRYPTION_KEY"+ let enabled = cfg.totpConfig.totpEnabled+ case fmap Text.pack raw of+ Just t | not (Text.null (Text.strip t)) ->+ case totpEncryptionKeyFromBase64 t of+ Right k -> pure k+ Left err -> die ("SHOMEI_TOTP_ENCRYPTION_KEY " <> Text.unpack err)+ _+ | enabled -> die "SHOMEI_TOTP_ENCRYPTION_KEY is required when totpEnabled is set (generate one with: openssl rand -base64 32)"+ | otherwise -> either (die . Text.unpack) pure (totpEncryptionKeyFromBytes (BS.replicate 32 0))+ where+ die msg = hPutStrLn stderr ("[shomei] " <> msg) >> exitFailure++-- | Milliseconds to a 'DiffTime', exactly (a 'DiffTime' counts picoseconds, so this loses+-- nothing). Used for the pool's acquisition timeout, which config carries as an integer count+-- of milliseconds.+millisToDiffTime :: Int -> DiffTime+millisToDiffTime ms = picosecondsToDiffTime (fromIntegral ms * 1_000_000_000)++-- | Build the WAI 'Application': EP-5's server with the @AuthProtect "shomei-jwt"@+-- 'Context'. Authentication is derived from the seam env's port runner and config, so it uses+-- the current verification keys and honors @sessionCheckMode@.+--+-- The served tree is 'shomeiRoutesApi' (EP-3): application routes under @\/v1@, JWKS and the+-- probes at unversioned root paths.+--+-- 'problemMiddleware' converts Servant's one un-formattable failure — the bare @405@ a method+-- mismatch raises below any 'ErrorFormatters' hook — into a problem document, so /every/ error+-- the process emits carries the same envelope.+application :: Env -> ProbeCheck -> ProbeCheck -> Application+application env liveness readiness =+ problemMiddleware (serveWithContext shomeiRoutesApi (authContext senv) (shomeiRoutes senv liveness readiness))+ where+ senv = seamEnv env++-- | The Servant 'Context' carrying the @AuthProtect "shomei-jwt"@ 'AuthHandler' (built from the+-- seam env itself) and the 'ErrorFormatters' that render Servant's own body-parse,+-- url-parse, header-parse, and not-found failures as RFC 9457 problem documents. A host app+-- embedding 'ShomeiAPI' serves with this same context.+authContext :: Seam.Env -> Context '[AuthHandler Request AuthUser, ErrorFormatters]+authContext senv =+ authHandler senv+ :. shomeiErrorFormatters+ :. EmptyContext++-- | Build EP-5's seam 'Seam.Env' from this server's assembly 'Env'. The port runner+-- bridges EP-5's smaller stack onto the PostgreSQL stack with @inject@ while preserving+-- its typed error channel.+seamEnv :: Env -> Seam.Env+seamEnv env =+ Seam.Env+ { Seam.runPorts = runPorts,+ Seam.config = env.envConfig,+ -- The JWKS getter reads swappable key material, so a rotation applied by 'reloadKeys'+ -- takes effect on the next request without rebuilding the WAI application. Token+ -- verification now goes through 'Seam.runPorts'; 'runAppIO' also re-reads these keys.+ Seam.jwksJson = (.jwksBody) <$> readIORef env.envKeys,+ Seam.accountKeyOf = AccountKey . sha256Hex+ }+ where+ runPorts :: forall a. Eff Seam.AppEffects a -> IO (Either AuthError a)+ runPorts act = runAppIO env (inject act)
+ src/Shomei/Server/BreachChecker.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE DataKinds #-}++-- | EP-3 production interpreter for 'PasswordBreachChecker': a HIBP Pwned Passwords range+-- query using k-anonymity. Only the 5-char SHA-1 prefix leaves the process; the full hash and the+-- plaintext never go on the wire. Constructed once when the effect stack is assembled, from a+-- shared TLS 'Manager' and a fixed per-call timeout (the EP-1 @breachCheckTimeoutMs@ policy).+--+-- Any transport error or timeout is caught and reported as 'BreachCheckUnavailable', leaving the+-- fail-open / fail-closed decision to 'Shomei.Account.Password.Breach.Workflow.enforceBreachPolicy'.+module Shomei.Server.BreachChecker (runPasswordBreachCheckerHibp, runPasswordBreachCheckerWith) where++import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)+import Data.ByteString.Lazy qualified as LBS+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Network.HTTP.Client+ ( Manager,+ Request (..),+ httpLbs,+ parseRequest,+ responseBody,+ responseTimeoutMicro,+ )+import Network.HTTP.Types.Header (hUserAgent)+import Shomei.Account.Password.Breach.Store+ ( BreachResult (..),+ PasswordBreachChecker (..),+ parseHibpResponse,+ sha1PrefixSuffix,+ )+import Shomei.Notify (classifyWebhookFailure, reasonText)+import System.IO (hPutStrLn, stderr)++-- | Build the interpreter from a shared TLS manager and a timeout in milliseconds (pass+-- @cfg.passwordPolicy.breachCheckTimeoutMs@ at assembly time).+runPasswordBreachCheckerHibp ::+ (IOE :> es) => Manager -> Int -> Eff (PasswordBreachChecker : es) a -> Eff es a+runPasswordBreachCheckerHibp mgr timeoutMs = runPasswordBreachCheckerWith (queryRange mgr timeoutMs) (hPutStrLn stderr . Text.unpack)++-- | Injectable dependency/report boundary. Only the k-anonymity prefix reaches the query;+-- reports carry a closed reason code and never contain the prefix or exception text.+runPasswordBreachCheckerWith ::+ (IOE :> es) => (Text -> IO Text) -> (Text -> IO ()) -> Eff (PasswordBreachChecker : es) a -> Eff es a+runPasswordBreachCheckerWith query report = interpret_ \case+ CheckPasswordBreached plain -> liftIO do+ let (prefix, suffix) = sha1PrefixSuffix plain+ result <- try (query prefix) :: IO (Either SomeException Text)+ case result of+ Left err | Just _ <- (fromException err :: Maybe SomeAsyncException) -> throwIO err+ Left err -> do+ report ("[shomei:breach-check] unavailable reason=" <> reasonText (classifyWebhookFailure err))+ pure BreachCheckUnavailable+ Right body -> pure (if parseHibpResponse body suffix then Breached else NotBreached)++-- | Fetch the HIBP range bucket for a 5-char prefix as decoded text. Throws on transport+-- errors and timeouts (caught by the interpreter above).+queryRange :: Manager -> Int -> Text -> IO Text+queryRange mgr timeoutMs prefix = do+ base <- parseRequest ("https://api.pwnedpasswords.com/range/" <> Text.unpack prefix)+ let req =+ base+ { requestHeaders =+ ("Add-Padding", "true")+ : (hUserAgent, "shomei")+ : requestHeaders base,+ responseTimeout = responseTimeoutMicro (timeoutMs * 1000)+ }+ resp <- httpLbs req mgr+ pure (TE.decodeUtf8 (LBS.toStrict (responseBody resp)))
+ src/Shomei/Server/Config.hs view
@@ -0,0 +1,1471 @@+-- | Load runtime configuration into the core 'ShomeiConfig' plus the server-only+-- 'ServerSettings' (listen port, connection string).+--+-- Precedence (lowest to highest, twelve-factor): (1) built-in 'defaultShomeiConfig'; (2) a typed+-- Dhall file at @$SHOMEI_CONFIG@ (if set and present), rendered to JSON by the @dhall-to-json@+-- CLI and decoded here; (3) individual @SHOMEI_*@ / @PG_CONNECTION_STRING@ environment variables.+-- If @$SHOMEI_CONFIG@ is unset the file step is skipped and the server still boots from defaults+-- + env (preserving the turnkey behavior).+--+-- Dhall is rendered with the @dhall-to-json@ binary (provided by the toolchain / container) rather+-- than the heavyweight @dhall@ Haskell library, and the rendered JSON is decoded with @aeson@ into+-- a flat 'FileConfig' of optional scalar overrides — see EP-5's Decision Log. @loadConfigFromEnv@+-- remains as the env-only entry point used by EP-4's @shomei-admin@.+module Shomei.Server.Config+ ( ServerSettings (..),+ ProxyProtocolMode (..),+ SweepSettings (..),+ defaultSweepSettings,+ defaultDbStatementTimeoutMs,+ toSweepConfig,+ loadConfig,+ loadConfigFromEnv,+ loadCoreConfig,+ loadNotifierSecretsFromEnv,+ defaultRolesFromEnv,+ cookieTransportWarnings,+ FileConfig (..),+ )+where++-- Argon2Params is re-exported through 'ServerSettings'; import it from "Shomei.Account.Password.Hash.Postgres".++import Data.Aeson (eitherDecodeStrict')+import Data.Char (isDigit)+import Data.Foldable (for_)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Time (NominalDiffTime)+import Shomei.Account.Password.Domain (PasswordPolicy (..))+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), argon2HardFloor, defaultArgon2Params)+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..), Role (..))+import Shomei.Config+ ( AttestationPolicy (..),+ CookieConfig (..),+ MachineTokenConfig (..),+ MfaConfig (..),+ NotifierConfig (..),+ NotifierTransport (..),+ OAuthConfig (..),+ ObservabilityConfig (..),+ RateLimitConfig (..),+ SameSitePolicy (..),+ SessionCheckMode (..),+ ShomeiConfig (..),+ SigningKeyConfig (..),+ SmtpConfig (..),+ SmtpTlsMode (..),+ TokenTransport (..),+ TotpConfig (..),+ UserVerificationPolicy (..),+ WebAuthnConfig (..),+ WebhookConfig (..),+ defaultCookieConfig,+ defaultShomeiConfig,+ normalizeOrigin,+ transportUsesCookies,+ )+import Shomei.Notify (NotifierSecrets (..), SmtpPassword (..), WebhookSecret (..))+import Shomei.Persistence.Maintenance.Postgres (SweepConfig (..), defaultSweepConfig)+import Shomei.Prelude+import Shomei.Server.Middleware.TrustedProxy (TrustedProxies, emptyTrustedProxies, parseTrustedProxies)+import Shomei.SigningKey.Verify.Jwt (checkStringOrUri)+import System.Environment (lookupEnv)+import System.IO (hPutStrLn, stderr)+import System.Process (readProcess)+import Text.Read (readMaybe)++-- | Server-only settings not part of the transport-agnostic 'ShomeiConfig'.+--+-- The pool knobs live here rather than in 'ShomeiConfig' because 'ShomeiConfig' is+-- deliberately infrastructure-agnostic — it has no @hasql@ types and no knowledge that+-- PostgreSQL exists. The pool is a server deployment concern exactly like the listen port.+data ServerSettings = ServerSettings+ { serverPort :: !Int,+ serverBindHost :: !Text,+ serverMaxConnections :: !Int,+ serverSlowClientTimeoutSeconds :: !Int,+ serverConnStr :: !Text,+ -- | socket peers allowed to supply X-Forwarded-For client identity+ serverTrustedProxies :: !TrustedProxies,+ -- | whether every TCP connection must carry a PROXY protocol v1 header+ serverProxyProtocol :: !ProxyProtocolMode,+ -- | connections held open by the @hasql@ pool+ serverDbPoolSize :: !Int,+ -- | how long a request waits for a free pooled connection before failing+ serverDbPoolAcquisitionTimeoutMs :: !Int,+ -- | PostgreSQL statement and idle-in-transaction timeout installed on every connection+ serverDbStatementTimeoutMs :: !Int,+ serverDbTransactionMaxAttempts :: !Int,+ serverDbTransactionDeadlineMs :: !Int,+ -- | bounded in-memory notification queue capacity+ serverNotifierQueueSize :: !Int,+ -- | non-fatal configuration posture warnings printed by the standalone boot path+ serverWarnings :: ![Text],+ -- | the background expired-data sweeper+ serverSweep :: !SweepSettings,+ -- | Argon2id cost parameters for hashing /new/ passwords. Existing hashes carry the+ -- parameters they were made with, so changing these never invalidates a credential.+ serverArgon2 :: !Argon2Params,+ -- | How many Argon2 derivations may run at once. Each pins a capability in an unsafe+ -- foreign call and transiently allocates its full memory cost, so this bounds both GC+ -- stalls and the memory spike. Raising it trades hot-path latency for login throughput.+ serverHashingMaxConcurrency :: !Int+ }+ deriving stock (Show, Generic)++data ProxyProtocolMode+ = ProxyProtocolOff+ | ProxyProtocolRequired+ deriving stock (Eq, Show, Generic)++-- | Two concurrent hashes bound the transient allocation at ~128 MiB and cap the capabilities+-- that can be pinned in unsafe FFI simultaneously, while still sustaining ~13–40 logins/second+-- at 50–150 ms per hash — far above any single-instance deployment's login rate. The+-- conservative end of the review's 2–4 suggestion: too-small merely queues logins, while+-- too-large is the global-GC-stall bug this bound exists to prevent.+defaultHashingMaxConcurrency :: Int+defaultHashingMaxConcurrency = 2++-- | Settings for the background expired-data sweeper (see 'Shomei.Persistence.Maintenance.Postgres').+--+-- These live here rather than in the core 'ShomeiConfig' for the same reason the pool knobs+-- do: retention is a deployment and storage concern with no domain meaning, and 'ShomeiConfig'+-- is deliberately infrastructure-agnostic.+--+-- 'sweepEnabled' is the off-switch. An operator who prefers external scheduling (cron, a+-- Kubernetes CronJob) sets @SHOMEI_SWEEP_ENABLED=false@ and runs @shomei-admin sweep@ instead;+-- both triggers call the same 'sweepOnce', and running them concurrently is harmless.+data SweepSettings = SweepSettings+ { sweepEnabled :: !Bool,+ -- | seconds between sweep cycles+ sweepIntervalSeconds :: !Int,+ sweepBatchSize :: !Int,+ sweepDeadSessionGraceDays :: !Int,+ sweepOneTimeTokenGraceDays :: !Int,+ sweepCeremonyGraceMinutes :: !Int,+ sweepLoginAttemptRetentionDays :: !Int,+ -- | 'Nothing' retains the audit trail forever, which is the default+ sweepAuthEventRetentionDays :: !(Maybe Int)+ }+ deriving stock (Show, Eq, Generic)++-- | Sweeper defaults: on, hourly, and with the grace periods documented on 'SweepConfig'.+defaultSweepSettings :: SweepSettings+defaultSweepSettings =+ SweepSettings+ { sweepEnabled = True,+ sweepIntervalSeconds = 3600,+ sweepBatchSize = defaultSweepConfig.batchSize,+ sweepDeadSessionGraceDays = defaultSweepConfig.deadSessionGraceDays,+ sweepOneTimeTokenGraceDays = defaultSweepConfig.oneTimeTokenGraceDays,+ sweepCeremonyGraceMinutes = defaultSweepConfig.ceremonyGraceMinutes,+ sweepLoginAttemptRetentionDays = defaultSweepConfig.loginAttemptRetentionDays,+ sweepAuthEventRetentionDays = defaultSweepConfig.authEventRetentionDays+ }++-- | Project the deployment-facing settings onto the sweep engine's own config record.+toSweepConfig :: SweepSettings -> SweepConfig+toSweepConfig s =+ SweepConfig+ { batchSize = s.sweepBatchSize,+ deadSessionGraceDays = s.sweepDeadSessionGraceDays,+ oneTimeTokenGraceDays = s.sweepOneTimeTokenGraceDays,+ ceremonyGraceMinutes = s.sweepCeremonyGraceMinutes,+ loginAttemptRetentionDays = s.sweepLoginAttemptRetentionDays,+ authEventRetentionDays = s.sweepAuthEventRetentionDays+ }++-- | The pool size a server boots with when neither the Dhall file nor the environment says+-- otherwise. Ten preserves the value that was hardcoded in @Shomei.Server.Boot.buildEnv@.+defaultDbPoolSize :: Int+defaultDbPoolSize = 10++-- | The acquisition timeout, in milliseconds, a server boots with by default: @hasql-pool@'s+-- own 10-second default, made explicit.+defaultDbPoolAcquisitionTimeoutMs :: Int+defaultDbPoolAcquisitionTimeoutMs = 10000++-- | Bound one PostgreSQL statement or an idle transaction to 30 seconds by default. Zero is the+-- explicit PostgreSQL off-switch for deployments that need an unbounded session.+defaultDbStatementTimeoutMs :: Int+defaultDbStatementTimeoutMs = 30000++defaultNotifierQueueSize :: Int+defaultNotifierQueueSize = 1024++-- | The flat, all-optional shape the Dhall config file is rendered into (via @dhall-to-json@).+-- Every field is a 'Maybe' scalar so a partial file is valid and absent keys fall back to the+-- defaults / env.+data FileConfig = FileConfig+ { issuer :: !(Maybe Text),+ audience :: !(Maybe Text),+ databaseUrl :: !(Maybe Text),+ port :: !(Maybe Int),+ bindHost :: !(Maybe Text),+ maxConnections :: !(Maybe Int),+ slowClientTimeoutSeconds :: !(Maybe Int),+ trustedProxies :: !(Maybe [Text]),+ proxyProtocol :: !(Maybe Text),+ -- | connections held open by the @hasql@ pool+ dbPoolSize :: !(Maybe Int),+ -- | how long a request waits for a free pooled connection, in milliseconds+ dbPoolAcquisitionTimeoutMs :: !(Maybe Int),+ -- | maximum statement or idle-in-transaction duration; zero disables both+ dbStatementTimeoutMs :: !(Maybe Int),+ dbTransactionMaxAttempts :: !(Maybe Int),+ dbTransactionDeadlineMs :: !(Maybe Int),+ -- | run the background expired-data sweeper in-process+ sweepEnabled :: !(Maybe Bool),+ sweepIntervalSeconds :: !(Maybe Int),+ sweepBatchSize :: !(Maybe Int),+ sweepDeadSessionGraceDays :: !(Maybe Int),+ sweepOneTimeTokenGraceDays :: !(Maybe Int),+ sweepCeremonyGraceMinutes :: !(Maybe Int),+ loginAttemptRetentionDays :: !(Maybe Int),+ -- | absent retains the audit trail forever; a value of 0 or less also means "forever"+ authEventRetentionDays :: !(Maybe Int),+ -- | Argon2id memory cost in KiB for newly hashed passwords+ argon2MemoryKiB :: !(Maybe Int),+ argon2Iterations :: !(Maybe Int),+ argon2Parallelism :: !(Maybe Int),+ -- | how many Argon2 derivations may run at once+ hashingMaxConcurrency :: !(Maybe Int),+ accessTokenTtlSeconds :: !(Maybe Int),+ refreshTokenTtlSeconds :: !(Maybe Int),+ sessionTtlSeconds :: !(Maybe Int),+ rememberedSessionTtlSeconds :: !(Maybe Int),+ maxPasskeysPerUser :: !(Maybe Int),+ maxActiveNonInteractiveSessions :: !(Maybe Int),+ publicBaseUrl :: !(Maybe Text),+ emailVerificationRequired :: !(Maybe Bool),+ -- | EP-8 notifier transport: @log@ (default) | @smtp@ | @webhook@. Secrets (SMTP password,+ -- webhook signing secret) are deliberately NOT config-file fields — they come from the+ -- environment only (@SHOMEI_SMTP_PASSWORD@, @SHOMEI_WEBHOOK_SECRET@).+ notifierTransport :: !(Maybe Text),+ -- | also tee every notification through the log sender (staged rollout). Default False.+ alsoLogNotifications :: !(Maybe Bool),+ smtpHost :: !(Maybe Text),+ smtpPort :: !(Maybe Int),+ -- | @plain@ (lab only) | @starttls@ | @implicit@+ smtpTlsMode :: !(Maybe Text),+ smtpUsername :: !(Maybe Text),+ smtpFromAddress :: !(Maybe Text),+ smtpTimeoutSeconds :: !(Maybe Int),+ webhookUrl :: !(Maybe Text),+ webhookTimeoutSeconds :: !(Maybe Int),+ webhookMaxAttempts :: !(Maybe Int),+ notifierQueueSize :: !(Maybe Int),+ rateLimitEnabled :: !(Maybe Bool),+ maxFailedLoginsPerAccount :: !(Maybe Int),+ maxFailedLoginsPerIp :: !(Maybe Int),+ perIpRequestsPerMinute :: !(Maybe Int),+ perIpBurst :: !(Maybe Int),+ lockoutWindowSeconds :: !(Maybe Int),+ lockoutDurationSeconds :: !(Maybe Int),+ metricsEnabled :: !(Maybe Bool),+ requestLoggingEnabled :: !(Maybe Bool),+ gracefulShutdownTimeoutSeconds :: !(Maybe Int),+ passwordMinLength :: !(Maybe Int),+ passwordMaxLength :: !(Maybe Int),+ passwordRejectCommon :: !(Maybe Bool),+ passwordRejectContextual :: !(Maybe Bool),+ passwordBreachCheckEnabled :: !(Maybe Bool),+ passwordBreachCheckFailClosed :: !(Maybe Bool),+ passwordBreachCheckTimeoutMs :: !(Maybe Int),+ webauthnRpId :: !(Maybe Text),+ webauthnRpName :: !(Maybe Text),+ webauthnOrigins :: !(Maybe [Text]),+ -- | @required@ | @preferred@ | @discouraged@+ webauthnUserVerification :: !(Maybe Text),+ -- | @none@ | @direct@+ webauthnAttestation :: !(Maybe Text),+ webauthnCeremonyTimeoutSeconds :: !(Maybe Int),+ webauthnPendingCeremonyTtlSeconds :: !(Maybe Int),+ mfaRequireSecondFactor :: !(Maybe Bool),+ -- | EP-7: enable the TOTP second factor (enrollment + login challenge). The AES-256-GCM+ -- encryption key for stored secrets is NOT here — it is a secret, read from+ -- @SHOMEI_TOTP_ENCRYPTION_KEY@; the server refuses to boot when TOTP is on and it is+ -- absent or malformed.+ totpEnabled :: !(Maybe Bool),+ totpEnrollmentTtlSeconds :: !(Maybe Int),+ machineTokenTtlSeconds :: !(Maybe Int),+ -- | enable the OIDC provider surface (discovery, @\/oauth\/authorize@). Requires @issuer@+ -- to be the deployment's public @http(s)@ base URL; the server refuses to boot otherwise.+ oidcEnabled :: !(Maybe Bool),+ -- | the host's login page, for the unauthenticated-authorize redirect+ oauthLoginUrl :: !(Maybe Text),+ oauthAuthorizationCodeTtlSeconds :: !(Maybe Int),+ oauthIdTokenTtlSeconds :: !(Maybe Int),+ -- | seconds of verifier tolerance for exp/nbf/iat; must be non-negative+ allowedClockSkewSeconds :: !(Maybe Int),+ legacyActorUntilUnix :: !(Maybe Int),+ emitLegacyActor :: !(Maybe Bool),+ -- | @ES256@ | @RS256@; the JWT signing algorithm for keys generated on first boot+ signingAlgorithm :: !(Maybe Text),+ -- | seconds between background reloads of signing-key material; 0 disables them+ keyRefreshIntervalSeconds :: !(Maybe Int),+ -- | @bearer@ | @cookie@ | @both@+ tokenTransport :: !(Maybe Text),+ cookieSecure :: !(Maybe Bool),+ -- | @strict@ | @lax@ | @none@+ cookieSameSite :: !(Maybe Text),+ -- | origins allowed to make cookie-authenticated mutating requests+ csrfAllowedOrigins :: !(Maybe [Text]),+ -- | roles granted to every new user at signup. Each must already exist in the+ -- @shomei_roles@ registry; the server refuses to boot otherwise.+ defaultRoles :: !(Maybe [Text])+ }+ deriving stock (Show, Generic)+ deriving anyclass (ToJSON)++instance FromJSON FileConfig where+ parseJSON = genericParseJSON defaultOptions {rejectUnknownFields = True}++-- | The full loader: defaults → Dhall file (if @$SHOMEI_CONFIG@) → env.+loadConfig :: IO (ShomeiConfig, ServerSettings)+loadConfig = do+ mFile <- loadDhallFile+ (cfg0, settings0) <- baseFromFile mFile+ overlayFromEnvBoth cfg0 settings0++-- | The env-only loader (no Dhall file). Stable entry point for @shomei-admin@.+loadConfigFromEnv :: IO (ShomeiConfig, ServerSettings)+loadConfigFromEnv = do+ (cfg, settings) <- baseDefaults+ overlayFromEnvBoth cfg settings++-- | Load only the transport-agnostic configuration: defaults → Dhall file → core environment+-- overlays. Administrative commands use this path so password policy, default roles, and other+-- workflow behavior match the server without requiring a server connection string or validating+-- listen/pool settings they do not consume.+loadCoreConfig :: IO ShomeiConfig+loadCoreConfig = do+ mFile <- loadDhallFile+ (cfg, _) <- baseFromFile mFile+ overlayCoreFromEnv cfg++-- Dhall file ----------------------------------------------------------------++loadDhallFile :: IO (Maybe FileConfig)+loadDhallFile = do+ mPath <- lookupEnv "SHOMEI_CONFIG"+ case mPath of+ Just path | not (null path) -> do+ out <- readProcess "dhall-to-json" ["--file", path] ""+ case eitherDecodeStrict' (TE.encodeUtf8 (Text.pack out)) of+ Right fc -> pure (Just fc)+ Left err -> do+ hPutStrLn stderr ("shomei: could not decode rendered Dhall config " <> path <> ": " <> err)+ ioError (userError ("invalid SHOMEI_CONFIG: " <> path))+ _ -> pure Nothing++baseDefaults :: IO (ShomeiConfig, ServerSettings)+baseDefaults =+ pure+ ( defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients"),+ ServerSettings+ { serverPort = 8080,+ serverBindHost = "*4",+ serverMaxConnections = 1024,+ serverSlowClientTimeoutSeconds = 30,+ serverConnStr = "",+ serverTrustedProxies = emptyTrustedProxies,+ serverProxyProtocol = ProxyProtocolOff,+ serverDbPoolSize = defaultDbPoolSize,+ serverDbPoolAcquisitionTimeoutMs = defaultDbPoolAcquisitionTimeoutMs,+ serverDbStatementTimeoutMs = defaultDbStatementTimeoutMs,+ serverDbTransactionMaxAttempts = 3,+ serverDbTransactionDeadlineMs = 5000,+ serverNotifierQueueSize = defaultNotifierQueueSize,+ serverWarnings = [],+ serverSweep = defaultSweepSettings,+ serverArgon2 = defaultArgon2Params,+ serverHashingMaxConcurrency = defaultHashingMaxConcurrency+ }+ )++baseFromFile :: Maybe FileConfig -> IO (ShomeiConfig, ServerSettings)+baseFromFile Nothing = baseDefaults+baseFromFile (Just fc) = do+ algFile <- traverse (normalizeSigningAlg "signingAlgorithm (config file)") fc.signingAlgorithm+ transportFile <- traverse (parseTransport "tokenTransport (config file)") fc.tokenTransport+ sameSiteFile <- traverse (parseSameSite "cookieSameSite (config file)") fc.cookieSameSite+ uvFile <- traverse (parseUserVerificationPolicy "webauthnUserVerification (config file)") fc.webauthnUserVerification+ attestationFile <- traverse (parseAttestationPolicy "webauthnAttestation (config file)") fc.webauthnAttestation+ notifierTransportFile <- traverse (parseNotifierTransport "notifierTransport (config file)") fc.notifierTransport+ smtpTlsModeFile <- traverse (parseSmtpTlsMode "smtpTlsMode (config file)") fc.smtpTlsMode+ csrfOriginsFile <- traverse normalizeOrigins fc.csrfAllowedOrigins+ trustedProxiesFile <- parseTrustedProxyList (fromMaybe [] fc.trustedProxies)+ proxyProtocolFile <- traverse (parseProxyProtocol "proxyProtocol (config file)") fc.proxyProtocol+ let iss = fromMaybe "shomei" fc.issuer+ aud = fromMaybe "shomei-clients" fc.audience+ cfg0 = defaultShomeiConfig (Issuer iss) (Audience aud)+ let cfg =+ cfg0+ { accessTokenTTL = maybe cfg0.accessTokenTTL fromIntegral fc.accessTokenTtlSeconds,+ refreshTokenTTL = maybe cfg0.refreshTokenTTL fromIntegral fc.refreshTokenTtlSeconds,+ sessionTTL = maybe cfg0.sessionTTL fromIntegral fc.sessionTtlSeconds,+ rememberedSessionTTL = maybe cfg0.rememberedSessionTTL fromIntegral fc.rememberedSessionTtlSeconds,+ maxPasskeysPerUser = fromMaybe cfg0.maxPasskeysPerUser fc.maxPasskeysPerUser,+ maxActiveNonInteractiveSessions = fromMaybe cfg0.maxActiveNonInteractiveSessions fc.maxActiveNonInteractiveSessions,+ notifierConfig =+ cfg0.notifierConfig+ { emailVerificationRequired = fromMaybe cfg0.notifierConfig.emailVerificationRequired fc.emailVerificationRequired,+ publicBaseUrl = fromMaybe cfg0.notifierConfig.publicBaseUrl fc.publicBaseUrl,+ notifierTransport = fromMaybe cfg0.notifierConfig.notifierTransport notifierTransportFile,+ alsoLogNotifications = fromMaybe cfg0.notifierConfig.alsoLogNotifications fc.alsoLogNotifications,+ smtpConfig = smtpFromFile smtpTlsModeFile fc,+ webhookConfig = webhookFromFile fc+ },+ rateLimitConfig =+ cfg0.rateLimitConfig+ { rateLimitEnabled = fromMaybe cfg0.rateLimitConfig.rateLimitEnabled fc.rateLimitEnabled,+ maxFailedLoginsPerAccount = fromMaybe cfg0.rateLimitConfig.maxFailedLoginsPerAccount fc.maxFailedLoginsPerAccount,+ maxFailedLoginsPerIp = fromMaybe cfg0.rateLimitConfig.maxFailedLoginsPerIp fc.maxFailedLoginsPerIp,+ lockoutWindow = maybe cfg0.rateLimitConfig.lockoutWindow fromIntegral fc.lockoutWindowSeconds,+ lockoutDuration = maybe cfg0.rateLimitConfig.lockoutDuration fromIntegral fc.lockoutDurationSeconds,+ perIpRequestsPerMinute = fromMaybe cfg0.rateLimitConfig.perIpRequestsPerMinute fc.perIpRequestsPerMinute,+ perIpBurst = fromMaybe cfg0.rateLimitConfig.perIpBurst fc.perIpBurst+ },+ passwordPolicy =+ cfg0.passwordPolicy+ { minLength = fromMaybe cfg0.passwordPolicy.minLength fc.passwordMinLength,+ maxLength = fromMaybe cfg0.passwordPolicy.maxLength fc.passwordMaxLength,+ rejectCommonPasswords = fromMaybe cfg0.passwordPolicy.rejectCommonPasswords fc.passwordRejectCommon,+ rejectContextualPasswords = fromMaybe cfg0.passwordPolicy.rejectContextualPasswords fc.passwordRejectContextual,+ breachCheckEnabled = fromMaybe cfg0.passwordPolicy.breachCheckEnabled fc.passwordBreachCheckEnabled,+ breachCheckFailClosed = fromMaybe cfg0.passwordPolicy.breachCheckFailClosed fc.passwordBreachCheckFailClosed,+ breachCheckTimeoutMs = fromMaybe cfg0.passwordPolicy.breachCheckTimeoutMs fc.passwordBreachCheckTimeoutMs+ },+ observabilityConfig =+ cfg0.observabilityConfig+ { metricsEnabled = fromMaybe cfg0.observabilityConfig.metricsEnabled fc.metricsEnabled,+ requestLoggingEnabled = fromMaybe cfg0.observabilityConfig.requestLoggingEnabled fc.requestLoggingEnabled,+ gracefulShutdownTimeoutSeconds = fromMaybe cfg0.observabilityConfig.gracefulShutdownTimeoutSeconds fc.gracefulShutdownTimeoutSeconds+ },+ webauthnConfig = mergeWebAuthn (webauthnConfig cfg0) uvFile attestationFile fc,+ mfaConfig =+ cfg0.mfaConfig+ { requireSecondFactor = fromMaybe cfg0.mfaConfig.requireSecondFactor fc.mfaRequireSecondFactor+ },+ totpConfig = mergeTotp cfg0.totpConfig fc,+ machineTokenConfig =+ cfg0.machineTokenConfig+ { machineTokenTTL = maybe cfg0.machineTokenConfig.machineTokenTTL fromIntegral fc.machineTokenTtlSeconds+ },+ oauthConfig = mergeOAuth cfg0.oauthConfig fc,+ signingKeyConfig =+ cfg0.signingKeyConfig+ { algorithm = fromMaybe cfg0.signingKeyConfig.algorithm algFile,+ refreshIntervalSeconds =+ fromMaybe cfg0.signingKeyConfig.refreshIntervalSeconds fc.keyRefreshIntervalSeconds,+ allowedClockSkewSeconds =+ fromMaybe cfg0.signingKeyConfig.allowedClockSkewSeconds fc.allowedClockSkewSeconds,+ legacyActorUntilUnix = fc.legacyActorUntilUnix,+ emitLegacyActor = fromMaybe False fc.emitLegacyActor+ },+ tokenTransport = fromMaybe cfg0.tokenTransport transportFile,+ cookieConfig =+ cfg0.cookieConfig+ { secure = fromMaybe cfg0.cookieConfig.secure fc.cookieSecure,+ sameSite = fromMaybe cfg0.cookieConfig.sameSite sameSiteFile,+ allowedOrigins = fromMaybe cfg0.cookieConfig.allowedOrigins csrfOriginsFile+ },+ defaultRoles = maybe cfg0.defaultRoles roleSet fc.defaultRoles+ }+ settings =+ ServerSettings+ { serverPort = fromMaybe 8080 fc.port,+ serverBindHost = fromMaybe "*4" fc.bindHost,+ serverMaxConnections = fromMaybe 1024 fc.maxConnections,+ serverSlowClientTimeoutSeconds = fromMaybe 30 fc.slowClientTimeoutSeconds,+ serverConnStr = fromMaybe "" fc.databaseUrl,+ serverTrustedProxies = trustedProxiesFile,+ serverProxyProtocol = fromMaybe ProxyProtocolOff proxyProtocolFile,+ serverDbPoolSize = fromMaybe defaultDbPoolSize fc.dbPoolSize,+ serverDbPoolAcquisitionTimeoutMs =+ fromMaybe defaultDbPoolAcquisitionTimeoutMs fc.dbPoolAcquisitionTimeoutMs,+ serverDbStatementTimeoutMs =+ fromMaybe defaultDbStatementTimeoutMs fc.dbStatementTimeoutMs,+ serverDbTransactionMaxAttempts = fromMaybe 3 fc.dbTransactionMaxAttempts,+ serverDbTransactionDeadlineMs = fromMaybe 5000 fc.dbTransactionDeadlineMs,+ serverNotifierQueueSize = fromMaybe defaultNotifierQueueSize fc.notifierQueueSize,+ serverWarnings = [],+ serverSweep = mergeSweep defaultSweepSettings fc,+ serverArgon2 = mergeArgon2 defaultArgon2Params fc,+ serverHashingMaxConcurrency =+ fromMaybe defaultHashingMaxConcurrency fc.hashingMaxConcurrency+ }+ pure (cfg, settings)++-- | Apply the optional @argon2*@ fields of a decoded Dhall 'FileConfig' onto the Argon2+-- defaults. Constructed in full rather than record-updated, for the same+-- @-Wambiguous-fields@ reason as 'mergeSweep'.+mergeArgon2 :: Argon2Params -> FileConfig -> Argon2Params+mergeArgon2 base fc =+ Argon2Params+ { memoryKiB = fromMaybe base.memoryKiB fc.argon2MemoryKiB,+ iterations = fromMaybe base.iterations fc.argon2Iterations,+ parallelism = fromMaybe base.parallelism fc.argon2Parallelism+ }++-- | Apply the optional @sweep*@ / @*RetentionDays@ fields of a decoded Dhall 'FileConfig' onto+-- the sweeper defaults. A non-positive @authEventRetentionDays@ means "retain forever", the+-- same as omitting it — there is no other way to spell "disable" for a 'Maybe' field, and an+-- operator who writes @0@ plainly means "do not delete audit history".+--+-- 'SweepSettings' is constructed in full rather than record-updated: 'FileConfig' shares most+-- of these field names, and under @DuplicateRecordFields@ an update would rely on type-directed+-- disambiguation (a @-Wambiguous-fields@ warning, and a mechanism GHC plans to drop). Naming+-- the constructor resolves the fields unambiguously.+mergeSweep :: SweepSettings -> FileConfig -> SweepSettings+mergeSweep base fc =+ SweepSettings+ { sweepEnabled = fromMaybe base.sweepEnabled fc.sweepEnabled,+ sweepIntervalSeconds = fromMaybe base.sweepIntervalSeconds fc.sweepIntervalSeconds,+ sweepBatchSize = fromMaybe base.sweepBatchSize fc.sweepBatchSize,+ sweepDeadSessionGraceDays = fromMaybe base.sweepDeadSessionGraceDays fc.sweepDeadSessionGraceDays,+ sweepOneTimeTokenGraceDays = fromMaybe base.sweepOneTimeTokenGraceDays fc.sweepOneTimeTokenGraceDays,+ sweepCeremonyGraceMinutes = fromMaybe base.sweepCeremonyGraceMinutes fc.sweepCeremonyGraceMinutes,+ sweepLoginAttemptRetentionDays = fromMaybe base.sweepLoginAttemptRetentionDays fc.loginAttemptRetentionDays,+ sweepAuthEventRetentionDays = maybe base.sweepAuthEventRetentionDays positiveOrForever fc.authEventRetentionDays+ }++-- | A retention window of zero or fewer days disables deletion rather than deleting everything.+positiveOrForever :: Int -> Maybe Int+positiveOrForever n = if n > 0 then Just n else Nothing++-- Env overrides --------------------------------------------------------------++overlayFromEnvBoth :: ShomeiConfig -> ServerSettings -> IO (ShomeiConfig, ServerSettings)+overlayFromEnvBoth baseCfg baseSettings = do+ connStr <- textEnv "PG_CONNECTION_STRING" baseSettings.serverConnStr+ portV <- intEnv "SHOMEI_PORT" baseSettings.serverPort+ host <- textEnv "SHOMEI_BIND_HOST" baseSettings.serverBindHost+ connections <- intEnv "SHOMEI_MAX_CONNECTIONS" baseSettings.serverMaxConnections+ slowTimeout <- intEnv "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS" baseSettings.serverSlowClientTimeoutSeconds+ trustedProxyOverride <- trustedProxiesEnv+ trustedProxies <- maybe (pure baseSettings.serverTrustedProxies) parseTrustedProxyList trustedProxyOverride+ proxyProtocol <- proxyProtocolEnv baseSettings.serverProxyProtocol+ poolSize <- intEnv "SHOMEI_DB_POOL_SIZE" baseSettings.serverDbPoolSize+ poolTimeout <- intEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS" baseSettings.serverDbPoolAcquisitionTimeoutMs+ statementTimeout <- intEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS" baseSettings.serverDbStatementTimeoutMs+ transactionAttempts <- intEnv "SHOMEI_DB_TRANSACTION_MAX_ATTEMPTS" baseSettings.serverDbTransactionMaxAttempts+ transactionDeadline <- intEnv "SHOMEI_DB_TRANSACTION_DEADLINE_MS" baseSettings.serverDbTransactionDeadlineMs+ notifierQueueSize <- intEnv "SHOMEI_NOTIFIER_QUEUE_SIZE" baseSettings.serverNotifierQueueSize+ sweep <- overlaySweepFromEnv baseSettings.serverSweep+ argon2 <- overlayArgon2FromEnv baseSettings.serverArgon2+ hashingConcurrency <- intEnv "SHOMEI_HASHING_MAX_CONCURRENCY" baseSettings.serverHashingMaxConcurrency+ iss <- textEnv "SHOMEI_ISSUER" (issuerText baseCfg.issuer)+ aud <- textEnv "SHOMEI_AUDIENCE" (audienceText baseCfg.audience)+ requireStringOrUri "SHOMEI_ISSUER" "issuer" iss+ requireStringOrUri "SHOMEI_AUDIENCE" "audience" aud+ cfg <- overlayCoreFromEnv baseCfg {issuer = Issuer iss, audience = Audience aud}+ let WebAuthnConfig {origins = originList} = cfg.webauthnConfig+ when (null originList) $+ ioError+ ( userError+ "SHOMEI_WEBAUTHN_ORIGINS (Dhall field webauthnOrigins) must list at least one origin; an empty list would fail every passkey ceremony"+ )+ when (Text.null connStr) (ioError (userError "PG_CONNECTION_STRING is not set (and no databaseUrl in the Dhall config)"))+ -- Validated after the overlay so a bad value fails the boot whichever layer supplied it.+ -- A zero-size pool deadlocks every request and a zero acquisition timeout fails every+ -- checkout, so neither is a survivable "disable" setting: refuse to start.+ when (Text.null (Text.strip host)) (ioError (userError "SHOMEI_BIND_HOST (Dhall field bindHost) must be non-empty"))+ requirePositive "SHOMEI_MAX_CONNECTIONS" "maxConnections" connections+ requirePositive "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS" "slowClientTimeoutSeconds" slowTimeout+ when (slowTimeout > maxBound `div` 1000000) (ioError (userError "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS is not representable in microseconds"))+ requirePositive "SHOMEI_DB_POOL_SIZE" "dbPoolSize" poolSize+ requirePositive "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS" "dbPoolAcquisitionTimeoutMs" poolTimeout+ requireNonNegative "SHOMEI_DB_STATEMENT_TIMEOUT_MS" "dbStatementTimeoutMs" statementTimeout+ requirePositive "SHOMEI_DB_TRANSACTION_MAX_ATTEMPTS" "dbTransactionMaxAttempts" transactionAttempts+ requirePositive "SHOMEI_DB_TRANSACTION_DEADLINE_MS" "dbTransactionDeadlineMs" transactionDeadline+ when (transactionDeadline > min 2147483647 (maxBound `div` 1000)) (ioError (userError "SHOMEI_DB_TRANSACTION_DEADLINE_MS is not representable"))+ requirePositive "SHOMEI_NOTIFIER_QUEUE_SIZE" "notifierQueueSize" notifierQueueSize+ -- A zero interval would spin the sweeper; a zero batch size would make it delete nothing+ -- forever. Neither is a survivable "disable" setting — sweepEnabled=false is.+ requirePositive "SHOMEI_SWEEP_INTERVAL_SECONDS" "sweepIntervalSeconds" sweep.sweepIntervalSeconds+ requirePositive "SHOMEI_SWEEP_BATCH_SIZE" "sweepBatchSize" sweep.sweepBatchSize+ requirePositive "SHOMEI_LOGIN_ATTEMPT_RETENTION_DAYS" "loginAttemptRetentionDays" sweep.sweepLoginAttemptRetentionDays+ -- Grace periods may legitimately be zero ("sweep as soon as it expires"), only not negative,+ -- which would sweep rows that have not expired yet.+ requireNonNegative "SHOMEI_SWEEP_DEAD_SESSION_GRACE_DAYS" "sweepDeadSessionGraceDays" sweep.sweepDeadSessionGraceDays+ requireNonNegative "SHOMEI_SWEEP_ONE_TIME_TOKEN_GRACE_DAYS" "sweepOneTimeTokenGraceDays" sweep.sweepOneTimeTokenGraceDays+ requireNonNegative "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES" "sweepCeremonyGraceMinutes" sweep.sweepCeremonyGraceMinutes+ -- crypton rejects non-positive Argon2 costs with a CryptoFailed deep inside a login; a+ -- boot-time message naming the variable is kinder.+ requirePositive "SHOMEI_ARGON2_MEMORY_KIB" "argon2MemoryKiB" argon2.memoryKiB+ requirePositive "SHOMEI_ARGON2_ITERATIONS" "argon2Iterations" argon2.iterations+ requirePositive "SHOMEI_ARGON2_PARALLELISM" "argon2Parallelism" argon2.parallelism+ for_ (argon2HardFloor argon2) \why ->+ ioError+ ( userError+ ( "SHOMEI_ARGON2_MEMORY_KIB/SHOMEI_ARGON2_ITERATIONS/SHOMEI_ARGON2_PARALLELISM "+ <> "(Dhall fields argon2MemoryKiB/argon2Iterations/argon2Parallelism) are rejected "+ <> "by the Argon2 implementation: "+ <> Text.unpack why+ )+ )+ -- Zero permits would block every login forever.+ requirePositive "SHOMEI_HASHING_MAX_CONCURRENCY" "hashingMaxConcurrency" hashingConcurrency+ requireNonNegative+ "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"+ "allowedClockSkewSeconds"+ cfg.signingKeyConfig.allowedClockSkewSeconds+ let rate = cfg.rateLimitConfig+ requirePositive "SHOMEI_MAX_FAILED_LOGINS_PER_ACCOUNT" "maxFailedLoginsPerAccount" rate.maxFailedLoginsPerAccount+ requirePositive "SHOMEI_MAX_FAILED_LOGINS_PER_IP" "maxFailedLoginsPerIp" rate.maxFailedLoginsPerIp+ requirePositive "SHOMEI_PER_IP_REQUESTS_PER_MINUTE" "perIpRequestsPerMinute" rate.perIpRequestsPerMinute+ requirePositive "SHOMEI_PER_IP_BURST" "perIpBurst" rate.perIpBurst+ when (rate.lockoutWindow <= 0) $+ ioError (userError "SHOMEI_LOCKOUT_WINDOW_SECONDS (Dhall field lockoutWindowSeconds) must be a positive number of seconds")+ when (rate.lockoutDuration <= 0) $+ ioError (userError "SHOMEI_LOCKOUT_DURATION_SECONDS (Dhall field lockoutDurationSeconds) must be a positive number of seconds")+ -- Validated after the overlay so a selected notifier transport is proven fully configured+ -- whichever layer supplied its fields (secrets always come from the environment).+ notifierWarnings <- validateNotifierConfig cfg.notifierConfig+ let warnings = notifierWarnings <> cookieTransportWarnings cfg+ pure+ ( cfg,+ ServerSettings+ { serverPort = portV,+ serverBindHost = host,+ serverMaxConnections = connections,+ serverSlowClientTimeoutSeconds = slowTimeout,+ serverConnStr = connStr,+ serverTrustedProxies = trustedProxies,+ serverProxyProtocol = proxyProtocol,+ serverDbPoolSize = poolSize,+ serverDbPoolAcquisitionTimeoutMs = poolTimeout,+ serverDbStatementTimeoutMs = statementTimeout,+ serverDbTransactionMaxAttempts = transactionAttempts,+ serverDbTransactionDeadlineMs = transactionDeadline,+ serverNotifierQueueSize = notifierQueueSize,+ serverWarnings = warnings,+ serverSweep = sweep,+ serverArgon2 = argon2,+ serverHashingMaxConcurrency = hashingConcurrency+ }+ )+ where+ issuerText (Issuer t) = t+ audienceText (Audience t) = t+ requirePositive = requireBound (> 0) "a positive integer"+ requireNonNegative = requireBound (>= 0) "a non-negative integer"+ requireStringOrUri envName dhallField value =+ case checkStringOrUri value of+ Right () -> pure ()+ Left reason ->+ ioError+ ( userError+ ( Text.unpack envName+ <> " (Dhall field "+ <> Text.unpack dhallField+ <> ") "+ <> Text.unpack reason+ <> ", got "+ <> show value+ )+ )+ requireBound ok expected envName dhallField n =+ unless (ok n) do+ ioError+ ( userError+ ( Text.unpack envName+ <> " (Dhall field "+ <> Text.unpack dhallField+ <> ") must be "+ <> expected+ <> ", got "+ <> show n+ )+ )++-- | Overlay the @SHOMEI_ARGON2_*@ environment variables onto the Argon2 cost parameters.+-- These affect only newly hashed passwords; see 'Shomei.Account.Password.Hash.Postgres.hashPasswordArgon2id'.+overlayArgon2FromEnv :: Argon2Params -> IO Argon2Params+overlayArgon2FromEnv base = do+ mem <- intEnvMaybe "SHOMEI_ARGON2_MEMORY_KIB"+ iters <- intEnvMaybe "SHOMEI_ARGON2_ITERATIONS"+ lanes <- intEnvMaybe "SHOMEI_ARGON2_PARALLELISM"+ pure+ Argon2Params+ { memoryKiB = fromMaybe base.memoryKiB mem,+ iterations = fromMaybe base.iterations iters,+ parallelism = fromMaybe base.parallelism lanes+ }++-- | Overlay the @SHOMEI_SWEEP_*@ / @SHOMEI_*_RETENTION_DAYS@ environment variables onto the+-- sweeper settings. As on the Dhall path, a non-positive @SHOMEI_AUTH_EVENT_RETENTION_DAYS@+-- means "retain forever" — that is how an operator turns audit deletion back off without+-- editing the config file.+overlaySweepFromEnv :: SweepSettings -> IO SweepSettings+overlaySweepFromEnv base = do+ enabled <- boolEnv "SHOMEI_SWEEP_ENABLED"+ interval <- intEnvMaybe "SHOMEI_SWEEP_INTERVAL_SECONDS"+ batch <- intEnvMaybe "SHOMEI_SWEEP_BATCH_SIZE"+ deadSession <- intEnvMaybe "SHOMEI_SWEEP_DEAD_SESSION_GRACE_DAYS"+ oneTimeToken <- intEnvMaybe "SHOMEI_SWEEP_ONE_TIME_TOKEN_GRACE_DAYS"+ ceremony <- intEnvMaybe "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES"+ loginAttempts <- intEnvMaybe "SHOMEI_LOGIN_ATTEMPT_RETENTION_DAYS"+ authEvents <- intEnvMaybe "SHOMEI_AUTH_EVENT_RETENTION_DAYS"+ pure+ SweepSettings+ { sweepEnabled = fromMaybe base.sweepEnabled enabled,+ sweepIntervalSeconds = fromMaybe base.sweepIntervalSeconds interval,+ sweepBatchSize = fromMaybe base.sweepBatchSize batch,+ sweepDeadSessionGraceDays = fromMaybe base.sweepDeadSessionGraceDays deadSession,+ sweepOneTimeTokenGraceDays = fromMaybe base.sweepOneTimeTokenGraceDays oneTimeToken,+ sweepCeremonyGraceMinutes = fromMaybe base.sweepCeremonyGraceMinutes ceremony,+ sweepLoginAttemptRetentionDays = fromMaybe base.sweepLoginAttemptRetentionDays loginAttempts,+ sweepAuthEventRetentionDays = maybe base.sweepAuthEventRetentionDays positiveOrForever authEvents+ }++overlayCoreFromEnv :: ShomeiConfig -> IO ShomeiConfig+overlayCoreFromEnv base = do+ acc <- ttlEnv "SHOMEI_ACCESS_TTL"+ ref <- ttlEnv "SHOMEI_REFRESH_TTL"+ ses <- ttlEnv "SHOMEI_SESSION_TTL"+ rememberedSes <- ttlEnv "SHOMEI_REMEMBERED_SESSION_TTL"+ activeSessionCap <- intEnvMaybe "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS"+ let activeCap = fromMaybe base.maxActiveNonInteractiveSessions activeSessionCap+ when (activeCap < 1) (ioError (userError "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS (Dhall field maxActiveNonInteractiveSessions) must be positive"))+ passkeyCap <- intEnvMaybe "SHOMEI_MAX_PASSKEYS_PER_USER"+ let cap = fromMaybe base.maxPasskeysPerUser passkeyCap+ when (cap < 1 || cap > 100) (ioError (userError "SHOMEI_MAX_PASSKEYS_PER_USER (Dhall field maxPasskeysPerUser) must be between 1 and 100"))+ tr <- transportEnv+ sc <- sessionCheckEnv+ wa <- overlayWebAuthnFromEnv base.webauthnConfig+ mfaRequired <- boolEnv "SHOMEI_MFA_REQUIRE_SECOND_FACTOR"+ totpCfg <- overlayTotpFromEnv base.totpConfig+ machineTokenTtl <- ttlEnv "SHOMEI_MACHINE_TOKEN_TTL"+ oauthCfg <- overlayOAuthFromEnv base.oauthConfig+ pwMin <- intEnvMaybe "SHOMEI_PASSWORD_MIN_LENGTH"+ pwMax <- intEnvMaybe "SHOMEI_PASSWORD_MAX_LENGTH"+ pwRejCommon <- boolEnv "SHOMEI_PASSWORD_REJECT_COMMON"+ pwRejCtx <- boolEnv "SHOMEI_PASSWORD_REJECT_CONTEXTUAL"+ pwBreach <- boolEnv "SHOMEI_PASSWORD_BREACH_CHECK"+ pwBreachFC <- boolEnv "SHOMEI_PASSWORD_BREACH_FAIL_CLOSED"+ pwBreachTo <- intEnvMaybe "SHOMEI_PASSWORD_BREACH_TIMEOUT_MS"+ alg <- signingAlgEnv+ keyRefresh <- keyRefreshIntervalEnv+ clockSkew <- clockSkewEnv+ actorUntil <- intEnvMaybe "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX"+ legacyEmission <- boolEnv "SHOMEI_EMIT_LEGACY_ACTOR"+ let deadline = actorUntil <|> base.signingKeyConfig.legacyActorUntilUnix+ emitLegacy = fromMaybe base.signingKeyConfig.emitLegacyActor legacyEmission+ for_ deadline \value -> when (value <= 0) (ioError (userError "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX (Dhall field legacyActorUntilUnix) must be a positive finite Unix timestamp"))+ when (emitLegacy && isNothing deadline) (ioError (userError "SHOMEI_EMIT_LEGACY_ACTOR requires SHOMEI_LEGACY_ACTOR_UNTIL_UNIX or legacyActorUntilUnix"))+ -- Deliberately env-only, with no Dhall-file field: logging raw one-time tokens must be an+ -- explicit per-process decision, not something that lingers unnoticed in a committed file.+ logSecrets <- boolEnv "SHOMEI_NOTIFIER_LOG_SECRETS"+ notifierCfg <- overlayNotifierFromEnv base.notifierConfig logSecrets+ rateLimitEnabled' <- boolEnv "SHOMEI_RATE_LIMIT_ENABLED"+ maxFailedLoginsPerAccount' <- intEnvMaybe "SHOMEI_MAX_FAILED_LOGINS_PER_ACCOUNT"+ maxFailedLoginsPerIp' <- intEnvMaybe "SHOMEI_MAX_FAILED_LOGINS_PER_IP"+ perIpRequestsPerMinute' <- intEnvMaybe "SHOMEI_PER_IP_REQUESTS_PER_MINUTE"+ perIpBurst' <- intEnvMaybe "SHOMEI_PER_IP_BURST"+ lockoutWindow' <- ttlEnv "SHOMEI_LOCKOUT_WINDOW_SECONDS"+ lockoutDuration' <- ttlEnv "SHOMEI_LOCKOUT_DURATION_SECONDS"+ cookieSecure' <- boolEnv "SHOMEI_COOKIE_SECURE"+ cookieSameSite' <- sameSiteEnv+ csrfOrigins <- csrfOriginsEnv+ defaultRoles' <- defaultRolesFromEnv+ pure+ base+ { accessTokenTTL = fromMaybe base.accessTokenTTL acc,+ defaultRoles = fromMaybe base.defaultRoles defaultRoles',+ notifierConfig = notifierCfg,+ rateLimitConfig =+ base.rateLimitConfig+ { rateLimitEnabled = fromMaybe base.rateLimitConfig.rateLimitEnabled rateLimitEnabled',+ maxFailedLoginsPerAccount = fromMaybe base.rateLimitConfig.maxFailedLoginsPerAccount maxFailedLoginsPerAccount',+ maxFailedLoginsPerIp = fromMaybe base.rateLimitConfig.maxFailedLoginsPerIp maxFailedLoginsPerIp',+ lockoutWindow = fromMaybe base.rateLimitConfig.lockoutWindow lockoutWindow',+ lockoutDuration = fromMaybe base.rateLimitConfig.lockoutDuration lockoutDuration',+ perIpRequestsPerMinute = fromMaybe base.rateLimitConfig.perIpRequestsPerMinute perIpRequestsPerMinute',+ perIpBurst = fromMaybe base.rateLimitConfig.perIpBurst perIpBurst'+ },+ signingKeyConfig =+ base.signingKeyConfig+ { algorithm = fromMaybe base.signingKeyConfig.algorithm alg,+ refreshIntervalSeconds = fromMaybe base.signingKeyConfig.refreshIntervalSeconds keyRefresh,+ allowedClockSkewSeconds = fromMaybe base.signingKeyConfig.allowedClockSkewSeconds clockSkew,+ legacyActorUntilUnix = deadline,+ emitLegacyActor = emitLegacy+ },+ refreshTokenTTL = fromMaybe base.refreshTokenTTL ref,+ sessionTTL = fromMaybe base.sessionTTL ses,+ rememberedSessionTTL = fromMaybe base.rememberedSessionTTL rememberedSes,+ maxPasskeysPerUser = cap,+ maxActiveNonInteractiveSessions = activeCap,+ tokenTransport = fromMaybe base.tokenTransport tr,+ cookieConfig =+ base.cookieConfig+ { secure = fromMaybe base.cookieConfig.secure cookieSecure',+ sameSite = fromMaybe base.cookieConfig.sameSite cookieSameSite',+ allowedOrigins = fromMaybe base.cookieConfig.allowedOrigins csrfOrigins+ },+ sessionCheckMode = fromMaybe base.sessionCheckMode sc,+ webauthnConfig = wa,+ mfaConfig =+ base.mfaConfig+ { requireSecondFactor = fromMaybe base.mfaConfig.requireSecondFactor mfaRequired+ },+ totpConfig = totpCfg,+ machineTokenConfig =+ base.machineTokenConfig+ { machineTokenTTL = fromMaybe base.machineTokenConfig.machineTokenTTL machineTokenTtl+ },+ oauthConfig = oauthCfg,+ passwordPolicy =+ base.passwordPolicy+ { minLength = fromMaybe base.passwordPolicy.minLength pwMin,+ maxLength = fromMaybe base.passwordPolicy.maxLength pwMax,+ rejectCommonPasswords = fromMaybe base.passwordPolicy.rejectCommonPasswords pwRejCommon,+ rejectContextualPasswords = fromMaybe base.passwordPolicy.rejectContextualPasswords pwRejCtx,+ breachCheckEnabled = fromMaybe base.passwordPolicy.breachCheckEnabled pwBreach,+ breachCheckFailClosed = fromMaybe base.passwordPolicy.breachCheckFailClosed pwBreachFC,+ breachCheckTimeoutMs = fromMaybe base.passwordPolicy.breachCheckTimeoutMs pwBreachTo+ }+ }++-- | Apply the optional @oidc*@ \/ @oauth*@ fields of a decoded Dhall 'FileConfig' onto the+-- 'OAuthConfig' defaults. Constructed in full rather than record-updated, for the same+-- @-Wambiguous-fields@ reason as 'mergeSweep'.+mergeOAuth :: OAuthConfig -> FileConfig -> OAuthConfig+mergeOAuth base fc =+ OAuthConfig+ { oidcEnabled = fromMaybe base.oidcEnabled fc.oidcEnabled,+ loginUrl = fc.oauthLoginUrl <|> base.loginUrl,+ authorizationCodeTTL = maybe base.authorizationCodeTTL fromIntegral fc.oauthAuthorizationCodeTtlSeconds,+ idTokenTTL = maybe base.idTokenTTL fromIntegral fc.oauthIdTokenTtlSeconds+ }++-- | Apply the optional @totp*@ fields of a decoded Dhall 'FileConfig' onto the 'TotpConfig'+-- defaults. Constructed in full rather than record-updated, for the same @-Wambiguous-fields@+-- reason as 'mergeSweep'.+mergeTotp :: TotpConfig -> FileConfig -> TotpConfig+mergeTotp base fc =+ TotpConfig+ { totpEnabled = fromMaybe base.totpEnabled fc.totpEnabled,+ enrollmentTTL = maybe base.enrollmentTTL fromIntegral fc.totpEnrollmentTtlSeconds+ }++-- | Overlay the @SHOMEI_TOTP_*@ environment variables onto the TOTP policy.+overlayTotpFromEnv :: TotpConfig -> IO TotpConfig+overlayTotpFromEnv base = do+ mEnabled <- boolEnv "SHOMEI_TOTP_ENABLED"+ mTtl <- ttlEnv "SHOMEI_TOTP_ENROLLMENT_TTL"+ pure+ TotpConfig+ { totpEnabled = fromMaybe base.totpEnabled mEnabled,+ enrollmentTTL = fromMaybe base.enrollmentTTL mTtl+ }++overlayOAuthFromEnv :: OAuthConfig -> IO OAuthConfig+overlayOAuthFromEnv base = do+ mEnabled <- boolEnv "SHOMEI_OIDC_ENABLED"+ mLoginUrl <- textEnvMaybe "SHOMEI_OAUTH_LOGIN_URL"+ mCodeTtl <- ttlEnv "SHOMEI_OAUTH_CODE_TTL"+ mIdTtl <- ttlEnv "SHOMEI_OAUTH_ID_TOKEN_TTL"+ pure+ OAuthConfig+ { oidcEnabled = fromMaybe base.oidcEnabled mEnabled,+ loginUrl = mLoginUrl <|> base.loginUrl,+ authorizationCodeTTL = fromMaybe base.authorizationCodeTTL mCodeTtl,+ idTokenTTL = fromMaybe base.idTokenTTL mIdTtl+ }++-- Notifier transport (EP-8) ---------------------------------------------------++-- | Conventional per-attempt defaults for the notifier transports. Missing scalar fields fall+-- back to these; they match the defaults documented on 'SmtpConfig'/'WebhookConfig'.+defaultSmtpTimeoutSeconds :: Int+defaultSmtpTimeoutSeconds = 10++defaultWebhookTimeoutSeconds :: Int+defaultWebhookTimeoutSeconds = 5++defaultWebhookMaxAttempts :: Int+defaultWebhookMaxAttempts = 3++-- | @log@ | @smtp@ | @webhook@. @label@ names the source in the error.+parseNotifierTransport :: Text -> Text -> IO NotifierTransport+parseNotifierTransport label t = case Text.toLower (Text.strip t) of+ "log" -> pure LogNotifier+ "smtp" -> pure SmtpNotifier+ "webhook" -> pure WebhookNotifier+ other -> ioError (userError (Text.unpack label <> " must be log|smtp|webhook, got " <> Text.unpack other))++-- | @plain@ (lab only) | @starttls@ | @implicit@. Accepts a couple of common spellings.+parseSmtpTlsMode :: Text -> Text -> IO SmtpTlsMode+parseSmtpTlsMode label t = case Text.toLower (Text.strip t) of+ "plain" -> pure SmtpPlain+ "starttls" -> pure SmtpStartTls+ "start-tls" -> pure SmtpStartTls+ "implicit" -> pure SmtpImplicitTls+ "implicit-tls" -> pure SmtpImplicitTls+ "tls" -> pure SmtpImplicitTls+ other -> ioError (userError (Text.unpack label <> " must be plain|starttls|implicit, got " <> Text.unpack other))++notifierTransportEnv :: IO (Maybe NotifierTransport)+notifierTransportEnv = do+ m <- lookupEnv "SHOMEI_NOTIFIER_TRANSPORT"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseNotifierTransport "SHOMEI_NOTIFIER_TRANSPORT" (Text.pack s)++smtpTlsModeEnv :: IO (Maybe SmtpTlsMode)+smtpTlsModeEnv = do+ m <- lookupEnv "SHOMEI_SMTP_TLS_MODE"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseSmtpTlsMode "SHOMEI_SMTP_TLS_MODE" (Text.pack s)++-- | Build a partial 'SmtpConfig' from the Dhall file's flat @smtp*@ fields, whenever any is+-- present. Credentials never enter this record. Missing scalars take their conventional defaults+-- (port 587, STARTTLS, 10 s); completeness is validated at boot.+smtpFromFile :: Maybe SmtpTlsMode -> FileConfig -> Maybe SmtpConfig+smtpFromFile tls fc+ | anyPresent =+ Just+ SmtpConfig+ { host = fromMaybe "" fc.smtpHost,+ port = fromMaybe 587 fc.smtpPort,+ tlsMode = fromMaybe SmtpStartTls tls,+ username = fc.smtpUsername,+ fromAddress = fromMaybe "" fc.smtpFromAddress,+ timeoutSeconds = fromMaybe defaultSmtpTimeoutSeconds fc.smtpTimeoutSeconds+ }+ | otherwise = Nothing+ where+ anyPresent =+ isJust fc.smtpHost+ || isJust fc.smtpPort+ || isJust tls+ || isJust fc.smtpUsername+ || isJust fc.smtpFromAddress+ || isJust fc.smtpTimeoutSeconds++-- | Build a partial 'WebhookConfig' from the Dhall file's flat @webhook*@ fields. The signing+-- secret never enters this record.+webhookFromFile :: FileConfig -> Maybe WebhookConfig+webhookFromFile fc+ | anyPresent =+ Just+ WebhookConfig+ { url = fromMaybe "" fc.webhookUrl,+ timeoutSeconds = fromMaybe defaultWebhookTimeoutSeconds fc.webhookTimeoutSeconds,+ maxAttempts = fromMaybe defaultWebhookMaxAttempts fc.webhookMaxAttempts+ }+ | otherwise = Nothing+ where+ anyPresent = isJust fc.webhookUrl || isJust fc.webhookTimeoutSeconds || isJust fc.webhookMaxAttempts++-- | Overlay the @SHOMEI_NOTIFIER_*@ / @SHOMEI_SMTP_*@ / @SHOMEI_WEBHOOK_*@ environment variables+-- onto the notifier policy the Dhall file produced. @logSecrets@ is the already-read+-- @SHOMEI_NOTIFIER_LOG_SECRETS@ (env-only; see 'overlayCoreFromEnv').+overlayNotifierFromEnv :: NotifierConfig -> Maybe Bool -> IO NotifierConfig+overlayNotifierFromEnv base logSecrets = do+ transport <- notifierTransportEnv+ alsoLog <- boolEnv "SHOMEI_NOTIFIER_ALSO_LOG"+ emailVerification <- boolEnv "SHOMEI_EMAIL_VERIFICATION_REQUIRED"+ publicBaseUrl' <- textEnvMaybe "SHOMEI_PUBLIC_BASE_URL"+ smtp <- overlaySmtpFromEnv base.smtpConfig+ webhook <- overlayWebhookFromEnv base.webhookConfig+ pure+ base+ { logRawTokens = fromMaybe base.logRawTokens logSecrets,+ emailVerificationRequired = fromMaybe base.emailVerificationRequired emailVerification,+ notifierTransport = fromMaybe base.notifierTransport transport,+ alsoLogNotifications = fromMaybe base.alsoLogNotifications alsoLog,+ publicBaseUrl = fromMaybe base.publicBaseUrl publicBaseUrl',+ smtpConfig = smtp,+ webhookConfig = webhook+ }++-- | Overlay the non-secret SMTP environment variables onto the file-supplied 'SmtpConfig' (or+-- build one if the environment supplies settings). The password is loaded separately into the+-- runtime-only 'NotifierSecrets'.+overlaySmtpFromEnv :: Maybe SmtpConfig -> IO (Maybe SmtpConfig)+overlaySmtpFromEnv base = do+ host <- textEnvMaybe "SHOMEI_SMTP_HOST"+ port <- intEnvMaybe "SHOMEI_SMTP_PORT"+ tls <- smtpTlsModeEnv+ username <- textEnvMaybe "SHOMEI_SMTP_USERNAME"+ fromAddr <- textEnvMaybe "SHOMEI_SMTP_FROM"+ timeout <- intEnvMaybe "SHOMEI_SMTP_TIMEOUT"+ let anyEnv =+ isJust host+ || isJust port+ || isJust tls+ || isJust username+ || isJust fromAddr+ || isJust timeout+ case base of+ Nothing | not anyEnv -> pure Nothing+ _ ->+ let SmtpConfig+ { host = bHost,+ port = bPort,+ tlsMode = bTls,+ username = bUser,+ fromAddress = bFrom,+ timeoutSeconds = bTimeout+ } = fromMaybe emptySmtp base+ in pure+ ( Just+ SmtpConfig+ { host = fromMaybe bHost host,+ port = fromMaybe bPort port,+ tlsMode = fromMaybe bTls tls,+ username = username <|> bUser,+ fromAddress = fromMaybe bFrom fromAddr,+ timeoutSeconds = fromMaybe bTimeout timeout+ }+ )+ where+ emptySmtp =+ SmtpConfig+ { host = "",+ port = 587,+ tlsMode = SmtpStartTls,+ username = Nothing,+ fromAddress = "",+ timeoutSeconds = defaultSmtpTimeoutSeconds+ }++-- | Overlay the non-secret webhook environment variables onto the file-supplied 'WebhookConfig'+-- (or build one from the environment). The signing secret is loaded separately into+-- runtime-only 'NotifierSecrets'.+overlayWebhookFromEnv :: Maybe WebhookConfig -> IO (Maybe WebhookConfig)+overlayWebhookFromEnv base = do+ url <- textEnvMaybe "SHOMEI_WEBHOOK_URL"+ timeout <- intEnvMaybe "SHOMEI_WEBHOOK_TIMEOUT"+ maxAttempts <- intEnvMaybe "SHOMEI_WEBHOOK_MAX_ATTEMPTS"+ let anyEnv = isJust url || isJust timeout || isJust maxAttempts+ case base of+ Nothing | not anyEnv -> pure Nothing+ _ ->+ let WebhookConfig+ { url = bUrl,+ timeoutSeconds = bTimeout,+ maxAttempts = bMax+ } = fromMaybe emptyWebhook base+ in pure+ ( Just+ WebhookConfig+ { url = fromMaybe bUrl url,+ timeoutSeconds = fromMaybe bTimeout timeout,+ maxAttempts = fromMaybe bMax maxAttempts+ }+ )+ where+ emptyWebhook =+ WebhookConfig+ { url = "",+ timeoutSeconds = defaultWebhookTimeoutSeconds,+ maxAttempts = defaultWebhookMaxAttempts+ }++-- | Load credential material into a runtime-only value after the printable config is complete.+-- Values are stripped, and selected transports fail closed if their credential pair is partial.+loadNotifierSecretsFromEnv :: ShomeiConfig -> IO NotifierSecrets+loadNotifierSecretsFromEnv cfg = do+ smtpSecret <- fmap SmtpPassword <$> textEnvMaybe "SHOMEI_SMTP_PASSWORD"+ webhookSigningSecret <- fmap WebhookSecret <$> textEnvMaybe "SHOMEI_WEBHOOK_SECRET"+ case cfg.notifierConfig.notifierTransport of+ LogNotifier -> pure ()+ SmtpNotifier -> do+ let hasUsername = maybe False (isJust . (.username)) cfg.notifierConfig.smtpConfig+ hasPassword = isJust smtpSecret+ when (hasUsername /= hasPassword) $+ failWith+ "SMTP username and password must be set together (SHOMEI_SMTP_USERNAME / SHOMEI_SMTP_PASSWORD): both for an authenticated relay, or neither for a lab sink"+ WebhookNotifier ->+ when (isNothing webhookSigningSecret) $+ failWith "notifier transport webhook requires a non-empty SHOMEI_WEBHOOK_SECRET"+ pure NotifierSecrets {smtpPassword = smtpSecret, webhookSecret = webhookSigningSecret}+ where+ failWith = ioError . userError++-- | Refuse incomplete or unsafe non-secret notifier settings. Lab escape hatches are env-only;+-- plaintext unauthenticated SMTP is permitted but returned as a boot warning.+validateNotifierConfig :: NotifierConfig -> IO [Text]+validateNotifierConfig nc = do+ allowInsecureWebhook <- fromMaybe False <$> boolEnv "SHOMEI_WEBHOOK_ALLOW_INSECURE"+ allowPlaintextAuth <- fromMaybe False <$> boolEnv "SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH"+ case nc.notifierTransport of+ LogNotifier -> pure []+ SmtpNotifier -> case nc.smtpConfig of+ Nothing ->+ failWith "SHOMEI_NOTIFIER_TRANSPORT=smtp requires SMTP settings (at least SHOMEI_SMTP_HOST and SHOMEI_SMTP_FROM)"+ Just sc -> do+ let SmtpConfig {host = h, fromAddress = fromA, username = u, tlsMode = tls} = sc+ when (Text.null (Text.strip h)) (failWith "notifier transport smtp requires a non-empty SMTP host (SHOMEI_SMTP_HOST / smtpHost)")+ when (Text.null (Text.strip fromA)) (failWith "notifier transport smtp requires a non-empty from address (SHOMEI_SMTP_FROM / smtpFromAddress)")+ when (Text.null (Text.strip nc.publicBaseUrl)) (failWith "notifier transport smtp requires a non-empty publicBaseUrl (the email body embeds confirm links)")+ when (tls == SmtpPlain && isJust u && not allowPlaintextAuth) $+ failWith "smtpTlsMode=plain with authentication is refused (set SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH=true for a lab relay)"+ pure+ [ if isJust u then "smtpTlsMode=plain sends mail and SMTP credentials in the clear; explicit lab override" else "smtpTlsMode=plain sends mail in the clear; lab sinks only"+ | tls == SmtpPlain+ ]+ WebhookNotifier -> case nc.webhookConfig of+ Nothing ->+ failWith "SHOMEI_NOTIFIER_TRANSPORT=webhook requires SHOMEI_WEBHOOK_URL"+ Just wc -> do+ let normalizedUrl = Text.toLower (Text.strip wc.url)+ isHttps = Text.isPrefixOf "https://" normalizedUrl+ isHttp = Text.isPrefixOf "http://" normalizedUrl+ unless (isHttps || (isHttp && allowInsecureWebhook)) $+ failWith "webhookUrl must be https:// (set SHOMEI_WEBHOOK_ALLOW_INSECURE=true for a lab receiver)"+ pure ["webhookUrl uses HTTP under SHOMEI_WEBHOOK_ALLOW_INSECURE; one-time links travel in the clear; lab receivers only" | isHttp]+ where+ failWith = ioError . userError++-- | Apply the optional @webauthn*@ fields of a decoded Dhall 'FileConfig' onto a base+-- 'WebAuthnConfig'. The base record is read via record destructuring (not @value.field@ dot+-- syntax), which the new passkey/config records do not support under @DuplicateRecordFields@+-- (MasterPlan 3, EP-1 discovery); @fc.field@ dot access on 'FileConfig' is unaffected.+mergeWebAuthn :: WebAuthnConfig -> Maybe UserVerificationPolicy -> Maybe AttestationPolicy -> FileConfig -> WebAuthnConfig+mergeWebAuthn base uvFile attestationFile fc =+ base+ { rpId = fromMaybe baseRpId fc.webauthnRpId,+ rpName = fromMaybe baseRpName fc.webauthnRpName,+ origins = fromMaybe baseOrigins fc.webauthnOrigins,+ userVerification = fromMaybe baseUv uvFile,+ attestation = fromMaybe baseAtt attestationFile,+ ceremonyTimeout = maybe baseTimeout fromIntegral fc.webauthnCeremonyTimeoutSeconds,+ pendingCeremonyTTL = maybe baseTtl fromIntegral fc.webauthnPendingCeremonyTtlSeconds+ }+ where+ WebAuthnConfig+ { rpId = baseRpId,+ rpName = baseRpName,+ origins = baseOrigins,+ userVerification = baseUv,+ attestation = baseAtt,+ ceremonyTimeout = baseTimeout,+ pendingCeremonyTTL = baseTtl+ } = base++-- | Overlay the @SHOMEI_WEBAUTHN_*@ environment variables onto the WebAuthn policy.+overlayWebAuthnFromEnv :: WebAuthnConfig -> IO WebAuthnConfig+overlayWebAuthnFromEnv base = do+ rpId' <- textEnv "SHOMEI_WEBAUTHN_RP_ID" baseRpId+ rpName' <- textEnv "SHOMEI_WEBAUTHN_RP_NAME" baseRpName+ origins' <- originsEnv baseOrigins+ uv <- uvEnv+ att <- attestationEnv+ timeout' <- ttlEnv "SHOMEI_WEBAUTHN_CEREMONY_TIMEOUT"+ ttl' <- ttlEnv "SHOMEI_WEBAUTHN_PENDING_TTL"+ pure+ base+ { rpId = rpId',+ rpName = rpName',+ origins = origins',+ userVerification = fromMaybe baseUv uv,+ attestation = fromMaybe baseAtt att,+ ceremonyTimeout = fromMaybe baseTimeout timeout',+ pendingCeremonyTTL = fromMaybe baseTtl ttl'+ }+ where+ WebAuthnConfig+ { rpId = baseRpId,+ rpName = baseRpName,+ origins = baseOrigins,+ userVerification = baseUv,+ attestation = baseAtt,+ ceremonyTimeout = baseTimeout,+ pendingCeremonyTTL = baseTtl+ } = base+ -- A comma-separated list, e.g. "https://auth.example.com,https://www.example.com".+ originsEnv def = do+ m <- lookupEnv "SHOMEI_WEBAUTHN_ORIGINS"+ pure $ case m of+ Just v | not (null v) -> filter (not . Text.null) (map Text.strip (Text.splitOn "," (Text.pack v)))+ _ -> def+ uvEnv = do+ m <- lookupEnv "SHOMEI_WEBAUTHN_USER_VERIFICATION"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseUserVerificationPolicy "SHOMEI_WEBAUTHN_USER_VERIFICATION" (Text.pack s)+ attestationEnv = do+ m <- lookupEnv "SHOMEI_WEBAUTHN_ATTESTATION"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseAttestationPolicy "SHOMEI_WEBAUTHN_ATTESTATION" (Text.pack s)++boolEnv :: Text -> IO (Maybe Bool)+boolEnv name = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> case Text.toLower (Text.pack s) of+ "true" -> pure (Just True)+ "false" -> pure (Just False)+ _ -> ioError (userError (Text.unpack name <> " must be true|false"))++-- | Parse a WebAuthn user-verification policy from either configuration source. Unknown text is+-- a boot error: silently falling back can weaken the ceremony an operator intended to require.+parseUserVerificationPolicy :: Text -> Text -> IO UserVerificationPolicy+parseUserVerificationPolicy label t = case Text.toLower (Text.strip t) of+ "required" -> pure UVRequired+ "preferred" -> pure UVPreferred+ "discouraged" -> pure UVDiscouraged+ other -> ioError (userError (Text.unpack label <> " must be required|preferred|discouraged, got " <> Text.unpack other))++-- | Parse a WebAuthn attestation policy from either configuration source.+parseAttestationPolicy :: Text -> Text -> IO AttestationPolicy+parseAttestationPolicy label t = case Text.toLower (Text.strip t) of+ "none" -> pure AttestationNone+ "direct" -> pure AttestationDirect+ other -> ioError (userError (Text.unpack label <> " must be none|direct, got " <> Text.unpack other))++textEnv :: Text -> Text -> IO Text+textEnv name def = do+ m <- lookupEnv (Text.unpack name)+ pure $ case m of+ Just v+ | let stripped = Text.strip (Text.pack v), not (Text.null stripped) -> stripped+ _ -> def++intEnv :: Text -> Int -> IO Int+intEnv name def = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Nothing -> pure def+ Just "" -> pure def+ Just s -> case readMaybe s of+ Just n -> pure n+ Nothing -> ioError (userError (Text.unpack name <> " must be an integer"))++-- | Like 'textEnv' but overlay-only: absent or empty → 'Nothing', so a caller can distinguish+-- "the operator set nothing" from "the operator set the empty string" and fall back to the+-- config-file value rather than clobbering it.+textEnvMaybe :: Text -> IO (Maybe Text)+textEnvMaybe name = do+ m <- lookupEnv (Text.unpack name)+ pure $ case m of+ Just v+ | let stripped = Text.strip (Text.pack v), not (Text.null stripped) -> Just stripped+ _ -> Nothing++-- | Like 'intEnv' but overlay-only: absent/empty → Nothing, non-integer → error.+intEnvMaybe :: Text -> IO (Maybe Int)+intEnvMaybe name = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> case readMaybe s of+ Just n -> pure (Just n)+ Nothing -> ioError (userError (Text.unpack name <> " must be an integer"))++ttlEnv :: Text -> IO (Maybe NominalDiffTime)+ttlEnv name = do+ m <- lookupEnv (Text.unpack name)+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> case readMaybe s :: Maybe Integer of+ Just n -> pure (Just (fromIntegral n))+ Nothing -> ioError (userError (Text.unpack name <> " must be an integer (seconds)"))++transportEnv :: IO (Maybe TokenTransport)+transportEnv = do+ m <- lookupEnv "SHOMEI_TOKEN_TRANSPORT"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseTransport "SHOMEI_TOKEN_TRANSPORT" (Text.pack s)++-- | @bearer@ | @cookie@ | @both@. @label@ names the source in the error.+parseTransport :: Text -> Text -> IO TokenTransport+parseTransport label t = case Text.toLower (Text.strip t) of+ "bearer" -> pure BearerToken+ "cookie" -> pure HttpOnlyCookie+ "both" -> pure BearerAndCookie+ other -> ioError (userError (Text.unpack label <> " must be bearer|cookie|both, got " <> Text.unpack other))++-- | @strict@ | @lax@ | @none@. Rejects anything else rather than silently defaulting: a typo+-- that quietly became @Lax@ when the operator meant @None@ would break their deployment in a+-- way that looks like a browser bug.+parseSameSite :: Text -> Text -> IO SameSitePolicy+parseSameSite label t = case Text.toLower (Text.strip t) of+ "strict" -> pure SameSiteStrict+ "lax" -> pure SameSiteLax+ "none" -> pure SameSiteNone+ other -> ioError (userError (Text.unpack label <> " must be strict|lax|none, got " <> Text.unpack other))++sameSiteEnv :: IO (Maybe SameSitePolicy)+sameSiteEnv = do+ m <- lookupEnv "SHOMEI_COOKIE_SAMESITE"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> parseSameSite "SHOMEI_COOKIE_SAMESITE" (Text.pack s)++parseTrustedProxyList :: [Text] -> IO TrustedProxies+parseTrustedProxyList values =+ case parseTrustedProxies values of+ Left reason -> ioError (userError (Text.unpack reason))+ Right proxies -> pure proxies++-- | A comma-separated CIDR list. An explicitly empty value clears a file-supplied list.+trustedProxiesEnv :: IO (Maybe [Text])+trustedProxiesEnv = do+ value <- lookupEnv "SHOMEI_TRUSTED_PROXIES"+ pure case value of+ Nothing -> Nothing+ Just raw ->+ Just+ ( filter+ (not . Text.null)+ (Text.strip <$> Text.splitOn "," (Text.pack raw))+ )++parseProxyProtocol :: Text -> Text -> IO ProxyProtocolMode+parseProxyProtocol label value =+ case Text.toLower (Text.strip value) of+ "none" -> pure ProxyProtocolOff+ "required" -> pure ProxyProtocolRequired+ other ->+ ioError+ ( userError+ (Text.unpack label <> " must be none|required, got " <> Text.unpack other)+ )++proxyProtocolEnv :: ProxyProtocolMode -> IO ProxyProtocolMode+proxyProtocolEnv fallback = do+ value <- lookupEnv "SHOMEI_PROXY_PROTOCOL"+ case value of+ Nothing -> pure fallback+ Just "" -> pure fallback+ Just raw -> parseProxyProtocol "SHOMEI_PROXY_PROTOCOL" (Text.pack raw)++normalizeOrigins :: [Text] -> IO [Text]+normalizeOrigins = traverse normalizeOne+ where+ normalizeOne origin =+ case normalizeOrigin origin of+ Left reason -> ioError (userError (Text.unpack reason))+ Right normalized -> pure normalized++-- | Warn when browser-cookie transport still has development-only or insecure origin policy.+cookieTransportWarnings :: ShomeiConfig -> [Text]+cookieTransportWarnings cfg+ | not (transportUsesCookies cfg.tokenTransport) = []+ | otherwise = defaultWarning <> insecureWarnings+ where+ origins = cfg.cookieConfig.allowedOrigins+ defaultWarning =+ [ "cookie transport is on with the development default csrfAllowedOrigins = [http://localhost:8080]; browsers on any other origin get 403 csrf_rejected — set SHOMEI_CSRF_ALLOWED_ORIGINS"+ | origins == defaultCookieConfig.allowedOrigins+ ]+ insecureWarnings =+ [ "csrfAllowedOrigins entry "+ <> origin+ <> " is not https; with cookieSecure the browser will not send the cookies to it"+ | origin <- origins,+ not ("https://" `Text.isPrefixOf` origin || isLocalhostOrigin origin)+ ]+ isLocalhostOrigin origin =+ case Text.stripPrefix "http://localhost" origin of+ Just "" -> True+ Just suffix+ | Just port <- Text.stripPrefix ":" suffix ->+ not (Text.null port) && Text.all isDigit port+ _ -> False++-- | A comma-separated origin allow-list, e.g. @https://app.example.com,https://admin.example.com@.+csrfOriginsEnv :: IO (Maybe [Text])+csrfOriginsEnv = do+ m <- lookupEnv "SHOMEI_CSRF_ALLOWED_ORIGINS"+ case m of+ Just v+ | not (null v) ->+ Just <$> normalizeOrigins (filter (not . Text.null) (map Text.strip (Text.splitOn "," (Text.pack v))))+ _ -> pure Nothing++-- | Read @SHOMEI_DEFAULT_ROLES@: a comma-separated role list, e.g. @member,beta-tester@, granted+-- to every new user at signup. 'Nothing' when unset or empty. The names are validated against+-- the @shomei_roles@ registry at boot (see @Shomei.Server.Boot.validateDefaultRoles@), not here.+--+-- Exported because @shomei-admin@ assembles its own minimal 'ShomeiConfig' rather than running+-- this whole loader, and @shomei-admin users create@ drives the same @signup@ workflow the HTTP+-- route does — so it must see the same default roles, or a CLI-created user would silently+-- differ from an API-created one.+defaultRolesFromEnv :: IO (Maybe (Set Role))+defaultRolesFromEnv = do+ m <- lookupEnv "SHOMEI_DEFAULT_ROLES"+ pure case m of+ Just v | not (null v) -> Just (roleSet (Text.splitOn "," (Text.pack v)))+ _ -> Nothing++-- | Trim, drop blanks, and collect role names into a set. Shared by the Dhall and env paths so+-- @"member, , staff"@ means the same thing from either.+roleSet :: [Text] -> Set Role+roleSet = Set.fromList . map Role . filter (not . Text.null) . map Text.strip++-- | Read @SHOMEI_KEY_REFRESH_INTERVAL@ (seconds between signing-key reloads; 0 disables+-- the periodic reload). Rejects a negative value, which would otherwise silently disable+-- the refresh the operator meant to tighten.+keyRefreshIntervalEnv :: IO (Maybe Int)+keyRefreshIntervalEnv = do+ m <- intEnvMaybe "SHOMEI_KEY_REFRESH_INTERVAL"+ case m of+ Just n | n < 0 -> ioError (userError "SHOMEI_KEY_REFRESH_INTERVAL must be >= 0 (0 disables the periodic reload)")+ _ -> pure m++-- | Read the JWT verifier's tolerance for exp/nbf/iat. A negative value has no+-- semantic meaning (jose treats skew as absolute), so reject it at load.+clockSkewEnv :: IO (Maybe Int)+clockSkewEnv = do+ m <- intEnvMaybe "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"+ case m of+ Just n+ | n < 0 ->+ ioError+ ( userError+ "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS (Dhall field allowedClockSkewSeconds) must be >= 0"+ )+ _ -> pure m++-- | Read and validate @SHOMEI_SIGNING_ALG@ (@ES256@|@RS256@); absent/empty → Nothing.+signingAlgEnv :: IO (Maybe Text)+signingAlgEnv = do+ m <- lookupEnv "SHOMEI_SIGNING_ALG"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just s -> Just <$> normalizeSigningAlg "SHOMEI_SIGNING_ALG" (Text.pack s)++-- | Validate a signing-algorithm string from config (file or env), erroring on+-- anything other than @ES256@/@RS256@ so a typo fails the boot loudly. @label@ names+-- the source for the error message.+normalizeSigningAlg :: Text -> Text -> IO Text+normalizeSigningAlg label t = case Text.strip t of+ "ES256" -> pure "ES256"+ "RS256" -> pure "RS256"+ other -> ioError (userError (Text.unpack label <> " must be ES256|RS256, got " <> Text.unpack other))++sessionCheckEnv :: IO (Maybe SessionCheckMode)+sessionCheckEnv = do+ m <- lookupEnv "SHOMEI_SESSION_CHECK"+ case m of+ Nothing -> pure Nothing+ Just "" -> pure Nothing+ Just "token-only" -> pure (Just VerifyTokenOnly)+ Just "token-and-session" -> pure (Just VerifyTokenAndSession)+ Just other -> ioError (userError ("SHOMEI_SESSION_CHECK must be token-only|token-and-session, got " <> other))
+ src/Shomei/Server/DatabaseError.hs view
@@ -0,0 +1,27 @@+-- | Secret-free summaries of PostgreSQL pool failures suitable for operator output and logs.+--+-- A 'UsageError' can carry the SQL text, rendered parameters, and PostgreSQL's primary message.+-- Those details may include encrypted key envelopes or other credentials and must remain inside+-- the persistence boundary.+module Shomei.Server.DatabaseError (summarizeUsageError) where++import Data.Text (Text)+import Hasql.Errors qualified as Hasql+import Shomei.Persistence.Pool.Postgres (UsageError (..))++summarizeUsageError :: UsageError -> Text+summarizeUsageError = \case+ ConnectionUsageError _ -> "could not connect to PostgreSQL"+ AcquisitionTimeoutUsageError -> "timed out acquiring a connection"+ SessionUsageError+ ( Hasql.StatementSessionError+ _+ _+ _+ _+ _+ (Hasql.ServerStatementError (Hasql.ServerError code _ _ _ _))+ ) -> "statement failed (SQLSTATE " <> code <> ")"+ SessionUsageError (Hasql.ScriptSessionError _ (Hasql.ServerError code _ _ _ _)) ->+ "statement failed (SQLSTATE " <> code <> ")"+ SessionUsageError _ -> "statement failed"
+ src/Shomei/Server/ExceptionResponse.hs view
@@ -0,0 +1,22 @@+-- | Convert exceptions that escape WAI or Warp parsing into Shōmei's public error envelope.+module Shomei.Server.ExceptionResponse+ ( problemExceptionResponse,+ )+where++import Control.Exception (SomeAsyncException, SomeException, fromException, throw)+import Network.Wai (Response)+import Network.Wai.Handler.Warp (InvalidRequest (..))+import Shomei.Servant.Error (noProblemOccurrence, pcBadRequest, pcInternal, pcPayloadTooLarge)+import Shomei.Servant.Middleware (problemResponse)++-- | Warp's exception-response hook. Asynchronous cancellation must retain its control-flow+-- meaning; every synchronous failure receives a stable, detail-free public representation.+problemExceptionResponse :: SomeException -> Response+problemExceptionResponse err+ | Just _ <- (fromException err :: Maybe SomeAsyncException) = throw err+ | Just PayloadTooLarge <- (fromException err :: Maybe InvalidRequest) =+ problemResponse pcPayloadTooLarge noProblemOccurrence+ | Just _ <- (fromException err :: Maybe InvalidRequest) =+ problemResponse pcBadRequest noProblemOccurrence+ | otherwise = problemResponse pcInternal noProblemOccurrence
+ src/Shomei/Server/Keys.hs view
@@ -0,0 +1,220 @@+-- | Loading signing-key material, and refreshing it on a live server.+--+-- 'loadKeyMaterial' is __the__ stored→live load path: it reads every publishable key+-- (active + retired) and assembles the three things the runtime needs — the private key+-- that signs new tokens, the public key set the verifier trusts, and the precomputed JWKS+-- document served at @\/.well-known\/jwks.json@. 'bootstrapKeys' wraps it with first-boot+-- key generation; 'reloadKeys' wraps it with the swap-or-keep-last-good policy the+-- periodic refresh and the @SIGHUP@ handler both use.+--+-- Publishing retired keys alongside the active one is what makes rotation zero-downtime:+-- a token signed moments before @shomei-admin keys activate@ keeps verifying until it+-- expires, here and at every downstream that fetches the JWKS.+--+-- Private key material is encrypted at rest (see "Shomei.SigningKey.Protection.Jwt"). Only the+-- __signer__ is decrypted; the verifier key set and the published JWKS are built from the+-- public column, so a missing or wrong key-encryption key can never break verification of+-- outstanding tokens; runtime startup still requires the KEK so minting is always available.+--+-- Runs over a minimal stack — just 'SigningKeyStore' + 'Clock' + 'Database' ++-- @Error AuthError@ + 'IOE' against the pool — because it must run /before/ the full+-- 'Shomei.Server.App.Env' exists (there is no key yet to build it).+module Shomei.Server.Keys+ ( LoadedKeys (..),+ loadKeyMaterial,+ bootstrapKeys,+ reloadKeys,+ publishedKids,+ loadKekFromEnv,+ loadNamedKekFromEnv,+ )+where++import Crypto.JOSE.JWK (JWK, JWKSet)+import Data.Aeson (Value (Object))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Bifunctor (first)+import Data.Foldable (toList)+import Data.IORef (IORef, readIORef, writeIORef)+import Data.Text qualified as Text+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Error.Static (runErrorNoCallStack)+import Shomei.Error (AuthError)+import Shomei.Persistence.Database.Postgres (runDatabasePool)+import Shomei.Persistence.Pool.Postgres (Pool)+import Shomei.Prelude+import Shomei.SigningKey.Domain (SigningAlgorithm, SigningKeyStatus (KeyActive), StoredSigningKey (..))+import Shomei.SigningKey.Jwks.Jwt (KeySet (..), jwksDocument, keySetPublicJwks)+import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, keyKid, toStoredSigningKeyFor)+import Shomei.SigningKey.Postgres (runSigningKeyStorePostgres)+import Shomei.SigningKey.Protection.Jwt+ ( KeyDecryptError (..),+ KeyEncryptionKey,+ decryptStoredSigningKey,+ keyEncryptionKeyFromBase64,+ protectStoredSigningKey,+ publicJwkFromStored,+ )+import Shomei.SigningKey.Store+ ( SigningKeyStore,+ insertSigningKey,+ listActiveSigningKeys,+ listPublishableSigningKeys,+ )+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (Clock, now)+import System.Environment (lookupEnv)+import System.IO (hPutStrLn, stderr)++-- | Read @SHOMEI_KEY_ENCRYPTION_KEY@ (32 bytes, base64).+loadKekFromEnv :: IO KeyEncryptionKey+loadKekFromEnv = loadNamedKekFromEnv "SHOMEI_KEY_ENCRYPTION_KEY"++-- | Read a required KEK from the named environment variable.+loadNamedKekFromEnv :: String -> IO KeyEncryptionKey+loadNamedKekFromEnv name = do+ raw <- lookupEnv name+ case raw of+ Nothing -> fatal "is not set (32 bytes, base64: head -c 32 /dev/urandom | base64)"+ Just "" -> fatal "is empty (32 bytes, base64: head -c 32 /dev/urandom | base64)"+ Just v -> either (fatal . Text.unpack) pure (keyEncryptionKeyFromBase64 (Text.pack v))+ where+ fatal reason = ioError (userError (name <> " " <> reason))++-- | Everything derived from the signing-key table in one load: the private key that signs+-- new tokens, the public key set the verifier trusts, and the precomputed JWKS document.+-- Swapped atomically on reload — readers see the old or the new record, never a mixture.+data LoadedKeys = LoadedKeys+ { signingKey :: !JWK,+ verifierJwks :: !JWKSet,+ jwksBody :: !Value+ }++-- | Load all publishable keys and assemble 'LoadedKeys'. A corrupt key row (one whose+-- stored JWK JSON does not parse, or whose ciphertext does not authenticate) fails the+-- whole load rather than being skipped silently: publishing a key set that is missing a key+-- downstreams may still hold tokens for is exactly the outage this module exists to prevent.+loadKeyMaterial :: KeyEncryptionKey -> Pool -> IO (Either Text LoadedKeys)+loadKeyMaterial kek pool = do+ result :: Either AuthError [StoredSigningKey] <-+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runSigningKeyStorePostgres+ $ listPublishableSigningKeys+ pure (either (Left . Text.pack . show) (assembleKeys kek) result)++-- | Assemble the loaded rows. Exactly one active key must exist; multiple active rows+-- fail closed even if an external writer bypassed the database invariant. Every+-- publishable key is published and trusted, the signer included.+--+-- Public material comes from @public_key_jwk@ and needs no key-encryption key; only the+-- signer's @private_key_jwk@ is decrypted.+assembleKeys :: KeyEncryptionKey -> [StoredSigningKey] -> Either Text LoadedKeys+assembleKeys kek stored = do+ publics <- traverse toPublic stored+ signerRow <-+ case [sk | sk <- stored, sk.status == KeyActive] of+ [one] -> Right one+ [] -> Left "no active signing key"+ several ->+ Left+ ( "multiple active signing keys ("+ <> Text.intercalate ", " (map (.keyId) several)+ <> ") violate shomei_signing_keys_one_active; retire all but one with shomei-admin keys retire"+ )+ signerJwk <- first (decryptError signerRow) (decryptStoredSigningKey kek signerRow)+ -- The signer's own row is in 'stored', so its public projection is in 'publics'.+ signerPub <-+ maybe (Left ("signing key " <> signerRow.keyId <> " has no public material")) Right $+ lookup signerRow.keyId [(sk.keyId, jwk) | (sk, jwk) <- publics]+ let others = [jwk | (sk, jwk) <- publics, sk.keyId /= signerRow.keyId]+ pure+ LoadedKeys+ { signingKey = signerJwk,+ verifierJwks = keySetPublicJwks (KeySet signerPub others),+ jwksBody = decodeJwks (jwksDocument (signerPub : others))+ }+ where+ toPublic sk = (\jwk -> (sk, jwk)) <$> first (publicError sk) (publicJwkFromStored sk)+ publicError sk err = "signing key " <> sk.keyId <> " has unparseable public JWK JSON: " <> err+ decryptError sk = \case+ KeyDecryptFailed ->+ "signing key "+ <> sk.keyId+ <> " did not decrypt: SHOMEI_KEY_ENCRYPTION_KEY is wrong, or the row was tampered with"+ MalformedEncryptedKey reason ->+ "signing key " <> sk.keyId <> " has a malformed encrypted private key: " <> reason+ KeyJsonInvalid reason ->+ "signing key " <> sk.keyId <> " has unparseable private JWK JSON: " <> reason+ -- jwksDocument emits a JWKSet object, so the decode cannot fail; the fallback keeps+ -- the total signature rather than partially matching on it.+ decodeJwks = fromMaybe (Object KM.empty) . Aeson.decode++-- | Establish key material at boot. Generates and persists one active key for @alg@ on+-- first boot (guarded on "no active key", so restarts reuse the existing key and+-- previously-issued tokens stay verifiable); then loads via 'loadKeyMaterial'. Unlike a+-- reload, a failure here is fatal: there is no previous material to fall back on.+bootstrapKeys :: KeyEncryptionKey -> SigningAlgorithm -> Pool -> IO LoadedKeys+bootstrapKeys kek alg pool = do+ result :: Either AuthError () <-+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runSigningKeyStorePostgres+ $ ensureActiveKey kek alg+ either (ioError . userError . show) pure result+ loaded <- loadKeyMaterial kek pool+ either (ioError . userError . Text.unpack) pure loaded++-- | Generate and insert an active key for @alg@ when the store has no active key. The new+-- key is encrypted before it is persisted, so a fresh+-- deployment never writes an unencrypted private key.+ensureActiveKey :: (SigningKeyStore :> es, Clock :> es, IOE :> es) => KeyEncryptionKey -> SigningAlgorithm -> Eff es ()+ensureActiveKey kek alg = do+ active <- listActiveSigningKeys+ when (null active) do+ t <- now+ jwk <- liftIO (generateSigningKeyFor alg)+ stored <- liftIO (either (ioError . userError . Text.unpack) pure (toStoredSigningKeyFor alg t jwk))+ protected <- liftIO (protectStoredSigningKey kek stored)+ insertSigningKey protected++-- | Reload key material and swap it in. On failure keep the last good material and log to+-- stderr: an operator mistake mid-rotation (say, retiring the only active key) must not+-- take the server down — it can still verify everything it issued and still sign with the+-- stale key, which downstreams still trust. The @\/health\/ready@ probe fails meanwhile, so+-- orchestration notices.+--+-- Safe from any thread: a plain 'writeIORef' of an immutable record, so a concurrent+-- request sees the old or the new material in full, never a mixture.+reloadKeys :: KeyEncryptionKey -> Pool -> IORef LoadedKeys -> IO ()+reloadKeys kek pool ref = do+ before <- readIORef ref+ loadKeyMaterial kek pool >>= either warn (swap before)+ where+ swap before after = do+ writeIORef ref after+ -- Only announce a reload that changed something, so the steady-state log stays quiet+ -- at one reload per refresh interval.+ when (kidsOf before /= kidsOf after) do+ hPutStrLn stderr $+ "[shomei] signing keys reloaded: active="+ <> Text.unpack (keyKid after.signingKey)+ <> " published="+ <> Text.unpack (Text.intercalate "," (publishedKids after))+ warn reason =+ hPutStrLn stderr ("[shomei] key reload failed: " <> Text.unpack reason <> "; keeping previous key material")+ kidsOf ks = (keyKid ks.signingKey, publishedKids ks)++-- | The @kid@ of every key in the published JWKS document, in document order.+publishedKids :: LoadedKeys -> [Text]+publishedKids ks = case ks.jwksBody of+ Object top | Just (Aeson.Array arr) <- KM.lookup "keys" top -> mapMaybe kidOf (toList arr)+ _ -> []+ where+ kidOf (Object k) | Just (Aeson.String kid) <- KM.lookup "kid" k = Just kid+ kidOf _ = Nothing
+ src/Shomei/Server/Middleware/BodyLimit.hs view
@@ -0,0 +1,58 @@+-- | Refuse oversized request bodies before they reach a handler (EP-8).+--+-- Nothing else in the stack bounds how much a client may send: warp has no request-body size+-- setting (body limiting is a middleware concern), and Shōmei's JSON handlers happily begin+-- consuming whatever arrives. This middleware answers HTTP 413 to any request that declares a+-- @Content-Length@ above the cap, without reading a byte of the body. Bodies without a declared+-- length are metered chunk by chunk as the downstream application consumes them.+module Shomei.Server.Middleware.BodyLimit+ ( bodyLimitMiddleware,+ defaultBodyLimitBytes,+ )+where++import Control.Exception (Exception, catch, throwIO)+import Control.Monad (when)+import Data.ByteString qualified as BS+import Data.IORef (atomicModifyIORef', newIORef, readIORef, writeIORef)+import Data.Word (Word64)+import Network.Wai+ ( Middleware,+ RequestBodyLength (KnownLength),+ getRequestBodyChunk,+ requestBodyLength,+ setRequestBodyChunks,+ )+import Shomei.Servant.Error (noProblemOccurrence, pcPayloadTooLarge)+import Shomei.Servant.Middleware (problemResponse)++data BodyOverCap = BodyOverCap Word64+ deriving stock (Show)+ deriving anyclass (Exception)++-- | 1 MiB. Three orders of magnitude above the largest legitimate Shōmei request body (a+-- WebAuthn attestation), and small enough that a flood of maximal bodies cannot exhaust memory.+defaultBodyLimitBytes :: Word64+defaultBodyLimitBytes = 1024 * 1024++-- | Reject a request once its declared or observed body length is strictly greater than @limit@.+bodyLimitMiddleware :: Word64 -> Middleware+bodyLimitMiddleware limit app req respond+ | KnownLength n <- requestBodyLength req, n > limit = respond tooLarge+ | otherwise = do+ seen <- newIORef 0+ responded <- newIORef False+ let metered = do+ chunk <- getRequestBodyChunk req+ total <-+ atomicModifyIORef' seen \n ->+ let n' = n + fromIntegral (BS.length chunk)+ in (n', n')+ when (total > limit) (throwIO (BodyOverCap limit))+ pure chunk+ respond' res = writeIORef responded True >> respond res+ app (setRequestBodyChunks metered req) respond' `catch` \e@(BodyOverCap _) -> do+ already <- readIORef responded+ if already then throwIO e else respond tooLarge+ where+ tooLarge = problemResponse pcPayloadTooLarge noProblemOccurrence
+ src/Shomei/Server/Middleware/RateLimit.hs view
@@ -0,0 +1,180 @@+-- | A per-IP request-rate limiter as a WAI 'Middleware' (EP-2, M4).+--+-- This is an in-process token bucket (an STM @TVar (HashMap ByteString Bucket)@ keyed by client+-- IP), deliberately NOT a distributed/Redis store: this plan targets a single-instance+-- deployment (see the MasterPlan Decision Log). Each bucket holds up to @capacity@ tokens and+-- refills at @refillPerSec@; a request with no token left is rejected with HTTP 429 BEFORE it+-- reaches the Servant application or the database. The limiter is scoped to the unauthenticated+-- POST endpoints (login/signup/refresh and EP-1's verify-email/password-reset request routes) so+-- authenticated traffic bearing a valid token is never throttled, and it is a no-op when+-- @rateLimitEnabled@ is False.+--+-- Distinct from the per-account brute-force lockout (which is PostgreSQL-backed and survives+-- restarts): this request-rate state is in-memory and resets on restart, which is acceptable+-- because it bounds raw request volume, not authentication failures.+--+-- == Bucket eviction (EP-4)+--+-- A bucket per client IP that is never removed is an unbounded-memory denial-of-service+-- primitive: a slow scan of the address space grows the map forever. Every @sweepEvery@+-- throttled requests, 'takeToken' therefore prunes — inside the same STM transaction it was+-- already running — every bucket that has refilled to capacity.+--+-- That policy is /semantically lossless/, which is why it needs no tuning knob: 'takeToken'+-- treats an absent key as a fresh full bucket, so a full bucket and a missing bucket are+-- indistinguishable to every subsequent request. No request that would have been throttled is+-- admitted, and none that would have been admitted is throttled. Cost is @O(size)@ once per+-- @sweepEvery@ calls, i.e. amortized @O(1)@. An idle server never prunes, which is harmless:+-- no requests means no new buckets either, so the map cannot grow while unpruned.+--+-- == Contention+--+-- All buckets live in one @TVar (HashMap ByteString Bucket)@ whose root pointer every+-- throttled request rewrites, so concurrent 'takeToken' transactions conflict and retry: the+-- limiter is a serialization point under very high concurrency. This is accepted for the+-- documented single-instance posture (throttled routes are the unauthenticated auth endpoints,+-- which are low-volume by construction, and each is about to spend ~100 ms in Argon2 anyway).+-- If it ever profiles hot, the escape hatch is to shard the map across N @TVar@s keyed by a+-- hash of the client IP; nothing outside this module observes the single-@TVar@ representation.+module Shomei.Server.Middleware.RateLimit+ ( RateLimiter,+ newRateLimiter,+ newRateLimiterFor,+ newRateLimiterWith,+ rateLimitMiddleware,+ throttledPath,+ takeToken,+ bucketCount,+ )+where++import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVar, readTVarIO, writeTVar)+import Data.ByteString (ByteString)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HM+import Data.Text.Encoding (encodeUtf8)+import GHC.Clock (getMonotonicTimeNSec)+import Network.Wai (Middleware, Request, pathInfo, remoteHost, requestMethod)+import Shomei.Config (RateLimitConfig (..))+import Shomei.Servant.Api (shomeiThrottledRoutes)+import Shomei.Servant.ClientIp (clientIpText)+import Shomei.Servant.Error (pcTooManyRequests, retryAfterOccurrence)+import Shomei.Servant.Middleware (problemResponse)+import Shomei.Servant.Throttle (ThrottledRoute, matchesThrottledRoute)++-- | One bucket per client IP: current token level + last-refill time (monotonic seconds).+data Bucket = Bucket !Double !Double++data RateLimiter = RateLimiter+ { buckets :: !(TVar (HashMap ByteString Bucket)),+ -- | Counts 'takeToken' calls since the last prune; see 'sweepEvery'.+ sweepCounter :: !(TVar Int),+ -- | Prune fully-refilled buckets every this many 'takeToken' calls (always >= 1).+ sweepEvery :: !Int,+ -- | perIpBurst+ capacity :: !Double,+ -- | perIpRequestsPerMinute / 60+ refillPerSec :: !Double,+ enabled :: !Bool,+ -- | Derived once from the API's 'Shomei.Servant.PreHandler.RateLimited' markers.+ throttled :: ![ThrottledRoute]+ }++-- | How many 'takeToken' calls pass between prunes. Large enough that the @O(size)@ prune is+-- lost in the noise of the Argon2 hash the throttled request is about to perform, small enough+-- that a scanning attacker cannot accumulate many buckets between sweeps.+defaultSweepEvery :: Int+defaultSweepEvery = 4096++-- | Build a limiter from the configured policy. Buckets are created lazily per client IP and+-- pruned once they refill (see the module haddock).+newRateLimiter :: RateLimitConfig -> IO RateLimiter+newRateLimiter = newRateLimiterFor shomeiThrottledRoutes++-- | Build a limiter over an explicitly derived route set. Embedding hosts can derive this from+-- their own Servant API; the standalone server passes 'shomeiThrottledRoutes'.+newRateLimiterFor :: [ThrottledRoute] -> RateLimitConfig -> IO RateLimiter+newRateLimiterFor = newRateLimiterWithFor defaultSweepEvery++-- | 'newRateLimiter' with the prune interval given explicitly. A test seam: production wants+-- 'newRateLimiter', but a test that had to issue 4096 requests to observe one prune would be+-- measuring the wrong thing. Values below 1 are clamped to 1.+newRateLimiterWith :: Int -> RateLimitConfig -> IO RateLimiter+newRateLimiterWith every = newRateLimiterWithFor every shomeiThrottledRoutes++newRateLimiterWithFor :: Int -> [ThrottledRoute] -> RateLimitConfig -> IO RateLimiter+newRateLimiterWithFor every routes cfg = do+ tv <- newTVarIO HM.empty+ counter <- newTVarIO 0+ pure+ RateLimiter+ { buckets = tv,+ sweepCounter = counter,+ sweepEvery = max 1 every,+ capacity = fromIntegral cfg.perIpBurst,+ refillPerSec = fromIntegral cfg.perIpRequestsPerMinute / 60,+ enabled = cfg.rateLimitEnabled,+ throttled = routes+ }++-- | How many per-IP buckets the limiter is currently holding. Exposed so a test can assert the+-- map stays bounded; nothing in the server reads it.+bucketCount :: RateLimiter -> IO Int+bucketCount rl = HM.size <$> readTVarIO rl.buckets++-- | Try to take one token for @key@ at time @nowSecs@; 'True' = allowed, 'False' = rejected.+-- A new key starts with a full bucket.+--+-- Every @sweepEvery@ calls this also prunes fully-refilled buckets, in the same transaction.+-- The just-touched key survives its own sweep whenever it is not itself full — and when it is+-- full, dropping it changes nothing (see the module haddock's losslessness argument).+takeToken :: RateLimiter -> ByteString -> Double -> IO Bool+takeToken rl key nowSecs = atomically do+ m <- readTVar rl.buckets+ let Bucket lvl t0 = HM.lookupDefault (Bucket rl.capacity nowSecs) key m+ refilled = min rl.capacity (lvl + (nowSecs - t0) * rl.refillPerSec)+ allowed = refilled >= 1+ taken = if allowed then refilled - 1 else refilled+ updated = HM.insert key (Bucket taken nowSecs) m+ sinceSweep <- readTVar rl.sweepCounter+ if sinceSweep + 1 >= rl.sweepEvery+ then do+ writeTVar rl.sweepCounter 0+ writeTVar rl.buckets (HM.filter (not . fullyRefilled) updated)+ else do+ writeTVar rl.sweepCounter (sinceSweep + 1)+ writeTVar rl.buckets updated+ pure allowed+ where+ -- Would this bucket have refilled to capacity by now? Then it is indistinguishable from an+ -- absent key, and dropping it is observationally a no-op.+ fullyRefilled (Bucket lvl t0) = lvl + (nowSecs - t0) * rl.refillPerSec >= rl.capacity++-- | The WAI middleware. Passes non-throttled paths straight through.+rateLimitMiddleware :: RateLimiter -> Middleware+rateLimitMiddleware rl app req respond+ | not rl.enabled || not (throttledPath rl req) = app req respond+ | otherwise = do+ nowSecs <- monotonicSeconds+ allowed <- takeToken rl (clientKey req) nowSecs+ if allowed then app req respond else respond tooMany+ where+ -- The same RFC 9457 document every other application error path returns, with+ -- @Retry-After@. This+ -- answers before Servant ever routes the request, so it cannot go through the handler+ -- error mapping — it shares the catalog constant instead.+ tooMany = problemResponse pcTooManyRequests (retryAfterOccurrence 60)++-- | Match the request against the routes derived from the API type. Method, path length, and+-- every literal segment must agree; captures match exactly one segment.+throttledPath :: RateLimiter -> Request -> Bool+throttledPath rl req = matchesThrottledRoute rl.throttled (requestMethod req) (pathInfo req)++-- | The per-IP key: the client's host address WITHOUT the ephemeral source port (otherwise+-- each connection would get its own bucket). Behind a reverse proxy this is the proxy address; a+-- trusted @X-Forwarded-For@ policy is out of scope for this single-instance plan.+clientKey :: Request -> ByteString+clientKey = encodeUtf8 . clientIpText . remoteHost++monotonicSeconds :: IO Double+monotonicSeconds = (\ns -> fromIntegral ns / 1e9) <$> getMonotonicTimeNSec
+ src/Shomei/Server/Middleware/TrustedProxy.hs view
@@ -0,0 +1,86 @@+-- | Resolve a client address only when the immediate peer is an explicitly trusted proxy.+module Shomei.Server.Middleware.TrustedProxy+ ( TrustedProxies,+ emptyTrustedProxies,+ parseTrustedProxies,+ trustedProxyTexts,+ isTrustedPeer,+ forwardedClient,+ trustedProxyMiddleware,+ )+where++import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as BC+import Data.IP (IP (..), IPRange (..), fromSockAddr, ipv4ToIPv6, isMatchedTo, toSockAddr)+import Data.Maybe (listToMaybe)+import Data.Text qualified as Text+import Network.Wai (Middleware, Request (..))+import Shomei.Prelude+import Text.Read (readMaybe)++newtype TrustedProxies = TrustedProxies [IPRange]+ deriving stock (Eq, Show)++emptyTrustedProxies :: TrustedProxies+emptyTrustedProxies = TrustedProxies []++trustedProxyTexts :: TrustedProxies -> [Text]+trustedProxyTexts (TrustedProxies ranges) = Text.pack . show <$> ranges++-- | Parse CIDR ranges. A bare address means a single-host @/32@ or @/128@ range.+parseTrustedProxies :: [Text] -> Either Text TrustedProxies+parseTrustedProxies = fmap TrustedProxies . traverse parseOne+ where+ parseOne raw =+ let value = Text.strip raw+ cidr+ | Text.any (== '/') value = value+ | Text.any (== ':') value = value <> "/128"+ | otherwise = value <> "/32"+ in maybe+ (Left ("trustedProxies: " <> raw <> " is not an IP address or CIDR block"))+ Right+ (readMaybe (Text.unpack cidr))++isTrustedPeer :: TrustedProxies -> IP -> Bool+isTrustedPeer (TrustedProxies ranges) ip = any (inRange ip) ranges+ where+ inRange (IPv4 address) (IPv4Range range) = address `isMatchedTo` range+ inRange (IPv6 address) (IPv6Range range) = address `isMatchedTo` range+ inRange (IPv4 address) (IPv6Range range) = ipv4ToIPv6 address `isMatchedTo` range+ inRange _ _ = False++-- | Resolve the rightmost untrusted forwarded hop. If every forwarded hop is trusted,+-- the leftmost hop is the original client. Headers are ignored unless the socket peer is trusted.+forwardedClient :: TrustedProxies -> Request -> Maybe IP+forwardedClient proxies req = do+ (peer, _) <- fromSockAddr (remoteHost req)+ guard (isTrustedPeer proxies peer)+ let hops =+ concatMap+ (mapMaybe parseHop . BC.split ',')+ [value | (name, value) <- requestHeaders req, name == "X-Forwarded-For"]+ untrusted = filter (not . isTrustedPeer proxies) hops+ case untrusted of+ [] -> listToMaybe hops+ _ -> listToMaybe (reverse untrusted)++-- | Accept the address forms emitted by common proxies: a bare address, IPv4 with a port,+-- or bracketed IPv6 with an optional port. Malformed entries are ignored.+parseHop :: ByteString -> Maybe IP+parseHop raw = readMaybe (BC.unpack address)+ where+ value = BC.strip raw+ address+ | Just rest <- BC.stripPrefix "[" value = BC.takeWhile (/= ']') rest+ | BC.count ':' value == 1 = BC.takeWhile (/= ':') value+ | otherwise = value++-- | Rewrite 'remoteHost' before downstream middleware and handlers read it.+trustedProxyMiddleware :: TrustedProxies -> Middleware+trustedProxyMiddleware proxies app req =+ app+ case forwardedClient proxies req of+ Nothing -> req+ Just ip -> req {remoteHost = toSockAddr (ip, 0)}
+ src/Shomei/Server/Observability/Logging.hs view
@@ -0,0 +1,165 @@+-- | Request-ID + structured-JSON request logging WAI 'Middleware' (EP-3, M1).+--+-- This is the OUTERMOST middleware in the server's WAI stack (IP-4): every request — including+-- any EP-2's rate limiter rejects with 429 — produces exactly one structured log line on stdout+-- carrying a correlation id. The correlation id (a short unique @request_id@) is taken from an+-- incoming @X-Request-Id@ header (sanitized to defeat log injection) or generated, and echoed+-- back in the @X-Request-Id@ response header.+--+-- Logging hygiene (a hard rule): the line is built ONLY from the request method, path, response+-- status, wall-clock duration, and the peer IP. It never reads request/response bodies or+-- sensitive headers (Authorization, Cookie), so no password, token, or cookie can leak.+--+-- Line atomicity (EP-4): a line is rendered to one /strict/ 'ByteString' by 'renderLogLine' and+-- written with a single 'BS.hPut'. A lazy write hands the handle chunk by chunk, taking and+-- releasing the handle lock each time, so a line-buffered handle may flush another thread's+-- chunk between two chunks of ours — interleaving two log lines and breaking the+-- one-JSON-object-per-line contract downstream pipelines rely on. One strict write cannot.+module Shomei.Server.Observability.Logging+ ( requestLoggingMiddleware,+ renderLogLine,+ emitLine,+ logServerError,+ serverErrorLine,+ responseLogLevel,+ )+where++import Control.Exception (SomeException)+import Data.Aeson (Value, encode, object, (.=))+import Data.Aeson.Key (Key)+import Data.Aeson.Key qualified as Key+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as BC+import Data.ByteString.Lazy qualified as BL+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)+import Data.UUID qualified as UUID+import Data.UUID.V4 qualified as UUIDv4+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import Network.HTTP.Types (Status (statusCode))+import Network.Wai+ ( Middleware,+ Request,+ mapResponseHeaders,+ rawPathInfo,+ remoteHost,+ requestHeaders,+ requestMethod,+ responseStatus,+ )+import Servant.Health.Paths (healthRawPaths)+import Shomei.Config (LogFormat (..), ObservabilityConfig (..))+import Shomei.Servant.ClientIp (clientIpText)+import System.IO (Handle, stdout)++requestLoggingMiddleware :: ObservabilityConfig -> Middleware+requestLoggingMiddleware cfg app req respond+ | not cfg.requestLoggingEnabled = app req respond+ | rawPathInfo req `elem` healthRawPaths = app req respond+ | otherwise = do+ reqId <- resolveRequestId req+ start <- getMonotonicTimeNSec+ app req \res -> do+ received <- respond (mapResponseHeaders (("X-Request-Id", reqId) :) res)+ end <- getMonotonicTimeNSec+ emit cfg.logFormat (lineFields reqId req (statusCode (responseStatus res)) (durationMs start end))+ pure received++-- | The fields every line carries, in a stable order.+lineFields :: ByteString -> Request -> Int -> Double -> [(Key, Value)]+lineFields reqId req status durMs =+ [ "level" .= responseLogLevel status,+ "msg" .= ("request" :: Text),+ "request_id" .= decodeUtf8Lenient reqId,+ "method" .= decodeUtf8Lenient (requestMethod req),+ "path" .= decodeUtf8Lenient (rawPathInfo req),+ "status" .= status,+ "duration_ms" .= durMs,+ "client_ip" .= clientIp req+ ]++emit :: LogFormat -> [(Key, Value)] -> IO ()+emit fmt fields = emitLine stdout (renderLogLine fmt fields)++-- | Render one complete log line — trailing newline included — as a strict 'ByteString'.+--+-- Pure, so the "exactly one line, and for 'LogJson' a valid JSON object" property is directly+-- testable. 'LogPlain' strips control characters (including the newline) from every rendered+-- value, which is what stops a hostile field value from forging a second log line;+-- 'LogJson' needs no such filter because the encoder escapes them.+renderLogLine :: LogFormat -> [(Key, Value)] -> ByteString+renderLogLine fmt fields = render fmt fields <> "\n"+ where+ render LogJson fs = BL.toStrict (encode (object fs))+ render LogPlain fs =+ encodeUtf8 (Text.intercalate " " [Key.toText k <> "=" <> renderVal v | (k, v) <- fs])+ renderVal v = Text.filter printable (decodeUtf8Lenient (BL.toStrict (encode v)))+ printable c = c >= ' ' && c /= '"'++-- | Write one pre-rendered line with a single 'BS.hPut', which holds the handle lock once and+-- so cannot interleave with a concurrent writer's line.+emitLine :: Handle -> ByteString -> IO ()+emitLine = BS.hPut++-- | Warp's @setOnException@ hook: report an exception that escaped a handler as one structured+-- JSON line on __stdout__, the same stream and shape as request lines, so an operator tailing+-- the log stream sees it. Warp's own default prints unstructured prose to stderr, where nobody+-- is looking.+--+-- Note this is stdout, unlike "Shomei.Server.Supervisor"'s @logJsonLine@ (stderr): a failed+-- request belongs with the requests, a failed background task does not.+logServerError :: Maybe Request -> SomeException -> IO ()+logServerError mreq e = emitLine stdout (serverErrorLine mreq e)++-- | The line 'logServerError' writes. Split out so a test can assert on the bytes rather than+-- capture the process's stdout. The request's method and path are included when warp knew+-- which request failed (it does not, for an exception raised before the request line parsed).+serverErrorLine :: Maybe Request -> SomeException -> ByteString+serverErrorLine mreq e = renderLogLine LogJson (base <> requestFields)+ where+ base =+ [ "level" .= ("error" :: Text),+ "msg" .= ("unhandled exception" :: Text),+ "error" .= Text.pack (show e)+ ]+ requestFields = case mreq of+ Nothing -> []+ Just req ->+ [ "method" .= decodeUtf8Lenient (requestMethod req),+ "path" .= decodeUtf8Lenient (rawPathInfo req)+ ]++durationMs :: Word64 -> Word64 -> Double+durationMs start end = fromIntegral (end - start) / 1_000_000++-- | The correlation id: a sanitized incoming @X-Request-Id@, or a freshly generated+-- @req_<uuid>@. Sanitizing (allow only @[A-Za-z0-9_.:-]@, cap length) stops a malicious client+-- from injecting a newline and forging a second log line.+resolveRequestId :: Request -> IO ByteString+resolveRequestId req =+ case lookup "X-Request-Id" (requestHeaders req) of+ Just raw | not (BS.null cleaned) -> pure cleaned+ where+ cleaned = sanitize raw+ _ -> do+ u <- UUIDv4.nextRandom+ pure ("req_" <> BC.filter (/= '-') (BC.pack (UUID.toString u)))+ where+ sanitize = BS.take 64 . BC.filter allowed+ allowed c = c `elem` ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-" :: String)++-- | The peer host as text (dotted-quad for IPv4), with the ephemeral port dropped.+clientIp :: Request -> Text+clientIp = clientIpText . remoteHost++-- Ordinary authentication failures are attacker-triggerable and stay at info, avoiding+-- warning floods. Availability failures and throttling need operational attention.+responseLogLevel :: Int -> Text+responseLogLevel status+ | status >= 500 = "error"+ | status == 429 = "warn"+ | otherwise = "info"
+ src/Shomei/Server/Observability/Metrics.hs view
@@ -0,0 +1,220 @@+-- | A small hand-rolled Prometheus metrics registry and the WAI middleware that records and+-- exposes it (EP-3, M2).+--+-- We deliberately do NOT pull in @prometheus-client@: it is not registered in the project's+-- dependency registry and would add a Hackage build plus transitive deps for what is, here, a+-- handful of counters, one gauge, and one latency histogram. Consistent with this plan's+-- hand-rolled request-logging decision, the registry is plain 'IORef's updated with+-- 'atomicModifyIORef'' and the @/metrics@ body is rendered as Prometheus text exposition by hand.+--+-- Metrics exposed:+--+-- * @http_requests_total{method,status}@ — counter of handled requests.+-- * @http_requests_in_flight@ — gauge of requests currently being handled.+-- * @http_request_duration_seconds@ — latency histogram (fixed buckets) with @_sum@/@_count@.+-- * @shomei_logins_succeeded_total@ / @shomei_logins_failed_total@ /+-- @shomei_tokens_issued_total@ — domain counters derived from the HTTP method/path/status+-- (a @POST /v1/auth/login@ → 200 is a success and issues a token; → 401 is a failure;+-- @POST /v1/auth/signup@ → 201 and @/v1/auth/refresh@ → 200 issue a token). This+-- HTTP-derived approach avoids instrumenting the effect stack; see the Decision Log.+module Shomei.Server.Observability.Metrics+ ( Metrics,+ newMetrics,+ metricsMiddleware,+ metricsEndpointMiddleware,+ exportMetrics,+ )+where++import Control.Exception (finally)+import Data.ByteString (ByteString)+import Data.ByteString.Builder (Builder, byteString, doubleDec, intDec, toLazyByteString)+import Data.ByteString.Lazy qualified as BL+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.List (sortOn)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding (decodeLatin1, encodeUtf8)+import GHC.Clock (getMonotonicTimeNSec)+import Network.HTTP.Types (Status (statusCode), methodGet, status200)+import Network.Wai+ ( Middleware,+ Request,+ rawPathInfo,+ requestMethod,+ responseLBS,+ responseStatus,+ )++data Metrics = Metrics+ { reqTotal :: !(IORef (Map (Text, Int) Int)),+ inFlight :: !(IORef Int),+ durSum :: !(IORef Double),+ durCount :: !(IORef Int),+ durBuckets :: !(IORef (Map Double Int)),+ loginsOk :: !(IORef Int),+ loginsFail :: !(IORef Int),+ tokensIssued :: !(IORef Int)+ }++histogramBuckets :: [Double]+histogramBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]++newMetrics :: IO Metrics+newMetrics =+ Metrics+ <$> newIORef Map.empty+ <*> newIORef 0+ <*> newIORef 0+ <*> newIORef 0+ <*> newIORef (Map.fromList [(le, 0) | le <- histogramBuckets])+ <*> newIORef 0+ <*> newIORef 0+ <*> newIORef 0++bumpInt :: IORef Int -> Int -> IO ()+bumpInt ref n = atomicModifyIORef' ref (\x -> (x + n, ()))++observeDuration :: Metrics -> Double -> IO ()+observeDuration m secs = do+ bumpInt m.durCount 1+ atomicModifyIORef' m.durSum (\s -> (s + secs, ()))+ atomicModifyIORef' m.durBuckets (\bs -> (Map.mapWithKey (\le c -> if secs <= le then c + 1 else c) bs, ()))++-- | Instrument every request: in-flight gauge, total counter, latency histogram, and the+-- HTTP-derived domain counters.+--+-- The in-flight gauge is decremented in a 'finally', not in the response continuation: an+-- application that throws instead of responding (the infrastructure path — @Shomei.Server.Boot@+-- raises an 'IOError' when the database is unreachable, which flies past this middleware to+-- warp) would otherwise inflate the gauge permanently, and after a week of sporadic 500s the+-- gauge — and every dashboard and alert built on it — reads a fiction. Placing the decrement in+-- the release action rather than alongside the latency observation is what makes it run exactly+-- once on every path, including an async exception delivered after the continuation returned.+--+-- The latency histogram and the request counter deliberately stay in the continuation: a+-- request aborted by an exception produced no response status to label, so it is counted+-- nowhere rather than counted wrong.+metricsMiddleware :: Metrics -> Middleware+metricsMiddleware m app req respond = do+ bumpInt m.inFlight 1+ start <- monotonicSeconds+ let handleResponse res = do+ received <- respond res+ end <- monotonicSeconds+ observeDuration m (end - start)+ recordRequest m req (statusCode (responseStatus res))+ pure received+ app req handleResponse `finally` bumpInt m.inFlight (-1)++recordRequest :: Metrics -> Request -> Int -> IO ()+recordRequest m req status = do+ let method = methodLabel (requestMethod req)+ path = rawPathInfo req+ atomicModifyIORef' m.reqTotal (\mp -> (Map.insertWith (+) (method, status) 1 mp, ()))+ case (requestMethod req, path, status) of+ ("POST", "/v1/auth/login", 200) -> bumpInt m.loginsOk 1 >> bumpInt m.tokensIssued 1+ ("POST", "/v1/auth/login", 401) -> bumpInt m.loginsFail 1+ ("POST", "/v1/auth/signup", 201) -> bumpInt m.tokensIssued 1+ ("POST", "/v1/auth/refresh", 200) -> bumpInt m.tokensIssued 1+ -- EP-4: the OAuth2 token endpoint mints machine tokens, and it is unversioned. Without this+ -- arm, a deployment that has migrated its services to /oauth/token watches+ -- shomei_tokens_issued_total fall toward zero while it is issuing more tokens than ever.+ ("POST", "/oauth/token", 200) -> bumpInt m.tokensIssued 1+ _ -> pure ()++-- | Serve @GET /metrics@ directly (bypassing Servant); pass everything else through.+metricsEndpointMiddleware :: Metrics -> Middleware+metricsEndpointMiddleware m app req respond+ | requestMethod req == methodGet && rawPathInfo req == "/metrics" = do+ body <- exportMetrics m+ respond (responseLBS status200 [("Content-Type", "text/plain; version=0.0.4")] body)+ | otherwise = app req respond++-- | Render the whole registry as Prometheus text exposition.+exportMetrics :: Metrics -> IO BL.ByteString+exportMetrics m = do+ reqs <- readIORef m.reqTotal+ flight <- readIORef m.inFlight+ dsum <- readIORef m.durSum+ dcount <- readIORef m.durCount+ buckets <- readIORef m.durBuckets+ ok <- readIORef m.loginsOk+ failed <- readIORef m.loginsFail+ issued <- readIORef m.tokensIssued+ pure+ ( toLazyByteString+ ( mconcat+ ( reqLines reqs+ <> flightLines flight+ <> histLines dsum dcount buckets+ <> domainLines ok failed issued+ )+ )+ )+ where+ reqLines reqs =+ helpType "http_requests_total" "counter"+ : [ line ("http_requests_total" <> labels [("method", method), ("status", tshow status)]) (intDec n)+ | ((method, status), n) <- Map.toList reqs+ ]+ flightLines flight =+ [ helpType "http_requests_in_flight" "gauge",+ line "http_requests_in_flight" (intDec flight)+ ]+ histLines dsum dcount buckets =+ helpType "http_request_duration_seconds" "histogram"+ : [ line ("http_request_duration_seconds_bucket" <> labels [("le", tshow le)]) (intDec c)+ | (le, c) <- sortOn fst (Map.toList buckets)+ ]+ <> [ line ("http_request_duration_seconds_bucket" <> labels [("le", "+Inf")]) (intDec dcount),+ line "http_request_duration_seconds_sum" (doubleDec dsum),+ line "http_request_duration_seconds_count" (intDec dcount)+ ]+ domainLines ok failed issued =+ [ helpType "shomei_logins_succeeded_total" "counter",+ line "shomei_logins_succeeded_total" (intDec ok),+ helpType "shomei_logins_failed_total" "counter",+ line "shomei_logins_failed_total" (intDec failed),+ helpType "shomei_tokens_issued_total" "counter",+ line "shomei_tokens_issued_total" (intDec issued)+ ]++-- Rendering helpers ----------------------------------------------------------++line :: Builder -> Builder -> Builder+line name val = name <> byteString " " <> val <> byteString "\n"++helpType :: ByteString -> ByteString -> Builder+helpType name ty = byteString "# TYPE " <> byteString name <> byteString " " <> byteString ty <> byteString "\n"++labels :: [(ByteString, Text)] -> Builder+labels [] = mempty+labels ls = byteString "{" <> go ls <> byteString "}"+ where+ go [] = mempty+ go [x] = lbl x+ go (x : xs) = lbl x <> byteString "," <> go xs+ lbl (k, v) = byteString k <> byteString "=\"" <> byteString (encodeUtf8 (escapeLabelValue v)) <> byteString "\""++-- | Keep attacker-controlled HTTP extension methods out of the series-key vocabulary.+methodLabel :: ByteString -> Text+methodLabel method+ | method `elem` ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] = decodeLatin1 method+ | otherwise = "other"++-- | Prometheus text-format label escaping: backslash first, then quote and newline.+escapeLabelValue :: Text -> Text+escapeLabelValue = Text.concatMap \case+ '\\' -> "\\\\"+ '"' -> "\\\""+ '\n' -> "\\n"+ c -> Text.singleton c++monotonicSeconds :: IO Double+monotonicSeconds = (\ns -> fromIntegral ns / 1e9) <$> getMonotonicTimeNSec++tshow :: (Show a) => a -> Text+tshow = Text.pack . show
+ src/Shomei/Server/Runtime.hs view
@@ -0,0 +1,53 @@+-- | Shared Warp lifecycle for standalone and embedded hosts. Rejected connections never+-- wait for a permit. Warp brackets onOpen/onClose even when onOpen returns False; track+-- admitted worker identities so rejected closes cannot release someone else's capacity.+module Shomei.Server.Runtime (hostWarpSettings) where++import Control.Concurrent (myThreadId)+import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)+import Control.Monad (when)+import Data.Set qualified as Set+import Data.String (fromString)+import Data.Text qualified as Text+import Network.Wai.Handler.Warp qualified as Warp+import Shomei.Config (ObservabilityConfig (..), ShomeiConfig (..))+import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..))+import Shomei.Server.ExceptionResponse (problemExceptionResponse)+import Shomei.Server.Observability.Logging (logServerError)++hostWarpSettings :: ShomeiConfig -> ServerSettings -> Warp.Settings -> IO Warp.Settings+hostWarpSettings cfg settings base = do+ when+ (settings.serverMaxConnections <= 0 || settings.serverSlowClientTimeoutSeconds <= 0 || settings.serverSlowClientTimeoutSeconds > maxBound `div` 1000000 || Text.null settings.serverBindHost)+ (ioError (userError "host bind, connection limit and slow-client timeout must be finite and positive"))+ admitted <- newTVarIO Set.empty+ let onOpen _ = do+ worker <- myThreadId+ atomically do+ active <- readTVar admitted+ if Set.size active >= settings.serverMaxConnections+ then pure False+ else do+ writeTVar admitted (Set.insert worker active)+ pure True+ onClose _ = do+ worker <- myThreadId+ atomically do+ active <- readTVar admitted+ writeTVar admitted (Set.delete worker active)+ proxy = case settings.serverProxyProtocol of+ ProxyProtocolOff -> Warp.setProxyProtocolNone+ ProxyProtocolRequired -> Warp.setProxyProtocolRequired+ onException request err = when (Warp.defaultShouldDisplayException err) (logServerError request err)+ pure+ $ Warp.setPort settings.serverPort+ . Warp.setHost (fromString (Text.unpack settings.serverBindHost))+ . Warp.setTimeout settings.serverSlowClientTimeoutSeconds+ . Warp.setOnOpen onOpen+ . Warp.setOnClose onClose+ . Warp.setGracefulShutdownTimeout (Just cfg.observabilityConfig.gracefulShutdownTimeoutSeconds)+ . Warp.setServerName "shomei"+ . Warp.setOnException onException+ . Warp.setOnExceptionResponse problemExceptionResponse+ . proxy+ $ base
+ src/Shomei/Server/Supervisor.hs view
@@ -0,0 +1,130 @@+-- | The house idiom for supervised background threads.+--+-- A /background thread/ here means a periodic maintenance task forked at server boot: the+-- expired-data sweeper ("Shomei.Persistence.Maintenance.Postgres"), the signing-key reload+-- ("Shomei.Server.Keys"), and whatever later plans add. Every one of them wants the same+-- three things, and none of them wants a supervisor hierarchy:+--+-- 1. Run a cycle, then sleep, forever.+-- 2. If a cycle throws, log loudly and try again later — never take the server down. A failed+-- sweep is strictly less bad than downtime, and the realistic failure mode is "the database+-- was briefly unreachable", which fixes itself.+-- 3. Back off while the failure persists, so a database outage does not turn into a tight+-- retry loop that fills the disk with log lines.+--+-- 'supervisedLoop' is that, and nothing more. It is not respawned by a monitor thread, and it+-- dies with the process: fork it with plain @forkIO@ from @Shomei.Server.Boot.main@ and let+-- process exit collect it. No shutdown plumbing is needed because every cycle is idempotent —+-- a cycle interrupted by exit simply did not happen, and the next boot picks it up.+module Shomei.Server.Supervisor+ ( supervisedLoop,+ supervisedLoopMicros,+ logJsonLine,+ )+where++import Control.Concurrent (threadDelay)+import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)+import Data.Aeson (Value, encode, object, (.=))+import Data.Aeson.Key (Key)+import Data.ByteString.Lazy qualified as BL+import Data.Text (Text)+import Data.Text qualified as Text+import System.IO (stderr)++-- | Seconds to wait after the first crash.+initialBackoffSeconds :: Int+initialBackoffSeconds = 5++-- | The ceiling the backoff doubles up to. Five minutes is long enough that a night-long+-- outage costs a few hundred log lines rather than a few million, and short enough that+-- recovery is noticed promptly.+maxBackoffSeconds :: Int+maxBackoffSeconds = 300++-- | Run @cycleAction@ forever, sleeping @intervalSeconds@ between clean cycles.+--+-- The first cycle runs immediately, so a freshly booted server does its maintenance at once+-- rather than after a full interval. A crash — any synchronous 'SomeException' — is caught,+-- logged as one structured JSON line on stderr, and retried after an exponential backoff+-- (5 s, doubling to a 300 s ceiling). The backoff resets after the next clean cycle. The loop+-- never rethrows a synchronous exception.+--+-- Asynchronous exceptions are re-thrown rather than swallowed, so that killing the thread (or+-- the process shutting down) actually stops it instead of being mistaken for a failed cycle+-- and retried. @taskName@ appears in every log line this loop emits.+supervisedLoop ::+ -- | Task name, e.g. @"sweeper"@.+ Text ->+ -- | Seconds to sleep between clean cycles.+ Int ->+ -- | One cycle.+ IO () ->+ IO ()+supervisedLoop taskName intervalSeconds =+ -- A non-positive interval would spin the loop at full speed, so clamp to one second: a+ -- misconfigured task that runs too often is recoverable, one that pins a core is not.+ supervisedLoopMicros+ taskName+ (max 1 intervalSeconds * microsPerSecond)+ (initialBackoffSeconds * microsPerSecond)+ (maxBackoffSeconds * microsPerSecond)++-- | 'supervisedLoop' with every duration in microseconds and the backoff bounds given+-- explicitly. Exposed so tests can exercise the crash/backoff/recovery path in milliseconds+-- instead of minutes; production code wants 'supervisedLoop'.+supervisedLoopMicros ::+ -- | Task name.+ Text ->+ -- | Microseconds between clean cycles.+ Int ->+ -- | Microseconds to wait after the first crash.+ Int ->+ -- | Ceiling the backoff doubles up to.+ Int ->+ -- | One cycle.+ IO () ->+ IO ()+supervisedLoopMicros taskName intervalMicros initialBackoff maxBackoff cycleAction =+ loop initialBackoff+ where+ loop backoff = do+ outcome <- try cycleAction+ case outcome of+ Right () -> do+ threadDelay intervalMicros+ loop initialBackoff+ Left err+ -- A ThreadKilled or an async cancellation means someone wants this thread gone.+ | isAsyncException err -> throwIO err+ | otherwise -> do+ logCrash taskName err backoff+ threadDelay backoff+ loop (min maxBackoff (backoff * 2))++microsPerSecond :: Int+microsPerSecond = 1_000_000++-- | Is this an asynchronous exception (@ThreadKilled@, @AsyncCancelled@, a timeout)? Both+-- @AsyncException@ and @async@'s @AsyncCancelled@ route through 'SomeAsyncException', so this+-- one check covers them.+isAsyncException :: SomeException -> Bool+isAsyncException err = case fromException err of+ Just (_ :: SomeAsyncException) -> True+ Nothing -> False++-- | @backoffMicros@ is the wait about to be taken, so successive crashes log 5, 10, 20, …+logCrash :: Text -> SomeException -> Int -> IO ()+logCrash taskName err backoffMicros =+ logJsonLine+ [ "level" .= ("error" :: Text),+ "msg" .= ("background task crashed" :: Text),+ "task" .= taskName,+ "error" .= Text.pack (show err),+ "backoff_s" .= (fromIntegral backoffMicros / 1_000_000 :: Double)+ ]++-- | Emit one structured JSON log line on stderr. Background tasks log here rather than on+-- stdout, where 'Shomei.Server.Observability.Logging' emits one line per HTTP request.+logJsonLine :: [(Key, Value)] -> IO ()+logJsonLine fields = BL.hPut stderr (encode (object fields) <> "\n")
+ test-health/Main.hs view
@@ -0,0 +1,143 @@+module Main (main) where++import Control.Concurrent (threadDelay)+import Data.ByteString qualified as BS+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Maybe (listToMaybe)+import GHC.Clock (getMonotonicTimeNSec)+import Network.HTTP.Client (defaultManagerSettings, newManager)+import Servant.Health (ProbeVerdict (..))+import Servant.Health.TestKit (probeContractTests)+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), newHashingLimiter)+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))+import Shomei.Config (defaultShomeiConfig)+import Shomei.Error (AuthDependency (PostgreSQL), AuthError (DependencyUnavailable))+import Shomei.Health.Server (HealthPolicy (..), buildHealthChecksWith, defaultHealthPolicy)+import Shomei.Mfa.Totp.Postgres (TotpEncryptionKey, totpEncryptionKeyFromBytes)+import Shomei.Migrations.TestSupport (withShomeiMigratedDatabase)+import Shomei.Notify (noNotifierSecrets)+import Shomei.Notify.Queue (newNotifierQueue)+import Shomei.Persistence.Database.Postgres (defaultTransactionRetryPolicy)+import Shomei.Persistence.Pool.Postgres (acquirePool)+import Shomei.Persistence.Pool.Postgres qualified as Pool+import Shomei.Prelude+import Shomei.Server.App (Env (..), runAppIO)+import Shomei.Server.Boot (application)+import Shomei.Server.Config (defaultDbStatementTimeoutMs)+import Shomei.Server.Keys (bootstrapKeys)+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256), SigningKeyStatus (KeyRevoked), StoredSigningKey (..))+import Shomei.SigningKey.Protection.Jwt (KeyEncryptionKey, keyEncryptionKeyFromBase64)+import Shomei.SigningKey.Store (listActiveSigningKeys, updateSigningKeyStatus)+import Shomei.Time.Store (now)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++main :: IO ()+main =+ withShomeiMigratedDatabase \connectionString -> do+ env <- buildTestEnv connectionString+ defaultMain (tests env)++tests :: Env -> TestTree+tests env =+ testGroup+ "Shomei health policy"+ [ probeContractTests+ "servant-health contract through the standalone application builder"+ (\liveness readiness -> pure (application env liveness readiness)),+ testCase "readiness distinguishes active keys, missing keys, and PostgreSQL unavailability" do+ (_, readiness) <- buildHealthChecksWith noCachePolicy (runAppIO env listActiveSigningKeys)+ readiness >>= (@?= Healthy)++ active <- expectRight =<< runAppIO env listActiveSigningKeys+ key <- maybe (assertFailure "bootstrap did not create an active signing key") pure (listToMaybe active)+ timestamp <- expectRight =<< runAppIO env now+ expectRight =<< runAppIO env (updateSigningKeyStatus key.keyId KeyRevoked timestamp)+ readiness >>= assertFailedAs "signing-key"++ unavailablePool <- acquirePool 1 1 defaultDbStatementTimeoutMs "postgresql://127.0.0.1:1/postgres"+ (_, unavailableReadiness) <-+ buildHealthChecksWith noCachePolicy (runAppIO env {envPool = unavailablePool} listActiveSigningKeys)+ unavailableReadiness >>= assertFailedAs "postgres"+ Pool.release unavailablePool,+ testCase "tracked readiness preserves onset, resets after recovery, and starts a new run" do+ queryResult <- newIORef (Left (DependencyUnavailable PostgreSQL))+ (_, readiness) <- buildHealthChecksWith noCachePolicy (readIORef queryResult)+ first <- readiness+ second <- readiness+ failureOnset first @?= failureOnset second+ writeIORef queryResult (Right [error "the policy checks only whether the list is empty"])+ readiness >>= (@?= Healthy)+ writeIORef queryResult (Left (DependencyUnavailable PostgreSQL))+ third <- readiness+ case (failureOnset first, failureOnset third) of+ (Just old, Just new) -> unless (new >= old) (assertFailure "a new failing run moved backwards")+ _ -> assertFailure "expected tracked failures before and after recovery",+ testCase "readiness is evaluated at most once per window" do+ calls <- newIORef (0 :: Int)+ let query = do+ modifyIORef' calls (+ 1)+ pure (Right [error "the policy checks only whether the list is empty"])+ (_, readiness) <- buildHealthChecksWith defaultHealthPolicy query+ readiness >>= (@?= Healthy)+ readiness >>= (@?= Healthy)+ readIORef calls >>= (@?= 1)+ threadDelay 1_100_000+ readiness >>= (@?= Healthy)+ readIORef calls >>= (@?= 2),+ testCase "readiness times out as a postgres failure" do+ let policy = defaultHealthPolicy {readinessTimeoutMicros = 200_000, readinessCacheMicros = 0}+ query = threadDelay 5_000_000 >> pure (Right [])+ (_, readiness) <- buildHealthChecksWith policy query+ started <- getMonotonicTimeNSec+ verdict <- readiness+ finished <- getMonotonicTimeNSec+ assertFailedAs "postgres" verdict+ let elapsedSeconds = fromIntegral (finished - started) / 1e9 :: Double+ assertBool ("readiness took " <> show elapsedSeconds <> " seconds") (elapsedSeconds < 2)+ ]++noCachePolicy :: HealthPolicy+noCachePolicy = defaultHealthPolicy {readinessCacheMicros = 0}++buildTestEnv :: Text -> IO Env+buildTestEnv connectionString = do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connectionString+ keys <- newIORef =<< bootstrapKeys testKek ES256 pool+ manager <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ let config = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ pure+ Env+ { envPool = pool,+ envDbRetryPolicy = defaultTransactionRetryPolicy,+ envConfig = config,+ envKeys = keys,+ envKek = testKek,+ envHttpManager = manager,+ envNotifierSecrets = noNotifierSecrets,+ envNotifierQueue = notifierQueue,+ envArgon2Params = Argon2Params {memoryKiB = 8192, iterations = 1, parallelism = 1},+ envHashingLimiter = limiter,+ envTotpKey = testTotpKey+ }++testKek :: KeyEncryptionKey+testKek = either (error . show) id (keyEncryptionKeyFromBase64 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")++testTotpKey :: TotpEncryptionKey+testTotpKey = either (error . show) id (totpEncryptionKeyFromBytes (BS.replicate 32 0))++expectRight :: (Show e) => Either e a -> IO a+expectRight = either (assertFailure . show) pure++assertFailedAs :: Text -> ProbeVerdict -> IO ()+assertFailedAs expected = \case+ Unhealthy actual _ -> actual @?= expected+ Healthy -> assertFailure ("expected an unhealthy " <> show expected <> " verdict")++failureOnset :: ProbeVerdict -> Maybe UTCTime+failureOnset = \case+ Healthy -> Nothing+ Unhealthy _ onset -> Just onset
+ test/Admin/Main.hs view
@@ -0,0 +1,1077 @@+-- | Integration tests for @shomei-admin@ (EP-4), against throwaway PostgreSQL databases+-- provisioned by @shomei-migrations:test-support@. They drive the real CLI action functions and+-- assert database state, and — the headline — prove the signing-key rotation lifecycle with+-- overlapping-key JWKS verification.+module Main (main) where++import Control.Exception (try)+import Control.Monad (forM_, void)+import Crypto.JOSE.JWK (JWK, JWKSet (JWKSet))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteArray.Encoding (Base (Base64), convertToBase)+import Data.ByteString.Char8 qualified as BS8+import Data.IORef (newIORef, readIORef)+import Data.Int (Int64)+import Data.List (find)+import Data.Maybe (isJust)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Time (addUTCTime, getCurrentTime)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, runEff)+import Effectful.Dispatch.Dynamic (interpret_)+import Effectful.Error.Static (Error, runErrorNoCallStack)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Errors qualified as Hasql+import Hasql.Session qualified as Session+import Hasql.Statement (preparable)+import Options.Applicative (ParserResult (..), defaultPrefs, execParserPure, info)+import Shomei.Account.Password.Domain (PasswordPolicy (..), PlainPassword (..))+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), sha256Hex)+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Admin.Audit (AuditFilters (..), runAuditReader, toJsonObject, toQuery)+import Shomei.Admin.Env (AdminEnv (..))+import Shomei.Admin.Keys+ ( keysActivate,+ keysGenerate,+ keysRetire,+ keysRevoke,+ keysRewrap,+ listAllKeys,+ listPublishableSigningKeys,+ summarizeUsageError,+ )+import Shomei.Admin.OAuthClients qualified as OAuthClients+import Shomei.Admin.Roles (GrantExpiry (..), RolesCommand (..), rolesParser, runRoles)+import Shomei.Admin.ServiceAccounts (createAction, listAction, revokeAction, rotateSecretAction)+import Shomei.Admin.Sweep (SweepOptions (..), defaultSweepOptions, runSweepReport)+import Shomei.Admin.Users (createUserAction)+import Shomei.Audit.Event.Domain qualified as Event+import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)+import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)+import Shomei.Audit.Reader.Store+ ( AuditEventQuery (..),+ StoredAuthEvent (..),+ countAuthEvents,+ emptyAuditQuery,+ queryAuthEvents,+ )+import Shomei.Authorization.Claims.Domain (Audience (..), AuthClaims (..), Issuer (..), Role (..), Scope (..))+import Shomei.Config (ShomeiConfig (..), defaultShomeiConfig)+import Shomei.Error (AuthError (OAuthClientInvalid))+import Shomei.Id (genSessionId, genUserId, idText, sessionIdToUUID, userIdToUUID)+import Shomei.Migrations.TestSupport (withShomeiMigratedDatabase)+import Shomei.OAuth.Client.Domain (ClientType (..), OAuthClient (..), OAuthClientStatus (..))+import Shomei.OAuth.IdToken.Domain (IdToken (..))+import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)+import Shomei.Persistence.Maintenance.Postgres (SweepReport (..))+import Shomei.Persistence.Pool.Postgres (Pool, acquirePool)+import Shomei.Persistence.Pool.Postgres qualified as Pool++-- qualified: 'Shomei.Admin.Keys' exports a same-named listPublishableSigningKeys++import Shomei.Server.Config (defaultDbStatementTimeoutMs)+import Shomei.Server.Keys (LoadedKeys (..), bootstrapKeys, reloadKeys)+import Shomei.Server.Keys qualified as Keys+import Shomei.ServiceAccount.ClientCredentials.Workflow (ClientCredentialsGrant (..), GrantedToken (..), grantClientCredentials)+import Shomei.ServiceAccount.Domain (ServiceAccount (..))+import Shomei.ServiceAccount.Postgres (runServiceAccountStorePostgres)+import Shomei.Session.Postgres (runSessionStorePostgres)+import Shomei.Session.Token.Domain (AccessToken (..))+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256), SigningKeyStatus (..), StoredSigningKey (..))+import Shomei.SigningKey.Key.Jwt (keyKid)+import Shomei.SigningKey.Protection.Jwt+ ( KeyDecryptError (KeyDecryptFailed),+ KeyEncryptionKey,+ decryptStoredSigningKey,+ isEncryptedPrivateJwk,+ keyEncryptionKeyFromBase64,+ publicJwkFromStored,+ )+import Shomei.SigningKey.Sign.Jwt (signAccessToken)+import Shomei.SigningKey.Signer (TokenSigner (..))+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Shomei.Time.Postgres (runClockIO)+import System.Exit (ExitCode (..))+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase, (@?=))++cfg :: ShomeiConfig+cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")++-- | Cheap Argon2 parameters for tests. @users create@ hashes a real password, and the+-- production cost (~100 ms) would dominate this suite. Strength is irrelevant here.+testArgon2Params :: Argon2Params+testArgon2Params = Argon2Params {memoryKiB = 8192, iterations = 1, parallelism = 1}++withDb :: (Pool -> Text -> IO a) -> IO a+withDb action = withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ action pool connStr++scalarInt :: Pool -> Text -> IO Int+scalarInt pool sql = do+ res <- Pool.use pool (Session.statement () stmt)+ either (\e -> assertFailure ("scalar query failed: " <> show e)) pure res+ where+ stmt = preparable sql E.noParams (D.singleRow (fromI <$> D.column (D.nonNullable D.int8)))+ fromI :: Int64 -> Int+ fromI = fromIntegral++-- | Run a (possibly multi-statement) SQL script directly against the pool, for seeding.+execSql :: Pool -> Text -> IO ()+execSql pool sql = do+ res <- Pool.use pool (Session.script sql)+ either (\e -> assertFailure ("seed script failed: " <> show e)) pure res++execSqlExpectState :: Pool -> Text -> Text -> IO ()+execSqlExpectState pool expected sql = do+ res <- Pool.use pool (Session.script sql)+ case res of+ Left err | sqlState err == Just expected -> pure ()+ Left err -> assertFailure ("expected SQLSTATE " <> Text.unpack expected <> ", got " <> show err)+ Right () -> assertFailure ("expected SQLSTATE " <> Text.unpack expected <> ", but SQL succeeded")+ where+ sqlState = \case+ Pool.SessionUsageError (Hasql.ScriptSessionError _ (Hasql.ServerError code _ _ _ _)) -> Just code+ Pool.SessionUsageError+ ( Hasql.StatementSessionError+ _+ _+ _+ _+ _+ (Hasql.ServerStatementError (Hasql.ServerError code _ _ _ _))+ ) -> Just code+ _ -> Nothing++-- | Run a CLI action that is expected to abort. The action functions call 'exitFailure' on a+-- user error, which throws 'ExitCode' — catching it is how a test observes a nonzero exit.+expectExitFailure :: String -> IO () -> IO ()+expectExitFailure what action = do+ result <- try @ExitCode action+ case result of+ Left (ExitFailure _) -> pure ()+ Left ExitSuccess -> assertFailure (what <> ": expected a nonzero exit, got success")+ Right () -> assertFailure (what <> ": expected the command to abort")++main :: IO ()+main =+ defaultMain+ ( testGroup+ "shomei-admin"+ [ testMigrateEmpty,+ testLifecycleOverlap,+ testReloadPicksUpRotation,+ testReloadKeepsLastGoodMaterial,+ testGeneratesEncryptedWithKek,+ testJwksNeedsNoKek,+ testRewrapRotatesTheKek,+ testRewrapWithWrongOldKekModifiesNothing,+ testKeyWriteRollback,+ testMultipleActiveFailClosed,+ testUsageErrorSummary,+ testUserCreate,+ testUserCreateEmailVerified,+ testUserCreateAppliesPasswordPolicy,+ testAuditQuery,+ testSweepCommand,+ testRolesLifecycle,+ testRolesGrantOfUndefinedRoleFails,+ testRolesGrantToUnknownUserFails,+ testRolesPermissionWiring,+ testRolesGrantWithExpiry,+ testRolesGrantBothExpiryFlagsFailsToParse,+ testUserCreateAppliesDefaultRoles,+ testUserCreateRefusesUndefinedDefaultRoles,+ testServiceAccountsLifecycle,+ testServiceAccountsPrivilegeScopeAllowed,+ testServiceAccountsRotateAndRevoke,+ testServiceAccountsUnknownClientIdFails,+ testOAuthClientsLifecycle,+ testOAuthClientsPublicHasNoSecret,+ testOAuthClientsPrivilegeScopeFails,+ testOAuthClientsRevokeUnknownFails+ ]+ )++-- Encryption at rest ---------------------------------------------------------++-- | With a KEK configured, a fresh deployment never writes a plaintext private key — and+-- the key it wrote still signs tokens that verify.+testGeneratesEncryptedWithKek :: TestTree+testGeneratesEncryptedWithKek = testCase "first boot with a KEK stores encrypted private material" $ withDb \pool _ -> do+ kek <- testKek 'a'+ keys <- bootstrapKeys kek ES256 pool+ priv <- storedPrivate pool+ assertBool ("expected an enc:v1: prefix, got " <> Text.unpack (Text.take 12 priv)) (isEncryptedPrivateJwk priv)+ token <- signWith keys.signingKey+ v <- verifyToken keys.verifierJwks cfg token+ assertBool "a token signed by the decrypted key verifies" (isRight v)++-- | Publication is independent of the KEK: the JWKS and the verifier key set come from the+-- public column, so KEK rotation can never break verification of live tokens.+testJwksNeedsNoKek :: TestTree+testJwksNeedsNoKek = testCase "JWKS and verification need no KEK, even for encrypted keys" $ withDb \pool _ -> do+ kek <- testKek 'a'+ keys <- bootstrapKeys kek ES256 pool+ token <- signWith keys.signingKey+ row <- onlyKey pool+ assertBool "the row is encrypted" (isEncryptedPrivateJwk row.privateKeyJwk)+ pub <- liftEither (publicJwkFromStored row)+ v <- verifyToken (JWKSet [pub]) cfg token+ assertBool "a token verifies against public material recovered without the KEK" (isRight v)++-- | Rotating the KEK: after a rewrap the old KEK no longer opens the row and the new one+-- does, while the public material — and therefore every outstanding token — is untouched.+testRewrapRotatesTheKek :: TestTree+testRewrapRotatesTheKek = testCase "keys rewrap moves rows from the old KEK to the new one" $ withDb \pool _ -> do+ oldKek <- testKek 'a'+ newKek <- testKek 'b'+ keys <- bootstrapKeys oldKek ES256 pool+ publicBefore <- storedPublic pool+ token <- signWith keys.signingKey++ keysRewrap oldKek newKek pool++ row <- onlyKey pool+ assertBool "still encrypted" (isEncryptedPrivateJwk row.privateKeyJwk)+ case decryptStoredSigningKey oldKek row of+ Left KeyDecryptFailed -> pure ()+ Left e -> assertFailure ("expected KeyDecryptFailed from the old KEK, got " <> show e)+ Right _ -> assertFailure "the old KEK must no longer decrypt the row"+ jwk <- either (assertFailure . show) pure (decryptStoredSigningKey newKek row)++ storedPublic pool >>= (@?= publicBefore)+ pub <- liftEither (publicJwkFromStored row)+ v <- verifyToken (JWKSet [pub]) cfg token+ assertBool "the pre-rewrap token still verifies" (isRight v)+ newToken <- signWith jwk+ v2 <- verifyToken (JWKSet [pub]) cfg newToken+ assertBool "the rewrapped key still signs" (isRight v2)++-- | A wrong old KEK must abort before the first write: a half-rewrapped table would be+-- readable by neither KEK.+testRewrapWithWrongOldKekModifiesNothing :: TestTree+testRewrapWithWrongOldKekModifiesNothing = testCase "keys rewrap with a wrong old KEK modifies no rows" $ withDb \pool _ -> do+ realKek <- testKek 'a'+ wrongKek <- testKek 'c'+ newKek <- testKek 'b'+ _ <- bootstrapKeys realKek ES256 pool+ before <- storedPrivate pool++ -- 'keysRewrap' aborts via exitFailure, which surfaces here as an ExitCode exception.+ outcome <- try (keysRewrap wrongKek newKek pool)+ case outcome of+ Left (_ :: ExitCode) -> pure ()+ Right () -> assertFailure "rewrap with a wrong old KEK must abort"+ after <- storedPrivate pool+ after @?= before+ row <- onlyKey pool+ case decryptStoredSigningKey realKek row of+ Right _ -> pure ()+ Left e -> assertFailure ("the original KEK must still work after an aborted rewrap: " <> show e)++testMigrateEmpty :: TestTree+testMigrateEmpty = testCase "after migration the keys table exists and is empty" $ withDb \pool _ -> do+ keys <- listAllKeys pool+ keys @?= []+ n <- scalarInt pool "SELECT count(*) FROM shomei.shomei_signing_keys"+ n @?= 0++testLifecycleOverlap :: TestTree+testLifecycleOverlap = testCase "generate→activate→(generate→activate auto-retires)→overlap verifies→revoke breaks it" $ withDb \pool _ -> do+ kek <- testKek 'a'+ -- First key: generate then activate.+ keysGenerate kek ES256 pool+ kid1 <- onlyPendingKid pool+ keysActivate pool kid1+ -- Second key: generate then activate; this auto-retires kid1.+ keysGenerate kek ES256 pool+ kid2 <- onlyPendingKid pool+ keysActivate pool kid2++ publishable <- listPublishableSigningKeys pool+ Set.fromList (map (.keyId) publishable) @?= Set.fromList [kid1, kid2]+ statusOf pool kid1 >>= (@?= KeyRetired)+ statusOf pool kid2 >>= (@?= KeyActive)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_signing_keys WHERE status = 'active'" >>= (@?= 1)+ oldAfterActivation <- requireKey pool kid1+ newAfterActivation <- requireKey pool kid2+ assertBool "auto-retirement stamps retired_at" (isJust oldAfterActivation.retiredAt)+ assertBool "activation stamps activated_at" (isJust newAfterActivation.activatedAt)++ -- The database invariant is independent of the CLI: a hand-written second activation+ -- is rejected by the partial unique index and leaves the one active row unchanged.+ execSqlExpectState+ pool+ "23505"+ ("UPDATE shomei.shomei_signing_keys SET status = 'active' WHERE key_id = '" <> kid1 <> "'")+ scalarInt pool "SELECT count(*) FROM shomei.shomei_signing_keys WHERE status = 'active'" >>= (@?= 1)++ -- A token signed by the now-RETIRED kid1 still verifies against the published JWKS.+ retired <- requireKey pool kid1+ jwk1 <- either (assertFailure . show) pure (decryptStoredSigningKey kek retired)+ jwkset <- buildJwks publishable+ token <- signWith jwk1+ v1 <- verifyToken jwkset cfg token+ assertBool "retired-key token verifies during overlap" (isRight v1)++ -- Revoke kid1: it leaves the JWKS and its token stops verifying.+ keysRevoke pool kid1+ revoked <- requireKey pool kid1+ assertBool "revocation stamps revoked_at" (isJust revoked.revokedAt)+ publishable2 <- listPublishableSigningKeys pool+ map (.keyId) publishable2 @?= [kid2]+ jwkset2 <- buildJwks publishable2+ v2 <- verifyToken jwkset2 cfg token+ assertBool "revoked-key token no longer verifies" (not (isRight v2))++-- | The hot-reload contract: after @keys generate@ + @keys activate@ on a *running*+-- server, one 'reloadKeys' makes the new key the signer while the auto-retired one stays+-- published — so tokens minted a moment ago keep verifying. Before this plan the running+-- server never saw the rotation at all.+testReloadPicksUpRotation :: TestTree+testReloadPicksUpRotation = testCase "reloadKeys picks up an activation: new signer, both keys published" $ withDb \pool _ -> do+ kek <- testKek 'a'+ ref <- newIORef =<< bootstrapKeys kek ES256 pool+ kid1 <- signerKid <$> readIORef ref++ keysGenerate kek ES256 pool+ kid2 <- onlyPendingKid pool+ keysActivate pool kid2 -- auto-retires kid1+ reloadKeys kek pool ref+ reloaded <- readIORef ref+ signerKid reloaded @?= kid2+ Set.fromList (Keys.publishedKids reloaded) @?= Set.fromList [kid1, kid2]++ -- The retired key is still trusted by the reloaded verifier, not merely published.+ retired <- requireKey pool kid1+ token <- signWith =<< either (assertFailure . show) pure (decryptStoredSigningKey kek retired)+ v <- verifyToken reloaded.verifierJwks cfg token+ assertBool "retired-key token verifies against the reloaded key set" (isRight v)++-- | A failed reload must never degrade a running server below its last good state: it+-- keeps signing and verifying with the material it already holds. Retiring the only active+-- key is the operator mistake that produces this (there is then nothing to sign with).+testReloadKeepsLastGoodMaterial :: TestTree+testReloadKeepsLastGoodMaterial = testCase "a failed reload keeps the previous key material" $ withDb \pool _ -> do+ kek <- testKek 'a'+ ref <- newIORef =<< bootstrapKeys kek ES256 pool+ before <- readIORef ref++ keysRetire pool (signerKid before)++ reloadKeys kek pool ref+ after <- readIORef ref+ signerKid after @?= signerKid before+ Keys.publishedKids after @?= Keys.publishedKids before++ -- and the stale-but-good material still verifies a token it signs+ token <- signWith after.signingKey+ v <- verifyToken after.verifierJwks cfg token+ assertBool "server still signs and verifies after a failed reload" (isRight v)++-- Sweep ----------------------------------------------------------------------++-- | @shomei-admin sweep@ against a seeded database: the CLI trigger must delete exactly what+-- the server's background sweeper would, and audit events must survive unless the operator+-- explicitly passes @--auth-event-retention-days@.+testSweepCommand :: TestTree+testSweepCommand = testCase "sweep deletes dead rows; audit events need an explicit window" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ execSql+ pool+ """+ INSERT INTO shomei.shomei_users (user_id, email, display_name, status, created_at, updated_at, login_id) VALUES+ ('11111111-1111-1111-1111-111111111111', 'sweep@example.com', 'Sweep', 'active', now(), now(), 'sweep@example.com');++ -- One session expired 40 days ago carrying a two-generation rotation family, one live.+ INSERT INTO shomei.shomei_sessions (session_id, user_id, status, created_at, expires_at, revoked_at) VALUES+ ('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'active', now() - interval '60 days', now() - interval '40 days', NULL),+ ('aaaaaaaa-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'active', now(), now() + interval '30 days', NULL);++ INSERT INTO shomei.shomei_refresh_tokens+ (refresh_token_id, session_id, token_hash, parent_token_id, status, created_at, expires_at, used_at, revoked_at) VALUES+ ('bbbbbbbb-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000001', 'h1', NULL, 'used', now(), now() - interval '40 days', now(), NULL),+ ('bbbbbbbb-0000-0000-0000-000000000002', 'aaaaaaaa-0000-0000-0000-000000000001', 'h2', 'bbbbbbbb-0000-0000-0000-000000000001', 'active', now(), now() - interval '40 days', NULL, NULL),+ ('bbbbbbbb-0000-0000-0000-000000000003', 'aaaaaaaa-0000-0000-0000-000000000002', 'h3', NULL, 'active', now(), now() + interval '30 days', NULL, NULL);++ INSERT INTO shomei.shomei_webauthn_pending_ceremonies (ceremony_id, user_id, kind, options_blob, created_at, expires_at) VALUES+ ('ffffffff-0000-0000-0000-000000000001', NULL, 'authentication', '\\x00'::bytea, now() - interval '3 hours', now() - interval '2 hours');++ INSERT INTO shomei.shomei_login_attempts (attempt_id, account_key, client_ip, outcome, occurred_at) VALUES+ ('99999999-0000-0000-0000-000000000001', 'acct', '10.0.0.1', 'failure', now() - interval '100 days');++ INSERT INTO shomei.shomei_auth_events (event_id, user_id, session_id, event_type, payload, created_at) VALUES+ ('88888888-0000-0000-0000-000000000001', NULL, NULL, 'login_succeeded', '{}'::jsonb, now() - interval '400 days');+ """++ report <- runSweepReport env defaultSweepOptions >>= expectSweep+ report.refreshTokensDeleted @?= 2+ report.sessionsDeleted @?= 1+ report.ceremoniesDeleted @?= 1+ report.loginAttemptsDeleted @?= 1+ -- Retention is off by default, so the 400-day-old audit event survives.+ report.authEventsDeleted @?= 0++ scalarInt pool "SELECT count(*) FROM shomei.shomei_sessions" >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_refresh_tokens" >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events" >>= (@?= 1)++ -- Asking for a window deletes it; a second run finds nothing left.+ let retaining = defaultSweepOptions {optAuthEventRetentionDays = Just 365}+ withWindow <- runSweepReport env retaining >>= expectSweep+ withWindow.authEventsDeleted @?= 1+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events" >>= (@?= 0)++ again <- runSweepReport env retaining >>= expectSweep+ again.authEventsDeleted @?= 0+ where+ expectSweep = either (\e -> assertFailure ("sweep failed: " <> show e)) pure++testUserCreate :: TestTree+testUserCreate = testCase "users create persists a user + credential whose hash verifies" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") (Just "Alice") False+ users <- scalarInt pool "SELECT count(*) FROM shomei.shomei_users WHERE email = 'alice@example.com'"+ creds <- scalarInt pool "SELECT count(*) FROM shomei.shomei_password_credentials"+ users @?= 1+ creds @?= 1++testUserCreateEmailVerified :: TestTree+testUserCreateEmailVerified = testCase "users create --email-verified stamps email_verified_at" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ createUserAction env "verified@example.com" (PlainPassword "correct horse battery staple") Nothing True+ verified <- scalarInt pool "SELECT count(*) FROM shomei.shomei_users WHERE email = 'verified@example.com' AND email_verified_at IS NOT NULL"+ verified @?= 1++testUserCreateAppliesPasswordPolicy :: TestTree+testUserCreateAppliesPasswordPolicy = testCase "users create applies config.passwordPolicy" $ withDb \pool connStr -> do+ let policy = cfg.passwordPolicy {minLength = 40}+ env = AdminEnv {config = cfg {passwordPolicy = policy}, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ expectExitFailure+ "users create with a password rejected by the deployment policy"+ (createUserAction env "policy@example.com" (PlainPassword "correct horse battery staple") Nothing False)+ users <- scalarInt pool "SELECT count(*) FROM shomei.shomei_users WHERE email = 'policy@example.com'"+ users @?= 0++testUsageErrorSummary :: TestTree+testUsageErrorSummary = testCase "database errors retain only category and SQLSTATE" do+ summarizeUsageError (Pool.ConnectionUsageError (Hasql.NetworkingConnectionError "password=secret"))+ @?= "could not connect to PostgreSQL"+ summarizeUsageError Pool.AcquisitionTimeoutUsageError+ @?= "timed out acquiring a connection"+ let detailed =+ Pool.SessionUsageError+ ( Hasql.StatementSessionError+ 1+ 0+ "INSERT INTO keys VALUES ($1) -- secret sql"+ ["encrypted-key-envelope"]+ True+ (Hasql.ServerStatementError (Hasql.ServerError "23505" "secret primary message" Nothing Nothing Nothing))+ )+ summary = summarizeUsageError detailed+ summary @?= "statement failed (SQLSTATE 23505)"+ assertBool "SQL and parameters are absent" (not ("secret" `Text.isInfixOf` summary) && not ("envelope" `Text.isInfixOf` summary))++-- Roles ----------------------------------------------------------------------++-- | The bootstrap path an operator actually runs: declare a role, grant it, read it back,+-- revoke it. Definitions publish no audit event; the grant and the revoke each publish one.+testRolesLifecycle :: TestTree+testRolesLifecycle =+ testCase "roles define/list-defined/grant/list/revoke round-trips and audits the grants" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") Nothing False+ uid <- scalarText pool "SELECT user_id::text FROM shomei.shomei_users WHERE email = 'alice@example.com'"++ -- The registry starts with the migration's seeded 'admin'.+ runRoles env RolesListDefined+ seeded <- scalarInt pool "SELECT count(*) FROM shomei.shomei_roles"+ seeded @?= 1++ runRoles env (RolesDefine "auditor" (Just "read the audit trail"))+ defined <- scalarInt pool "SELECT count(*) FROM shomei.shomei_roles"+ defined @?= 2+ -- Re-defining is idempotent and does not overwrite the description.+ runRoles env (RolesDefine "auditor" (Just "something else"))+ stillTwo <- scalarInt pool "SELECT count(*) FROM shomei.shomei_roles"+ stillTwo @?= 2+ desc <- scalarText pool "SELECT description FROM shomei.shomei_roles WHERE role = 'auditor'"+ desc @?= "read the audit trail"++ -- The CLI accepts a bare UUID as well as the typed id.+ runRoles env (RolesGrant uid "auditor" Nothing)+ granted <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants WHERE role = 'auditor'"+ granted @?= 1+ -- A CLI grant records no acting admin.+ noActor <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants WHERE granted_by IS NULL"+ noActor @?= 1+ -- Re-granting changes nothing and publishes nothing.+ runRoles env (RolesGrant uid "auditor" Nothing)+ stillOne <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants"+ stillOne @?= 1++ runRoles env (RolesList uid)+ runRoles env (RolesRevoke uid "auditor")+ afterRevoke <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants"+ afterRevoke @?= 0+ -- Revoking a grant that is not there is a no-op, not an error, and publishes nothing.+ runRoles env (RolesRevoke uid "auditor")++ -- Exactly one grant and one revoke landed in the audit trail; the two definitions and the+ -- repeated grant/revoke added nothing.+ grantEvents <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'role_granted'"+ revokeEvents <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'role_revoked'"+ grantEvents @?= 1+ revokeEvents @?= 1++-- | @users create@ drives the same 'Shomei.Session.Authentication.Workflow.signup' the HTTP route does, so a+-- CLI-created user must receive the configured default roles — audited, with no acting admin.+-- (Regression: 'Shomei.Admin.Env.loadAdminEnv' builds its own 'ShomeiConfig' rather than running+-- the server's loader, so it once ignored @SHOMEI_DEFAULT_ROLES@ entirely.)+testUserCreateAppliesDefaultRoles :: TestTree+testUserCreateAppliesDefaultRoles =+ testCase "users create applies config.defaultRoles, audited with no actor" $ withDb \pool connStr -> do+ let cfgWithDefaults = cfg {defaultRoles = Set.singleton (Role "member")}+ env = AdminEnv {config = cfgWithDefaults, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ registryEnv = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ runRoles registryEnv (RolesDefine "member" Nothing)+ createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") Nothing False+ granted <-+ scalarInt+ pool+ """+ SELECT count(*) FROM shomei.shomei_role_grants g+ JOIN shomei.shomei_users u USING (user_id)+ WHERE u.email = 'alice@example.com' AND g.role = 'member' AND g.granted_by IS NULL+ """+ granted @?= 1+ events <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'role_granted'"+ events @?= 1++-- | The CLI has no boot-time validation, so it checks the registry itself: a default role that+-- was never defined aborts before any row is written, rather than surfacing a raw FK violation.+testUserCreateRefusesUndefinedDefaultRoles :: TestTree+testUserCreateRefusesUndefinedDefaultRoles =+ testCase "users create refuses an undefined default role and writes nothing" $ withDb \pool connStr -> do+ let env =+ AdminEnv+ { config = cfg {defaultRoles = Set.singleton (Role "nosuchrole")},+ pool = pool,+ connStr = connStr,+ argon2 = testArgon2Params+ }+ expectExitFailure+ "users create with an undefined default role"+ (createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") Nothing False)+ users <- scalarInt pool "SELECT count(*) FROM shomei.shomei_users"+ users @?= 0++-- | The typo guard: @roles grant --role adminn@ must fail loudly rather than mint a role no+-- gate will ever check. This is the failure mode the role registry exists to close.+testRolesGrantOfUndefinedRoleFails :: TestTree+testRolesGrantOfUndefinedRoleFails =+ testCase "roles grant of an undefined role exits nonzero and writes nothing" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") Nothing False+ uid <- scalarText pool "SELECT user_id::text FROM shomei.shomei_users WHERE email = 'alice@example.com'"+ expectExitFailure "grant of an undefined role" (runRoles env (RolesGrant uid "adminn" Nothing))+ grants <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants"+ grants @?= 0++-- | A grant naming a user that does not exist fails before touching the grant table.+testRolesGrantToUnknownUserFails :: TestTree+testRolesGrantToUnknownUserFails =+ testCase "roles grant to an unknown user exits nonzero and writes nothing" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ expectExitFailure+ "grant to an unknown user"+ (runRoles env (RolesGrant "11111111-1111-1111-1111-111111111111" "admin" Nothing))+ grants <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_grants"+ grants @?= 0++-- | EP-9 permission wiring over the CLI: allow attaches a row, show renders (smoke), disallow+-- removes it, and allow on an undefined role exits nonzero without writing.+testRolesPermissionWiring :: TestTree+testRolesPermissionWiring =+ testCase "roles allow/show/disallow round-trip; allow on an undefined role exits nonzero" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ runRoles env (RolesDefine "support" (Just "support staff"))+ runRoles env (RolesAllow "support" "tickets:write")+ afterAllow <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_permissions WHERE role = 'support' AND permission = 'tickets:write'"+ afterAllow @?= 1+ -- Idempotent: a second allow adds no row.+ runRoles env (RolesAllow "support" "tickets:write")+ stillOne <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_permissions WHERE role = 'support'"+ stillOne @?= 1+ -- show is a read; assert it runs without error (its output goes to stdout).+ runRoles env (RolesShow "support")+ runRoles env (RolesDisallow "support" "tickets:write")+ afterDisallow <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_permissions WHERE role = 'support'"+ afterDisallow @?= 0+ -- Wiring publishes no audit event.+ permEvents <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type IN ('permission_allowed','permission_disallowed')"+ permEvents @?= 0+ -- Allow on an undefined role is a loud failure that writes nothing.+ expectExitFailure "allow on an undefined role" (runRoles env (RolesAllow "nosuchrole" "x:y"))+ none <- scalarInt pool "SELECT count(*) FROM shomei.shomei_role_permissions WHERE role = 'nosuchrole'"+ none @?= 0++-- | @roles grant --expires-in 1h@ lands a grant whose @expires_at@ is about an hour out, and the+-- @role_granted@ audit payload carries that same expiry (passive expiry: the audit trail is the+-- only record of the window).+testRolesGrantWithExpiry :: TestTree+testRolesGrantWithExpiry =+ testCase "roles grant --expires-in stamps the grant row and the audit payload with the window" $ withDb \pool connStr -> do+ let env = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}+ runRoles env (RolesDefine "support" (Just "support staff"))+ createUserAction env "alice@example.com" (PlainPassword "correct horse battery staple") Nothing False+ uid <- scalarText pool "SELECT user_id::text FROM shomei.shomei_users WHERE email = 'alice@example.com'"+ runRoles env (RolesGrant uid "support" (Just (ExpiresIn 3600)))+ -- The grant row's expiry is ~1h out (allow a wide window for clock/setup slack).+ secs <- scalarInt pool "SELECT EXTRACT(EPOCH FROM (expires_at - now()))::bigint FROM shomei.shomei_role_grants WHERE role = 'support'"+ assertBool ("expected ~3600s out, got " <> show secs) (secs > 3000 && secs <= 3600)+ -- The audit payload carries an expiresAt (non-null); pre-EP-9 grants carried none.+ withExpiry <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'role_granted' AND payload ? 'expiresAt' AND payload->>'expiresAt' IS NOT NULL"+ withExpiry @?= 1++-- | Parser-level: @--expires-in@ and @--expires-at@ are mutually exclusive, so supplying both is a+-- parse failure rather than a silently-ignored flag.+testRolesGrantBothExpiryFlagsFailsToParse :: TestTree+testRolesGrantBothExpiryFlagsFailsToParse =+ testCase "roles grant with both --expires-in and --expires-at fails to parse" $+ case execParserPure defaultPrefs (info rolesParser mempty) argv of+ Success _ -> assertFailure "expected supplying both expiry flags to fail parsing"+ _ -> pure ()+ where+ argv =+ [ "grant",+ "--user",+ "user_01ABC",+ "--role",+ "support",+ "--expires-in",+ "4h",+ "--expires-at",+ "2026-07-11T21:00:00Z"+ ]++testAuditQuery :: TestTree+testAuditQuery = testCase "audit reader returns published events; type filter + count work" $ withDb \pool _ -> do+ uid <- genUserId+ sid <- genSessionId+ let filters = AuditFilters Nothing Nothing [] Nothing Nothing 50 True+ typed <- toQuery filters {fUser = Just (idText uid), fSession = Just (idText sid)}+ legacy <- toQuery filters {fUser = Just (UUID.toText (userIdToUUID uid)), fSession = Just (UUID.toText (sessionIdToUUID sid))}+ typed.queryUserId @?= legacy.queryUserId+ typed.querySessionId @?= legacy.querySessionId+ typed.queryUserId @?= Just (userIdToUUID uid)+ typed.querySessionId @?= Just (sessionIdToUUID sid)++ t <- getCurrentTime+ -- Seed two events through the real PostgreSQL publisher.+ okR+ =<< runPublish+ pool+ ( do+ publishAuthEvent (Event.LoginFailed (Event.LoginFailedData Nothing Nothing t))+ publishAuthEvent (Event.LoginSucceeded (Event.LoginSucceededData uid sid (addUTCTime 1 t)))+ )+ -- Read them back through the CLI's reader stack (newest-first).+ allEvents <- okR =<< runAuditReader pool (queryAuthEvents emptyAuditQuery)+ map (.storedEventType) allEvents @?= ["login_succeeded", "login_failed"]+ filtered <- okR =<< runAuditReader pool (queryAuthEvents typed)+ length filtered @?= 1+ case map toJsonObject filtered of+ [Aeson.Object row] -> do+ KeyMap.lookup "userId" row @?= Just (Aeson.String (idText uid))+ KeyMap.lookup "sessionId" row @?= Just (Aeson.String (idText sid))+ _ -> assertFailure "expected one canonical audit object"+ -- Type filter narrows to the failed login only.+ failed <- okR =<< runAuditReader pool (queryAuthEvents emptyAuditQuery {queryEventTypes = ["login_failed"]})+ map (.storedEventType) failed @?= ["login_failed"]+ -- Count matches the total.+ n <- okR =<< runAuditReader pool (countAuthEvents emptyAuditQuery)+ n @?= 2++-- | Run the audit-event publisher over the pool (test-only seeding helper).+runPublish ::+ Pool ->+ Eff '[AuthEventPublisher, Database, Error AuthError, IOE] a ->+ IO (Either AuthError a)+runPublish pool =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runAuthEventPublisherPostgres++okR :: (Show e) => Either e a -> IO a+okR = either (assertFailure . show) pure++-- Helpers --------------------------------------------------------------------++onlyPendingKid :: Pool -> IO Text+onlyPendingKid pool = do+ keys <- listAllKeys pool+ case filter (\k -> k.status == KeyPending) keys of+ [k] -> pure k.keyId+ ks -> assertFailure ("expected exactly one pending key, got " <> show (length ks))++requireKey :: Pool -> Text -> IO StoredSigningKey+requireKey pool kid = do+ keys <- listAllKeys pool+ maybe (assertFailure ("no key " <> show kid)) pure (find (\k -> k.keyId == kid) keys)++statusOf :: Pool -> Text -> IO SigningKeyStatus+statusOf pool kid = (.status) <$> requireKey pool kid++-- | The @kid@ of the key that currently signs.+signerKid :: Keys.LoadedKeys -> Text+signerKid ks = keyKid ks.signingKey++-- | A deterministic 32-byte KEK, distinct per character.+testKek :: Char -> IO KeyEncryptionKey+testKek c =+ either+ (assertFailure . Text.unpack)+ pure+ (keyEncryptionKeyFromBase64 (TE.decodeUtf8 (convertToBase Base64 (BS8.replicate 32 c))))++-- | The exactly-one key row the encryption tests seed.+onlyKey :: Pool -> IO StoredSigningKey+onlyKey pool =+ listAllKeys pool >>= \case+ [k] -> pure k+ ks -> assertFailure ("expected exactly one signing key, got " <> show (length ks))++-- | Read the raw column, bypassing every conversion — the tests assert on stored bytes.+storedPrivate :: Pool -> IO Text+storedPrivate pool = scalarText pool "SELECT private_key_jwk FROM shomei.shomei_signing_keys"++storedPublic :: Pool -> IO Text+storedPublic pool = scalarText pool "SELECT public_key_jwk FROM shomei.shomei_signing_keys"++scalarText :: Pool -> Text -> IO Text+scalarText pool sql = do+ res <- Pool.use pool (Session.statement () stmt)+ either (\e -> assertFailure ("scalar query failed: " <> show e)) pure res+ where+ stmt = preparable sql E.noParams (D.singleRow (D.column (D.nonNullable D.text)))++buildJwks :: [StoredSigningKey] -> IO JWKSet+buildJwks stored = do+ jwks <- traverse (liftEither . publicJwkFromStored) stored+ pure (JWKSet jwks)++signWith :: JWK -> IO Text+signWith jwk = do+ uid <- genUserId+ sid <- genSessionId+ t <- getCurrentTime+ let claims =+ AuthClaims+ { subject = uid,+ sessionId = sid,+ issuer = cfg.issuer,+ audience = cfg.audience,+ issuedAt = t,+ expiresAt = addUTCTime 900 t,+ authTime = t,+ scopes = Set.empty,+ roles = Set.empty,+ permissions = Set.empty,+ actor = Nothing,+ extraClaims = mempty+ }+ r <- signAccessToken jwk claims+ case r of+ Right (AccessToken tok) -> pure tok+ Left e -> assertFailure ("signing failed: " <> show e)++liftEither :: (Show e) => Either e a -> IO a+liftEither = either (assertFailure . show) pure++isRight :: Either a b -> Bool+isRight = either (const False) (const True)++-- Service accounts (MasterPlan 7 EP-4) --------------------------------------++-- | These drive the CLI's *action* functions rather than 'runServiceAccounts', because the+-- generated secret is printed exactly once and never re-readable. Capturing the process's stdout+-- to recover it is not an option: tasty runs these cases in parallel, and @hDuplicateTo@ on the+-- global 'stdout' is process-wide, so two capturing cases interleave and read each other's+-- output. (Observed: the same suite reporting "All 22 tests passed" on one run and+-- "2 out of 22 tests failed" on the next.) The actions return what they did; the printing+-- wrapper is a one-liner over them.+adminEnv :: Pool -> Text -> AdminEnv+adminEnv pool connStr = AdminEnv {config = cfg, pool = pool, connStr = connStr, argon2 = testArgon2Params}++saClientId' :: ServiceAccount -> Text+saClientId' ServiceAccount {clientId} = clientId++-- | Run the real @client_credentials@ workflow against PostgreSQL, exactly as @\/oauth\/token@+-- does, with a fake signer (this suite is not testing JWT signing).+runGrant ::+ Pool ->+ ClientCredentialsGrant ->+ IO (Either AuthError (Either AuthError GrantedToken))+runGrant pool grant =+ runEff+ . runErrorNoCallStack+ . runDatabasePool pool+ . runClockIO+ . runAuthEventPublisherPostgres+ . runTokenSignerFakeAdmin+ . runSessionStorePostgres+ . runServiceAccountStorePostgres+ . runUserStorePostgres+ $ grantClientCredentials cfg grant++runTokenSignerFakeAdmin :: Eff (TokenSigner : es) a -> Eff es a+runTokenSignerFakeAdmin = interpret_ \case+ SignAccessToken _ -> pure (AccessToken "admin-test-token")+ SignIdToken _ -> pure (IdToken "admin-test-id-token")++grantWith :: Pool -> Text -> Text -> Maybe (Set Scope) -> IO (Either AuthError GrantedToken)+grantWith pool cid secret scopes = do+ outcome <- runGrant pool ClientCredentialsGrant {clientId = cid, clientSecret = secret, requestedScopes = scopes}+ either (\e -> assertFailure ("infrastructure error: " <> show e)) pure outcome++-- | create → the account exists, is active, owns a backing user row, and the secret it returned+-- authenticates through the real grant workflow. Only the digest is stored.+testServiceAccountsLifecycle :: TestTree+testServiceAccountsLifecycle =+ testCase "service-accounts create: yields a working secret, stores only its digest" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ (account, secret) <- createAction env "rei connector" ["kawa:ingest", "signal:raise"]+ let cid = saClientId' account++ n <- scalarInt pool "SELECT count(*) FROM shomei.shomei_service_accounts WHERE status = 'active'"+ n @?= 1+ backing <-+ scalarInt+ pool+ ("SELECT count(*) FROM shomei.shomei_users u JOIN shomei.shomei_service_accounts s ON s.user_id = u.user_id WHERE u.login_id = '" <> cid <> "'")+ assertEqual "the account owns a backing user row keyed by its client id" 1 backing++ -- The plaintext secret is nowhere in the database; only its SHA-256 hex digest is.+ plaintext <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_service_accounts WHERE secret_hash = '" <> secret <> "'")+ assertEqual "the plaintext secret is never stored" 0 plaintext+ digest <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_service_accounts WHERE secret_hash = '" <> sha256Hex secret <> "'")+ assertEqual "the stored hash is sha256Hex of the returned secret" 1 digest++ -- And it authenticates: an omitted scope grants the whole allow-list.+ granted <- grantWith pool cid secret Nothing+ case granted of+ Left e -> assertFailure ("expected the generated secret to authenticate, got " <> show e)+ Right g -> g.grantedScopes @?= Set.fromList [Scope "kawa:ingest", Scope "signal:raise"]++ -- A wrong secret does not.+ wrong <- grantWith pool cid "not-the-secret" Nothing+ fmap (const ()) wrong @?= Left OAuthClientInvalid++ created <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'service_account_created'"+ created @?= 1+ withUser <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'service_account_created' AND user_id IS NOT NULL"+ assertEqual "the lifecycle event files under the backing user" 1 withUser++testServiceAccountsPrivilegeScopeAllowed :: TestTree+testServiceAccountsPrivilegeScopeAllowed =+ testCase "service-accounts create allows a privilege scope as the account's own authority" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ (account, secret) <- createAction env "support operator" ["impersonate:user"]+ granted <- grantWith pool (saClientId' account) secret Nothing+ case granted of+ Left err -> assertFailure ("the intended service-account holder was refused: " <> show err)+ Right token -> token.grantedScopes @?= Set.singleton (Scope "impersonate:user")++-- | rotate-secret invalidates the old secret immediately (single-secret model, no overlap);+-- revoke refuses every subsequent token while keeping the row.+testServiceAccountsRotateAndRevoke :: TestTree+testServiceAccountsRotateAndRevoke =+ testCase "service-accounts rotate-secret then revoke: old secret dies at once, revoked account refuses all" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ (account, oldSecret) <- createAction env "rei connector" ["kawa:ingest"]+ let cid = saClientId' account++ (_, newSecret) <- rotateSecretAction env cid+ assertBool "rotation produces a different secret" (newSecret /= oldSecret)++ old <- grantWith pool cid oldSecret Nothing+ fmap (const ()) old @?= Left OAuthClientInvalid+ new <- grantWith pool cid newSecret Nothing+ case new of+ Left e -> assertFailure ("expected the rotated secret to authenticate, got " <> show e)+ Right g -> g.grantedScopes @?= Set.singleton (Scope "kawa:ingest")++ -- rotated_at is stamped, status is untouched.+ stillActive <- scalarInt pool "SELECT count(*) FROM shomei.shomei_service_accounts WHERE status = 'active' AND rotated_at IS NOT NULL"+ stillActive @?= 1++ _ <- revokeAction env cid+ revoked <- grantWith pool cid newSecret Nothing+ fmap (const ()) revoked @?= Left OAuthClientInvalid++ -- The row survives revocation, so the audit trail and the refusal both still resolve it.+ rows <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_service_accounts WHERE client_id = '" <> cid <> "' AND status = 'revoked' AND revoked_at IS NOT NULL")+ rows @?= 1+ events <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type IN ('service_account_secret_rotated', 'service_account_revoked')"+ events @?= 2++ -- list reports it, still exactly one row.+ accounts <- listAction env+ map saClientId' accounts @?= [cid]++-- | A typo in the client id aborts rather than silently updating zero rows.+testServiceAccountsUnknownClientIdFails :: TestTree+testServiceAccountsUnknownClientIdFails =+ testCase "service-accounts rotate-secret/revoke on an unknown client id abort" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ expectExitFailure "rotate-secret of an unknown client" (void (rotateSecretAction env "svcacct_nope"))+ expectExitFailure "revoke of an unknown client" (void (revokeAction env "svcacct_nope"))++-- shomei-admin oauth-clients (EP-5) -----------------------------------------++ocClientId' :: OAuthClient -> Text+ocClientId' OAuthClient {clientId} = clientId++ocStatus' :: OAuthClient -> OAuthClientStatus+ocStatus' OAuthClient {status} = status++ocDisplayName' :: OAuthClient -> Text+ocDisplayName' OAuthClient {displayName} = displayName++-- | create → the client exists, is active, stores only the digest of the once-printed secret,+-- and owns NO backing user row (unlike a service account, it is never a token subject).+-- Then list sees it, and revoke keeps the row while flipping its status.+testOAuthClientsLifecycle :: TestTree+testOAuthClientsLifecycle =+ testCase "oauth-clients create/list/revoke: digest-only secret, no backing user, row survives revocation" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ (client, mSecret) <-+ OAuthClients.createAction+ env+ "grafana"+ ConfidentialClient+ ["https://grafana.example.com/callback"]+ ["openid", "profile"]+ secret <- maybe (assertFailure "a confidential client must be issued a secret") pure mSecret+ let cid = ocClientId' client++ ocStatus' client @?= OAuthClientActive+ n <- scalarInt pool "SELECT count(*) FROM shomei.shomei_oauth_clients WHERE status = 'active'"+ n @?= 1++ -- The plaintext secret is nowhere in the database; only its SHA-256 hex digest is.+ plaintext <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_oauth_clients WHERE secret_hash = '" <> secret <> "'")+ assertEqual "the plaintext secret is never stored" 0 plaintext+ digest <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_oauth_clients WHERE secret_hash = '" <> sha256Hex secret <> "'")+ assertEqual "the stored hash is sha256Hex of the returned secret" 1 digest++ -- An OAuth client is not a principal: no shomei_users row is provisioned for it.+ backing <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_users WHERE login_id = '" <> cid <> "'")+ assertEqual "an oauth client gets no backing user row" 0 backing++ listed <- OAuthClients.listAction env+ map ocDisplayName' listed @?= ["grafana"]++ created <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'oauth_client_created'"+ created @?= 1+ -- The client is not a user, so the audit row files under no user.+ withUser <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'oauth_client_created' AND user_id IS NOT NULL"+ assertEqual "an oauth client lifecycle event names no user" 0 withUser++ _ <- OAuthClients.revokeAction env cid+ revoked <- scalarInt pool ("SELECT count(*) FROM shomei.shomei_oauth_clients WHERE client_id = '" <> cid <> "' AND status = 'revoked' AND revoked_at IS NOT NULL")+ assertEqual "revocation flips status, stamps revoked_at, and keeps the row" 1 revoked+ revokedEvents <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'oauth_client_revoked'"+ revokedEvents @?= 1++-- | A public client is issued no secret at all — not one that is stored and never checked. PKCE+-- is what binds its authorize request to its token request.+testOAuthClientsPublicHasNoSecret :: TestTree+testOAuthClientsPublicHasNoSecret =+ testCase "oauth-clients create --type public: no secret is generated or stored" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ (client, mSecret) <-+ OAuthClients.createAction env "spa" PublicClient ["https://spa.example.com/cb"] ["openid"]+ assertEqual "a public client is issued no secret" Nothing mSecret+ nulls <-+ scalarInt+ pool+ ("SELECT count(*) FROM shomei.shomei_oauth_clients WHERE client_id = '" <> ocClientId' client <> "' AND secret_hash IS NULL AND client_type = 'public'")+ assertEqual "its secret_hash is a real NULL" 1 nulls++testOAuthClientsPrivilegeScopeFails :: TestTree+testOAuthClientsPrivilegeScopeFails =+ testCase "oauth-clients create refuses a privilege scope without inserting a row" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ expectExitFailure+ "registering shomei:admin on an OAuth client"+ ( void+ ( OAuthClients.createAction+ env+ "privilege escalation client"+ ConfidentialClient+ ["https://client.example.com/callback"]+ ["openid", "shomei:admin"]+ )+ )+ rows <- scalarInt pool "SELECT count(*) FROM shomei.shomei_oauth_clients"+ rows @?= 0++-- | Revoking an unknown client_id exits nonzero rather than silently updating zero rows.+testOAuthClientsRevokeUnknownFails :: TestTree+testOAuthClientsRevokeUnknownFails =+ testCase "oauth-clients revoke of an unknown client_id exits nonzero and writes nothing" $ withDb \pool connStr -> do+ let env = adminEnv pool connStr+ expectExitFailure "revoke unknown" (void (OAuthClients.revokeAction env "oauthclient_does_not_exist"))+ events <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'oauth_client_revoked'"+ events @?= 0++testKeyWriteRollback :: TestTree+testKeyWriteRollback = testCase "key activation and rewrap roll back every write on an injected later failure" $ withDb \pool _ -> do+ oldKek <- testKek 'a'+ newKek <- testKek 'b'+ keysGenerate oldKek ES256 pool+ first <- onlyPendingKid pool+ keysActivate pool first+ keysGenerate oldKek ES256 pool+ second <- onlyPendingKid pool+ before <- listAllKeys pool+ execSql pool "CREATE FUNCTION shomei.reject_activation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.status='active' THEN RAISE EXCEPTION 'injected activation failure' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_activation BEFORE UPDATE ON shomei.shomei_signing_keys FOR EACH ROW EXECUTE FUNCTION shomei.reject_activation()"+ expectExitFailure "activation write rollback" (keysActivate pool second)+ statusOf pool first >>= (@?= KeyActive)+ statusOf pool second >>= (@?= KeyPending)+ afterActivation <- listAllKeys pool+ map (\key -> (key.keyId, key.status, key.activatedAt, key.retiredAt)) afterActivation @?= map (\key -> (key.keyId, key.status, key.activatedAt, key.retiredAt)) before+ execSql pool "DROP TRIGGER reject_activation ON shomei.shomei_signing_keys; CREATE SEQUENCE shomei.rewrap_writes; CREATE FUNCTION shomei.reject_second_rewrap() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF nextval('shomei.rewrap_writes')=2 THEN RAISE EXCEPTION 'injected rewrap failure' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_second_rewrap BEFORE UPDATE OF private_key_jwk ON shomei.shomei_signing_keys FOR EACH ROW EXECUTE FUNCTION shomei.reject_second_rewrap()"+ expectExitFailure "second rewrap write rollback" (keysRewrap oldKek newKek pool)+ scalarInt pool "SELECT last_value FROM shomei.rewrap_writes" >>= (@?= 2)+ after <- listAllKeys pool+ map (.privateKeyJwk) after @?= map (.privateKeyJwk) before+ forM_ after \key -> do+ assertBool "old KEK still decrypts every original row" (isRight (decryptStoredSigningKey oldKek key))+ assertBool "new KEK cannot decrypt a partially committed row" (not (isRight (decryptStoredSigningKey newKek key)))++testMultipleActiveFailClosed :: TestTree+testMultipleActiveFailClosed = testCase "key material loader rejects multiple active keys even after index bypass" $ withDb \pool _ -> do+ kek <- testKek 'a'+ keysGenerate kek ES256 pool+ first <- onlyPendingKid pool+ keysActivate pool first+ keysGenerate kek ES256 pool+ second <- onlyPendingKid pool+ execSql pool ("DROP INDEX shomei.shomei_signing_keys_one_active; UPDATE shomei.shomei_signing_keys SET status='active',activated_at=now() WHERE key_id='" <> second <> "'")+ loaded <- Keys.loadKeyMaterial kek pool+ case loaded of+ Left reason -> assertBool "diagnostic names the invariant" ("multiple active signing keys" `Text.isInfixOf` reason)+ Right _ -> assertFailure "ambiguous signer was accepted"
+ test/Main.hs view
@@ -0,0 +1,16 @@+module Main (main) where++import Shomei.Server.E2ESpec qualified as E2ESpec+import Shomei.Server.MiddlewareSpec qualified as MiddlewareSpec+import Shomei.Server.NotifySpec qualified as NotifySpec+import Shomei.Server.RuntimeSpec qualified as RuntimeSpec+import Shomei.Server.SupervisorSpec qualified as SupervisorSpec+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main =+ defaultMain+ ( testGroup+ "shomei-server"+ [RuntimeSpec.tests, NotifySpec.tests, SupervisorSpec.tests, MiddlewareSpec.tests, E2ESpec.tests]+ )
+ test/Shomei/Server/ConfigSpec.hs view
@@ -0,0 +1,868 @@+-- | Tests for EP-5's Dhall + environment configuration loader. Writes a small Dhall file to a+-- temp path, loads it through 'loadConfig' (which renders it via @dhall-to-json@ and decodes the+-- result), asserts the parsed values win over the built-in defaults, and then proves an+-- environment variable overrides the file value (twelve-factor precedence). Using a temp file+-- avoids any dependency on the test's working directory.+module Main (main) where++import Control.Exception (try)+import Data.Aeson (Value (Object), eitherDecodeStrict', encode, toJSON)+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy.Char8 qualified as LBS8+import Data.List (isInfixOf)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Shomei.Account.Password.Domain (PasswordPolicy (..))+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..))+import Shomei.Authorization.Claims.Domain (Issuer (..))+import Shomei.Config (CookieConfig (..), MachineTokenConfig (..), MfaConfig (..), NotifierConfig (..), NotifierTransport (..), RateLimitConfig (..), ShomeiConfig (..), SigningKeyConfig (..), SmtpConfig (..), SmtpTlsMode (..), WebAuthnConfig (..), WebhookConfig (..))+import Shomei.Notify (NotifierSecrets (..), smtpPasswordText, webhookSecretBytes)+import Shomei.Server.Config (FileConfig, ServerSettings (..), SweepSettings (..), loadConfig, loadConfigFromEnv, loadCoreConfig, loadNotifierSecretsFromEnv)+import Shomei.Server.Keys (loadKekFromEnv)+import System.Directory (makeAbsolute)+import System.Environment (setEnv, unsetEnv)+import System.IO.Error (isUserError)+import System.Process (readProcess)+import Test.Tasty (TestTree, defaultMain)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))++configPath :: FilePath+configPath = "/tmp/shomei-config-test.dhall"++schemaProbePath :: FilePath+schemaProbePath = "/tmp/shomei-schema-probe.dhall"++-- A partial config (FileConfig's fields are all optional, so absent keys fall back to defaults).+dhallContents :: String+dhallContents =+ "{ issuer = \"shomei-prod\""+ <> ", databaseUrl = \"host=fromfile dbname=shomei\""+ <> ", port = 8080"+ <> ", bindHost = \"127.0.0.1\""+ <> ", maxConnections = 32"+ <> ", slowClientTimeoutSeconds = 12"+ <> ", dbPoolSize = 25"+ <> ", dbPoolAcquisitionTimeoutMs = 2500"+ <> ", dbStatementTimeoutMs = 5000"+ <> ", notifierQueueSize = 12"+ <> ", maxFailedLoginsPerAccount = 7"+ <> ", metricsEnabled = False"+ <> ", keyRefreshIntervalSeconds = 15"+ <> ", allowedClockSkewSeconds = 20"+ <> ", passwordMinLength = 16"+ <> ", passwordRejectCommon = False"+ <> ", webauthnRpId = \"auth.fromfile.test\""+ <> ", webauthnOrigins = [ \"https://auth.fromfile.test\" ]"+ <> ", webauthnUserVerification = \"required\""+ <> ", mfaRequireSecondFactor = False"+ <> ", machineTokenTtlSeconds = 120"+ <> " }"++main :: IO ()+main = do+ writeFile configPath dhallContents+ defaultMain testLoadAndOverride++-- | With neither Dhall file nor pool env vars, the pool knobs reproduce the values that were+-- hardcoded before they became configuration.+poolDefaults :: Assertion+poolDefaults = do+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_DB_POOL_SIZE"+ unsetEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS"+ unsetEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS"+ unsetEnv "SHOMEI_NOTIFIER_QUEUE_SIZE"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ (_, settings) <- loadConfigFromEnv+ settings.serverBindHost @?= "*4"+ settings.serverMaxConnections @?= 1024+ settings.serverSlowClientTimeoutSeconds @?= 30+ settings.serverDbPoolSize @?= 10+ settings.serverDbPoolAcquisitionTimeoutMs @?= 10000+ settings.serverDbStatementTimeoutMs @?= 30000+ settings.serverDbTransactionMaxAttempts @?= 3+ settings.serverDbTransactionDeadlineMs @?= 5000+ settings.serverNotifierQueueSize @?= 1024+ unsetEnv "PG_CONNECTION_STRING"++-- | A zero-size pool deadlocks every request and a zero acquisition timeout fails every+-- checkout, so the loader refuses to start rather than booting a server that cannot serve.+poolRejectsNonPositive :: Assertion+poolRejectsNonPositive = do+ unsetEnv "SHOMEI_CONFIG"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_DB_POOL_SIZE" "0"+ sizeResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_DB_POOL_SIZE" sizeResult+ unsetEnv "SHOMEI_DB_POOL_SIZE"+ setEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS" "-1"+ timeoutResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS" timeoutResult+ unsetEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS"+ setEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS" "-1"+ statementTimeoutResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_DB_STATEMENT_TIMEOUT_MS" statementTimeoutResult+ unsetEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS"+ setEnv "SHOMEI_NOTIFIER_QUEUE_SIZE" "0"+ notifierQueueResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_NOTIFIER_QUEUE_SIZE" notifierQueueResult+ unsetEnv "SHOMEI_NOTIFIER_QUEUE_SIZE"+ mapM_+ ( \key -> do+ setEnv key "0"+ invalid <- try loadConfigFromEnv+ expectUserErrorNaming key invalid+ unsetEnv key+ )+ ["SHOMEI_DB_TRANSACTION_MAX_ATTEMPTS", "SHOMEI_DB_TRANSACTION_DEADLINE_MS", "SHOMEI_MAX_CONNECTIONS", "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS"]+ setEnv "SHOMEI_DB_TRANSACTION_MAX_ATTEMPTS" "2"+ setEnv "SHOMEI_DB_TRANSACTION_DEADLINE_MS" "250"+ (_, bounded) <- loadConfigFromEnv+ bounded.serverDbTransactionMaxAttempts @?= 2+ bounded.serverDbTransactionDeadlineMs @?= 250+ unsetEnv "SHOMEI_DB_TRANSACTION_MAX_ATTEMPTS"+ unsetEnv "SHOMEI_DB_TRANSACTION_DEADLINE_MS"+ unsetEnv "PG_CONNECTION_STRING"++sweepEnvVars :: [String]+sweepEnvVars =+ [ "SHOMEI_SWEEP_ENABLED",+ "SHOMEI_SWEEP_INTERVAL_SECONDS",+ "SHOMEI_SWEEP_BATCH_SIZE",+ "SHOMEI_SWEEP_DEAD_SESSION_GRACE_DAYS",+ "SHOMEI_SWEEP_ONE_TIME_TOKEN_GRACE_DAYS",+ "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES",+ "SHOMEI_LOGIN_ATTEMPT_RETENTION_DAYS",+ "SHOMEI_AUTH_EVENT_RETENTION_DAYS"+ ]++-- | With no file and no env, the sweeper is on, hourly, and retains the audit trail forever.+sweepDefaults :: Assertion+sweepDefaults = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv sweepEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ (_, settings) <- loadConfigFromEnv+ let sweep = settings.serverSweep+ sweep.sweepEnabled @?= True+ sweep.sweepIntervalSeconds @?= 3600+ sweep.sweepBatchSize @?= 1000+ sweep.sweepDeadSessionGraceDays @?= 30+ sweep.sweepOneTimeTokenGraceDays @?= 7+ sweep.sweepCeremonyGraceMinutes @?= 60+ sweep.sweepLoginAttemptRetentionDays @?= 90+ -- The one conservative default: never delete the compliance record on your own initiative.+ sweep.sweepAuthEventRetentionDays @?= Nothing+ unsetEnv "PG_CONNECTION_STRING"++-- | Every sweep knob is settable from the environment, and a non-positive audit-retention+-- window means "retain forever" rather than "delete everything" — the failure mode of getting+-- that backwards is unrecoverable.+sweepEnvOverrides :: Assertion+sweepEnvOverrides = do+ unsetEnv "SHOMEI_CONFIG"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_SWEEP_ENABLED" "false"+ setEnv "SHOMEI_SWEEP_INTERVAL_SECONDS" "900"+ setEnv "SHOMEI_SWEEP_BATCH_SIZE" "250"+ setEnv "SHOMEI_SWEEP_DEAD_SESSION_GRACE_DAYS" "0"+ setEnv "SHOMEI_LOGIN_ATTEMPT_RETENTION_DAYS" "30"+ setEnv "SHOMEI_AUTH_EVENT_RETENTION_DAYS" "365"+ (_, settings) <- loadConfigFromEnv+ let sweep = settings.serverSweep+ sweep.sweepEnabled @?= False+ sweep.sweepIntervalSeconds @?= 900+ sweep.sweepBatchSize @?= 250+ -- A zero grace period is legal: sweep a session the moment it expires.+ sweep.sweepDeadSessionGraceDays @?= 0+ sweep.sweepLoginAttemptRetentionDays @?= 30+ sweep.sweepAuthEventRetentionDays @?= Just 365++ -- Zero (or negative) turns audit deletion back off.+ setEnv "SHOMEI_AUTH_EVENT_RETENTION_DAYS" "0"+ (_, offSettings) <- loadConfigFromEnv+ offSettings.serverSweep.sweepAuthEventRetentionDays @?= Nothing++ mapM_ unsetEnv sweepEnvVars+ unsetEnv "PG_CONNECTION_STRING"++-- | A zero interval would spin the sweeper thread and a zero batch size would make it delete+-- nothing forever, so the loader refuses both. @sweepEnabled = false@ is the off-switch.+sweepRejectsNonPositive :: Assertion+sweepRejectsNonPositive = do+ unsetEnv "SHOMEI_CONFIG"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_SWEEP_INTERVAL_SECONDS" "0"+ intervalResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_SWEEP_INTERVAL_SECONDS" intervalResult+ unsetEnv "SHOMEI_SWEEP_INTERVAL_SECONDS"++ setEnv "SHOMEI_SWEEP_BATCH_SIZE" "0"+ batchResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_SWEEP_BATCH_SIZE" batchResult+ unsetEnv "SHOMEI_SWEEP_BATCH_SIZE"++ -- A negative grace period would sweep rows that have not expired yet.+ setEnv "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES" "-1"+ graceResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES" graceResult+ unsetEnv "SHOMEI_SWEEP_CEREMONY_GRACE_MINUTES"+ unsetEnv "PG_CONNECTION_STRING"++-- | Argon2 defaults, env overrides, and the refusal of non-positive costs.+argon2Settings :: Assertion+argon2Settings = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv argon2EnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"++ (_, defaults) <- loadConfigFromEnv+ defaults.serverArgon2.memoryKiB @?= 65536+ defaults.serverArgon2.iterations @?= 3+ defaults.serverArgon2.parallelism @?= 1+ defaults.serverHashingMaxConcurrency @?= 2++ setEnv "SHOMEI_ARGON2_MEMORY_KIB" "32768"+ setEnv "SHOMEI_ARGON2_ITERATIONS" "4"+ setEnv "SHOMEI_HASHING_MAX_CONCURRENCY" "4"+ (_, overridden) <- loadConfigFromEnv+ overridden.serverArgon2.memoryKiB @?= 32768+ overridden.serverArgon2.iterations @?= 4+ overridden.serverArgon2.parallelism @?= 1+ overridden.serverHashingMaxConcurrency @?= 4+ mapM_ unsetEnv argon2EnvVars++ setEnv "SHOMEI_ARGON2_MEMORY_KIB" "64"+ setEnv "SHOMEI_ARGON2_PARALLELISM" "16"+ hardFloorResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_ARGON2_MEMORY_KIB" hardFloorResult++ setEnv "SHOMEI_ARGON2_MEMORY_KIB" "128"+ (_, boundary) <- loadConfigFromEnv+ boundary.serverArgon2 @?= Argon2Params 128 3 16+ mapM_ unsetEnv argon2EnvVars++ -- crypton would reject these deep inside a login; the loader names the variable instead.+ -- A zero permit count would block every login forever.+ setEnv "SHOMEI_ARGON2_MEMORY_KIB" "0"+ memResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_ARGON2_MEMORY_KIB" memResult+ unsetEnv "SHOMEI_ARGON2_MEMORY_KIB"++ setEnv "SHOMEI_HASHING_MAX_CONCURRENCY" "0"+ concResult <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_HASHING_MAX_CONCURRENCY" concResult+ mapM_ unsetEnv argon2EnvVars+ unsetEnv "PG_CONNECTION_STRING"++argon2EnvVars :: [String]+argon2EnvVars =+ [ "SHOMEI_ARGON2_MEMORY_KIB",+ "SHOMEI_ARGON2_ITERATIONS",+ "SHOMEI_ARGON2_PARALLELISM",+ "SHOMEI_HASHING_MAX_CONCURRENCY"+ ]++-- | Misspelled keys, enum typos, and an empty origin set are deployment errors, not requests to+-- silently keep a default. The email-verification policy is available from the environment too.+strictConfigurationSettings :: Assertion+strictConfigurationSettings = do+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_CONFIG" configPath++ writeFile configPath "{ cookieSecue = False }"+ unknownKey <- try loadConfig+ expectUserErrorNaming "invalid SHOMEI_CONFIG" unknownKey++ writeFile configPath "{ webauthnAttestation = \"nope\" }"+ badAttestation <- try loadConfig+ expectUserErrorNaming "webauthnAttestation" badAttestation++ writeFile configPath "{ webauthnOrigins = ([] : List Text) }"+ emptyFileOrigins <- try loadConfig+ expectUserErrorNaming "webauthnOrigins" emptyFileOrigins++ unsetEnv "SHOMEI_CONFIG"+ setEnv "SHOMEI_WEBAUTHN_ORIGINS" ","+ emptyEnvOrigins <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_WEBAUTHN_ORIGINS" emptyEnvOrigins+ unsetEnv "SHOMEI_WEBAUTHN_ORIGINS"++ setEnv "SHOMEI_EMAIL_VERIFICATION_REQUIRED" "true"+ (emailRequired, _) <- loadConfigFromEnv+ emailRequired.notifierConfig.emailVerificationRequired @?= True+ unsetEnv "SHOMEI_EMAIL_VERIFICATION_REQUIRED"++ writeFile configPath dhallContents+ unsetEnv "PG_CONNECTION_STRING"++-- | Render the schema's completed default while preserving nulls, then compare its key set to+-- the all-'Nothing' JSON representation of 'FileConfig'. This also asks Dhall to check that the+-- shipped default still has exactly the exported 'Type'.+dhallSchemaMatchesFileConfig :: Assertion+dhallSchemaMatchesFileConfig = do+ schema <- makeAbsolute "../config/shomei-types.dhall"+ writeFile schemaProbePath ("(" <> schema <> ")::{=}")+ rendered <- readProcess "dhall-to-json" ["--preserve-null", "--file", schemaProbePath] ""+ dhallKeys <-+ either assertFailure (pure . KeyMap.keys) $+ (eitherDecodeStrict' (Text.encodeUtf8 (Text.pack rendered)) :: Either String (KeyMap.KeyMap Value))+ blank <- either assertFailure pure (eitherDecodeStrict' "{}" :: Either String FileConfig)+ loaderKeys <- case toJSON blank of+ Object fields -> pure (KeyMap.keys fields)+ _ -> assertFailure "FileConfig did not encode as an object"+ Set.fromList dhallKeys @?= Set.fromList loaderKeys++-- | Assert a config load failed with a 'userError' whose message names the offending variable.+expectUserErrorNaming :: String -> Either IOError a -> Assertion+expectUserErrorNaming name = \case+ Left e ->+ assertBool+ ("expected a userError naming " <> name <> ", got: " <> show e)+ (isUserError e && Text.isInfixOf (Text.pack name) (Text.pack (show e)))+ Right _ -> assertFailure (name <> " out of range should have failed the config load")++testLoadAndOverride :: TestTree+testLoadAndOverride = testCase "Dhall file is loaded and env vars override it" do+ setEnv "SHOMEI_CONFIG" configPath+ setEnv "PG_CONNECTION_STRING" "host=fromenv dbname=shomei"+ unsetEnv "SHOMEI_PORT"+ unsetEnv "SHOMEI_BIND_HOST"+ unsetEnv "SHOMEI_MAX_CONNECTIONS"+ unsetEnv "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS"+ unsetEnv "SHOMEI_ISSUER"+ unsetEnv "SHOMEI_WEBAUTHN_RP_ID"+ unsetEnv "SHOMEI_MFA_REQUIRE_SECOND_FACTOR"+ unsetEnv "SHOMEI_MACHINE_TOKEN_TTL"+ unsetEnv "SHOMEI_KEY_REFRESH_INTERVAL"+ unsetEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"+ unsetEnv "SHOMEI_NOTIFIER_LOG_SECRETS"+ unsetEnv "SHOMEI_DB_POOL_SIZE"+ unsetEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS"+ unsetEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS"+ unsetEnv "SHOMEI_NOTIFIER_QUEUE_SIZE"+ (cfg, settings) <- loadConfig+ -- File values beat the defaults (default maxFailedLoginsPerAccount is 5, metrics default True):+ settings.serverPort @?= 8080+ settings.serverBindHost @?= "127.0.0.1"+ settings.serverMaxConnections @?= 32+ settings.serverSlowClientTimeoutSeconds @?= 12+ -- The pool knobs load from the file (defaults are 10 / 10000):+ settings.serverDbPoolSize @?= 25+ settings.serverDbPoolAcquisitionTimeoutMs @?= 2500+ settings.serverDbStatementTimeoutMs @?= 5000+ settings.serverNotifierQueueSize @?= 12+ cfg.rateLimitConfig.maxFailedLoginsPerAccount @?= 7+ -- The signing-key refresh interval loads from the file (default is 60):+ cfg.signingKeyConfig.refreshIntervalSeconds @?= 15+ cfg.signingKeyConfig.allowedClockSkewSeconds @?= 20+ -- Raw one-time tokens are never logged unless the env flag opts in (there is deliberately+ -- no Dhall field for it):+ cfg.notifierConfig.logRawTokens @?= False+ -- File values beat the default password policy (default minLength is 12, rejectCommon True):+ cfg.passwordPolicy.minLength @?= 16+ cfg.passwordPolicy.rejectCommonPasswords @?= False+ -- PG_CONNECTION_STRING (env) overrides the file's databaseUrl:+ settings.serverConnStr @?= "host=fromenv dbname=shomei"+ -- WebAuthn and MFA fields load from their respective Dhall settings.+ -- WebAuthnConfig is read via record destructuring, not value.field dot syntax (HasField is+ -- unreliable for it under DuplicateRecordFields — MasterPlan 3, EP-1 discovery).+ let WebAuthnConfig {rpId = fileRpId, origins = fileOrigins} = webauthnConfig cfg+ fileRpId @?= "auth.fromfile.test"+ fileOrigins @?= ["https://auth.fromfile.test"]+ cfg.mfaConfig.requireSecondFactor @?= False+ cfg.machineTokenConfig.machineTokenTTL @?= 120+ -- An env var overrides the file's port:+ setEnv "SHOMEI_PORT" "9999"+ setEnv "SHOMEI_BIND_HOST" "::1"+ setEnv "SHOMEI_MAX_CONNECTIONS" "64"+ setEnv "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS" "8"+ -- and the file's pool knobs (file says 25 / 2500):+ setEnv "SHOMEI_DB_POOL_SIZE" "33"+ setEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS" "2000"+ setEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS" "7000"+ setEnv "SHOMEI_NOTIFIER_QUEUE_SIZE" "24"+ -- Concept-specific env vars override the file (twelve-factor precedence):+ setEnv "SHOMEI_WEBAUTHN_RP_ID" "auth.fromenv.test"+ setEnv "SHOMEI_MFA_REQUIRE_SECOND_FACTOR" "true"+ setEnv "SHOMEI_MACHINE_TOKEN_TTL" "60"+ (cfg2, settings2) <- loadConfig+ settings2.serverPort @?= 9999+ settings2.serverBindHost @?= "::1"+ settings2.serverMaxConnections @?= 64+ settings2.serverSlowClientTimeoutSeconds @?= 8+ settings2.serverDbPoolSize @?= 33+ settings2.serverDbPoolAcquisitionTimeoutMs @?= 2000+ settings2.serverDbStatementTimeoutMs @?= 7000+ settings2.serverNotifierQueueSize @?= 24+ let WebAuthnConfig {rpId = envRpId} = webauthnConfig cfg2+ envRpId @?= "auth.fromenv.test"+ cfg2.mfaConfig.requireSecondFactor @?= True+ cfg2.machineTokenConfig.machineTokenTTL @?= 60+ -- An env var overrides the file's password min length (file says 16):+ setEnv "SHOMEI_PASSWORD_MIN_LENGTH" "20"+ -- and the file's signing-key refresh interval (file says 15); 0 disables the reload:+ setEnv "SHOMEI_KEY_REFRESH_INTERVAL" "0"+ setEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS" "45"+ -- SHOMEI_NOTIFIER_LOG_SECRETS is env-only; it is the sole way to turn raw-token logging on:+ setEnv "SHOMEI_NOTIFIER_LOG_SECRETS" "true"+ (cfg3, _) <- loadConfig+ cfg3.passwordPolicy.minLength @?= 20+ cfg3.signingKeyConfig.refreshIntervalSeconds @?= 0+ cfg3.signingKeyConfig.allowedClockSkewSeconds @?= 45+ cfg3.notifierConfig.logRawTokens @?= True+ unsetEnv "SHOMEI_NOTIFIER_LOG_SECRETS"+ unsetEnv "SHOMEI_KEY_REFRESH_INTERVAL"+ unsetEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"+ unsetEnv "SHOMEI_PASSWORD_MIN_LENGTH"+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_PORT"+ unsetEnv "SHOMEI_BIND_HOST"+ unsetEnv "SHOMEI_MAX_CONNECTIONS"+ unsetEnv "SHOMEI_SLOW_CLIENT_TIMEOUT_SECONDS"+ unsetEnv "SHOMEI_DB_POOL_SIZE"+ unsetEnv "SHOMEI_DB_POOL_ACQUISITION_TIMEOUT_MS"+ unsetEnv "SHOMEI_DB_STATEMENT_TIMEOUT_MS"+ unsetEnv "SHOMEI_NOTIFIER_QUEUE_SIZE"+ unsetEnv "PG_CONNECTION_STRING"+ unsetEnv "SHOMEI_WEBAUTHN_RP_ID"+ unsetEnv "SHOMEI_MFA_REQUIRE_SECOND_FACTOR"+ unsetEnv "SHOMEI_MACHINE_TOKEN_TTL"+ -- Run inline rather than as sibling tasty test cases: each of these mutates process-wide+ -- environment variables, and tasty runs the members of a test group in parallel.+ poolDefaults+ poolRejectsNonPositive+ sweepDefaults+ sweepEnvOverrides+ sweepRejectsNonPositive+ argon2Settings+ strictConfigurationSettings+ dhallSchemaMatchesFileConfig+ coreLoaderNeedsNoConnectionString+ verifierSettings+ rateLimitAndCookieEdgeSettings+ notifierDefaults+ notifierSmtpDhallAndEnv+ passkeyQuotaSettings+ sessionQuotaSettings+ actorCompatibilitySettings++ notifierWebhookEnv+ notifierSmtpMissingHostFails+ notifierSmtpUsernameWithoutPasswordFails+ notifierWebhookMissingSecretFails+ notifierSecretsAreStrippedAndConfigIsSecretFree+ notifierInsecureTransportFlags+ kekIsRequired++passkeyQuotaSettings :: Assertion+passkeyQuotaSettings = do+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_MAX_PASSKEYS_PER_USER"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ (defaults, _) <- loadConfigFromEnv+ defaults.maxPasskeysPerUser @?= 20+ setEnv "SHOMEI_MAX_PASSKEYS_PER_USER" "1"+ (limited, _) <- loadConfigFromEnv+ limited.maxPasskeysPerUser @?= 1+ setEnv "SHOMEI_MAX_PASSKEYS_PER_USER" "101"+ bad <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_MAX_PASSKEYS_PER_USER" bad+ unsetEnv "SHOMEI_MAX_PASSKEYS_PER_USER"++-- | Administrative workflows consume the deployment's core policy without pretending to be a+-- listening server or requiring the server's PostgreSQL variable.+coreLoaderNeedsNoConnectionString :: Assertion+coreLoaderNeedsNoConnectionString = do+ setEnv "SHOMEI_CONFIG" configPath+ unsetEnv "PG_CONNECTION_STRING"+ cfg <- loadCoreConfig+ cfg.passwordPolicy.minLength @?= 16+ cfg.passwordPolicy.rejectCommonPasswords @?= False+ unsetEnv "SHOMEI_CONFIG"++-- | The verifier skew has a safe default, supports env override, and rejects+-- negative values. Issuer and audience are validated as RFC 7519 StringOrURI+-- values before the signer can reach jose's partial IsString conversion.+verifierSettings :: Assertion+verifierSettings = do+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"+ unsetEnv "SHOMEI_ISSUER"+ unsetEnv "SHOMEI_AUDIENCE"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"++ (defaults, _) <- loadConfigFromEnv+ defaults.signingKeyConfig.allowedClockSkewSeconds @?= 30++ setEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS" "45"+ (overridden, _) <- loadConfigFromEnv+ overridden.signingKeyConfig.allowedClockSkewSeconds @?= 45++ setEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS" "-1"+ negativeSkew <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS" negativeSkew+ unsetEnv "SHOMEI_ALLOWED_CLOCK_SKEW_SECONDS"++ -- RFC 3986 permits a scheme with a rootless path, so this is a valid URI.+ setEnv "SHOMEI_ISSUER" "shomei:prod"+ (schemeIssuer, _) <- loadConfigFromEnv+ let Issuer schemeIssuerText = schemeIssuer.issuer+ schemeIssuerText @?= "shomei:prod"++ setEnv "SHOMEI_ISSUER" "https://bad host"+ invalidIssuer <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_ISSUER" invalidIssuer+ unsetEnv "SHOMEI_ISSUER"++ setEnv "SHOMEI_AUDIENCE" "https://bad audience"+ invalidAudience <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_AUDIENCE" invalidAudience++ unsetEnv "SHOMEI_AUDIENCE"+ unsetEnv "PG_CONNECTION_STRING"++rateLimitEnvVars :: [String]+rateLimitEnvVars =+ [ "SHOMEI_RATE_LIMIT_ENABLED",+ "SHOMEI_MAX_FAILED_LOGINS_PER_ACCOUNT",+ "SHOMEI_MAX_FAILED_LOGINS_PER_IP",+ "SHOMEI_PER_IP_REQUESTS_PER_MINUTE",+ "SHOMEI_PER_IP_BURST",+ "SHOMEI_LOCKOUT_WINDOW_SECONDS",+ "SHOMEI_LOCKOUT_DURATION_SECONDS"+ ]++-- | The complete abuse-policy surface is configurable, invalid zero bounds fail closed,+-- and browser origins are canonical before exact CSRF comparison and warning generation.+rateLimitAndCookieEdgeSettings :: Assertion+rateLimitAndCookieEdgeSettings = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv rateLimitEnvVars+ unsetEnv "SHOMEI_TOKEN_TRANSPORT"+ unsetEnv "SHOMEI_CSRF_ALLOWED_ORIGINS"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"++ setEnv "SHOMEI_RATE_LIMIT_ENABLED" "false"+ setEnv "SHOMEI_MAX_FAILED_LOGINS_PER_ACCOUNT" "7"+ setEnv "SHOMEI_MAX_FAILED_LOGINS_PER_IP" "23"+ setEnv "SHOMEI_PER_IP_REQUESTS_PER_MINUTE" "71"+ setEnv "SHOMEI_PER_IP_BURST" "11"+ setEnv "SHOMEI_LOCKOUT_WINDOW_SECONDS" "601"+ setEnv "SHOMEI_LOCKOUT_DURATION_SECONDS" "902"+ (configured, _) <- loadConfigFromEnv+ let rate = configured.rateLimitConfig+ rate.rateLimitEnabled @?= False+ rate.maxFailedLoginsPerAccount @?= 7+ rate.maxFailedLoginsPerIp @?= 23+ rate.perIpRequestsPerMinute @?= 71+ rate.perIpBurst @?= 11+ rate.lockoutWindow @?= 601+ rate.lockoutDuration @?= 902++ setEnv "SHOMEI_PER_IP_BURST" "0"+ zeroBurst <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_PER_IP_BURST" zeroBurst+ mapM_ unsetEnv rateLimitEnvVars++ setEnv "SHOMEI_CSRF_ALLOWED_ORIGINS" "HTTPS://App.Example.com/"+ (normalized, _) <- loadConfigFromEnv+ normalized.cookieConfig.allowedOrigins @?= ["https://app.example.com"]+ setEnv "SHOMEI_CSRF_ALLOWED_ORIGINS" "https://app.example.com/login"+ invalidOrigin <- try loadConfigFromEnv+ expectUserErrorNaming "must be scheme://host[:port] with no path" invalidOrigin++ unsetEnv "SHOMEI_CSRF_ALLOWED_ORIGINS"+ setEnv "SHOMEI_TOKEN_TRANSPORT" "cookie"+ (_, defaultWarningSettings) <- loadConfigFromEnv+ defaultWarningSettings.serverWarnings+ @?= ["cookie transport is on with the development default csrfAllowedOrigins = [http://localhost:8080]; browsers on any other origin get 403 csrf_rejected — set SHOMEI_CSRF_ALLOWED_ORIGINS"]++ setEnv "SHOMEI_CSRF_ALLOWED_ORIGINS" "https://app.example.com"+ (_, productionSettings) <- loadConfigFromEnv+ productionSettings.serverWarnings @?= []++ unsetEnv "SHOMEI_TOKEN_TRANSPORT"+ unsetEnv "SHOMEI_CSRF_ALLOWED_ORIGINS"+ unsetEnv "PG_CONNECTION_STRING"++kekIsRequired :: Assertion+kekIsRequired = do+ unsetEnv "SHOMEI_KEY_ENCRYPTION_KEY"+ missing <- try loadKekFromEnv+ expectUserErrorNaming "SHOMEI_KEY_ENCRYPTION_KEY" missing+ setEnv "SHOMEI_KEY_ENCRYPTION_KEY" "not-base64"+ malformed <- try loadKekFromEnv+ expectUserErrorNaming "SHOMEI_KEY_ENCRYPTION_KEY" malformed+ setEnv "SHOMEI_KEY_ENCRYPTION_KEY" "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="+ _ <- loadKekFromEnv+ unsetEnv "SHOMEI_KEY_ENCRYPTION_KEY"++-- EP-8 notifier transport ------------------------------------------------------++notifierEnvVars :: [String]+notifierEnvVars =+ [ "SHOMEI_NOTIFIER_TRANSPORT",+ "SHOMEI_NOTIFIER_ALSO_LOG",+ "SHOMEI_SMTP_HOST",+ "SHOMEI_SMTP_PORT",+ "SHOMEI_SMTP_TLS_MODE",+ "SHOMEI_SMTP_USERNAME",+ "SHOMEI_SMTP_PASSWORD",+ "SHOMEI_SMTP_FROM",+ "SHOMEI_SMTP_TIMEOUT",+ "SHOMEI_WEBHOOK_URL",+ "SHOMEI_WEBHOOK_SECRET",+ "SHOMEI_WEBHOOK_TIMEOUT",+ "SHOMEI_WEBHOOK_MAX_ATTEMPTS",+ "SHOMEI_WEBHOOK_ALLOW_INSECURE",+ "SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH",+ "SHOMEI_NOTIFIER_QUEUE_SIZE"+ ]++-- | With no config and no env, the notifier is the log sender and both sub-configs are absent.+notifierDefaults :: Assertion+notifierDefaults = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ (cfg, _) <- loadConfigFromEnv+ let nc = cfg.notifierConfig+ nc.notifierTransport @?= LogNotifier+ nc.smtpConfig @?= Nothing+ nc.webhookConfig @?= Nothing+ nc.alsoLogNotifications @?= False+ unsetEnv "PG_CONNECTION_STRING"++smtpConfigPath :: FilePath+smtpConfigPath = "/tmp/shomei-config-smtp-test.dhall"++-- | The SMTP transport loads from the Dhall file, while the relay password enters the separate+-- runtime-only secret value.+notifierSmtpDhallAndEnv :: Assertion+notifierSmtpDhallAndEnv = do+ mapM_ unsetEnv notifierEnvVars+ writeFile+ smtpConfigPath+ ( "{ databaseUrl = \"host=fromfile dbname=shomei\""+ <> ", notifierTransport = \"smtp\""+ <> ", smtpHost = \"email-smtp.us-east-1.amazonaws.com\""+ <> ", smtpPort = 587"+ <> ", smtpTlsMode = \"starttls\""+ <> ", smtpUsername = \"AKIAEXAMPLE\""+ <> ", smtpFromAddress = \"auth@example.com\""+ <> ", smtpTimeoutSeconds = 20"+ <> ", publicBaseUrl = \"https://auth.example.com\""+ <> " }"+ )+ setEnv "SHOMEI_CONFIG" smtpConfigPath+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_SMTP_PASSWORD" "smtp-secret-from-env"+ -- publicBaseUrl is set in the Dhall file; the env var overrides it (twelve-factor).+ setEnv "SHOMEI_PUBLIC_BASE_URL" "https://auth.env.example.com"+ (cfg, _) <- loadConfig+ let nc = cfg.notifierConfig+ nc.notifierTransport @?= SmtpNotifier+ nc.publicBaseUrl @?= "https://auth.env.example.com"+ case nc.smtpConfig of+ Just (SmtpConfig {host = h, port = p, tlsMode = tls, username = u, fromAddress = fromA, timeoutSeconds = to}) -> do+ h @?= "email-smtp.us-east-1.amazonaws.com"+ p @?= 587+ tls @?= SmtpStartTls+ u @?= Just "AKIAEXAMPLE"+ fromA @?= "auth@example.com"+ to @?= 20+ Nothing -> assertFailure "expected smtpConfig to be populated for transport=smtp"+ secrets <- loadNotifierSecretsFromEnv cfg+ fmap smtpPasswordText secrets.smtpPassword @?= Just "smtp-secret-from-env"+ unsetEnv "SHOMEI_SMTP_PASSWORD"+ unsetEnv "SHOMEI_PUBLIC_BASE_URL"+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "PG_CONNECTION_STRING"++-- | The webhook transport can be configured entirely from the environment, with its secret+-- held separately from the printable config.+notifierWebhookEnv :: Assertion+notifierWebhookEnv = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "webhook"+ setEnv "SHOMEI_WEBHOOK_URL" "https://hooks.example.com/shomei"+ setEnv "SHOMEI_WEBHOOK_SECRET" "webhook-secret"+ setEnv "SHOMEI_WEBHOOK_MAX_ATTEMPTS" "5"+ setEnv "SHOMEI_NOTIFIER_ALSO_LOG" "true"+ (cfg, _) <- loadConfigFromEnv+ let nc = cfg.notifierConfig+ nc.notifierTransport @?= WebhookNotifier+ nc.alsoLogNotifications @?= True+ case nc.webhookConfig of+ Just (WebhookConfig {url = u, maxAttempts = ma}) -> do+ u @?= "https://hooks.example.com/shomei"+ ma @?= 5+ Nothing -> assertFailure "expected webhookConfig to be populated for transport=webhook"+ secrets <- loadNotifierSecretsFromEnv cfg+ fmap webhookSecretBytes secrets.webhookSecret @?= Just "webhook-secret"+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++-- | Selecting smtp without a host fails the boot, naming the missing variable.+notifierSmtpMissingHostFails :: Assertion+notifierSmtpMissingHostFails = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "smtp"+ setEnv "SHOMEI_SMTP_FROM" "auth@example.com"+ result <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_SMTP_HOST" result+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++-- | An SMTP username with no password (or vice versa) is refused: it is neither a valid+-- authenticated relay nor a valid lab sink.+notifierSmtpUsernameWithoutPasswordFails :: Assertion+notifierSmtpUsernameWithoutPasswordFails = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "smtp"+ setEnv "SHOMEI_SMTP_HOST" "smtp.example.com"+ setEnv "SHOMEI_SMTP_FROM" "auth@example.com"+ setEnv "SHOMEI_SMTP_USERNAME" "relay-user"+ (cfg, _) <- loadConfigFromEnv+ result <- try (loadNotifierSecretsFromEnv cfg)+ expectUserErrorNaming "SHOMEI_SMTP_PASSWORD" result+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++-- | Selecting webhook without a signing secret fails the boot, naming the missing variable.+notifierWebhookMissingSecretFails :: Assertion+notifierWebhookMissingSecretFails = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "webhook"+ setEnv "SHOMEI_WEBHOOK_URL" "https://hooks.example.com/shomei"+ (cfg, _) <- loadConfigFromEnv+ result <- try (loadNotifierSecretsFromEnv cfg)+ expectUserErrorNaming "SHOMEI_WEBHOOK_SECRET" result+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++notifierSecretsAreStrippedAndConfigIsSecretFree :: Assertion+notifierSecretsAreStrippedAndConfigIsSecretFree = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" " host=localhost dbname=shomei "+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "webhook"+ setEnv "SHOMEI_WEBHOOK_URL" " https://hooks.example.com/shomei "+ setEnv "SHOMEI_WEBHOOK_SECRET" " webhook-secret\n"+ setEnv "SHOMEI_SMTP_PASSWORD" " smtp-secret\n"+ (cfg, settings) <- loadConfigFromEnv+ settings.serverConnStr @?= "host=localhost dbname=shomei"+ secrets <- loadNotifierSecretsFromEnv cfg+ fmap webhookSecretBytes secrets.webhookSecret @?= Just "webhook-secret"+ fmap smtpPasswordText secrets.smtpPassword @?= Just "smtp-secret"+ let notifier = cfg.notifierConfig+ fullConfig =+ cfg+ { notifierConfig =+ notifier+ { smtpConfig =+ Just+ SmtpConfig+ { host = "smtp.example.com",+ port = 587,+ tlsMode = SmtpStartTls,+ username = Just "relay-user",+ fromAddress = "auth@example.com",+ timeoutSeconds = 10+ }+ }+ }+ encoded = LBS8.unpack (encode fullConfig)+ assertBool "ShomeiConfig JSON must not have an SMTP password field" (not ("\"password\":" `isInfixOf` encoded))+ assertBool "ShomeiConfig JSON must not have a webhook secret field" (not ("\"secret\":" `isInfixOf` encoded))+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++notifierInsecureTransportFlags :: Assertion+notifierInsecureTransportFlags = do+ unsetEnv "SHOMEI_CONFIG"+ mapM_ unsetEnv notifierEnvVars+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "webhook"+ setEnv "SHOMEI_WEBHOOK_URL" "http://127.0.0.1:9999/hook"+ setEnv "SHOMEI_WEBHOOK_SECRET" "secret"+ insecureWebhook <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_WEBHOOK_ALLOW_INSECURE" insecureWebhook+ setEnv "SHOMEI_WEBHOOK_ALLOW_INSECURE" "true"+ (webhookCfg, _) <- loadConfigFromEnv+ _ <- loadNotifierSecretsFromEnv webhookCfg+ setEnv "SHOMEI_WEBHOOK_URL" "http://hooks.example.com/hook?secret=hidden"+ (_, httpSettings) <- loadConfigFromEnv+ assertBool "non-loopback HTTP lab receiver warns" ("webhookUrl uses HTTP under SHOMEI_WEBHOOK_ALLOW_INSECURE; one-time links travel in the clear; lab receivers only" `elem` httpSettings.serverWarnings)+ assertBool "warning contains no URL secrets" (not (any (Text.isInfixOf "hidden") httpSettings.serverWarnings))++ mapM_ unsetEnv notifierEnvVars+ setEnv "SHOMEI_NOTIFIER_TRANSPORT" "smtp"+ setEnv "SHOMEI_SMTP_HOST" "smtp.example.com"+ setEnv "SHOMEI_SMTP_FROM" "auth@example.com"+ setEnv "SHOMEI_SMTP_TLS_MODE" "plain"+ setEnv "SHOMEI_SMTP_USERNAME" "relay-user"+ setEnv "SHOMEI_SMTP_PASSWORD" "relay-password"+ plaintextAuth <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH" plaintextAuth+ setEnv "SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH" "true"+ (smtpCfg, smtpSettings) <- loadConfigFromEnv+ assertBool "plaintext credentials warn under the explicit lab override" ("smtpTlsMode=plain sends mail and SMTP credentials in the clear; explicit lab override" `elem` smtpSettings.serverWarnings)+ _ <- loadNotifierSecretsFromEnv smtpCfg++ unsetEnv "SHOMEI_SMTP_USERNAME"+ unsetEnv "SHOMEI_SMTP_PASSWORD"+ unsetEnv "SHOMEI_SMTP_ALLOW_PLAINTEXT_AUTH"+ (_, warningSettings) <- loadConfigFromEnv+ assertBool+ "unauthenticated plaintext SMTP warns"+ ("smtpTlsMode=plain sends mail in the clear; lab sinks only" `elem` warningSettings.serverWarnings)+ mapM_ unsetEnv notifierEnvVars+ unsetEnv "PG_CONNECTION_STRING"++actorCompatibilitySettings :: Assertion+actorCompatibilitySettings = do+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX"+ unsetEnv "SHOMEI_EMIT_LEGACY_ACTOR"+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ (defaults, _) <- loadConfigFromEnv+ defaults.signingKeyConfig.legacyActorUntilUnix @?= Nothing+ defaults.signingKeyConfig.emitLegacyActor @?= False+ setEnv "SHOMEI_EMIT_LEGACY_ACTOR" "true"+ missing <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX" missing+ setEnv "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX" "2000000000"+ (compatible, _) <- loadConfigFromEnv+ compatible.signingKeyConfig.legacyActorUntilUnix @?= Just 2000000000+ compatible.signingKeyConfig.emitLegacyActor @?= True+ setEnv "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX" "0"+ invalid <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX" invalid+ unsetEnv "SHOMEI_LEGACY_ACTOR_UNTIL_UNIX"+ unsetEnv "SHOMEI_EMIT_LEGACY_ACTOR"++sessionQuotaSettings :: Assertion+sessionQuotaSettings = do+ setEnv "PG_CONNECTION_STRING" "host=localhost dbname=shomei"+ unsetEnv "SHOMEI_CONFIG"+ unsetEnv "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS"+ (defaults, _) <- loadConfigFromEnv+ defaults.maxActiveNonInteractiveSessions @?= 1000+ setEnv "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS" "1"+ (limited, _) <- loadConfigFromEnv+ limited.maxActiveNonInteractiveSessions @?= 1+ setEnv "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS" "0"+ bad <- try loadConfigFromEnv+ expectUserErrorNaming "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS" bad+ unsetEnv "SHOMEI_MAX_ACTIVE_NON_INTERACTIVE_SESSIONS"
+ test/Shomei/Server/E2ESpec.hs view
@@ -0,0 +1,997 @@+-- | End-to-end test for the standalone server: a fresh ephemeral PostgreSQL database+-- (via @shomei-migrations:test-support@), the real server in-process through warp's+-- 'testWithApplication', driven over HTTP with @http-client@. Asserts the full lifecycle —+-- signup, login, me (with and without a token), refresh rotation, refresh-token reuse+-- detection (HTTP 401 plus the persisted session revocation and reuse event), logout, the+-- public JWKS document, and health — i.e. the vertical slice actually behaves against real+-- PostgreSQL and real ES256 signing.+module Shomei.Server.E2ESpec (tests) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)+import Control.Monad (forM_)+import Data.Aeson (Value (Array, Bool, Object, String), decode, encode, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.ByteString.Base64.URL qualified as B64U+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.IORef (newIORef)+import Data.Int (Int64)+import Data.Maybe (isJust, listToMaybe)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (addUTCTime, getCurrentTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)+import Effectful (runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import GHC.Clock (getMonotonicTime)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Network.HTTP.Client+ ( Manager,+ RequestBody (RequestBodyLBS),+ applyBasicAuth,+ defaultManagerSettings,+ httpLbs,+ method,+ newManager,+ parseRequest,+ redirectCount,+ requestBody,+ requestHeaders,+ responseBody,+ responseHeaders,+ responseStatus,+ urlEncodedBody,+ )+import Network.HTTP.Types (Header, status200, statusCode)+import Network.Wai qualified as Wai+import Network.Wai.Handler.Warp (testWithApplication)+import Servant.Health (ProbeCheck, ProbeVerdict (Healthy))+import Shomei.Account.LoginId.Domain (mkLoginId)+import Shomei.Account.Password.Hash.Postgres (Argon2Params (..), newHashingLimiter)+import Shomei.Account.User.Domain (NewUser (..), User (..))+import Shomei.Account.User.Postgres (runUserStorePostgres)+import Shomei.Account.User.Store (createUser)+import Shomei.Authorization.Claims.Domain (Audience (..), AuthClaims (..), Issuer (..), Scope (..))+import Shomei.Config (NotifierConfig (..), NotifierTransport (..), OAuthConfig (..), ShomeiConfig (..), SigningKeyConfig (..), TotpConfig (..), WebhookConfig (..), defaultShomeiConfig)+import Shomei.Error (AuthError)+import Shomei.Id (SessionId, UserId, genOAuthClientId, genServiceAccountDbId, idText)+import Shomei.Mfa.Totp.Algorithm (base32ToSecret, totpCode, totpCounter)+import Shomei.Mfa.Totp.Postgres (TotpEncryptionKey, totpEncryptionKeyFromBytes)+import Shomei.Migrations.TestSupport (withShomeiMigratedDatabase)+import Shomei.Notify (NotifierSecrets (..), WebhookSecret (..), noNotifierSecrets, webhookSignature)+import Shomei.Notify.Queue (newNotifierQueue)+import Shomei.OAuth.Client.Domain (ClientType (..), NewOAuthClient (..))+import Shomei.OAuth.Client.Postgres (runOAuthClientStorePostgres)+import Shomei.OAuth.Client.Store (createOAuthClient)+import Shomei.OAuth.TokenGrant.Workflow (pkceChallengeFor)+import Shomei.Persistence.Database.Postgres (defaultTransactionRetryPolicy, runDatabasePool)+import Shomei.Persistence.Pool.Postgres (Pool, acquirePool)+import Shomei.Persistence.Pool.Postgres qualified as Pool+import Shomei.Server.App (Env (..))+import Shomei.Server.Boot (NotifierWorker (..), application, installNotifierWorker)+import Shomei.Server.Config (defaultDbStatementTimeoutMs)+import Shomei.Server.Keys (LoadedKeys (..), bootstrapKeys)+import Shomei.ServiceAccount.Domain (NewServiceAccount (..))+import Shomei.ServiceAccount.Postgres (runServiceAccountStorePostgres)+import Shomei.ServiceAccount.Secret (sha256Hex)+import Shomei.ServiceAccount.Store (createServiceAccount)+import Shomei.Session.Domain (NewSession (..), Session (..), SessionKind (InteractiveSession))+import Shomei.Session.Postgres (runSessionStorePostgres)+import Shomei.Session.Store (createSession)+import Shomei.Session.Token.Domain (AccessToken (..))+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256))+import Shomei.SigningKey.Protection.Jwt (KeyEncryptionKey, keyEncryptionKeyFromBase64)+import Shomei.SigningKey.Sign.Jwt (signAccessToken)+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Shomei.Time.Postgres (runClockIO)+import Shomei.Time.Store (now)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++e2eKek :: KeyEncryptionKey+e2eKek =+ either (error . Text.unpack) id $+ keyEncryptionKeyFromBase64 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="++tests :: TestTree+tests =+ testGroup+ "auth lifecycle over HTTP against PostgreSQL"+ [ testCase "signup → login → me(±token) → refresh → reuse-detect → logout → jwks → health" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ let cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = noNotifierSecrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ testWithApplication (pure (application env healthyProbe healthyProbe)) (scenario pool),+ testCase "EP-4: service account → POST /oauth/token → the token authenticates and is audited" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ let cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = noNotifierSecrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ clientId <- seedServiceAccount pool+ testWithApplication (pure (application env healthyProbe healthyProbe)) (oauthScenario pool clientId),+ testCase "EP-5: authorize → exchange (PKCE) → verify id_token vs JWKS → userinfo → introspect → revoke → introspect" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ -- OIDC needs an http(s) issuer (it doubles as the endpoint base URL) and the provider on.+ let baseCfg = defaultShomeiConfig (Issuer "http://localhost") (Audience "shomei-clients")+ cfg = baseCfg {oauthConfig = baseCfg.oauthConfig {oidcEnabled = True}}+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = noNotifierSecrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ clientId <- seedOAuthClient pool+ testWithApplication (pure (application env healthyProbe healthyProbe)) (oidcScenario clientId),+ testCase "EP-6: token-exchange (on-behalf-of + impersonation) → verified vs JWKS → audited" $+ forM_ [False, True] \legacy -> withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keys <- bootstrapKeys e2eKek ES256 pool+ keysRef <- newIORef keys+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ windowNow <- getCurrentTime+ let baseCfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ cfg = baseCfg {signingKeyConfig = baseCfg.signingKeyConfig {emitLegacyActor = legacy, legacyActorUntilUnix = Just (ceiling (utcTimeToPOSIXSeconds (addUTCTime 7200 windowNow)))}}+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = noNotifierSecrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ clientId <- seedExchangeServiceAccount pool+ -- The operator token must verify against the server's own key material, so it is signed+ -- with the active signing key (scope granting is host-side; here the host is the test).+ -- The operator must be a real user row: a delegated session's actor_user_id is a foreign+ -- key into shomei_users.+ (opUid, opSid) <- seedOperatorPrincipal pool+ t <- getCurrentTime+ let opClaims =+ AuthClaims+ { subject = opUid,+ sessionId = opSid,+ issuer = cfg.issuer,+ audience = cfg.audience,+ issuedAt = t,+ expiresAt = addUTCTime 900 t,+ authTime = t,+ scopes = Set.singleton (Scope "impersonate:user"),+ roles = Set.empty,+ permissions = Set.empty,+ actor = Nothing,+ extraClaims = mempty+ }+ AccessToken opTok <- either (assertFailure . ("could not sign the operator token: " <>) . show) pure =<< signAccessToken keys.signingKey opClaims+ testWithApplication (pure (application env healthyProbe healthyProbe)) (exchangeScenario cfg pool clientId opTok),+ testCase "EP-7: enroll TOTP → verify → login(mfa) → complete → recovery codes → audited" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ let baseCfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ cfg = baseCfg {totpConfig = baseCfg.totpConfig {totpEnabled = True}}+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = noNotifierSecrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ testWithApplication (pure (application env healthyProbe healthyProbe)) (totpScenario pool),+ testCase "EP-8: webhook transport delivers a signed verification payload whose token is live" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ captured <- newMVar []+ let whSecret = "e2e-webhook-secret" :: Text+ stubApp rq respond = do+ b <- LBS.toStrict <$> Wai.strictRequestBody rq+ modifyMVar_ captured (pure . (<> [(Wai.requestHeaders rq, b)]))+ respond (Wai.responseLBS status200 [] "")+ -- The stub receiver and notifier worker must be up before the app accepts requests.+ testWithApplication (pure stubApp) \stubPort -> do+ let baseCfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ nc0 = baseCfg.notifierConfig+ webhookCfg =+ WebhookConfig+ { url = Text.pack ("http://127.0.0.1:" <> show stubPort <> "/hook"),+ timeoutSeconds = 5,+ maxAttempts = 1+ }+ cfg = baseCfg {notifierConfig = nc0 {notifierTransport = WebhookNotifier, webhookConfig = Just webhookCfg}}+ secrets = NotifierSecrets {smtpPassword = Nothing, webhookSecret = Just (WebhookSecret whSecret)}+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = secrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ worker <- installNotifierWorker env+ testWithApplication (pure (application env healthyProbe healthyProbe)) (webhookScenario captured whSecret)+ drainNotifierWorker worker 10,+ testCase "E2E: password-reset/request answers 202 in under a second while the receiver sleeps 3 s" $+ withShomeiMigratedDatabase \connStr -> do+ pool <- acquirePool 4 10 defaultDbStatementTimeoutMs connStr+ keysRef <- newIORef =<< bootstrapKeys e2eKek ES256 pool+ envMgr <- newManager defaultManagerSettings+ limiter <- newHashingLimiter 2+ notifierQueue <- newNotifierQueue 64+ captured <- newMVar []+ let whSecret = "slow-webhook-secret" :: Text+ stubApp rq respond = do+ b <- LBS.toStrict <$> Wai.strictRequestBody rq+ threadDelay 3_000_000+ modifyMVar_ captured (pure . (<> [(Wai.requestHeaders rq, b)]))+ respond (Wai.responseLBS status200 [] "")+ testWithApplication (pure stubApp) \stubPort -> do+ let baseCfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ nc0 = baseCfg.notifierConfig+ webhookCfg =+ WebhookConfig+ { url = Text.pack ("http://127.0.0.1:" <> show stubPort <> "/hook"),+ timeoutSeconds = 5,+ maxAttempts = 1+ }+ cfg = baseCfg {notifierConfig = nc0 {notifierTransport = WebhookNotifier, webhookConfig = Just webhookCfg}}+ secrets = NotifierSecrets {smtpPassword = Nothing, webhookSecret = Just (WebhookSecret whSecret)}+ env = Env {envPool = pool, envDbRetryPolicy = defaultTransactionRetryPolicy, envConfig = cfg, envKeys = keysRef, envKek = e2eKek, envHttpManager = envMgr, envNotifierSecrets = secrets, envNotifierQueue = notifierQueue, envArgon2Params = testArgon2Params, envHashingLimiter = limiter, envTotpKey = e2eTotpKey}+ worker <- installNotifierWorker env+ testWithApplication (pure (application env healthyProbe healthyProbe)) (slowWebhookScenario captured)+ drainNotifierWorker worker 10+ ]++healthyProbe :: ProbeCheck+healthyProbe = pure Healthy++-- | EP-8 against the real server: a signup + verify-email request delivered over the webhook+-- transport produces one signed @email_verification_requested@ POST whose token, replayed at+-- @\/v1\/auth\/verify-email\/confirm@, verifies the email — proving the delivered token is live.+webhookScenario :: MVar [([Header], BS.ByteString)] -> Text -> Int -> IO ()+webhookScenario captured whSecret port = do+ mgr <- newManager defaultManagerSettings+ let email = "webhook-user@example.com" :: Text+ pw = "correct horse battery staple" :: Text+ (sStatus, _) <- postJSON mgr port "/v1/auth/signup" (object ["loginId" .= email, "email" .= email, "password" .= pw, "displayName" .= ("" :: Text)])+ sStatus @?= 201+ (reqStatus, _) <- postJSON mgr port "/v1/auth/verify-email/request" (object ["email" .= email])+ reqStatus @?= 202+ reqs <- awaitNotifications captured 1 100+ case reqs of+ [(hdrs, body)] -> do+ lookup "X-Shomei-Notification-Type" hdrs @?= Just "email_verification_requested"+ lookup "Content-Type" hdrs @?= Just "application/json"+ timestamp <- must "timestamp header" (lookup "X-Shomei-Timestamp" hdrs)+ -- The signature covers the receiver-visible timestamp and exact bytes delivered.+ lookup "X-Shomei-Signature" hdrs @?= Just (webhookSignature (Text.encodeUtf8 whSecret) timestamp body)+ token <- must "token in webhook payload" (decode (LBS.fromStrict body) >>= dig ["token"] >>= asText)+ -- the delivered token is the live one: it confirms the email.+ (cStatus, _) <- postJSON mgr port "/v1/auth/verify-email/confirm" (object ["token" .= token])+ cStatus @?= 200+ _ -> assertFailure ("expected exactly one webhook delivery, got " <> show (length reqs))++awaitNotifications :: MVar [a] -> Int -> Int -> IO [a]+awaitNotifications captured expected attempts = go attempts+ where+ go remaining = do+ values <- readMVar captured+ if length values >= expected || remaining <= 0+ then pure values+ else threadDelay 100_000 >> go (remaining - 1)++slowWebhookScenario :: MVar [([Header], BS.ByteString)] -> Int -> IO ()+slowWebhookScenario captured port = do+ mgr <- newManager defaultManagerSettings+ let email = "slow-webhook-user@example.com" :: Text+ pw = "correct horse battery staple" :: Text+ (signupStatus, _) <-+ postJSON mgr port "/v1/auth/signup" (object ["loginId" .= email, "email" .= email, "password" .= pw, "displayName" .= ("" :: Text)])+ signupStatus @?= 201+ (verifyStatus, _) <- postJSON mgr port "/v1/auth/verify-email/request" (object ["email" .= email])+ verifyStatus @?= 202+ -- Let the setup notification finish so the timed reset starts with an idle worker.+ firstDeliveries <- awaitNotifications captured 1 100+ length firstDeliveries @?= 1++ started <- getMonotonicTime+ (resetStatus, _) <- postJSON mgr port "/v1/auth/password-reset/request" (object ["email" .= email])+ elapsed <- subtract started <$> getMonotonicTime+ resetStatus @?= 202+ assertBool ("request waited on the three-second receiver: " <> show elapsed <> "s") (elapsed < 1.0)+ deliveries <- awaitNotifications captured 2 100+ length deliveries @?= 2++-- | A fixed 32-byte AES-256-GCM key for the E2E TOTP secrets. Its value is irrelevant to the+-- test; it only has to round-trip encrypt→decrypt through 'Shomei.Mfa.Totp.Postgres'.+e2eTotpKey :: TotpEncryptionKey+e2eTotpKey = either (error . Text.unpack) id (totpEncryptionKeyFromBytes (BS.replicate 32 9))++-- | The secret the seeded OAuth client authenticates with; only its digest reaches the row.+oidcClientSecret :: Text+oidcClientSecret = "e2e-oauth-client-secret"++oidcRedirectUri :: Text+oidcRedirectUri = "http://localhost:9999/callback"++-- | Insert a confidential OAuth client through the PostgreSQL interpreter, as+-- @shomei-admin oauth-clients create@ does. Returns its @client_id@.+seedOAuthClient :: Pool -> IO Text+seedOAuthClient pool = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePool pool+ . runClockIO+ . runOAuthClientStorePostgres+ $ do+ ocid <- genOAuthClientId+ let cid = idText ocid+ ts <- now+ _ <-+ createOAuthClient+ NewOAuthClient+ { oauthClientId = ocid,+ clientId = cid,+ secretHash = Just (sha256Hex oidcClientSecret),+ clientType = ConfidentialClient,+ displayName = "e2e rp",+ redirectUris = [oidcRedirectUri],+ allowedScopes = Set.fromList [Scope "openid", Scope "profile"],+ createdAt = ts+ }+ pure cid+ either (assertFailure . ("could not seed the oauth client: " <>) . show) pure outcome++-- | The full OIDC transcript from this plan's Purpose, against real PostgreSQL and real ES256.+oidcScenario :: Text -> Int -> IO ()+oidcScenario clientId port = do+ mgr <- newManager defaultManagerSettings++ -- A user to authenticate the authorize request.+ (sStatus, sBody) <- postJSON mgr port "/v1/auth/signup" (object ["loginId" .= ("oidc-user" :: Text), "password" .= ("correct horse battery staple" :: Text), "displayName" .= ("" :: Text)])+ sStatus @?= 201+ sresp <- must "signup body" sBody+ userToken <- must "signup accessToken" (dig ["token", "accessToken"] sresp >>= asText)++ let verifier = "an-e2e-pkce-code-verifier-of-more-than-forty-three-chars" :: Text+ challenge = pkceChallengeFor verifier+ basic = Just (clientId, oidcClientSecret)++ -- authorize with the user's bearer token → 302 carrying a single-use code.+ authResp <-+ getNoRedirect+ mgr+ port+ ( "/oauth/authorize?response_type=code&client_id="+ <> Text.unpack clientId+ <> "&redirect_uri="+ <> escape oidcRedirectUri+ <> "&scope=openid%20profile&state=xyz&nonce=n-0S6&code_challenge="+ <> Text.unpack challenge+ <> "&code_challenge_method=S256"+ )+ (bearer userToken)+ let (authStatus, authHdrs, _) = authResp+ authStatus @?= 302+ loc <- must "Location" (lookup "Location" authHdrs >>= (Just . Text.decodeUtf8))+ code <- must "code in redirect" (paramFrom "code" loc)++ -- exchange with the PKCE verifier → all three tokens.+ (xStatus, _, xBody) <- postForm mgr port "/oauth/token" basic [("grant_type", "authorization_code"), ("code", Text.encodeUtf8 code), ("redirect_uri", Text.encodeUtf8 oidcRedirectUri), ("code_verifier", Text.encodeUtf8 verifier)]+ xStatus @?= 200+ xresp <- must "token body" xBody+ access <- must "access_token" (dig ["access_token"] xresp >>= asText)+ refresh <- must "refresh_token" (dig ["refresh_token"] xresp >>= asText)+ idToken <- must "id_token" (dig ["id_token"] xresp >>= asText)++ -- the id_token's signing key is published, its aud is the client, its nonce echoes.+ (jStatus, jBody) <- getJSON mgr port "/.well-known/jwks.json" []+ jStatus @?= 200+ jwks <- must "jwks body" jBody+ assertBool "the id_token's kid is in the served JWKS" (maybe False (`elem` jwksKids jwks) (jwtHeaderKid idToken))+ idPayload <- must "id_token payload" (jwtPayload idToken)+ (dig ["aud"] idPayload >>= asText) @?= Just clientId+ (dig ["nonce"] idPayload >>= asText) @?= Just "n-0S6"++ -- userinfo: same sub as the id_token.+ (uStatus, uBody) <- getJSON mgr port "/oauth/userinfo" (bearer access)+ uStatus @?= 200+ uresp <- must "userinfo body" uBody+ (dig ["sub"] uresp >>= asText) @?= (dig ["sub"] idPayload >>= asText)++ -- introspect the access token → active.+ (iStatus, _, iBody) <- postForm mgr port "/oauth/introspect" basic [("token", Text.encodeUtf8 access)]+ iStatus @?= 200+ iresp <- must "introspect body" iBody+ dig ["active"] iresp @?= Just (jsonBool True)++ -- revoke the refresh token → 200.+ (rStatus, _, _) <- postForm mgr port "/oauth/revoke" basic [("token", Text.encodeUtf8 refresh), ("token_type_hint", "refresh_token")]+ rStatus @?= 200++ -- introspect again → inactive (the session is gone, and introspection is session-aware).+ (i2Status, _, i2Body) <- postForm mgr port "/oauth/introspect" basic [("token", Text.encodeUtf8 access)]+ i2Status @?= 200+ i2resp <- must "introspect body 2" i2Body+ dig ["active"] i2resp @?= Just (jsonBool False)++-- | The @Location@ query parameter named @k@, percent-decoded enough for our fixed values.+paramFrom :: Text -> Text -> Maybe Text+paramFrom k loc = do+ let (_, query) = Text.breakOn "?" loc+ listToMaybe [v | pair <- Text.splitOn "&" (Text.drop 1 query), let (n, rest) = Text.breakOn "=" pair, n == k, let v = Text.drop 1 rest]++-- | GET without following redirects, so a 302 is the response under test.+getNoRedirect :: Manager -> Int -> String -> [Header] -> IO (Int, [Header], Maybe Value)+getNoRedirect mgr port path hdrs = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req = req0 {method = "GET", requestHeaders = hdrs, redirectCount = 0}+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), responseHeaders resp, decode (responseBody resp))++-- | A JWT's payload segment as JSON (unverified; the kid/JWKS check above is the signature proof).+jwtPayload :: Text -> Maybe Value+jwtPayload token = do+ seg <- case Text.splitOn "." token of (_ : p : _) -> Just p; _ -> Nothing+ raw <- either (const Nothing) Just (B64U.decodeBase64UnpaddedUntyped (Text.encodeUtf8 seg))+ decode (LBS.fromStrict raw)++-- | Minimal percent-encoding for the fixed redirect URI in the authorize query string.+escape :: Text -> String+escape = concatMap enc . Text.unpack+ where+ enc ':' = "%3A"+ enc '/' = "%2F"+ enc c = [c]++jsonBool :: Bool -> Value+jsonBool = Aeson.Bool++-- | The secret the seeded service account authenticates with. Only its digest reaches the row.+oauthSecret :: Text+oauthSecret = "e2e-service-secret"++-- | Insert a service account and its backing user straight through the PostgreSQL interpreters,+-- as @shomei-admin service-accounts create@ does. Returns its @client_id@.+seedServiceAccount :: Pool -> IO Text+seedServiceAccount pool = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePool pool+ . runClockIO+ . runServiceAccountStorePostgres+ . runUserStorePostgres+ $ do+ said <- genServiceAccountDbId+ let cid = idText said+ ts <- now+ loginId <- either (const (error "bad login id")) pure (mkLoginId cid)+ User {userId = backingUserId} <- createUser NewUser {loginId, email = Nothing, displayName = Just "e2e connector"}+ _ <-+ createServiceAccount+ NewServiceAccount+ { serviceAccountId = said,+ clientId = cid,+ userId = backingUserId,+ secretHash = sha256Hex oauthSecret,+ displayName = "e2e connector",+ allowedScopes = Set.singleton (Scope "kawa:ingest"),+ createdAt = ts+ }+ pure cid+ either (assertFailure . ("could not seed the service account: " <>) . show) pure outcome++-- | The whole EP-4 promise, against the real stack: a stock OAuth2 request mints a token that the+-- server's own verifier accepts, whose signing key is published, and whose issuance is audited.+oauthScenario :: Pool -> Text -> Int -> IO ()+oauthScenario pool clientId port = do+ mgr <- newManager defaultManagerSettings++ -- (a) A textbook client_credentials request with client_secret_basic.+ (tStatus, tHdrs, tBody) <- postForm mgr port "/oauth/token" (Just (clientId, oauthSecret)) [("grant_type", "client_credentials"), ("scope", "kawa:ingest")]+ tStatus @?= 200+ -- RFC 6749 §5.1: a token response must never be cached.+ lookup "Cache-Control" tHdrs @?= Just "no-store"+ tresp <- must "token body" tBody+ (dig ["token_type"] tresp >>= asText) @?= Just "Bearer"+ (dig ["scope"] tresp >>= asText) @?= Just "kawa:ingest"+ access <- must "access_token" (dig ["access_token"] tresp >>= asText)++ -- (b) The minted token is a real Shōmei token: the server's own auth handler accepts it on an+ -- ordinary authenticated route, and resolves it to the account's backing user.+ (meStatus, meBody) <- getJSON mgr port "/v1/auth/me" (bearer access)+ meStatus @?= 200+ meresp <- must "me body" meBody+ (dig ["displayName"] meresp >>= asText) @?= Just "e2e connector"++ -- (c) Its signing key is the one published at the JWKS endpoint, so any downstream verifier+ -- that fetches /.well-known/jwks.json can check this token offline.+ (jStatus, jBody) <- getJSON mgr port "/.well-known/jwks.json" []+ jStatus @?= 200+ jwks <- must "jwks body" jBody+ let tokenKid = jwtHeaderKid access+ assertBool "the token's kid is published in the JWKS document" (maybe False (`elem` jwksKids jwks) tokenKid)++ -- (d) A wrong secret is an RFC 6749 object, not a problem document.+ (bStatus, bHdrs, bBody) <- postForm mgr port "/oauth/token" (Just (clientId, "wrong")) [("grant_type", "client_credentials")]+ bStatus @?= 401+ assertBool+ "OAuth errors use application/json"+ (maybe False ("application/json" `BS.isPrefixOf`) (lookup "Content-Type" bHdrs))+ lookup "WWW-Authenticate" bHdrs @?= Just "Basic realm=\"shomei\""+ berr <- must "error body" bBody+ (dig ["error"] berr >>= asText) @?= Just "invalid_client"++ -- (e) The successful issuance is audited; the failure mints nothing.+ issued <- scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'service_token_issued'"+ issued @?= 1+ sessions <- scalarInt pool "SELECT count(*) FROM shomei.shomei_sessions"+ sessions @?= 1+ -- A client_credentials session is refresh-less: the credential cannot outlive its TTL.+ refreshes <- scalarInt pool "SELECT count(*) FROM shomei.shomei_refresh_tokens"+ refreshes @?= 0++-- | The secret the EP-6 exchange service account authenticates with.+exchangeSecret :: Text+exchangeSecret = "e2e-exchange-secret"++-- | Create the operator and its live interactive session directly through the PostgreSQL ports.+-- The scoped token is hand-signed by the host, but its principal and session are both real.+seedOperatorPrincipal :: Pool -> IO (UserId, SessionId)+seedOperatorPrincipal pool = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePool pool+ . runClockIO+ . runSessionStorePostgres+ . runUserStorePostgres+ $ do+ loginId <- either (const (error "bad login id")) pure (mkLoginId "e2e-operator")+ User {userId} <- createUser NewUser {loginId, email = Nothing, displayName = Just "e2e operator"}+ ts <- now+ session <-+ createSession+ NewSession+ { userId,+ createdAt = ts,+ expiresAt = addUTCTime 3600 ts,+ actor = Nothing,+ oauthClientId = Nothing,+ kind = InteractiveSession,+ grantedScopes = Set.empty,+ authenticatedAt = ts,+ remembered = False+ }+ pure (userId, session.sessionId)+ either (assertFailure . ("could not seed the operator user: " <>) . show) pure outcome++-- | Seed a service account holding both @kawa:ingest@ and the @token-exchange:subject@ gate scope,+-- so it can drive the RFC 8693 on-behalf-of grant. Returns its @client_id@.+seedExchangeServiceAccount :: Pool -> IO Text+seedExchangeServiceAccount pool = do+ outcome <-+ runEff+ . runErrorNoCallStack @AuthError+ . runDatabasePool pool+ . runClockIO+ . runServiceAccountStorePostgres+ . runUserStorePostgres+ $ do+ said <- genServiceAccountDbId+ let cid = idText said+ ts <- now+ loginId <- either (const (error "bad login id")) pure (mkLoginId cid)+ User {userId = backingUserId} <- createUser NewUser {loginId, email = Nothing, displayName = Just "e2e exchange svc"}+ _ <-+ createServiceAccount+ NewServiceAccount+ { serviceAccountId = said,+ clientId = cid,+ userId = backingUserId,+ secretHash = sha256Hex exchangeSecret,+ displayName = "e2e exchange svc",+ allowedScopes = Set.fromList [Scope "kawa:ingest", Scope "token-exchange:subject"],+ createdAt = ts+ }+ pure cid+ either (assertFailure . ("could not seed the exchange service account: " <>) . show) pure outcome++-- | The RFC 8693 token-exchange promise against the real stack: both modes mint a delegated token+-- the server's own verifier and JWKS accept, and both write their audit event with subject + actor.+exchangeScenario :: ShomeiConfig -> Pool -> Text -> Text -> Int -> IO ()+exchangeScenario cfg pool clientId opTok port = do+ mgr <- newManager defaultManagerSettings+ let teGrant = ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")+ userIdType = "urn:shomei:params:oauth:token-type:user-id"+ accessType = "urn:ietf:params:oauth:token-type:access_token"+ accessTypeText = "urn:ietf:params:oauth:token-type:access_token" :: Text+ enc = Text.encodeUtf8+ pw = "correct horse battery staple" :: Text+ signup loginId = do+ (s, b) <- postJSON mgr port "/v1/auth/signup" (object ["loginId" .= loginId, "password" .= pw, "displayName" .= ("" :: Text)])+ s @?= 201+ resp <- must (loginId <> " signup body") b+ tok <- must (loginId <> " accessToken") (dig ["token", "accessToken"] resp >>= asText)+ uid <- must (loginId <> " userId") (dig ["user", "userId"] resp >>= asText)+ pure (tok, uid)++ (subjectAccess, subjectId) <- signup "exchange-subject"+ (_, targetId) <- signup "exchange-target"++ -- ===== Service on-behalf-of =====+ (obStatus, obHdrs, obBody) <-+ postForm mgr port "/oauth/token" (Just (clientId, exchangeSecret)) [teGrant, ("subject_token", enc subjectAccess), ("subject_token_type", accessType), ("scope", "kawa:ingest")]+ obStatus @?= 200+ lookup "Cache-Control" obHdrs @?= Just "no-store"+ obResp <- must "on-behalf body" obBody+ (dig ["scope"] obResp >>= asText) @?= Just "kawa:ingest"+ (dig ["issued_token_type"] obResp >>= asText) @?= Just accessTypeText+ obAccess <- must "on-behalf access_token" (dig ["access_token"] obResp >>= asText)++ -- The delegated token is signed by the published key: a downstream verifier checks it offline.+ (jStatus, jBody) <- getJSON mgr port "/.well-known/jwks.json" []+ jStatus @?= 200+ jwks <- must "jwks body" jBody+ assertBool "the on-behalf token's kid is published" (maybe False (`elem` jwksKids jwks) (jwtHeaderKid obAccess))++ publicKeys <- case Aeson.fromJSON jwks of+ Aeson.Success value -> pure value+ Aeson.Error err -> assertFailure err+ verified <- either (assertFailure . show) pure =<< verifyToken publicKeys cfg obAccess+ idText verified.subject @?= subjectId+ assertBool "standalone verifier preserves the delegated actor" (isJust verified.actor)++ -- Introspection verifies the JWT server-side and reports sub (the user), the scope, and act (the+ -- service) — sub/act/scopes asserted against a real verify, not a hand decode.+ (iStatus, _, iBody) <- postForm mgr port "/oauth/introspect" (Just (clientId, exchangeSecret)) [("token", enc obAccess)]+ iStatus @?= 200+ intro <- must "on-behalf introspect body" iBody+ dig ["active"] intro @?= Just (Bool True)+ (dig ["sub"] intro >>= asText) @?= Just subjectId+ (dig ["scope"] intro >>= asText) @?= Just "kawa:ingest"+ assertBool "on-behalf token carries an act member" (isJust (dig ["act", "sub"] intro >>= asText))++ actorId <- must "introspected actor" (dig ["act", "sub"] intro >>= asText)+ payload <- must "delegated JWT payload" (jwtPayload obAccess)+ dig ["act"] payload+ @?= Just+ ( if cfg.signingKeyConfig.emitLegacyActor+ then String actorId+ else object ["sub" .= actorId]+ )++ -- ===== Impersonation =====+ (impStatus, _, impBody) <-+ postForm mgr port "/oauth/token" Nothing [teGrant, ("subject_token", enc targetId), ("subject_token_type", userIdType), ("actor_token", enc opTok), ("actor_token_type", accessType), ("reason", "e2e ticket")]+ impStatus @?= 200+ impResp <- must "impersonation body" impBody+ (dig ["issued_token_type"] impResp >>= asText) @?= Just accessTypeText+ impAccess <- must "impersonation access_token" (dig ["access_token"] impResp >>= asText)+ (impIStatus, _, impIBody) <- postForm mgr port "/oauth/introspect" (Just (clientId, exchangeSecret)) [("token", enc impAccess)]+ impIStatus @?= 200+ impIntro <- must "impersonation introspect body" impIBody+ (dig ["sub"] impIntro >>= asText) @?= Just targetId+ assertBool "impersonation token carries an act member" (isJust (dig ["act", "sub"] impIntro >>= asText))++ -- ===== Audit: both events written, each carrying subject and actor ids =====+ onBehalf <-+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'service_on_behalf_issued' AND payload->>'subjectUserId' IS NOT NULL AND payload->>'actorUserId' IS NOT NULL"+ onBehalf @?= 1+ impStarted <-+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'impersonation_started' AND payload->>'subjectUserId' IS NOT NULL AND payload->>'actorUserId' IS NOT NULL"+ impStarted @?= 1++-- | The @kid@ from a JWT's header, without verifying anything.+jwtHeaderKid :: Text -> Maybe Text+jwtHeaderKid token = do+ seg <- listToMaybe (Text.splitOn "." token)+ raw <- either (const Nothing) Just (B64U.decodeBase64UnpaddedUntyped (Text.encodeUtf8 seg))+ val <- decode (LBS.fromStrict raw)+ dig ["kid"] val >>= asText++jwksKids :: Value -> [Text]+jwksKids v = case dig ["keys"] v of+ Just (Array ks) -> [k | Object o <- toList ks, Just (String k) <- [KM.lookup "kid" o]]+ _ -> []++-- | EP-7's Purpose transcript against real PostgreSQL and real ES256: enroll TOTP, activate it,+-- log in and complete the challenge with a fresh code, then complete a second login with a+-- recovery code and watch the count drop — with the whole flow recorded in the audit trail.+--+-- The wall clock is real here, so a confirmed code cannot be replayed at the same time-step+-- counter. The completion presents the NEXT counter's code, which the ±1 acceptance window+-- accepts whether or not the clock has ticked into the next step, and which is strictly greater+-- than the confirming counter — so the test never has to wait 30 seconds.+totpScenario :: Pool -> Int -> IO ()+totpScenario pool port = do+ mgr <- newManager defaultManagerSettings+ let loginId = "totp-e2e-user" :: Text+ pw = "correct horse battery staple totp" :: Text+ login = postJSON mgr port "/v1/auth/login" (object ["loginId" .= loginId, "password" .= pw])++ (sStatus, sBody) <- postJSON mgr port "/v1/auth/signup" (object ["loginId" .= loginId, "password" .= pw, "displayName" .= ("" :: Text)])+ sStatus @?= 201+ access <- must "signup accessToken" (sBody >>= dig ["token", "accessToken"] >>= asText)++ -- enroll: the secret is shown once.+ (enStatus, enBody) <- postAuthNoBody mgr port "/v1/auth/totp/enroll" (bearer access)+ enStatus @?= 200+ secretB32 <- must "enroll secret" (enBody >>= dig ["secret"] >>= asText)+ secret <- either (assertFailure . ("bad base32 secret: " <>)) pure (base32ToSecret secretB32)++ -- activate with the current code.+ t <- getCurrentTime+ let c = totpCounter t+ (vStatus, _) <- postJSONAuth mgr port "/v1/auth/totp/verify" (bearer access) (object ["code" .= totpCode 6 secret c])+ vStatus @?= 200++ -- login now challenges for the TOTP factor; complete with the next counter's code.+ (mStatus, mBody) <- login+ mStatus @?= 200+ (mBody >>= dig ["status"] >>= asText) @?= Just "mfa_required"+ methods <- must "methods" (mBody >>= dig ["methods"] >>= asTextArray)+ assertBool "totp advertised in methods" ("totp" `elem` methods)+ cid <- must "ceremonyId" (mBody >>= dig ["ceremonyId"] >>= asText)+ (cStatus, cBody) <-+ postJSON+ mgr+ port+ "/v1/auth/mfa/complete"+ (object ["ceremonyId" .= cid, "proof" .= object ["type" .= ("totp" :: Text), "code" .= totpCode 6 secret (c + 1)]])+ cStatus @?= 200+ mfaAccess <- must "mfa accessToken" (cBody >>= dig ["accessToken"] >>= asText)+ -- the MFA-issued token verifies against the running server's key material.+ (meStatus, _) <- getJSON mgr port "/v1/auth/me" (bearer mfaAccess)+ meStatus @?= 200++ -- generate recovery codes, complete another login with one, and watch the count drop to nine.+ (gStatus, gBody) <- postAuthNoBody mgr port "/v1/auth/recovery-codes" (bearer mfaAccess)+ gStatus @?= 200+ codes <- must "recovery codes" (gBody >>= dig ["codes"] >>= asTextArray)+ length codes @?= 10+ (m2Status, m2Body) <- login+ m2Status @?= 200+ cid2 <- must "cid2" (m2Body >>= dig ["ceremonyId"] >>= asText)+ (rcStatus, rcBody) <-+ postJSON+ mgr+ port+ "/v1/auth/mfa/complete"+ (object ["ceremonyId" .= cid2, "proof" .= object ["type" .= ("recovery_code" :: Text), "code" .= head codes]])+ rcStatus @?= 200+ rcAccess <- must "recovery accessToken" (rcBody >>= dig ["accessToken"] >>= asText)+ (cntStatus, cntBody) <- getJSON mgr port "/v1/auth/recovery-codes" (bearer rcAccess)+ cntStatus @?= 200+ (cntBody >>= dig ["remaining"] >>= asInt) @?= Just 9++ -- the whole flow is recorded against real PostgreSQL, and the secret sits encrypted at rest.+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'totp_enrolled'" >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'recovery_codes_generated'" >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'recovery_code_used'" >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'mfa_succeeded'" >>= (@?= 2)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_totp_credentials WHERE confirmed_at IS NOT NULL" >>= (@?= 1)++scenario :: Pool -> Int -> IO ()+scenario pool port = do+ mgr <- newManager defaultManagerSettings++ -- (a) signup: 201 with a token pair and an active user.+ (sStatus, sBody) <- postJSON mgr port "/v1/auth/signup" signupBody+ sStatus @?= 201+ sresp <- must "signup body" sBody+ (dig ["user", "email"] sresp >>= asText) @?= Just email+ (dig ["user", "status"] sresp >>= asText) @?= Just "active"+ signupRefresh <- must "signup refreshToken" (dig ["token", "refreshToken"] sresp >>= asText)++ -- (b) login: a fresh token pair.+ (lStatus, lBody) <- postJSON mgr port "/v1/auth/login" loginBody+ lStatus @?= 200+ lresp <- must "login body" lBody+ loginAccess <- must "login accessToken" (dig ["token", "accessToken"] lresp >>= asText)+ -- A login without "remember" is not remembered, and neither is its refresh.+ dig ["token", "remembered"] lresp @?= Just (Bool False)+ loginRefresh <- must "login refreshToken" (dig ["token", "refreshToken"] lresp >>= asText)+ (lrStatus, lrBody) <- postJSON mgr port "/v1/auth/refresh" (object ["refreshToken" .= loginRefresh])+ lrStatus @?= 200+ (lrBody >>= dig ["remembered"]) @?= Just (Bool False)++ -- (b') a remembered login: reported on sign-in and on every refresh, with a year-long session.+ (rlStatus, rlBody) <-+ postJSON mgr port "/v1/auth/login" (object ["loginId" .= email, "password" .= password, "remember" .= True])+ rlStatus @?= 200+ rlresp <- must "remembered login body" rlBody+ dig ["token", "remembered"] rlresp @?= Just (Bool True)+ rememberedRefresh <- must "remembered refreshToken" (dig ["token", "refreshToken"] rlresp >>= asText)+ (rrStatus, rrBody) <- postJSON mgr port "/v1/auth/refresh" (object ["refreshToken" .= rememberedRefresh])+ rrStatus @?= 200+ (rrBody >>= dig ["remembered"]) @?= Just (Bool True)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_sessions WHERE remembered AND expires_at - created_at = interval '365 days'"+ >>= (@?= 1)+ scalarInt pool "SELECT count(*) FROM shomei.shomei_sessions WHERE NOT remembered AND expires_at - created_at = interval '30 days'"+ >>= (@?= 2)++ -- (c) me with Bearer → 200; (d) without → 401.+ (meStatus, meBody) <- getJSON mgr port "/v1/auth/me" (bearer loginAccess)+ meStatus @?= 200+ meresp <- must "me body" meBody+ (dig ["email"] meresp >>= asText) @?= Just email+ (noTokStatus, _) <- getJSON mgr port "/v1/auth/me" []+ noTokStatus @?= 401++ -- (e) refresh rotates the signup token.+ (rStatus, rBody) <- postJSON mgr port "/v1/auth/refresh" (object ["refreshToken" .= signupRefresh])+ rStatus @?= 200+ rresp <- must "refresh body" rBody+ rotatedRefresh <- must "rotated refreshToken" (dig ["refreshToken"] rresp >>= asText)+ assertBool "rotated refresh token differs" (rotatedRefresh /= signupRefresh)++ -- (f) replaying the OLD signup refresh token is detected as theft → 401.+ (reuseStatus, _) <- postJSON mgr port "/v1/auth/refresh" (object ["refreshToken" .= signupRefresh])+ reuseStatus @?= 401++ -- reuse must have landed in PostgreSQL: the family's session is revoked and a+ -- reuse event row exists.+ revokedSessions <- scalarInt pool "SELECT count(*) FROM shomei.shomei_sessions WHERE status = 'revoked'"+ assertBool "reuse revoked the signup session" (revokedSessions >= 1)+ reuseEvents <-+ scalarInt+ pool+ "SELECT count(*) FROM shomei.shomei_auth_events WHERE event_type = 'refresh_token_reuse_detected'"+ reuseEvents @?= 1++ -- (g) the rotated token is now dead too (the whole session was revoked).+ (deadStatus, _) <- postJSON mgr port "/v1/auth/refresh" (object ["refreshToken" .= rotatedRefresh])+ deadStatus @?= 401++ -- (h) logout the login session → 204, and again → 204: logout is idempotent, so a retry+ -- after a network blip succeeds instead of reporting session_not_found.+ (logoutStatus, _) <- postJSON mgr port "/v1/auth/logout" (object [])+ -- logout needs a Bearer token; send it as a header-only POST.+ logoutStatus' <- postNoBody mgr port "/v1/auth/logout" (bearer loginAccess)+ logoutStatus @?= 401 -- no token → 401+ logoutStatus' @?= 204+ logoutAgain <- postNoBody mgr port "/v1/auth/logout" (bearer loginAccess)+ logoutAgain @?= 204++ -- (i) JWKS: a public key with a kid and no private "d".+ (jStatus, jBody) <- getJSON mgr port "/.well-known/jwks.json" []+ jStatus @?= 200+ jwks <- must "jwks body" jBody+ assertBool "jwks has keys[].kid" (jwksHasKid jwks)+ assertBool "jwks has no private 'd'" (not (hasKeyDeep "d" jwks))++ -- (j) liveness → 200.+ (hStatus, _) <- getJSON mgr port "/health/live" []+ hStatus @?= 200+ where+ email = "ada@example.com" :: Text+ password = "correct horse battery staple" :: Text+ signupBody = object ["loginId" .= email, "email" .= email, "password" .= password, "displayName" .= ("Ada Lovelace" :: Text)]+ loginBody = object ["loginId" .= email, "password" .= password]++-- HTTP helpers (parseRequest does not throw on non-2xx, so 401/404 come back as responses).++-- | POST an @application\/x-www-form-urlencoded@ body, optionally with @client_secret_basic@.+-- Exposes response headers too, because RFC 6749 puts contract in them (@Cache-Control@,+-- @WWW-Authenticate@).+postForm :: Manager -> Int -> String -> Maybe (Text, Text) -> [(BS.ByteString, BS.ByteString)] -> IO (Int, [Header], Maybe Value)+postForm mgr port path mBasic params = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let withBody = urlEncodedBody params req0+ req = maybe withBody (\(c, sec) -> applyBasicAuth (Text.encodeUtf8 c) (Text.encodeUtf8 sec) withBody) mBasic+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), responseHeaders resp, decode (responseBody resp))++postJSON :: Manager -> Int -> String -> Value -> IO (Int, Maybe Value)+postJSON mgr port path body = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req =+ req0+ { method = "POST",+ requestHeaders = [("Content-Type", "application/json")],+ requestBody = RequestBodyLBS (encode body)+ }+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), decode (responseBody resp))++-- | POST with no body, only headers (used for the authenticated logout). Returns status.+postNoBody :: Manager -> Int -> String -> [Header] -> IO Int+postNoBody mgr port path hdrs = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req = req0 {method = "POST", requestHeaders = hdrs}+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp))++-- | POST a JSON body with a bearer token (EP-7 TOTP verify).+postJSONAuth :: Manager -> Int -> String -> [Header] -> Value -> IO (Int, Maybe Value)+postJSONAuth mgr port path hdrs body = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req =+ req0+ { method = "POST",+ requestHeaders = ("Content-Type", "application/json") : hdrs,+ requestBody = RequestBodyLBS (encode body)+ }+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), decode (responseBody resp))++-- | POST with a bearer token and no body, returning the response body (EP-7 enroll / generate).+postAuthNoBody :: Manager -> Int -> String -> [Header] -> IO (Int, Maybe Value)+postAuthNoBody mgr port path hdrs = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req = req0 {method = "POST", requestHeaders = hdrs}+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), decode (responseBody resp))++getJSON :: Manager -> Int -> String -> [Header] -> IO (Int, Maybe Value)+getJSON mgr port path hdrs = do+ req0 <- parseRequest ("http://127.0.0.1:" <> show port <> path)+ let req = req0 {method = "GET", requestHeaders = hdrs}+ resp <- httpLbs req mgr+ pure (statusCode (responseStatus resp), decode (responseBody resp))++bearer :: Text -> [Header]+bearer tok = [("Authorization", "Bearer " <> Text.encodeUtf8 tok)]++-- A scalar count(*) query run directly against the pool, for DB-state assertions.+scalarInt :: Pool -> Text -> IO Int+scalarInt pool sql = do+ res <- Pool.use pool (Session.statement () stmt)+ either (assertFailure . ("scalar query failed: " <>) . show) pure res+ where+ stmt :: Statement () Int+ stmt = preparable sql E.noParams (D.singleRow (fromIntegral64 <$> D.column (D.nonNullable D.int8)))+ fromIntegral64 :: Int64 -> Int+ fromIntegral64 = fromIntegral++-- JSON navigation helpers.++must :: String -> Maybe a -> IO a+must label = maybe (assertFailure ("missing: " <> label)) pure++field :: Text -> Value -> Maybe Value+field k (Object o) = KM.lookup (K.fromText k) o+field _ _ = Nothing++dig :: [Text] -> Value -> Maybe Value+dig ks v0 = foldl (\mv k -> mv >>= field k) (Just v0) ks++asText :: Value -> Maybe Text+asText (String t) = Just t+asText _ = Nothing++asTextArray :: Value -> Maybe [Text]+asTextArray (Array xs) = traverse asText (toList xs)+asTextArray _ = Nothing++asInt :: Value -> Maybe Int+asInt (Aeson.Number n) = Just (round n)+asInt _ = Nothing++jwksHasKid :: Value -> Bool+jwksHasKid v = case dig ["keys"] v of+ Just (Array xs) -> case toList xs of+ (k0 : _) -> isJust (field "kid" k0)+ [] -> False+ _ -> False++hasKeyDeep :: Text -> Value -> Bool+hasKeyDeep k = go+ where+ go (Object o) = any (\(kk, vv) -> K.toText kk == k || go vv) (KM.toList o)+ go (Array xs) = any go (toList xs)+ go _ = False++-- | Cheap Argon2 parameters for tests. This suite hashes and verifies real passwords, and the+-- production cost (~100 ms per hash) would dominate its runtime. Hash strength is irrelevant+-- here; only that hashing round-trips.+testArgon2Params :: Argon2Params+testArgon2Params = Argon2Params {memoryKiB = 8192, iterations = 1, parallelism = 1}
+ test/Shomei/Server/MiddlewareSpec.hs view
@@ -0,0 +1,546 @@+-- | Tests for the standalone server's edge middleware. All of them are database-free: the+-- rate limiter takes its clock as an argument, the metrics registry is plain 'IORef's, and the+-- WAI middlewares are just functions we can apply to a 'defaultRequest'.+module Shomei.Server.MiddlewareSpec (tests) where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (AsyncException (ThreadKilled), ErrorCall (..), SomeException, evaluate, throwIO, toException, try)+import Control.Monad (forM, forM_, unless)+import Data.Aeson (Value (Object, String), decode, decodeStrict, (.=))+import Data.Aeson.Key (Key)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Builder (toLazyByteString)+import Data.ByteString.Char8 qualified as BC+import Data.ByteString.Lazy qualified as BL+import Data.ByteString.Lazy.Char8 qualified as BLC+import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)+import Data.List (sort)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Word (Word16, Word64, Word8)+import Network.HTTP.Types (hContentType, methodDelete, methodPost, statusCode)+import Network.HTTP.Types.Status (status200)+import Network.Socket (SockAddr (..), tupleToHostAddress, tupleToHostAddress6)+import Network.Wai (Request (..), RequestBodyLength (..), Response, defaultRequest, getRequestBodyChunk, responseHeaders, responseLBS, responseStatus, responseToStream, setRequestBodyChunks)+import Network.Wai.Handler.Warp (InvalidRequest (BadFirstLine, PayloadTooLarge))+import Network.Wai.Internal (ResponseReceived (..))+import Servant.Health.Paths (healthRawPaths)+import Shomei.Account.Password.Hash.Postgres (defaultArgon2Params)+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))+import Shomei.Config (LogFormat (..), RateLimitConfig (..), ShomeiConfig (..), defaultObservabilityConfig, defaultRateLimitConfig, defaultShomeiConfig)+import Shomei.Servant.Api (shomeiThrottledRoutes)+import Shomei.Servant.ClientIp (clientIpText)+import Shomei.Servant.Error (ProblemDetails (..))+import Shomei.Servant.Throttle (PathSegment (Literal), ThrottledRoute (..))+import Shomei.Server.Boot (hostMiddleware)+import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), SweepSettings (..), defaultSweepSettings)+import Shomei.Server.ExceptionResponse (problemExceptionResponse)+import Shomei.Server.Middleware.BodyLimit (bodyLimitMiddleware)+import Shomei.Server.Middleware.RateLimit (bucketCount, newRateLimiterFor, newRateLimiterWith, takeToken, throttledPath)+import Shomei.Server.Middleware.TrustedProxy (emptyTrustedProxies, parseTrustedProxies, trustedProxyMiddleware)+import Shomei.Server.Observability.Logging (emitLine, renderLogLine, requestLoggingMiddleware, responseLogLevel, serverErrorLine)+import Shomei.Server.Observability.Metrics (exportMetrics, metricsMiddleware, newMetrics)+import System.Directory (getTemporaryDirectory, removeFile)+import System.IO (BufferMode (LineBuffering), hClose, hSetBuffering, openTempFile)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "middleware hardening"+ [ testGroup+ "trusted proxies"+ [ testUntrustedPeerIgnoresForwardedFor,+ testTrustedPeerUsesRightmostUntrustedHop,+ testSpoofedForwardedHopIsIgnored,+ testAllTrustedHopsUseOrigin,+ testInvalidTrustedProxyRefused+ ],+ testGroup+ "client ip rendering"+ [ testIpv4Rendering,+ testIpv6Rendering+ ],+ testGroup+ "rate limiter"+ [ testIdleBucketsEvicted,+ testDrainedBucketSurvivesSweep,+ testBucketMapStaysBounded,+ testThrottledPathsAreVersioned+ ],+ testGroup+ "metrics"+ [ testThrowingHandlerLeavesGaugeAtZero,+ testNormalRequestCountedAndGaugeReturnsToZero,+ testHostileMethodsShareTheBoundedSeries+ ],+ testGroup+ "logging"+ [ testCase "request severity distinguishes 5xx, throttle and ordinary authentication failures" do+ map responseLogLevel [200, 401, 403, 429, 500, 503] @?= ["info", "info", "info", "warn", "error", "error"],+ testRenderLogLineIsOneJsonLine,+ testPlainFormatStripsControlCharacters,+ testConcurrentWritersProduceIntactLines,+ testServerErrorLineIsStructured,+ testHealthRequestsSkipLogging+ ],+ testGroup+ "body limit"+ [ testHostMiddlewareOrder,+ testOversizedBodyRejected,+ testKnownSmallBodyPassesThrough,+ testChunkedBodyOverCapRejected,+ testSmallChunkedBodyPassesThrough,+ testEscapedExceptionRendersProblem,+ testWarpInvalidRequestsRenderProblems,+ testAsyncExceptionIsRethrown+ ]+ ]++-- Trusted proxies and canonical client identity --------------------------------++testUntrustedPeerIgnoresForwardedFor :: TestTree+testUntrustedPeerIgnoresForwardedFor = testCase "an untrusted peer's X-Forwarded-For is ignored" do+ actual <- observedClient ["10.0.0.0/8"] (ipv4 198 51 100 9) "203.0.113.7"+ actual @?= "198.51.100.9"++testTrustedPeerUsesRightmostUntrustedHop :: TestTree+testTrustedPeerUsesRightmostUntrustedHop = testCase "a trusted peer yields the rightmost untrusted hop" do+ actual <- observedClient ["10.0.0.0/8"] (ipv4 10 0 0 5) "203.0.113.7, 10.0.0.6"+ actual @?= "203.0.113.7"++testSpoofedForwardedHopIsIgnored :: TestTree+testSpoofedForwardedHopIsIgnored = testCase "a spoofed hop behind a trusted proxy is not the client" do+ actual <- observedClient ["10.0.0.0/8"] (ipv4 10 0 0 5) "1.2.3.4, 203.0.113.7"+ actual @?= "203.0.113.7"++testAllTrustedHopsUseOrigin :: TestTree+testAllTrustedHopsUseOrigin = testCase "every hop trusted falls back to the chain's origin" do+ actual <- observedClient ["10.0.0.0/8"] (ipv4 10 0 0 5) "10.0.0.1, 10.0.0.2"+ actual @?= "10.0.0.1"++testInvalidTrustedProxyRefused :: TestTree+testInvalidTrustedProxyRefused = testCase "an invalid trusted-proxy entry is refused" do+ case parseTrustedProxies ["10.0.0.0/8", "nope"] of+ Left _ -> pure ()+ Right _ -> fail "expected the invalid proxy entry to be rejected"++testIpv4Rendering :: TestTree+testIpv4Rendering = testCase "IPv4 renders as a dotted quad" do+ clientIpText (ipv4 127 0 0 1) @?= "127.0.0.1"++testIpv6Rendering :: TestTree+testIpv6Rendering = testCase "IPv6 renders per RFC 5952" do+ clientIpText (ipv6 (0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)) @?= "2001:db8::1"+ clientIpText (ipv6 (0, 0, 0, 0, 0, 0, 0, 1)) @?= "::1"+ clientIpText (ipv6 (0x2001, 0x0db8, 0, 1, 0, 0, 0, 0)) @?= "2001:db8:0:1::"+ clientIpText (ipv6 (0x2001, 0, 0, 1, 0, 0, 0, 1)) @?= "2001:0:0:1::1"++observedClient :: [Text] -> SockAddr -> ByteString -> IO Text+observedClient proxyRanges peer forwarded = do+ proxies <- either (fail . show) pure (parseTrustedProxies proxyRanges)+ observed <- newIORef ""+ let req =+ defaultRequest+ { remoteHost = peer,+ requestHeaders = [("X-Forwarded-For", forwarded)]+ }+ inner request' respond = writeIORef observed (clientIpText (remoteHost request')) >> respond okResponse+ ResponseReceived <- trustedProxyMiddleware proxies inner req (const (pure ResponseReceived))+ readIORef observed++ipv4 :: Word8 -> Word8 -> Word8 -> Word8 -> SockAddr+ipv4 a b c d = SockAddrInet 4711 (tupleToHostAddress (a, b, c, d))++ipv6 :: (Word16, Word16, Word16, Word16, Word16, Word16, Word16, Word16) -> SockAddr+ipv6 groups = SockAddrInet6 4711 0 (tupleToHostAddress6 groups) 0++-- Rate limiter ---------------------------------------------------------------++-- | A limiter with a 3-token burst refilling at 1 token/second, pruning every @every@ calls.+testLimiterConfig :: RateLimitConfig+testLimiterConfig =+ defaultRateLimitConfig+ { perIpBurst = 3,+ perIpRequestsPerMinute = 60,+ rateLimitEnabled = True+ }++-- | Every bucket touched at @t=0@ has long since refilled by @t=100@, so the sweep that fires+-- on the eighth call drops all of them. The freshly drained bucket that triggered the sweep+-- survives, because it is not full.+testIdleBucketsEvicted :: TestTree+testIdleBucketsEvicted = testCase "idle buckets are evicted after a sweep" do+ rl <- newRateLimiterWith 8 testLimiterConfig+ -- Eight distinct IPs, one call each at t=0. The eighth call triggers a sweep, but at t=0+ -- every bucket holds 2 of its 3 tokens, so nothing is evictable.+ forM_ [1 .. 8 :: Int] \i -> takeToken rl (ipKey i) 0+ before <- bucketCount rl+ assertEqual "no bucket is full at t=0, so the first sweep evicts nothing" 8 before+ -- Eight calls for a ninth IP at t=100. The sweep on the eighth prunes ip1..ip8 (each has+ -- refilled from 2 tokens to well past its 3-token capacity) and keeps ip9 (drained to 0).+ forM_ [1 .. 8 :: Int] \_ -> takeToken rl "ip9" 100+ after <- bucketCount rl+ assertEqual "only the mid-refill bucket survives" 1 after++-- | Eviction must be observationally lossless: a bucket that still owes tokens is never+-- dropped, so a client that has been throttled stays throttled across a sweep.+testDrainedBucketSurvivesSweep :: TestTree+testDrainedBucketSurvivesSweep = testCase "a drained bucket survives a sweep (lossless)" do+ rl <- newRateLimiterWith 8 testLimiterConfig+ -- Drain ipA at t=0: three allowed, the fourth refused.+ allowed <- forM [1 .. 4 :: Int] \_ -> takeToken rl "ipA" 0+ assertEqual "burst of 3, then refusal" [True, True, True, False] allowed+ -- Four more calls (still t=0) push the counter to 8 and fire the sweep.+ forM_ [1 .. 4 :: Int] \_ -> takeToken rl "ipB" 0+ -- If the sweep had dropped ipA, this would be treated as a fresh full bucket and allowed.+ stillRefused <- takeToken rl "ipA" 0+ assertEqual "the drained bucket was not resurrected by the sweep" False stillRefused++-- | The whole point: ten thousand one-shot IPs must not leave ten thousand buckets behind.+testBucketMapStaysBounded :: TestTree+testBucketMapStaysBounded = testCase "10k one-shot IPs leave a bounded map" do+ let every = 8+ rl <- newRateLimiterWith every testLimiterConfig+ -- One call per IP, ten seconds apart, so each bucket is fully refilled by the next sweep.+ forM_ [1 .. 10_000 :: Int] \i -> takeToken rl (ipKey i) (fromIntegral i * 10)+ n <- bucketCount rl+ assertBool+ ("expected at most " <> show (every + 1) <> " buckets, got " <> show n)+ (n <= every + 1)++-- | Pin the API-derived set to the credential proofs documented for operators. Adding or moving a+-- 'RateLimited' marker changes this value automatically and makes the conformance assertion fail+-- until the documentation and threat-model inventory are reviewed together.+testThrottledPathsAreVersioned :: TestTree+testThrottledPathsAreVersioned = testCase "the derived throttled set matches every credential proof" do+ let actual = Set.fromList shomeiThrottledRoutes+ expected =+ Set.fromList+ [ postRoute ["v1", "auth", "login"],+ postRoute ["v1", "auth", "signup"],+ postRoute ["v1", "auth", "refresh"],+ postRoute ["v1", "auth", "mfa", "complete"],+ postRoute ["v1", "auth", "login", "passkey", "begin"],+ postRoute ["v1", "auth", "login", "passkey", "complete"],+ postRoute ["v1", "auth", "password", "change"],+ postRoute ["v1", "auth", "verify-email", "request"],+ postRoute ["v1", "auth", "verify-email", "confirm"],+ postRoute ["v1", "auth", "password-reset", "request"],+ postRoute ["v1", "auth", "password-reset", "confirm"],+ route methodDelete ["v1", "auth", "totp"],+ postRoute ["oauth", "token"]+ ]+ assertEqual "the marker-derived operation set" expected actual+ assertEqual "no derived operation is duplicated" (Set.size actual) (length shomeiThrottledRoutes)+ rl <- newRateLimiterWith 8 testLimiterConfig+ assertBool+ "the unversioned login path is not throttled (it no longer exists)"+ (not (throttledPath rl (request methodPost ["auth", "login"])))+ assertBool+ "authenticated routes are not throttled"+ (not (throttledPath rl (request methodPost ["v1", "auth", "logout"])))+ assertBool+ "GET is not throttled"+ (not (throttledPath rl (request "GET" ["v1", "auth", "login"])))+ where+ route method segments = ThrottledRoute method (Literal <$> segments)+ postRoute = route methodPost+ request method segments = defaultRequest {requestMethod = method, pathInfo = segments}++ipKey :: Int -> ByteString+ipKey i = BC.pack ("ip" <> show i)++-- Metrics --------------------------------------------------------------------++-- | An application that throws instead of responding must still decrement the gauge. Before+-- this fix the decrement lived only in the response continuation, which never ran.+testThrowingHandlerLeavesGaugeAtZero :: TestTree+testThrowingHandlerLeavesGaugeAtZero = testCase "a throwing handler leaves in-flight at 0" do+ m <- newMetrics+ let boomApp _req _respond = throwIO (ErrorCall "boom")+ -- The continuation is never reached, so 'undefined' is safe and keeps the test honest.+ outcome <- try (metricsMiddleware m boomApp defaultRequest (\_ -> undefined))+ case outcome :: Either ErrorCall ResponseReceived of+ Right _ -> fail "expected the exception to propagate through the middleware"+ Left _ -> pure ()+ body <- exportMetrics m+ assertMetricLine "http_requests_in_flight 0" body++-- | The normal path decrements exactly once too — no double-decrement into negative territory.+testNormalRequestCountedAndGaugeReturnsToZero :: TestTree+testNormalRequestCountedAndGaugeReturnsToZero = testCase "a normal request is counted and the gauge returns to 0" do+ m <- newMetrics+ let okApp _req respond = respond (responseLBS status200 [] "ok")+ ResponseReceived <- metricsMiddleware m okApp defaultRequest (\_ -> pure ResponseReceived)+ body <- exportMetrics m+ assertMetricLine "http_requests_in_flight 0" body+ assertMetricLine "http_requests_total{method=\"GET\",status=\"200\"} 1" body++testHostileMethodsShareTheBoundedSeries :: TestTree+testHostileMethodsShareTheBoundedSeries = testCase "a hostile method is labelled other and escaped" do+ m <- newMetrics+ let okApp _req respond = respond (responseLBS status200 [] "ok")+ record method =+ metricsMiddleware m okApp defaultRequest {requestMethod = method} (\_ -> pure ResponseReceived)+ ResponseReceived <- record "EVIL\"}"+ ResponseReceived <- record "BREW"+ body <- exportMetrics m+ assertMetricLine "http_requests_total{method=\"other\",status=\"200\"} 2" body+ assertBool "the hostile method is not emitted as a label value" (not ("EVIL" `BS.isInfixOf` BL.toStrict body))++-- | Assert the exported Prometheus text contains @wanted@ as a whole line.+assertMetricLine :: BL.ByteString -> BL.ByteString -> IO ()+assertMetricLine wanted body =+ assertBool+ ("expected a line " <> show wanted <> " in:\n" <> BLC.unpack body)+ (wanted `elem` BLC.lines body)++-- Logging --------------------------------------------------------------------++-- | Whatever a field value contains, the rendered line is exactly one line, and in JSON format+-- it is exactly one JSON object.+testRenderLogLineIsOneJsonLine :: TestTree+testRenderLogLineIsOneJsonLine = testCase "renderLogLine emits exactly one valid JSON line" do+ let line = renderLogLine LogJson hostileFields+ assertBool "line ends with a newline" ("\n" `BS.isSuffixOf` line)+ assertEqual "exactly one newline, at the end" 1 (BC.count '\n' line)+ case decodeStrict (BS.init line) :: Maybe Value of+ Just (Object _) -> pure ()+ other -> fail ("expected a JSON object, got " <> show other)++-- | The plain format has no encoder to escape a newline for it, so it must filter one out —+-- otherwise a hostile value forges a second log line.+testPlainFormatStripsControlCharacters :: TestTree+testPlainFormatStripsControlCharacters = testCase "the plain format strips control characters" do+ let line = renderLogLine LogPlain hostileFields+ assertBool "line ends with a newline" ("\n" `BS.isSuffixOf` line)+ assertEqual "exactly one newline, at the end" 1 (BC.count '\n' line)+ assertEqual "no embedded quotes survive" 0 (BC.count '"' line)++testHealthRequestsSkipLogging :: TestTree+testHealthRequestsSkipLogging = testCase "health paths bypass request logging" do+ let okApp _req respond = respond (responseLBS status200 [] "ok")+ forM_ healthRawPaths \path -> do+ let request = defaultRequest {rawPathInfo = path}+ ResponseReceived <- requestLoggingMiddleware defaultObservabilityConfig okApp request \response -> do+ lookup "X-Request-Id" (responseHeaders response) @?= Nothing+ pure ResponseReceived+ pure ()++-- | A value carrying the two characters that could break the one-line contract.+hostileFields :: [(Key, Value)]+hostileFields =+ [ "level" .= ("info" :: Text),+ "msg" .= String "line one\nlevel=error msg=\"forged\"",+ "status" .= (200 :: Int)+ ]++-- | Two hundred threads writing five lines each through the real emit path must produce a+-- thousand intact lines: no chunk of one line interleaved into another.+testConcurrentWritersProduceIntactLines :: TestTree+testConcurrentWritersProduceIntactLines = testCase "200 concurrent writers produce 1000 intact lines" do+ let threads = 200 :: Int+ linesPerThread = 5 :: Int+ tmpDir <- getTemporaryDirectory+ (path, h) <- openTempFile tmpDir "shomei-log-concurrency.jsonl"+ hSetBuffering h LineBuffering+ dones <- forM [1 .. threads] \t -> do+ done <- newEmptyMVar+ _ <- forkIO do+ forM_ [1 .. linesPerThread] \s ->+ emitLine h (renderLogLine LogJson ["thread" .= t, "seq" .= s])+ putMVar done ()+ pure done+ forM_ dones takeMVar+ hClose h+ contents <- BS.readFile path+ removeFile path+ let ls = BC.lines contents+ assertEqual "one line per write" (threads * linesPerThread) (length ls)+ decoded <- forM ls \l ->+ case decodeStrict l :: Maybe (Map Text Int) of+ Nothing -> fail ("line did not parse as a JSON object: " <> show l)+ Just obj -> pure (Map.lookup "thread" obj, Map.lookup "seq" obj)+ let expected = sort [(Just t, Just s) | t <- [1 .. threads], s <- [1 .. linesPerThread]]+ assertEqual "every (thread, seq) pair arrived exactly once" expected (sort decoded)++-- | Warp's exception hook must produce the same structured shape as a request line.+testServerErrorLineIsStructured :: TestTree+testServerErrorLineIsStructured = testCase "the warp exception logger renders structured JSON" do+ let req = defaultRequest {requestMethod = "POST", rawPathInfo = "/v1/auth/login"}+ err = toException (ErrorCall "database is on fire")+ line = serverErrorLine (Just req) err+ case decodeStrict (BS.init line) :: Maybe (Map Text Text) of+ Nothing -> fail ("expected a JSON object, got " <> show line)+ Just obj -> do+ Map.lookup "level" obj @?= Just "error"+ Map.lookup "msg" obj @?= Just "unhandled exception"+ Map.lookup "method" obj @?= Just "POST"+ Map.lookup "path" obj @?= Just "/v1/auth/login"+ assertBool "the exception text is carried" (Map.member "error" obj)+ -- Without a request, the method/path fields are simply absent.+ let bare = serverErrorLine Nothing err+ case decodeStrict (BS.init bare) :: Maybe (Map Text Text) of+ Nothing -> fail "expected a JSON object for the request-less case"+ Just obj -> assertBool "no method field without a request" (not (Map.member "method" obj))++-- Body limit -----------------------------------------------------------------++testHostMiddlewareOrder :: TestTree+testHostMiddlewareOrder = testCase "hostMiddleware refuses oversized bodies outside the limiter and throttles inside it" do+ let cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")+ settings =+ ServerSettings+ { serverPort = 0,+ serverBindHost = "127.0.0.1",+ serverMaxConnections = 1024,+ serverSlowClientTimeoutSeconds = 30,+ serverConnStr = "",+ serverTrustedProxies = emptyTrustedProxies,+ serverProxyProtocol = ProxyProtocolOff,+ serverDbPoolSize = 1,+ serverDbPoolAcquisitionTimeoutMs = 1000,+ serverDbStatementTimeoutMs = 1000,+ serverDbTransactionMaxAttempts = 3,+ serverDbTransactionDeadlineMs = 5000,+ serverNotifierQueueSize = 16,+ serverWarnings = [],+ serverSweep = defaultSweepSettings {sweepEnabled = False},+ serverArgon2 = defaultArgon2Params,+ serverHashingMaxConcurrency = 1+ }+ large =+ defaultRequest+ { requestMethod = methodPost,+ pathInfo = ["v1", "auth", "login"],+ requestBodyLength = KnownLength (2 * 1024 * 1024),+ remoteHost = ipv4 203 0 113 9+ }+ small = large {requestBodyLength = KnownLength 16}+ inner _ respond = respond okResponse+ limiter <- newRateLimiterFor shomeiThrottledRoutes cfg.rateLimitConfig+ metrics <- newMetrics+ let app = hostMiddleware cfg settings limiter metrics inner+ run request' = do+ captured <- newIORef (0, [])+ ResponseReceived <- app request' \response -> do+ writeIORef captured (statusCode (responseStatus response), responseHeaders response)+ pure ResponseReceived+ readIORef captured+ forM_ [1 .. 100 :: Int] \_ -> do+ (status, headers) <- run large+ status @?= 413+ assertBool "logging is outermost and adds X-Request-Id" (lookup "X-Request-Id" headers /= Nothing)+ statuses <- forM [1 .. 61 :: Int] \_ -> fst <$> run small+ assertEqual "oversized requests did not spend limiter tokens" (replicate 60 200 <> [429]) statuses++-- | An oversized declared body is refused with 413 and the inner application never runs.+testOversizedBodyRejected :: TestTree+testOversizedBodyRejected = testCase "a 2 MiB Content-Length is rejected with 413" do+ (status, reached) <- runBodyLimit (KnownLength (2 * 1024 * 1024))+ status @?= 413+ assertEqual "the inner application must not be reached" False reached++-- | A body within the cap passes through untouched.+testKnownSmallBodyPassesThrough :: TestTree+testKnownSmallBodyPassesThrough = testCase "a small Content-Length passes through" do+ (status, reached) <- runBodyLimit (KnownLength 512)+ status @?= 200+ assertEqual "the inner application handled it" True reached++-- | A chunked body is metered as the application consumes it. The third chunk crosses the cap,+-- so the inner application cannot send its success response.+testChunkedBodyOverCapRejected :: TestTree+testChunkedBodyOverCapRejected = testCase "a chunked body over the cap is rejected with 413" do+ (status, innerResponded) <- runChunkedBodyLimit (replicate 3 (BS.replicate (512 * 1024) 97))+ status @?= 413+ assertEqual "the inner success response must not run" False innerResponded++-- | Metering is transparent below the cap, including the end-of-stream marker.+testSmallChunkedBodyPassesThrough :: TestTree+testSmallChunkedBodyPassesThrough = testCase "a small chunked body passes through" do+ (status, innerResponded) <- runChunkedBodyLimit (replicate 2 (BS.replicate (4 * 1024) 97))+ status @?= 200+ assertEqual "the inner application handled it" True innerResponded++-- | Warp's last-resort exception response carries the same public envelope as handler failures.+testEscapedExceptionRendersProblem :: TestTree+testEscapedExceptionRendersProblem = testCase "an escaped exception renders a problem document" do+ assertExceptionProblem 500 "internal" (toException (ErrorCall "database is on fire"))++testWarpInvalidRequestsRenderProblems :: TestTree+testWarpInvalidRequestsRenderProblems = testCase "warp invalid requests retain the problem envelope" do+ assertExceptionProblem 413 "payload_too_large" (toException PayloadTooLarge)+ assertExceptionProblem 400 "bad_request" (toException (BadFirstLine "hostile"))++testAsyncExceptionIsRethrown :: TestTree+testAsyncExceptionIsRethrown = testCase "the exception response hook rethrows asynchronous cancellation" do+ outcome <- try (evaluate (problemExceptionResponse (toException ThreadKilled)))+ case outcome :: Either AsyncException Response of+ Left ThreadKilled -> pure ()+ Left other -> fail ("unexpected async exception: " <> show other)+ Right _ -> fail "the asynchronous exception was converted into a response"++-- | Drive 'bodyLimitMiddleware' with a 1 MiB cap over a request declaring @len@, reporting the+-- response status and whether the inner application ran.+runBodyLimit :: RequestBodyLength -> IO (Int, Bool)+runBodyLimit len = do+ reachedRef <- newIORef False+ statusRef <- newIORef (0 :: Int)+ let cap = 1024 * 1024 :: Word64+ req = defaultRequest {requestBodyLength = len}+ innerApp _r respond = writeIORef reachedRef True >> respond okResponse+ capture :: Response -> IO ResponseReceived+ capture res = writeIORef statusRef (statusCode (responseStatus res)) >> pure ResponseReceived+ ResponseReceived <- bodyLimitMiddleware cap innerApp req capture+ (,) <$> readIORef statusRef <*> readIORef reachedRef++-- | Drive an unknown-length body whose chunks are supplied one at a time. The Boolean reports+-- whether the inner application reached its response continuation, not merely whether it began.+runChunkedBodyLimit :: [ByteString] -> IO (Int, Bool)+runChunkedBodyLimit chunks = do+ chunksRef <- newIORef chunks+ innerRespondedRef <- newIORef False+ statusRef <- newIORef (0 :: Int)+ let cap = 1024 * 1024 :: Word64+ nextChunk =+ atomicModifyIORef' chunksRef \case+ [] -> ([], "")+ chunk : rest -> (rest, chunk)+ req = setRequestBodyChunks nextChunk defaultRequest {requestBodyLength = ChunkedBody}+ drain request = do+ chunk <- getRequestBodyChunk request+ unless (BS.null chunk) (drain request)+ innerApp request respond = do+ drain request+ writeIORef innerRespondedRef True+ respond okResponse+ capture res = writeIORef statusRef (statusCode (responseStatus res)) >> pure ResponseReceived+ ResponseReceived <- bodyLimitMiddleware cap innerApp req capture+ (,) <$> readIORef statusRef <*> readIORef innerRespondedRef++collectResponseBody :: Response -> IO BL.ByteString+collectResponseBody response = do+ builderRef <- newIORef mempty+ let (_, _, withBody) = responseToStream response+ withBody \streamBody -> streamBody (\chunk -> modifyIORef' builderRef (<> chunk)) (pure ())+ toLazyByteString <$> readIORef builderRef++assertExceptionProblem :: Int -> Text -> SomeException -> IO ()+assertExceptionProblem expectedStatus expectedCode err = do+ let response = problemExceptionResponse err+ statusCode (responseStatus response) @?= expectedStatus+ lookup hContentType (responseHeaders response) @?= Just "application/problem+json"+ body <- collectResponseBody response+ problem <- maybe (fail ("expected problem JSON, got " <> show body)) pure (decode body :: Maybe ProblemDetails)+ problem.code @?= expectedCode+ problem.status @?= expectedStatus++okResponse :: Response+okResponse = responseLBS status200 [] "ok"
+ test/Shomei/Server/NotifySpec.hs view
@@ -0,0 +1,487 @@+-- | The 'LogNotifier' must not write a usable one-time token to the log. These tests pin+-- both halves of that contract: the default redacted line carries no raw token and no+-- @token=@ URL parameter, and the @logRawTokens@ escape hatch restores the full dev link.+module Shomei.Server.NotifySpec (tests) where++import Control.Concurrent (forkIO, killThread)+import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)+import Control.Exception (AsyncException (ThreadKilled), SomeException, bracket, catch, finally, throwIO, toException, try)+import Control.Monad (forM_, forever)+import Control.Monad.IO.Class (liftIO)+import Data.Aeson (decode)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Char (toUpper)+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef)+import Data.List (isInfixOf)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime (..), fromGregorian)+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Network.HTTP.Client (Manager, defaultManagerSettings, newManager)+import Network.HTTP.Types (RequestHeaders)+import Network.HTTP.Types.Status (mkStatus)+import Network.Socket+import Network.Wai (Application, requestHeaders, responseLBS, strictRequestBody)+import Network.Wai.Handler.Warp (testWithApplication)+import Shomei.Account.Email.Domain (Email, mkEmail)+import Shomei.Account.Notification.Domain (Notification (..))+import Shomei.Account.Notification.Store (sendNotification)+import Shomei.Account.OneTimeToken.Domain (OneTimeToken (..))+import Shomei.Account.Password.Breach.Store (BreachResult (..), checkPasswordBreached, sha1PrefixSuffix)+import Shomei.Account.Password.Domain (PlainPassword (..))+import Shomei.Account.Password.Hash.Postgres (sha256Hex)+import Shomei.Audit.Event.Domain (AuthEvent (NotificationDeliveryFailed), NotificationDeliveryFailedData (..))+import Shomei.Audit.Publisher.Store (AuthEventPublisher (..))+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))+import Shomei.Config (NotifierConfig (..), ShomeiConfig (..), SmtpConfig (..), SmtpTlsMode (..), WebhookConfig (..), defaultShomeiConfig)+import Shomei.Notify+ ( DeliveryReason (..),+ SmtpStage (..),+ WebhookSecret (..),+ classifySmtpFailure,+ renderNotification,+ runNotifierEnqueue,+ runNotifierSmtp,+ runNotifierWebhook,+ webhookSignature,+ )+import Shomei.Notify.Queue qualified as Queue+import Shomei.Server.BreachChecker (runPasswordBreachCheckerWith)+import Shomei.Time.Postgres (runClockIO)+import System.IO (BufferMode (LineBuffering), Handle, IOMode (ReadWriteMode), hClose, hFlush, hGetLine, hIsEOF, hPutStr, hSetBuffering)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++-- | A token whose text is distinctive enough that a substring check is meaningful.+rawToken :: Text.Text+rawToken = "s3cr3t-one-time-token-do-not-log-me"++tests :: TestTree+tests =+ testGroup+ "Notify"+ [ testCase "HIBP cancellation propagates instead of becoming an unavailable lookup" do+ reports <- newIORef []+ let query _ = throwIO ThreadKilled+ report line = modifyIORef' reports (line :)+ result <- try @AsyncException (runEff . runPasswordBreachCheckerWith query report $ checkPasswordBreached (PlainPassword "cancel-fixture"))+ result @?= Left ThreadKilled+ readIORef reports >>= (@?= []),+ testCase "HIBP dependency failures expose only a bounded reason code" do+ reports <- newIORef []+ let plain = PlainPassword "do-not-log-password"+ (prefix, suffix) = sha1PrefixSuffix plain+ query sent = do+ sent @?= prefix+ throwIO (userError ("https://api.pwnedpasswords.com/range/" <> Text.unpack prefix <> "?secret=hidden " <> Text.unpack suffix <> " do-not-log-password"))+ report line = modifyIORef' reports (line :)+ result <- runEff . runPasswordBreachCheckerWith query report $ checkPasswordBreached plain+ result @?= BreachCheckUnavailable+ lines' <- readIORef reports+ lines' @?= ["[shomei:breach-check] unavailable reason=transport_error"]+ forM_ [prefix, suffix, "do-not-log-password", "hidden", "https://"] \secret ->+ assertBool "no sensitive dependency context in diagnostic" (not (any (Text.isInfixOf secret) lines')),+ testCase "redacts the one-time token by default" do+ email <- testEmail+ let out = render False (PasswordResetRequested email (OneTimeToken rawToken) expires)+ assertBool+ ("raw token must not appear in: " <> out)+ (not (Text.unpack rawToken `isInfixOf` out))+ assertBool+ ("no ?token= URL parameter in: " <> out)+ (not ("?token=" `isInfixOf` out))+ assertBool+ ("hash prefix must appear in: " <> out)+ (("token_sha256=" <> Text.unpack expectedPrefix) `isInfixOf` out)+ assertBool "kind is labelled" ("password_reset" `isInfixOf` out),+ testCase "logs the full link when logRawTokens is set" do+ email <- testEmail+ let out = render True (EmailVerificationRequested email (OneTimeToken rawToken) expires)+ assertBool+ ("full link expected in: " <> out)+ (("/v1/auth/verify-email/confirm?token=" <> Text.unpack rawToken) `isInfixOf` out)+ assertBool "no hash prefix in raw mode" (not ("token_sha256=" `isInfixOf` out)),+ smtpDeliversTest,+ smtpFailureTest,+ smtpDataRejectTest,+ smtpClassifierTest,+ notifierQueueTest,+ webhookDeliversTest,+ webhookRetriesThenSucceeds,+ webhookExhaustsThenAudits,+ webhookResponseBodyNeverAuditedTest,+ webhookTransportFailureRedactsRequestTest+ ]+ where+ expires = fixtureExpiry+ expectedPrefix = Text.take 8 (sha256Hex rawToken)+ render raw n = renderNotification (notifierCfg raw) n+ notifierCfg raw =+ (defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")).notifierConfig+ { logRawTokens = raw+ }++testEmail :: IO Email+testEmail = either (\e -> assertFailure ("bad email: " <> show e)) pure (mkEmail "a@example.com")++-- SMTP interpreter (EP-8 M2) --------------------------------------------------++fixtureExpiry :: UTCTime+fixtureExpiry = UTCTime (fromGregorian 2030 7 8) 0++-- | The stock notifier config; only 'publicBaseUrl' (the link base) matters to these tests.+baseNotifierCfg :: NotifierConfig+baseNotifierCfg = (defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")).notifierConfig++-- | A plaintext SMTP relay pointed at @127.0.0.1:port@, unauthenticated (lab-sink shape).+plainSmtpConfig :: Int -> SmtpConfig+plainSmtpConfig p =+ SmtpConfig+ { host = "127.0.0.1",+ port = p,+ tlsMode = SmtpPlain,+ username = Nothing,+ fromAddress = "auth@example.com",+ timeoutSeconds = 5+ }++-- | Capture published audit events into an 'IORef', standing in for the PostgreSQL publisher.+runAuthEventCapture :: (IOE :> es) => IORef [AuthEvent] -> Eff (AuthEventPublisher : es) a -> Eff es a+runAuthEventCapture ref = interpret_ \case+ PublishAuthEvent e -> liftIO (modifyIORef' ref (<> [e]))++-- | Run @runNotifierSmtp@ for one notification against the given config, collecting any published+-- audit events. Uses the real IO clock; no HTTP or database is involved.+deliverViaSmtp :: SmtpConfig -> Notification -> IO [AuthEvent]+deliverViaSmtp sc n = do+ events <- newIORef []+ runEff . runClockIO . runAuthEventCapture events . runNotifierSmtp baseNotifierCfg sc Nothing $ sendNotification n+ readIORef events++-- | The delivered message reaches a sink with the right recipient, subject, and confirm link, and+-- a successful send publishes no failure event.+smtpDeliversTest :: TestTree+smtpDeliversTest = testCase "SMTP: delivers the message to a sink with the confirm link" do+ email <- testEmail+ bracket openSink (close . sinkSocket) \sink -> bracket (forkIO (acceptLoop sink)) killThread \_ -> do+ events <- deliverViaSmtp (plainSmtpConfig (sinkPort sink)) (EmailVerificationRequested email (OneTimeToken rawToken) fixtureExpiry)+ transcript <- readMVar (sinkTranscript sink)+ assertBool+ ("RCPT TO for the recipient in: " <> show transcript)+ (any (\l -> "RCPT TO" `isInfixOf` map toUpper l && "a@example.com" `isInfixOf` l) transcript)+ assertBool+ ("subject line in: " <> show transcript)+ (any ("Verify your email address" `isInfixOf`) transcript)+ assertBool+ ("confirm link in: " <> show transcript)+ (any ("/v1/auth/verify-email/confirm?token=" `isInfixOf`) transcript)+ assertBool ("no failure event on success, got: " <> show events) (null events)++-- | A refused connection is fire-and-forget: the interpreter never throws, publishes exactly one+-- redacted 'NotificationDeliveryFailed' event, and that event carries no token.+smtpFailureTest :: TestTree+smtpFailureTest = testCase "SMTP: a refused connection audits a failure and never throws" do+ email <- testEmail+ port <- closedPort+ events <- deliverViaSmtp (plainSmtpConfig port {- unreachable -}) (PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry)+ case events of+ [NotificationDeliveryFailed d] -> do+ d.channel @?= "smtp"+ d.notificationType @?= "password_reset_requested"+ d.recipient @?= "a@example.com"+ d.errorText @?= "connect_failed"+ assertBool+ ("the token must not appear in the audit error: " <> Text.unpack d.errorText)+ (not (Text.unpack rawToken `isInfixOf` Text.unpack d.errorText))+ _ -> assertFailure ("expected exactly one delivery-failure event, got: " <> show events)++-- | smtp-mail includes the entire rendered message in the exception raised when the relay+-- rejects DATA. Persisting that exception therefore persists the one-time token too.+smtpDataRejectTest :: TestTree+smtpDataRejectTest = testCase "SMTP: a 451 at DATA audits a reason code, never the message" do+ email <- testEmail+ bracket openSink (close . sinkSocket) \sink -> bracket (forkIO (acceptLoopWithDataReply "451 4.7.1 greylisted, try again later\r\n" sink)) killThread \_ -> do+ events <- deliverViaSmtp (plainSmtpConfig (sinkPort sink)) (PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry)+ case events of+ [NotificationDeliveryFailed d] -> do+ d.errorText @?= "rejected_at_data:451"+ assertBool+ ("the rendered message must not appear in the audit reason: " <> Text.unpack d.errorText)+ (not ("token=" `Text.isInfixOf` unfoldQuotedPrintable d.errorText))+ assertBool+ ("the token must not appear in the audit reason: " <> Text.unpack d.errorText)+ (not (rawToken `Text.isInfixOf` unfoldQuotedPrintable d.errorText))+ _ -> assertFailure ("expected exactly one delivery-failure event, got: " <> show events)++unfoldQuotedPrintable :: Text.Text -> Text.Text+unfoldQuotedPrintable =+ Text.replace "=2E" "."+ . Text.replace "=3D" "="+ . Text.replace "=\\r\\n" ""+ . Text.replace "=\r\n" ""++smtpClassifierTest :: TestTree+smtpClassifierTest = testCase "classifySmtpFailure maps smtp-mail messages to reasons" do+ classifySmtpFailure+ ( toException+ ( userError+ "Unexpected reply to: DATA \"rendered message\", Expected reply code: 250, Got this instead: 451 \"greylisted\""+ )+ )+ @?= RejectedAt AtData 451+ classifySmtpFailure (toException (userError "authentication failed.")) @?= AuthFailed+ classifySmtpFailure (toException (userError "SMTP delivery timed out")) @?= Timeout+ classifySmtpFailure (toException (userError "unclassified SMTP failure")) @?= Unknown++notifierQueueTest :: TestTree+notifierQueueTest = testCase "queue: overflow and shutdown audit without blocking; drain reports remainder" do+ email <- testEmail+ let notification = PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry+ enqueueThroughPort queue = do+ events <- newIORef []+ runEff . runClockIO . runAuthEventCapture events . runNotifierEnqueue queue "webhook" $ sendNotification notification+ readIORef events++ queue <- Queue.newNotifierQueue 2+ enqueueThroughPort queue >>= (@?= [])+ enqueueThroughPort queue >>= (@?= [])+ fullEvents <- enqueueThroughPort queue+ deliveryReason fullEvents @?= Just "queue_full"+ Queue.closeNotifierQueue queue+ closedEvents <- enqueueThroughPort queue+ deliveryReason closedEvents @?= Just "shutting_down"++ drained <- Queue.newNotifierQueue 1+ Queue.enqueueNotification drained notification >>= (@?= Queue.Enqueued)+ consumer <- forkIO (Queue.withDequeued drained (const (pure ())))+ Queue.closeNotifierQueue drained+ Queue.drainNotifierQueue drained 1 >>= (@?= 0)+ killThread consumer++ undrained <- Queue.newNotifierQueue 1+ Queue.enqueueNotification undrained notification >>= (@?= Queue.Enqueued)+ Queue.closeNotifierQueue undrained+ Queue.drainNotifierQueue undrained 0 >>= (@?= 1)+ where+ deliveryReason = \case+ [NotificationDeliveryFailed d] -> Just d.errorText+ _ -> Nothing++-- A minimal in-process SMTP sink -------------------------------------------------++data Sink = Sink+ { sinkSocket :: !Socket,+ sinkPort :: !Int,+ sinkTranscript :: !(MVar [String])+ }++-- | Bind a listening socket on an ephemeral @127.0.0.1@ port and return it with an empty+-- transcript. The caller forks 'acceptLoop' and closes the socket when done.+openSink :: IO Sink+openSink = do+ let hints = defaultHints {addrFlags = [AI_PASSIVE], addrSocketType = Stream}+ addr <- head <$> getAddrInfo (Just hints) (Just "127.0.0.1") (Just "0")+ sock <- socket (addrFamily addr) Stream defaultProtocol+ setSocketOption sock ReuseAddr 1+ bind sock (addrAddress addr)+ listen sock 5+ p <- socketPort sock+ transcript <- newMVar []+ pure Sink {sinkSocket = sock, sinkPort = fromIntegral p, sinkTranscript = transcript}++-- | A port with nothing listening: bind then immediately release, so a later connect is refused.+closedPort :: IO Int+closedPort = bracket openSink (close . sinkSocket) (pure . sinkPort)++-- | Accept connections and serve each in its own thread, speaking just enough plaintext SMTP.+-- All exceptions are swallowed: when the test kills this thread or closes the listening socket,+-- the resulting @accept@/@threadWait@ error must not print or fail the suite.+acceptLoop :: Sink -> IO ()+acceptLoop = acceptLoopWithDataReply "250 OK queued\r\n"++acceptLoopWithDataReply :: String -> Sink -> IO ()+acceptLoopWithDataReply dataReply sink = loop `catch` \(_ :: SomeException) -> pure ()+ where+ loop = forever do+ (conn, _) <- accept (sinkSocket sink)+ h <- socketToHandle conn ReadWriteMode+ _ <- forkIO ((serveSmtp dataReply (sinkTranscript sink) h `finally` hClose h) `catch` \(_ :: SomeException) -> pure ())+ pure ()++-- | The sink SMTP dialogue: greet, answer EHLO/MAIL/RCPT with 250, DATA with 354 then 250 after+-- the terminating dot, and QUIT with 221. Every command line and DATA body line is recorded.+serveSmtp :: String -> MVar [String] -> Handle -> IO ()+serveSmtp dataReply transcript h = do+ hSetBuffering h LineBuffering+ respond "220 shomei-test-sink ready\r\n"+ loop+ where+ respond s = hPutStr h s >> hFlush h+ record l = modifyMVar_ transcript (pure . (<> [l]))+ loop = do+ eof <- hIsEOF h+ if eof+ then pure ()+ else do+ line <- stripCR <$> hGetLine h+ record line+ case verb line of+ "EHLO" -> respond "250 shomei-test-sink\r\n" >> loop+ "HELO" -> respond "250 shomei-test-sink\r\n" >> loop+ "MAIL" -> respond "250 OK\r\n" >> loop+ "RCPT" -> respond "250 OK\r\n" >> loop+ "DATA" -> respond "354 end with .\r\n" >> readData >> respond dataReply >> loop+ "QUIT" -> respond "221 Bye\r\n"+ _ -> respond "250 OK\r\n" >> loop+ readData = do+ line <- stripCR <$> hGetLine h+ record ("DATA> " <> line)+ if line == "." then pure () else readData+ verb = map toUpper . take 4+ stripCR s = if not (null s) && last s == '\r' then init s else s++-- Webhook interpreter (EP-8 M3) -----------------------------------------------++webhookSecretText :: Text.Text+webhookSecretText = "test-webhook-secret"++webhookConfigFor :: Int -> Int -> WebhookConfig+webhookConfigFor p maxA =+ WebhookConfig+ { url = Text.pack ("http://127.0.0.1:" <> show p <> "/hook"),+ timeoutSeconds = 5,+ maxAttempts = maxA+ }++deliverViaWebhook :: Manager -> WebhookConfig -> Notification -> IO [AuthEvent]+deliverViaWebhook mgr wc n = do+ events <- newIORef []+ runEff . runClockIO . runAuthEventCapture events . runNotifierWebhook mgr wc (WebhookSecret webhookSecretText) $ sendNotification n+ readIORef events++-- | A stub receiver that records every @(headers, raw body)@ it gets and answers the k-th+-- request (1-indexed) with @statusFor k@. The body is fully drained before responding so an+-- attempt count is never miscounted.+webhookStub ::+ IORef Int ->+ (Int -> Int) ->+ MVar [(RequestHeaders, BS.ByteString)] ->+ Application+webhookStub counter statusFor captured req respond = do+ body <- LBS.toStrict <$> strictRequestBody req+ modifyMVar_ captured (pure . (<> [(requestHeaders req, body)]))+ k <- atomicModifyIORef' counter (\c -> (c + 1, c + 1))+ respond (responseLBS (mkStatus (statusFor k) "") [] "")++-- | A single successful POST carries the JSON body, the type header, and a signature that+-- verifies against the shared secret recomputed over the exact bytes received.+webhookDeliversTest :: TestTree+webhookDeliversTest = testCase "webhook: signed JSON POST the receiver can verify" do+ email <- testEmail+ mgr <- newManager defaultManagerSettings+ captured <- newMVar []+ counter <- newIORef 0+ let n = EmailVerificationRequested email (OneTimeToken rawToken) fixtureExpiry+ testWithApplication (pure (webhookStub counter (const 200) captured)) \port -> do+ events <- deliverViaWebhook mgr (webhookConfigFor port 1) n+ reqs <- readMVar captured+ case reqs of+ [(hdrs, body)] -> do+ lookup "Content-Type" hdrs @?= Just "application/json"+ lookup "X-Shomei-Notification-Type" hdrs @?= Just "email_verification_requested"+ decode (LBS.fromStrict body) @?= Just n+ case lookup "X-Shomei-Timestamp" hdrs of+ Nothing -> assertFailure "missing X-Shomei-Timestamp"+ Just timestamp ->+ lookup "X-Shomei-Signature" hdrs @?= Just (webhookSignature (TE.encodeUtf8 webhookSecretText) timestamp body)+ _ -> assertFailure ("expected exactly one delivery, got " <> show (length reqs))+ assertBool ("no failure event on success, got: " <> show events) (null events)++-- | A 5xx is retried; a later success ends the delivery with no failure event.+webhookRetriesThenSucceeds :: TestTree+webhookRetriesThenSucceeds = testCase "webhook: retries a 5xx then succeeds, no failure event" do+ email <- testEmail+ mgr <- newManager defaultManagerSettings+ captured <- newMVar []+ counter <- newIORef 0+ let n = PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry+ statusFor k = if k <= 1 then 500 else 200+ testWithApplication (pure (webhookStub counter statusFor captured)) \port -> do+ events <- deliverViaWebhook mgr (webhookConfigFor port 2) n+ reqs <- readMVar captured+ length reqs @?= 2+ let timestamps = map (lookup "X-Shomei-Timestamp" . fst) reqs+ assertBool ("each retry must be re-stamped, got: " <> show timestamps) (case timestamps of [Just first, Just second] -> first /= second; _ -> False)+ mapM_+ ( \(headers, body) -> case lookup "X-Shomei-Timestamp" headers of+ Nothing -> assertFailure "retry missing X-Shomei-Timestamp"+ Just timestamp ->+ lookup "X-Shomei-Signature" headers @?= Just (webhookSignature (TE.encodeUtf8 webhookSecretText) timestamp body)+ )+ reqs+ assertBool ("no failure event on eventual success, got: " <> show events) (null events)++-- | A receiver that always 5xxes is attempted exactly @maxAttempts@ times, then a redacted+-- failure event is published — carrying no token.+webhookExhaustsThenAudits :: TestTree+webhookExhaustsThenAudits = testCase "webhook: exhausts attempts then audits a redacted failure" do+ email <- testEmail+ mgr <- newManager defaultManagerSettings+ captured <- newMVar []+ counter <- newIORef 0+ let n = EmailVerificationRequested email (OneTimeToken rawToken) fixtureExpiry+ testWithApplication (pure (webhookStub counter (const 500) captured)) \port -> do+ events <- deliverViaWebhook mgr (webhookConfigFor port 2) n+ reqs <- readMVar captured+ length reqs @?= 2+ case events of+ [NotificationDeliveryFailed d] -> do+ d.channel @?= "webhook"+ d.notificationType @?= "email_verification_requested"+ d.recipient @?= "a@example.com"+ assertBool+ ("the token must not appear in the audit error: " <> Text.unpack d.errorText)+ (not (Text.unpack rawToken `isInfixOf` Text.unpack d.errorText))+ _ -> assertFailure ("expected exactly one delivery-failure event, got: " <> show events)++webhookResponseBodyNeverAuditedTest :: TestTree+webhookResponseBodyNeverAuditedTest = testCase "webhook: a 500 that echoes the body audits only the status" do+ email <- testEmail+ mgr <- newManager defaultManagerSettings+ captured <- newMVar []+ let n = PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry+ echoingStub req respond = do+ body <- LBS.toStrict <$> strictRequestBody req+ modifyMVar_ captured (pure . (<> [(requestHeaders req, body)]))+ respond (responseLBS (mkStatus 500 "") [] (LBS.fromStrict body))+ testWithApplication (pure echoingStub) \port -> do+ events <- deliverViaWebhook mgr (webhookConfigFor port 1) n+ case events of+ [NotificationDeliveryFailed d] -> do+ d.errorText @?= "http_status:500"+ assertBool+ ("the response body must not appear in the audit reason: " <> Text.unpack d.errorText)+ (not (rawToken `Text.isInfixOf` d.errorText))+ _ -> assertFailure ("expected exactly one delivery-failure event, got: " <> show events)++-- | http-client's Show instance includes the request query string. A transport exception must+-- therefore be classified before it is persisted.+webhookTransportFailureRedactsRequestTest :: TestTree+webhookTransportFailureRedactsRequestTest = testCase "webhook: a transport failure never persists the request" do+ email <- testEmail+ mgr <- newManager defaultManagerSettings+ port <- closedPort+ let wc =+ (webhookConfigFor port 1)+ { url = Text.pack ("http://127.0.0.1:" <> show port <> "/hook?key=url-secret-hunter2")+ }+ events <- deliverViaWebhook mgr wc (PasswordResetRequested email (OneTimeToken rawToken) fixtureExpiry)+ case events of+ [NotificationDeliveryFailed d] -> do+ d.errorText @?= "connect_failed"+ assertBool+ ("the request URL must not appear in the audit reason: " <> Text.unpack d.errorText)+ (not ("hunter2" `Text.isInfixOf` d.errorText))+ _ -> assertFailure ("expected exactly one delivery-failure event, got: " <> show events)
+ test/Shomei/Server/RuntimeSpec.hs view
@@ -0,0 +1,126 @@+module Shomei.Server.RuntimeSpec (tests) where++import Control.Concurrent (threadDelay)+import Control.Exception (IOException, bracket, throwIO, try)+import Control.Monad (forM_)+import Data.ByteString qualified as BS+import Data.IORef (newIORef, readIORef, writeIORef)+import Network.HTTP.Types (status200)+import Network.Socket qualified as Socket+import Network.Socket.ByteString (recv, sendAll)+import Network.Wai (Application, rawPathInfo, responseLBS)+import Network.Wai.Handler.Warp qualified as Warp+import Shomei.Account.Password.Hash.Postgres (defaultArgon2Params)+import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))+import Shomei.Config (ObservabilityConfig (..), ShomeiConfig (..), defaultShomeiConfig)+import Shomei.Server.Config (ProxyProtocolMode (..), ServerSettings (..), defaultSweepSettings)+import Shomei.Server.Middleware.TrustedProxy (emptyTrustedProxies)+import Shomei.Server.Runtime (hostWarpSettings)+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++cfg :: ShomeiConfig+cfg = let c = defaultShomeiConfig (Issuer "test") (Audience "test") in c {observabilityConfig = c.observabilityConfig {gracefulShutdownTimeoutSeconds = 1}}++settings :: ServerSettings+settings =+ ServerSettings+ { serverPort = 0,+ serverBindHost = "127.0.0.1",+ serverMaxConnections = 1,+ serverSlowClientTimeoutSeconds = 1,+ serverConnStr = "",+ serverTrustedProxies = emptyTrustedProxies,+ serverProxyProtocol = ProxyProtocolOff,+ serverDbPoolSize = 1,+ serverDbPoolAcquisitionTimeoutMs = 1000,+ serverDbStatementTimeoutMs = 1000,+ serverDbTransactionMaxAttempts = 3,+ serverDbTransactionDeadlineMs = 5000,+ serverNotifierQueueSize = 16,+ serverWarnings = [],+ serverSweep = defaultSweepSettings,+ serverArgon2 = defaultArgon2Params,+ serverHashingMaxConcurrency = 1+ }++connectTo :: Int -> IO Socket.Socket+connectTo port = do+ sock <- Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol+ Socket.connect sock (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (127, 0, 0, 1)))+ pure sock++request :: Socket.Socket -> BS.ByteString -> IO BS.ByteString+request sock path = do+ sendAll sock ("GET " <> path <> " HTTP/1.1\r\nHost: localhost\r\n\r\n")+ result <- timeout 2000000 (recv sock 4096)+ maybe (assertFailure "socket response exceeded the acceptance deadline") pure result++rejected :: Int -> IO ()+rejected port = bracket (connectTo port) Socket.close \sock -> do+ reply <- try (request sock "/") :: IO (Either IOException BS.ByteString)+ assertBool "surplus connection is closed immediately" (case reply of Left _ -> True; Right bytes -> BS.null bytes)++awaitHealthy :: Int -> IO ()+awaitHealthy port = awaitResponse port "/" "HTTP/1.1 200"++awaitResponse :: Int -> BS.ByteString -> BS.ByteString -> IO ()+awaitResponse port path expected = do+ let attempt = do+ result <- try (bracket (connectTo port) Socket.close (\sock -> request sock path)) :: IO (Either IOException BS.ByteString)+ case result of+ Right bytes | expected `BS.isPrefixOf` bytes -> pure ()+ _ -> threadDelay 10000 >> attempt+ timeout 3000000 attempt >>= (@?= Just ())++app :: Application+app request' respond+ | rawPathInfo request' == "/explode" = throwIO (userError "runtime fixture failure")+ | otherwise = respond (responseLBS status200 [] "ok")++tests :: TestTree+tests =+ testGroup+ "shared Warp runtime sockets"+ [ testCase "admission rejects surplus sockets without leaking permits on rejection, disconnect or handler exception" do+ runtime <- hostWarpSettings cfg settings Warp.defaultSettings+ Warp.withApplicationSettings runtime (pure app) \port -> do+ bracket (connectTo port) Socket.close \first -> do+ bytes <- request first "/"+ assertBool "first connection admitted" ("HTTP/1.1 200" `BS.isPrefixOf` bytes)+ rejected port+ rejected port+ awaitHealthy port+ awaitResponse port "/explode" "HTTP/1.1 500"+ awaitHealthy port,+ testCase "explicit slow-client timeout releases an idle connection and shutdown releases shared capacity" do+ runtime <- hostWarpSettings cfg settings Warp.defaultSettings+ Warp.withApplicationSettings runtime (pure app) \port ->+ bracket (connectTo port) Socket.close \sock -> do+ bytes <- request sock "/"+ assertBool "idle connection first admitted" ("HTTP/1.1 200" `BS.isPrefixOf` bytes)+ rejected port+ let awaitClose = do+ bytes <- recv sock 4096+ if BS.null bytes then pure () else awaitClose+ closed <- timeout 5000000 awaitClose+ closed @?= Just ()+ awaitHealthy port+ -- Reuse the same admission state after the first listener shuts down.+ Warp.withApplicationSettings runtime (pure app) awaitHealthy,+ testCase "shutdown with an admitted socket restores permits for a new listener" do+ runtime <- hostWarpSettings cfg settings {serverSlowClientTimeoutSeconds = 30} Warp.defaultSettings+ held <- newIORef Nothing+ Warp.withApplicationSettings runtime (pure app) \port -> do+ sock <- connectTo port+ writeIORef held (Just sock)+ bytes <- request sock "/"+ assertBool "socket remains admitted when listener stops" ("HTTP/1.1 200" `BS.isPrefixOf` bytes)+ readIORef held >>= mapM_ Socket.close+ Warp.withApplicationSettings runtime (pure app) awaitHealthy,+ testCase "invalid direct-library host policies fail before serving" do+ forM_ [settings {serverSlowClientTimeoutSeconds = maxBound}, settings {serverMaxConnections = 0}, settings {serverSlowClientTimeoutSeconds = 0}, settings {serverBindHost = ""}] \bad -> do+ result <- try (hostWarpSettings cfg bad Warp.defaultSettings) :: IO (Either IOException Warp.Settings)+ assertBool "invalid policy rejected" (case result of Left _ -> True; _ -> False)+ ]
+ test/Shomei/Server/SupervisorSpec.hs view
@@ -0,0 +1,114 @@+-- | Tests for the supervised-background-thread idiom ('Shomei.Server.Supervisor').+--+-- The two properties that matter operationally are that a crashing maintenance cycle never+-- takes the server down, and that killing the thread actually kills it. Both are exercised+-- through 'supervisedLoopMicros', which is 'supervisedLoop' with the interval and backoff+-- bounds given in microseconds, so these run in milliseconds rather than the production+-- loop's 5-second-to-5-minute backoff.+module Shomei.Server.SupervisorSpec (tests) where++import Control.Concurrent (forkIO, killThread, newEmptyMVar, putMVar, takeMVar, threadDelay)+import Control.Exception (Exception, SomeException, throwIO, try)+import Control.Monad (when)+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import Shomei.Server.Supervisor (supervisedLoopMicros)+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase)++tests :: TestTree+tests =+ testGroup+ "supervisor"+ [ testCrashingCycleIsRetried,+ testBackoffIsBounded,+ testAsyncExceptionStopsTheLoop+ ]++data Boom = Boom+ deriving stock (Show)++instance Exception Boom++-- | Run @loop@ on a forked thread until @done@ holds, then kill it. 'False' means @done@ never+-- held within @budgetMicros@.+--+-- Waiting for a /condition/ rather than for a fixed slice of wall clock is what keeps these+-- tests honest under load: a machine running twelve test suites at once gives this thread fewer+-- scheduling slices, and "did N cycles happen in 50 ms?" then answers no for reasons that have+-- nothing to do with the supervisor. The budget is deliberately orders of magnitude larger than+-- any of the delays under test, so it only fires when the loop is genuinely stuck.+runLoopUntil :: Int -> IO Bool -> IO () -> IO Bool+runLoopUntil budgetMicros done loop = do+ tid <- forkIO loop+ reached <- timeout budgetMicros poll+ killThread tid+ pure (reached == Just ())+ where+ poll = do+ finished <- done+ if finished then pure () else threadDelay 500 >> poll++-- | A cycle that throws forever must not escape the loop, and the loop must keep calling it.+-- (Note the crash lines this prints on stderr are the point, not noise.)+testCrashingCycleIsRetried :: TestTree+testCrashingCycleIsRetried = testCase "a crashing cycle is retried, never fatal" do+ calls <- newIORef (0 :: Int)+ let cycleAction = do+ atomicModifyIORef' calls \n -> (n + 1, ())+ throwIO Boom+ -- interval 1ms, backoff 1ms doubling to a 2ms ceiling: three retries need ~5ms of delays.+ retried <- runLoopUntil 10_000_000 ((>= 3) <$> readIORef calls) (supervisedLoopMicros "test" 1_000 1_000 2_000 cycleAction)+ n <- readIORef calls+ assertBool ("expected the loop to retry the crashing cycle, got " <> show n <> " calls") retried++-- | After a clean cycle the backoff resets, so the next crash waits the /initial/ backoff+-- rather than the doubled one it had grown to.+--+-- Measured directly rather than inferred from a call count. Three consecutive crashes grow the+-- backoff to 50 → 100 → 200 ms, so a fourth crash would wait 400 ms. Cycle 4 is clean, which+-- must reset the backoff; cycle 5 crashes, and the gap to cycle 6 is the wait it actually took.+-- With the reset that gap is ~50 ms; without it, ~400 ms. Asserting @< 250 ms@ separates the two+-- by a margin far wider than any scheduling jitter, which is measured in milliseconds.+testBackoffIsBounded :: TestTree+testBackoffIsBounded = testCase "backoff resets after a clean cycle" do+ ticks <- newIORef ([] :: [Word64]) -- reverse-ordered start time of each cycle+ let cycleAction = do+ t <- getMonotonicTimeNSec+ n <- atomicModifyIORef' ticks \ts -> (t : ts, length ts + 1)+ when (n <= 3 || n == 5) (throwIO Boom)+ reached <- runLoopUntil 30_000_000 ((>= 6) . length <$> readIORef ticks) (supervisedLoopMicros "test" 1_000 50_000 5_000_000 cycleAction)+ assertBool "the loop never reached its sixth cycle" reached+ ts <- reverse <$> readIORef ticks+ case ts of+ (_ : _ : _ : _ : fifth : sixth : _) -> do+ let gapMs = fromIntegral (sixth - fifth) / 1e6 :: Double+ assertBool+ ("the backoff did not reset after the clean cycle: waited " <> show gapMs <> "ms, expected ~50ms")+ (gapMs < 250)+ _ -> assertFailure ("expected at least six cycles, got " <> show (length ts))++-- | 'killThread' delivers an asynchronous 'ThreadKilled'. The loop must re-throw it rather+-- than treat it as a failed cycle and retry, otherwise the thread would be unkillable and+-- process shutdown would hang.+testAsyncExceptionStopsTheLoop :: TestTree+testAsyncExceptionStopsTheLoop = testCase "an async exception stops the loop" do+ started <- newEmptyMVar+ stopped <- newEmptyMVar+ let cycleAction = do+ putMVar started ()+ -- Block forever; the only way out is the async exception.+ threadDelay 60_000_000+ tid <- forkIO do+ outcome <- try (supervisedLoopMicros "test" 1_000 1_000 2_000 cycleAction)+ putMVar stopped (outcome :: Either SomeException ())+ takeMVar started+ killThread tid+ -- If the loop swallowed ThreadKilled and looped again, nothing is ever put into `stopped`.+ result <- timeout 2_000_000 (takeMVar stopped)+ case result of+ Nothing -> assertFailure "the loop swallowed the async exception: thread still running"+ Just (Right ()) -> assertFailure "the loop returned normally instead of propagating"+ Just (Left _) -> pure ()