packages feed

shomei-core-0.3.0.0: src/Shomei/Session/Store.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeFamilies #-}

-- | The session-store port: persisting, looking up, and revoking sessions.
module Shomei.Session.Store
  ( SessionStore (..),
    createSession,
    createSessionWithinLimit,
    findSessionById,
    revokeSession,
    revokeAllUserSessions,
    listSessionsForUser,
    SessionCursor (..),
    SessionListQuery (..),
    emptySessionListQuery,
    clampSessionLimit,
    listSessionPage,
  )
where

import Effectful (Dispatch (..), DispatchOf, Eff, Effect, (:>))
import Effectful.Dispatch.Dynamic (send)
import Shomei.Error (AuthError (..))
import Shomei.Id (SessionId, UserId)
import Shomei.Prelude
import Shomei.Session.Domain (NewSession (..), Session, SessionKind (InteractiveSession))

data SessionStore :: Effect where
  CreateSession :: NewSession -> SessionStore m Session
  CreateSessionWithinLimit :: Int -> NewSession -> SessionStore m (Either AuthError Session)
  FindSessionById :: SessionId -> SessionStore m (Maybe Session)
  RevokeSession :: SessionId -> UTCTime -> SessionStore m ()
  RevokeAllUserSessions :: UserId -> UTCTime -> SessionStore m [SessionId]
  ListSessionPage :: UserId -> SessionListQuery -> SessionStore m [Session]
  -- | Compatibility only: every session, materialized in memory. HTTP must use ListSessionPage.
  ListSessionsForUser :: UserId -> SessionStore m [Session]

type instance DispatchOf SessionStore = Dynamic

createSession :: (SessionStore :> es) => NewSession -> Eff es Session
createSession = send . CreateSession

findSessionById :: (SessionStore :> es) => SessionId -> Eff es (Maybe Session)
findSessionById = send . FindSessionById

revokeSession :: (SessionStore :> es) => SessionId -> UTCTime -> Eff es ()
revokeSession sid t = send (RevokeSession sid t)

revokeAllUserSessions :: (SessionStore :> es) => UserId -> UTCTime -> Eff es [SessionId]
revokeAllUserSessions uid t = send (RevokeAllUserSessions uid t)

listSessionsForUser :: (SessionStore :> es) => UserId -> Eff es [Session]
listSessionsForUser = send . ListSessionsForUser

-- | Stable newest-first keyset over timestamp and unique ID.
data SessionCursor = SessionCursor
  { cursorCreatedAt :: !UTCTime,
    cursorSessionId :: !SessionId
  }
  deriving stock (Eq, Show)

data SessionListQuery = SessionListQuery
  { queryLimit :: !Int,
    queryBefore :: !(Maybe SessionCursor)
  }
  deriving stock (Eq, Show)

emptySessionListQuery :: SessionListQuery
emptySessionListQuery = SessionListQuery 50 Nothing

clampSessionLimit :: Int -> Int
clampSessionLimit = max 1 . min 200

listSessionPage :: (SessionStore :> es) => UserId -> SessionListQuery -> Eff es [Session]
listSessionPage uid query = send (ListSessionPage uid query {queryLimit = clampSessionLimit query.queryLimit})

-- | Each machine/delegated session spends its subject and (when distinct) actor's active
-- budget. Lock both principals in stable order at persistence. Expired sessions spend none.
createSessionWithinLimit :: (SessionStore :> es) => Int -> NewSession -> Eff es (Either AuthError Session)
createSessionWithinLimit cap session
  | cap < 1 || session.kind == InteractiveSession = pure (Left InvalidInput)
  | otherwise = send (CreateSessionWithinLimit cap session)