packages feed

shomei-postgres-0.2.0.0: src/Shomei/Session/UnitOfWork/Postgres.hs

-- | PostgreSQL interpreter for the 'AuthUnitOfWork' port: each operation is exactly one
-- @BEGIN … COMMIT@.
--
-- This module uses 'Shomei.Persistence.Database.Postgres.runTransaction' for workflow write
-- tails. 'Shomei.Session.LoginAttempt.Postgres' also owns one transaction because its
-- transaction-scoped advisory lock must enclose the corresponding insert and count; that is a
-- serialized store operation rather than a workflow tail. Other interpreters issue one statement
-- per 'Shomei.Persistence.Database.Postgres.runSession', which is one pool checkout per statement.
--
-- No SQL is written here. Every statement is the prepared 'Statement' its own store
-- interpreter already uses, lifted into the transaction with 'Tx.statement'. That matters most
-- for 'markUsedStmt', the refresh-token compare-and-swap whose shape is owned by
-- @docs/plans/28-enforce-absolute-session-expiry-and-atomic-token-state-transitions.md@: this
-- module moves it inside a transaction and reads its result, but never alters it.
module Shomei.Session.UnitOfWork.Postgres
  ( runAuthUnitOfWorkPostgres,
  )
where

import Data.Foldable (traverse_)
import Data.Set qualified as Set
import Data.UUID.V4 qualified as UUIDv4
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Transaction qualified as Tx
import Shomei.Account.Credential.Postgres (updatePasswordHashStmt)
import Shomei.Account.Password.Domain (PasswordHash (..))
import Shomei.Account.PasswordReset.Postgres qualified as PR
import Shomei.Audit.Event.Codec (projectAuthEvent)
import Shomei.Audit.Event.Domain (AuthEvent)
import Shomei.Audit.Publisher.Postgres (AuthEventRow, insertAuthEventStmt)
import Shomei.Authorization.Claims.Domain (Scope (..))
import Shomei.Error (AuthError (..))
import Shomei.Id
  ( RefreshTokenId,
    genRefreshTokenId,
    genSessionId,
    passwordResetTokenIdToUUID,
    refreshTokenIdToUUID,
    sessionIdToUUID,
    userIdToUUID,
  )
import Shomei.Persistence.Codec.Postgres (refreshTokenStatusToText, sessionKindToText, sessionStatusToText)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runTransaction)
import Shomei.Prelude
import Shomei.Session.Domain (Session (..), SessionStatus (SessionActive))
import Shomei.Session.Postgres
  ( SessionRow,
    insertSessionStmt,
    mkSession,
    revokeAllUserSessionsStmt,
    revokeSessionStmt,
  )
import Shomei.Session.RefreshToken.Domain (NewRefreshToken (..))
import Shomei.Session.RefreshToken.Domain qualified as RT
import Shomei.Session.RefreshToken.Postgres
  ( RefreshTokenRow,
    insertRefreshTokenStmt,
    markUsedStmt,
    mkPersisted,
    refreshTokenHashText,
  )
import Shomei.Session.RefreshToken.Postgres qualified as RTP
import Shomei.Session.UnitOfWork.Store (AuthUnitOfWork (..), NewSessionToken (..), RotationOutcome (..))

runAuthUnitOfWorkPostgres ::
  (Database :> es, IOE :> es, Error AuthError :> es) =>
  Eff (AuthUnitOfWork : es) a ->
  Eff es a
runAuthUnitOfWorkPostgres = interpret_ \case
  PersistNewSession ns nst mkEvents -> do
    -- The ids are generated here, before the transaction opens, exactly as the per-table
    -- interpreters generate them: they are client-side (TypeID/UUIDv7-style) values, so no
    -- round-trip is needed and the events can name the session id.
    sid <- genSessionId
    rid <- genRefreshTokenId
    let session = mkSession sid ns
        newToken =
          NewRefreshToken
            { sessionId = sid,
              tokenHash = nst.tokenHash,
              parentTokenId = Nothing,
              createdAt = nst.createdAt,
              expiresAt = nst.expiresAt
            }
        persisted = mkPersisted rid newToken
    eventRows <- traverse toEventRow (mkEvents sid)
    res <- runTransaction do
      Tx.statement (sessionRow session) insertSessionStmt
      Tx.statement (tokenRow rid newToken) insertRefreshTokenStmt
      traverse_ (\row -> Tx.statement row insertAuthEventStmt) eventRows
    either dbFail (const (pure (session, persisted))) res
  RotateRefreshToken presentedId usedAt newToken ev -> do
    rid <- genRefreshTokenId
    eventRow <- toEventRow ev
    let persisted = mkPersisted rid newToken
    res <- runTransaction do
      -- The compare-and-swap runs first and its result decides the rest. A conflict leaves the
      -- transaction with nothing but a no-op UPDATE to commit; there is no need to abort it,
      -- and nothing to roll back.
      won <- Tx.statement (refreshTokenIdToUUID presentedId, usedAt) markUsedStmt
      case won of
        Nothing -> pure RotationConflict
        Just _ -> do
          Tx.statement (tokenRow rid newToken) insertRefreshTokenStmt
          Tx.statement eventRow insertAuthEventStmt
          pure (Rotated persisted)
    either dbFail pure res
  CompletePasswordReset tid uid newHash ts events -> do
    eventRows <- traverse toEventRow events
    res <- runTransaction do
      won <- Tx.statement (passwordResetTokenIdToUUID tid, ts) PR.markConsumedStmt
      case won of
        Nothing -> pure False
        Just _ -> do
          Tx.statement (userIdToUUID uid, passwordHashText newHash) updatePasswordHashStmt
          Tx.statement (userIdToUUID uid, ts) revokeAllUserSessionsStmt
          Tx.statement (userIdToUUID uid, ts) RTP.revokeUserTokensStmt
          -- The consumed token is no longer active, so this revokes only its live siblings.
          Tx.statement (userIdToUUID uid, ts) PR.revokeUserTokensStmt
          True <$ traverse_ (\row -> Tx.statement row insertAuthEventStmt) eventRows
    either dbFail pure res
  CompletePasswordChange uid newHash ts events -> do
    eventRows <- traverse toEventRow events
    res <- runTransaction do
      Tx.statement (userIdToUUID uid, passwordHashText newHash) updatePasswordHashStmt
      Tx.statement (userIdToUUID uid, ts) revokeAllUserSessionsStmt
      Tx.statement (userIdToUUID uid, ts) RTP.revokeUserTokensStmt
      traverse_ (\row -> Tx.statement row insertAuthEventStmt) eventRows
    either dbFail pure res
  RevokeSessionWithTokens sid ts events -> do
    eventRows <- traverse toEventRow events
    res <- runTransaction do
      won <- Tx.statement (sessionIdToUUID sid, ts) revokeSessionStmt
      case won of
        Nothing -> pure False
        Just _ -> do
          Tx.statement (sessionIdToUUID sid, ts) RTP.revokeSessionTokensStmt
          True <$ traverse_ (\row -> Tx.statement row insertAuthEventStmt) eventRows
    either dbFail pure res
  where
    dbFail = throwError . postgresUnavailable

passwordHashText :: PasswordHash -> Text
passwordHashText (PasswordHash t) = t

-- | Mint the event's row id outside the transaction (it is a random UUID, not a database
-- default), and project the event exactly as 'Shomei.Audit.Publisher.Postgres' does.
toEventRow :: (IOE :> es) => AuthEvent -> Eff es AuthEventRow
toEventRow ev = do
  eid <- liftIO UUIDv4.nextRandom
  let (mUser, mSession, etype, payload, ts) = projectAuthEvent ev
  pure (eid, mUser, mSession, etype, payload, ts)

-- | The column tuple 'insertSessionStmt' encodes, built from the session this interpreter just
-- constructed. A fresh session is always active and never revoked.
sessionRow :: Session -> SessionRow
sessionRow session =
  ( sessionIdToUUID session.sessionId,
    userIdToUUID session.userId,
    sessionStatusToText SessionActive,
    session.createdAt,
    session.expiresAt,
    Nothing,
    userIdToUUID <$> session.actor,
    session.oauthClientId,
    Just (sessionKindToText session.kind),
    [scope | Scope scope <- Set.toList session.grantedScopes],
    Just session.authenticatedAt
  )

-- | The column tuple 'insertRefreshTokenStmt' encodes. A freshly inserted token is always
-- active, never used, never revoked.
tokenRow :: RefreshTokenId -> NewRefreshToken -> RefreshTokenRow
tokenRow rid nrt =
  ( refreshTokenIdToUUID rid,
    sessionIdToUUID nrt.sessionId,
    refreshTokenHashText nrt.tokenHash,
    fmap refreshTokenIdToUUID nrt.parentTokenId,
    refreshTokenStatusToText RT.RefreshTokenActive,
    nrt.createdAt,
    nrt.expiresAt,
    Nothing,
    Nothing
  )