diff --git a/changelog.d/5-internal/saml2-effect b/changelog.d/5-internal/saml2-effect new file mode 100644 index 00000000000..6c97bc700a6 --- /dev/null +++ b/changelog.d/5-internal/saml2-effect @@ -0,0 +1 @@ +Add some new Spar effects, completely isolating us from saml2-web-sso interface diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 7086e210753..48e9772c412 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -4,7 +4,7 @@ cabal-version: 1.12 -- -- see: https://github.com/sol/hpack -- --- hash: 573e0f5c3d7b76dbb9fbf48aff2a535df3059af23f8375307021c6c005d98a5b +-- hash: fe28e95f2571e0a2583e7d160ff87f80422801408c265139b1cd2392a425fd72 name: spar version: 0.1 @@ -22,6 +22,7 @@ library exposed-modules: Spar.API Spar.App + Spar.CanonicalInterpreter Spar.Data Spar.Data.Instances Spar.Error @@ -52,8 +53,14 @@ library Spar.Sem.IdP.Mem Spar.Sem.Logger Spar.Sem.Logger.TinyLog + Spar.Sem.Now + Spar.Sem.Now.IO Spar.Sem.Random Spar.Sem.Random.IO + Spar.Sem.SAML2 + Spar.Sem.SAML2.Library + Spar.Sem.SamlProtocolSettings + Spar.Sem.SamlProtocolSettings.Servant Spar.Sem.SAMLUserStore Spar.Sem.SAMLUserStore.Cassandra Spar.Sem.ScimExternalIdStore diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 4598a56cd76..ae21f03f0fa 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -59,6 +59,7 @@ import qualified SAML2.WebSSO as SAML import Servant import qualified Servant.Multipart as Multipart import Spar.App +import Spar.CanonicalInterpreter import qualified Spar.Data as Data (GetIdPResult (..), Replaced (..), Replacing (..)) import Spar.Error import qualified Spar.Intra.BrigApp as Brig @@ -78,10 +79,15 @@ import qualified Spar.Sem.GalleyAccess as GalleyAccess import qualified Spar.Sem.IdP as IdPEffect import Spar.Sem.Logger (Logger) import qualified Spar.Sem.Logger as Logger +import Spar.Sem.Now (Now) import Spar.Sem.Random (Random) import qualified Spar.Sem.Random as Random +import Spar.Sem.SAML2 (SAML2) +import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) +import qualified Spar.Sem.SamlProtocolSettings as SamlProtocolSettings import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore @@ -97,7 +103,7 @@ import Wire.API.User.Saml app :: Env -> Application app ctx = SAML.setHttpCachePolicy $ - serve (Proxy @API) (hoistServer (Proxy @API) (SAML.nt @SparError @(Spar _) ctx) (api $ sparCtxOpts ctx) :: Server API) + serve (Proxy @API) (hoistServer (Proxy @API) (runSparToHandler ctx) (api $ sparCtxOpts ctx) :: Server API) api :: Members @@ -115,9 +121,14 @@ api :: IdPEffect.IdP, SAMLUserStore, Random, + Error SparError, + SAML2, + Now, + SamlProtocolSettings, Logger String, Logger (Msg -> Msg), - Error SparError + -- TODO(sandy): Remove me when we get rid of runSparInSem + Final IO ] r => Opts -> @@ -144,14 +155,19 @@ apiSSO :: DefaultSsoCode, IdPEffect.IdP, Random, - SAMLUserStore + Error SparError, + SAML2, + SamlProtocolSettings, + SAMLUserStore, + -- TODO(sandy): Remove me when we get rid of runSparInSem + Final IO ] r => Opts -> ServerT APISSO (Spar r) apiSSO opts = - SAML.meta appName (sparSPIssuer Nothing) (sparResponseURI Nothing) - :<|> (\tid -> SAML.meta appName (sparSPIssuer (Just tid)) (sparResponseURI (Just tid))) + (liftSem $ SAML2.meta appName (SamlProtocolSettings.spIssuer Nothing) (SamlProtocolSettings.responseURI Nothing)) + :<|> (\tid -> liftSem $ SAML2.meta appName (SamlProtocolSettings.spIssuer (Just tid)) (SamlProtocolSettings.responseURI (Just tid))) :<|> authreqPrecheck :<|> authreq (maxttlAuthreqDiffTime opts) DoInitiateLogin :<|> authresp Nothing @@ -202,7 +218,7 @@ appName = "spar" authreqPrecheck :: Member IdPEffect.IdP r => Maybe URI.URI -> Maybe URI.URI -> SAML.IdPId -> Spar r NoContent authreqPrecheck msucc merr idpid = validateAuthreqParams msucc merr - *> SAML.getIdPConfig idpid + *> getIdPConfig idpid *> return NoContent authreq :: @@ -213,6 +229,8 @@ authreq :: BindCookieStore, AssIDStore, AReqIDStore, + SAML2, + SamlProtocolSettings, IdPEffect.IdP ] r => @@ -233,7 +251,7 @@ authreq authreqttl _ zusr msucc merr idpid = do mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . wiApiVersion) of WireIdPAPIV1 -> Nothing WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . wiTeam - SAML.authreq authreqttl (sparSPIssuer mbtid) idpid + liftSem $ SAML2.authReq authreqttl (SamlProtocolSettings.spIssuer mbtid) idpid wrapMonadClientSem $ AReqIDStore.storeVerdictFormat authreqttl reqid vformat cky <- initializeBindCookie zusr authreqttl liftSem $ Logger.log SAML.Debug $ "setting bind cookie: " <> show cky @@ -243,7 +261,14 @@ authreq authreqttl _ zusr msucc merr idpid = do -- domain, and store it in C*. If the user is not authenticated, return a deletion 'SetCookie' -- value that deletes any bind cookies on the client. initializeBindCookie :: - Members '[Random, Input Opts, Logger String, BindCookieStore] r => + Members + '[ Random, + SAML2, + Input Opts, + Logger String, + BindCookieStore + ] + r => Maybe UserId -> NominalDiffTime -> Spar r SetBindCookie @@ -253,7 +278,7 @@ initializeBindCookie zusr authreqttl = do if isJust zusr then liftSem $ Just . cs . ES.encode <$> Random.bytes 32 else pure Nothing - cky <- fmap SetBindCookie . SAML.toggleCookie derivedOptsBindCookiePath $ (,authreqttl) <$> msecret + cky <- fmap SetBindCookie . liftSem . SAML2.toggleCookie derivedOptsBindCookiePath $ (,authreqttl) <$> msecret forM_ zusr $ \userid -> wrapMonadClientSem $ BindCookieStore.insert cky userid authreqttl pure cky @@ -289,28 +314,33 @@ authresp :: AReqIDStore, ScimTokenStore, IdPEffect.IdP, - SAMLUserStore + SAML2, + SamlProtocolSettings, + Error SparError, + SAMLUserStore, + -- TODO(sandy): Remove me when we get rid of runSparInSem + Final IO ] r => Maybe TeamId -> Maybe ST -> SAML.AuthnResponseBody -> Spar r Void -authresp mbtid ckyraw arbody = logErrors $ SAML.authresp mbtid (sparSPIssuer mbtid) (sparResponseURI mbtid) go arbody +authresp mbtid ckyraw arbody = logErrors $ liftSem $ SAML2.authResp mbtid (SamlProtocolSettings.spIssuer mbtid) (SamlProtocolSettings.responseURI mbtid) go arbody where cky :: Maybe BindCookie cky = ckyraw >>= bindCookieFromHeader - go :: SAML.AuthnResponse -> SAML.AccessVerdict -> Spar r Void + go :: SAML.AuthnResponse -> SAML.AccessVerdict -> Sem r Void go resp verdict = do - result :: SAML.ResponseVerdict <- verdictHandler cky mbtid resp verdict - throwError $ SAML.CustomServant result + result :: SAML.ResponseVerdict <- runSparInSem $ verdictHandler cky mbtid resp verdict + throw @SparError $ SAML.CustomServant result logErrors :: Spar r Void -> Spar r Void - logErrors = flip catchError $ \case - e@(SAML.CustomServant _) -> throwError e + logErrors action = liftSem . catch @SparError (runSparInSem action) $ \case + e@(SAML.CustomServant _) -> throw e e -> do - throwError . SAML.CustomServant $ + throw @SparError . SAML.CustomServant $ errorPage e (Multipart.inputs (SAML.authnResponseBodyRaw arbody)) @@ -337,7 +367,7 @@ idpGet :: SAML.IdPId -> Spar r IdP idpGet zusr idpid = withDebugLog "idpGet" (Just . show . (^. SAML.idpId)) $ do - idp <- SAML.getIdPConfig idpid + idp <- getIdPConfig idpid _ <- liftSem $ authorizeIdP zusr idp pure idp @@ -347,7 +377,7 @@ idpGetRaw :: SAML.IdPId -> Spar r RawIdPMetadata idpGetRaw zusr idpid = do - idp <- SAML.getIdPConfig idpid + idp <- getIdPConfig idpid _ <- liftSem $ authorizeIdP zusr idp wrapMonadClientSem (IdPEffect.getRawMetadata idpid) >>= \case Just txt -> pure $ RawIdPMetadata txt @@ -396,7 +426,7 @@ idpDelete :: Maybe Bool -> Spar r NoContent idpDelete zusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (const Nothing) $ do - idp <- SAML.getIdPConfig idpid + idp <- getIdPConfig idpid _ <- liftSem $ authorizeIdP zusr idp let issuer = idp ^. SAML.idpMetadata . SAML.edIssuer team = idp ^. SAML.idpExtraInfo . wiTeam @@ -491,7 +521,7 @@ idpCreateXML zusr raw idpmeta mReplaces (fromMaybe defWireIdPAPIVersion -> apive assertNoScimOrNoIdP teamid idp <- validateNewIdP apiversion idpmeta teamid mReplaces wrapMonadClientSem $ IdPEffect.storeRawMetadata (idp ^. SAML.idpId) raw - SAML.storeIdPConfig idp + storeIdPConfig idp forM_ mReplaces $ \replaces -> wrapMonadClientSem $ do IdPEffect.setReplacedBy (Data.Replaced replaces) (Data.Replacing (idp ^. SAML.idpId)) pure idp @@ -539,7 +569,7 @@ validateNewIdP :: Maybe SAML.IdPId -> m IdP validateNewIdP apiversion _idpMetadata teamId mReplaces = withDebugLog "validateNewIdP" (Just . show . (^. SAML.idpId)) $ do - _idpId <- SAML.IdPId <$> SAML.createUUID + _idpId <- SAML.IdPId <$> liftSem Random.uuid oldIssuers :: [SAML.Issuer] <- case mReplaces of Nothing -> pure [] Just replaces -> do @@ -616,7 +646,7 @@ idpUpdateXML zusr raw idpmeta idpid = withDebugLog "idpUpdate" (Just . show . (^ -- (if raw metadata is stored and then spar goes out, raw metadata won't match the -- structured idp config. since this will lead to a 5xx response, the client is epected to -- try again, which would clean up cassandra state.) - SAML.storeIdPConfig idp + storeIdPConfig idp pure idp -- | Check that: idp id is valid; calling user is admin in that idp's home team; team id in diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 733effb5e30..7b7bfc9d3b4 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -38,7 +38,10 @@ module Spar.App deleteTeam, wrapSpar, liftSem, - type RealInterpretation, + getIdPConfig, + storeIdPConfig, + getIdPConfigByIssuerOptionalSPId, + runSparInSem, ) where @@ -59,26 +62,17 @@ import Data.Id import Data.String.Conversions import Data.Text.Ascii (encodeBase64, toText) import qualified Data.Text.Lazy as LT -import Imports hiding (log) +import Imports hiding (MonadReader, asks, log) import qualified Network.HTTP.Types.Status as Http import qualified Network.Wai.Utilities.Error as Wai import Polysemy import Polysemy.Error import Polysemy.Final -import Polysemy.Input (Input, input, inputs, runInputConst) +import Polysemy.Input (Input, input) import SAML2.Util (renderURI) import SAML2.WebSSO - ( Assertion (..), - AuthnRequest (..), - HasConfig (..), - HasCreateUUID (..), - HasLogger (..), - HasNow (..), - IdPId (..), + ( IdPId (..), Issuer (..), - SPHandler (..), - SPStoreID (..), - SPStoreIdP (getIdPConfigByIssuerOptionalSPId), UnqualifiedNameID (..), explainDeniedReason, idpExtraInfo, @@ -95,41 +89,24 @@ import qualified Spar.Intra.BrigApp as Intra import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) import qualified Spar.Sem.AReqIDStore as AReqIDStore -import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra, ttlErrorToSparError) -import Spar.Sem.AssIDStore (AssIDStore) -import qualified Spar.Sem.AssIDStore as AssIDStore -import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) import Spar.Sem.BindCookieStore (BindCookieStore) import qualified Spar.Sem.BindCookieStore as BindCookieStore -import Spar.Sem.BindCookieStore.Cassandra (bindCookieStoreToCassandra) import Spar.Sem.BrigAccess (BrigAccess) import qualified Spar.Sem.BrigAccess as BrigAccess -import Spar.Sem.BrigAccess.Http (brigAccessToHttp) -import Spar.Sem.DefaultSsoCode (DefaultSsoCode) -import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) import Spar.Sem.GalleyAccess (GalleyAccess) import qualified Spar.Sem.GalleyAccess as GalleyAccess -import Spar.Sem.GalleyAccess.Http (galleyAccessToHttp) import Spar.Sem.IdP (GetIdPResult (..)) import qualified Spar.Sem.IdP as IdPEffect -import Spar.Sem.IdP.Cassandra (idPToCassandra) import Spar.Sem.Logger (Logger) import qualified Spar.Sem.Logger as Logger -import Spar.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog) import Spar.Sem.Random (Random) import qualified Spar.Sem.Random as Random -import Spar.Sem.Random.IO (randomToIO) import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.SAMLUserStore.Cassandra (interpretClientToIO, samlUserStoreToCassandra) import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore -import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import Spar.Sem.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) import qualified System.Logger as TinyLog import URI.ByteString as URI import Web.Cookie (SetCookie, renderSetCookie) @@ -160,9 +137,6 @@ instance MonadError SparError (Spar r) where instance MonadIO (Spar r) where liftIO m = Spar $ lift $ embedFinal m -instance Members '[Input Opts, Logger String] r => HasLogger (Spar r) where - logger lvl = liftSem . Logger.log lvl - data Env = Env { sparCtxOpts :: Opts, sparCtxLogger :: TinyLog.Logger, @@ -173,42 +147,26 @@ data Env = Env sparCtxRequestId :: RequestId } -instance Member (Input Opts) r => HasConfig (Spar r) where - getConfig = liftSem $ inputs saml - -instance HasNow (Spar r) - -instance Member Random r => HasCreateUUID (Spar r) where - createUUID = liftSem Random.uuid - -instance Member AReqIDStore r => SPStoreID AuthnRequest (Spar r) where - storeID i r = wrapMonadClientSem $ AReqIDStore.store i r - unStoreID r = wrapMonadClientSem $ AReqIDStore.unStore r - isAliveID r = wrapMonadClientSem $ AReqIDStore.isAlive r +runSparInSem :: Members '[Final IO, Error SparError] r => Spar r a -> Sem r a +runSparInSem (Spar action) = + runExceptT action >>= \case + Left err -> throw err + Right a -> pure a -instance Member AssIDStore r => SPStoreID Assertion (Spar r) where - storeID i r = wrapMonadClientSem $ AssIDStore.store i r - unStoreID r = wrapMonadClientSem $ AssIDStore.unStore r - isAliveID r = wrapMonadClientSem $ AssIDStore.isAlive r +getIdPConfig :: Member IdPEffect.IdP r => IdPId -> Spar r IdP +getIdPConfig = (>>= maybe (throwSpar (SparIdPNotFound mempty)) pure) . wrapMonadClientSem . IdPEffect.getConfig -instance Member IdPEffect.IdP r => SPStoreIdP SparError (Spar r) where - type IdPConfigExtra (Spar r) = WireIdP - type IdPConfigSPId (Spar r) = TeamId +storeIdPConfig :: Member IdPEffect.IdP r => IdP -> Spar r () +storeIdPConfig idp = wrapMonadClientSem $ IdPEffect.storeConfig idp - storeIdPConfig :: IdP -> Spar r () - storeIdPConfig idp = wrapMonadClientSem $ IdPEffect.storeConfig idp - - getIdPConfig :: IdPId -> Spar r IdP - getIdPConfig = (>>= maybe (throwSpar (SparIdPNotFound mempty)) pure) . wrapMonadClientSem . IdPEffect.getConfig - - getIdPConfigByIssuerOptionalSPId :: Issuer -> Maybe TeamId -> Spar r IdP - getIdPConfigByIssuerOptionalSPId issuer mbteam = do - wrapSpar (getIdPConfigByIssuerAllowOld issuer mbteam) >>= \case - Data.GetIdPFound idp -> pure idp - Data.GetIdPNotFound -> throwSpar $ SparIdPNotFound mempty - res@(Data.GetIdPDanglingId _) -> throwSpar $ SparIdPNotFound (cs $ show res) - res@(Data.GetIdPNonUnique _) -> throwSpar $ SparIdPNotFound (cs $ show res) - res@(Data.GetIdPWrongTeam _) -> throwSpar $ SparIdPNotFound (cs $ show res) +getIdPConfigByIssuerOptionalSPId :: Member IdPEffect.IdP r => Issuer -> Maybe TeamId -> Spar r IdP +getIdPConfigByIssuerOptionalSPId issuer mbteam = do + wrapSpar (getIdPConfigByIssuerAllowOld issuer mbteam) >>= \case + Data.GetIdPFound idp -> pure idp + Data.GetIdPNotFound -> throwSpar $ SparIdPNotFound mempty + res@(Data.GetIdPDanglingId _) -> throwSpar $ SparIdPNotFound (cs $ show res) + res@(Data.GetIdPNonUnique _) -> throwSpar $ SparIdPNotFound (cs $ show res) + res@(Data.GetIdPWrongTeam _) -> throwSpar $ SparIdPNotFound (cs $ show res) instance Member (Final IO) r => Catch.MonadThrow (Sem r) where throwM = embedFinal . Catch.throwM @IO @@ -421,69 +379,6 @@ bindUser buid userref = do Ephemeral -> err oldStatus PendingInvitation -> liftSem $ BrigAccess.setStatus buid Active -type RealInterpretation = - '[ BindCookieStore, - AssIDStore, - AReqIDStore, - ScimExternalIdStore, - ScimUserTimesStore, - ScimTokenStore, - DefaultSsoCode, - IdPEffect.IdP, - SAMLUserStore, - Embed (Cas.Client), - BrigAccess, - GalleyAccess, - Error TTLError, - Error SparError, - -- TODO(sandy): Make this a Logger Text instead - Logger String, - Logger (TinyLog.Msg -> TinyLog.Msg), - Input Opts, - Input TinyLog.Logger, - Random, - Embed IO, - Final IO - ] - -instance r ~ RealInterpretation => SPHandler SparError (Spar r) where - type NTCTX (Spar r) = Env - nt :: forall a. Env -> Spar r a -> Handler a - nt ctx (Spar action) = do - err <- actionHandler - throwErrorAsHandlerException err - where - actionHandler :: Handler (Either SparError a) - actionHandler = - fmap join - . liftIO - . runFinal - . embedToFinal @IO - . randomToIO - . runInputConst (sparCtxLogger ctx) - . runInputConst (sparCtxOpts ctx) - . loggerToTinyLog (sparCtxLogger ctx) - . stringLoggerToTinyLog - . runError @SparError - . ttlErrorToSparError - . galleyAccessToHttp (sparCtxHttpManager ctx) (sparCtxHttpGalley ctx) - . brigAccessToHttp (sparCtxHttpManager ctx) (sparCtxHttpBrig ctx) - . interpretClientToIO (sparCtxCas ctx) - . samlUserStoreToCassandra @Cas.Client - . idPToCassandra @Cas.Client - . defaultSsoCodeToCassandra - . scimTokenStoreToCassandra - . scimUserTimesStoreToCassandra - . scimExternalIdStoreToCassandra - . aReqIDStoreToCassandra - . assIDStoreToCassandra - . bindCookieStoreToCassandra - $ runExceptT action - throwErrorAsHandlerException :: Either SparError a -> Handler a - throwErrorAsHandlerException (Left err) = - sparToServerErrorWithLogging (sparCtxLogger ctx) err >>= throwError - throwErrorAsHandlerException (Right a) = pure a - -- | The from of the response on the finalize-login request depends on the verdict (denied or -- granted), plus the choice that the client has made during the initiate-login request. Here we -- call either 'verdictHandlerWeb' or 'verdictHandlerMobile', resp., on the 'SAML.AccessVerdict'. diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs new file mode 100644 index 00000000000..3cc7481258f --- /dev/null +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -0,0 +1,115 @@ +{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-} + +module Spar.CanonicalInterpreter where + +import qualified Cassandra as Cas +import Control.Monad.Except +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.Input (Input, runInputConst) +import Servant +import Spar.App +import Spar.Error +import Spar.Orphans () +import Spar.Sem.AReqIDStore (AReqIDStore) +import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra, ttlErrorToSparError) +import Spar.Sem.AssIDStore (AssIDStore) +import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) +import Spar.Sem.BindCookieStore (BindCookieStore) +import Spar.Sem.BindCookieStore.Cassandra (bindCookieStoreToCassandra) +import Spar.Sem.BrigAccess (BrigAccess) +import Spar.Sem.BrigAccess.Http (brigAccessToHttp) +import Spar.Sem.DefaultSsoCode (DefaultSsoCode) +import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) +import Spar.Sem.GalleyAccess (GalleyAccess) +import Spar.Sem.GalleyAccess.Http (galleyAccessToHttp) +import qualified Spar.Sem.IdP as IdPEffect +import Spar.Sem.IdP.Cassandra (idPToCassandra) +import Spar.Sem.Logger (Logger) +import Spar.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog) +import Spar.Sem.Now (Now) +import Spar.Sem.Now.IO (nowToIO) +import Spar.Sem.Random (Random) +import Spar.Sem.Random.IO (randomToIO) +import Spar.Sem.SAML2 (SAML2) +import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) +import Spar.Sem.SAMLUserStore (SAMLUserStore) +import Spar.Sem.SAMLUserStore.Cassandra (interpretClientToIO, samlUserStoreToCassandra) +import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) +import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) +import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) +import Spar.Sem.ScimTokenStore (ScimTokenStore) +import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) +import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) +import Spar.Sem.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) +import qualified System.Logger as TinyLog +import Wire.API.User.Saml + +type CanonicalEffs = + '[ SAML2, + SamlProtocolSettings, + BindCookieStore, + AssIDStore, + AReqIDStore, + ScimExternalIdStore, + ScimUserTimesStore, + ScimTokenStore, + DefaultSsoCode, + IdPEffect.IdP, + SAMLUserStore, + Embed (Cas.Client), + BrigAccess, + GalleyAccess, + Error TTLError, + Error SparError, + -- TODO(sandy): Make this a Logger Text instead + Logger String, + Logger (TinyLog.Msg -> TinyLog.Msg), + Input Opts, + Input TinyLog.Logger, + Random, + Now, + Embed IO, + Final IO + ] + +runSparToIO :: Env -> Spar CanonicalEffs a -> IO (Either SparError a) +runSparToIO ctx (Spar action) = + fmap join + . runFinal + . embedToFinal @IO + . nowToIO + . randomToIO + . runInputConst (sparCtxLogger ctx) + . runInputConst (sparCtxOpts ctx) + . loggerToTinyLog (sparCtxLogger ctx) + . stringLoggerToTinyLog + . runError @SparError + . ttlErrorToSparError + . galleyAccessToHttp (sparCtxHttpManager ctx) (sparCtxHttpGalley ctx) + . brigAccessToHttp (sparCtxHttpManager ctx) (sparCtxHttpBrig ctx) + . interpretClientToIO (sparCtxCas ctx) + . samlUserStoreToCassandra + . idPToCassandra + . defaultSsoCodeToCassandra + . scimTokenStoreToCassandra + . scimUserTimesStoreToCassandra + . scimExternalIdStoreToCassandra + . aReqIDStoreToCassandra + . assIDStoreToCassandra + . bindCookieStoreToCassandra + . sparRouteToServant (saml $ sparCtxOpts ctx) + . saml2ToSaml2WebSso + $ runExceptT action + +runSparToHandler :: Env -> Spar CanonicalEffs a -> Handler a +runSparToHandler ctx spar = do + err <- liftIO $ runSparToIO ctx spar + throwErrorAsHandlerException err + where + throwErrorAsHandlerException :: Either SparError a -> Handler a + throwErrorAsHandlerException (Left err) = + sparToServerErrorWithLogging (sparCtxLogger ctx) err >>= throwError + throwErrorAsHandlerException (Right a) = pure a diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs index fb96e017b7e..1bb2be8d966 100644 --- a/services/spar/src/Spar/Error.hs +++ b/services/spar/src/Spar/Error.hs @@ -100,6 +100,12 @@ data SparCustomError | SparIdPIssuerInUse | SparProvisioningMoreThanOneIdP LT | SparProvisioningTokenLimitReached + | -- | FUTUREWORK(fisx): This constructor is used in exactly one place (see + -- "Spar.Sem.SAML2.Library"), for an error that immediately gets caught. + -- Instead, we could just use an IO exception, and catch it with + -- 'catchErrors' (see "Spar.Run"). Maybe we want to remove this case + -- altogether? Not sure. + SparInternalError LT | -- | All errors returned from SCIM handlers are wrapped into 'SparScimError' SparScimError Scim.ScimError deriving (Eq, Show) @@ -184,6 +190,7 @@ renderSparError (SAML.CustomError (SparProvisioningMoreThanOneIdP msg)) = Right renderSparError (SAML.CustomError SparProvisioningTokenLimitReached) = Right $ Wai.mkError status403 "token-limit-reached" "The limit of provisioning tokens per team has been reached" -- SCIM errors renderSparError (SAML.CustomError (SparScimError err)) = Left $ Scim.scimToServerError err +renderSparError (SAML.CustomError (SparInternalError err)) = Right $ Wai.mkError status500 "server-error" ("Internal error: " <> err) -- Other renderSparError (SAML.CustomServant err) = Left err diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index ac883f39d39..8f6d0db1765 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -88,6 +88,7 @@ import Spar.Sem.BrigAccess (BrigAccess) import Spar.Sem.GalleyAccess (GalleyAccess) import qualified Spar.Sem.IdP as IdPEffect import Spar.Sem.Logger (Logger) +import Spar.Sem.Now (Now) import Spar.Sem.Random (Random) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) @@ -121,6 +122,7 @@ apiScim :: Input Opts, Logger (Msg -> Msg), Logger String, + Now, Error SparError, GalleyAccess, BrigAccess, diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index d74b3a65dc5..64cbd0d6e9d 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -76,6 +76,8 @@ import Spar.Sem.GalleyAccess (GalleyAccess) import qualified Spar.Sem.IdP as IdPEffect import Spar.Sem.Logger (Logger) import qualified Spar.Sem.Logger as Logger +import Spar.Sem.Now (Now) +import qualified Spar.Sem.Now as Now import Spar.Sem.Random (Random) import qualified Spar.Sem.Random as Random import Spar.Sem.SAMLUserStore (SAMLUserStore) @@ -115,6 +117,7 @@ instance Logger String, Random, Input Opts, + Now, GalleyAccess, BrigAccess, ScimExternalIdStore, @@ -394,6 +397,7 @@ createValidScimUser :: (m ~ Scim.ScimHandler (Spar r)) => Members '[ Random, + Now, Input Opts, Logger (Msg -> Msg), Logger String, @@ -491,6 +495,7 @@ updateValidScimUser :: Input Opts, Logger (Msg -> Msg), Logger String, + Now, GalleyAccess, BrigAccess, ScimExternalIdStore, @@ -609,13 +614,13 @@ toScimStoredUser' createdAt lastChangedAt baseuri uid usr = } updScimStoredUser :: - forall m. - (SAML.HasNow m) => + forall r. + Member Now r => Scim.User ST.SparTag -> Scim.StoredUser ST.SparTag -> - m (Scim.StoredUser ST.SparTag) + Spar r (Scim.StoredUser ST.SparTag) updScimStoredUser usr storedusr = do - SAML.Time (toUTCTimeMillis -> now) <- SAML.getNow + SAML.Time (toUTCTimeMillis -> now) <- liftSem Now.get pure $ updScimStoredUser' now usr storedusr updScimStoredUser' :: @@ -769,6 +774,7 @@ synthesizeStoredUser :: forall r. Members '[ Input Opts, + Now, Logger (Msg -> Msg), BrigAccess, ScimUserTimesStore @@ -807,7 +813,7 @@ synthesizeStoredUser usr veid = liftSem $ BrigAccess.setRichInfo uid newRichInfo (richInfo, accessTimes, baseuri) <- lift readState - SAML.Time (toUTCTimeMillis -> now) <- lift SAML.getNow + SAML.Time (toUTCTimeMillis -> now) <- lift $ liftSem Now.get let (createdAt, lastUpdatedAt) = fromMaybe (now, now) accessTimes handle <- lift $ liftSem $ Brig.giveDefaultHandle (accountUser usr) @@ -865,6 +871,7 @@ synthesizeScimUser info = scimFindUserByHandle :: Members '[ Input Opts, + Now, Logger (Msg -> Msg), BrigAccess, ScimUserTimesStore @@ -892,6 +899,7 @@ scimFindUserByEmail :: forall r. Members '[ Input Opts, + Now, Logger (Msg -> Msg), BrigAccess, ScimExternalIdStore, diff --git a/services/spar/src/Spar/Sem/Now.hs b/services/spar/src/Spar/Sem/Now.hs new file mode 100644 index 00000000000..4c43be4a466 --- /dev/null +++ b/services/spar/src/Spar/Sem/Now.hs @@ -0,0 +1,9 @@ +module Spar.Sem.Now where + +import Polysemy +import qualified SAML2.WebSSO as SAML + +data Now m a where + Get :: Now m SAML.Time + +makeSem ''Now diff --git a/services/spar/src/Spar/Sem/Now/IO.hs b/services/spar/src/Spar/Sem/Now/IO.hs new file mode 100644 index 00000000000..74f75f738b8 --- /dev/null +++ b/services/spar/src/Spar/Sem/Now/IO.hs @@ -0,0 +1,10 @@ +module Spar.Sem.Now.IO where + +import Imports +import Polysemy +import SAML2.WebSSO (getNowIO) +import Spar.Sem.Now + +nowToIO :: Member (Embed IO) r => Sem (Now ': r) a -> Sem r a +nowToIO = interpret $ \case + Get -> embed @IO getNowIO diff --git a/services/spar/src/Spar/Sem/SAML2.hs b/services/spar/src/Spar/Sem/SAML2.hs new file mode 100644 index 00000000000..ae5007e9709 --- /dev/null +++ b/services/spar/src/Spar/Sem/SAML2.hs @@ -0,0 +1,33 @@ +module Spar.Sem.SAML2 where + +import Data.Id (TeamId) +import Data.String.Conversions (SBS, ST) +import Data.Time (NominalDiffTime) +import GHC.TypeLits (KnownSymbol) +import Imports hiding (log) +import Polysemy +import SAML2.WebSSO +import URI.ByteString (URI) + +data SAML2 m a where + AuthReq :: + NominalDiffTime -> + m Issuer -> + IdPId -> + SAML2 m (FormRedirect AuthnRequest) + AuthResp :: + Maybe TeamId -> + m Issuer -> + m URI -> + (AuthnResponse -> AccessVerdict -> m resp) -> + AuthnResponseBody -> + SAML2 m resp + Meta :: ST -> m Issuer -> m URI -> SAML2 m SPMetadata + ToggleCookie :: + KnownSymbol name => + SBS -> + Maybe (ST, NominalDiffTime) -> + SAML2 m (SimpleSetCookie name) + +-- TODO(sandy): Inline this definition --- no TH +makeSem ''SAML2 diff --git a/services/spar/src/Spar/Sem/SAML2/Library.hs b/services/spar/src/Spar/Sem/SAML2/Library.hs new file mode 100644 index 00000000000..3de69921410 --- /dev/null +++ b/services/spar/src/Spar/Sem/SAML2/Library.hs @@ -0,0 +1,139 @@ +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-} + +module Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) where + +import qualified Control.Monad.Catch as Catch +import Control.Monad.Except +import Data.Id (TeamId) +import Data.String.Conversions (cs) +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.Input +import Polysemy.Internal.Tactics +import SAML2.WebSSO hiding (Error) +import qualified SAML2.WebSSO as SAML hiding (Error) +import qualified Spar.App as App +import Spar.Error (SparCustomError (..), SparError) +import Spar.Sem.AReqIDStore (AReqIDStore) +import qualified Spar.Sem.AReqIDStore as AReqIDStore +import Spar.Sem.AssIDStore (AssIDStore) +import qualified Spar.Sem.AssIDStore as AssIDStore +import qualified Spar.Sem.IdP as IdPEffect +import Spar.Sem.Logger (Logger) +import qualified Spar.Sem.Logger as Logger +import Spar.Sem.SAML2 +import Wire.API.User.IdentityProvider (WireIdP) +import Wire.API.User.Saml + +wrapMonadClientSPImpl :: Members '[Error SparError, Final IO] r => Sem r a -> SPImpl r a +wrapMonadClientSPImpl action = + SPImpl $ + action + `Catch.catch` (throw . SAML.CustomError . SparCassandraError . cs . show @SomeException) + +newtype SPImpl r a = SPImpl {unSPImpl :: Sem r a} + deriving (Functor, Applicative, Monad) + +instance Member (Input Opts) r => HasConfig (SPImpl r) where + getConfig = SPImpl $ inputs saml + +instance Members '[Input Opts, Logger String] r => HasLogger (SPImpl r) where + logger lvl = SPImpl . Logger.log lvl + +instance Member (Embed IO) r => MonadIO (SPImpl r) where + liftIO = SPImpl . embed @IO + +instance Member (Embed IO) r => HasCreateUUID (SPImpl r) + +instance Member (Embed IO) r => HasNow (SPImpl r) + +instance Members '[Error SparError, Final IO, AReqIDStore] r => SPStoreID AuthnRequest (SPImpl r) where + storeID = (wrapMonadClientSPImpl .) . AReqIDStore.store + unStoreID = wrapMonadClientSPImpl . AReqIDStore.unStore + isAliveID = wrapMonadClientSPImpl . AReqIDStore.isAlive + +instance Members '[Error SparError, Final IO, AssIDStore] r => SPStoreID Assertion (SPImpl r) where + storeID = (wrapMonadClientSPImpl .) . AssIDStore.store + unStoreID = wrapMonadClientSPImpl . AssIDStore.unStore + isAliveID = wrapMonadClientSPImpl . AssIDStore.isAlive + +instance Members '[Error SparError, IdPEffect.IdP, Final IO] r => SPStoreIdP SparError (SPImpl r) where + type IdPConfigExtra (SPImpl r) = WireIdP + type IdPConfigSPId (SPImpl r) = TeamId + + storeIdPConfig = SPImpl . App.runSparInSem . App.storeIdPConfig + getIdPConfig = SPImpl . App.runSparInSem . App.getIdPConfig + getIdPConfigByIssuerOptionalSPId a = SPImpl . App.runSparInSem . App.getIdPConfigByIssuerOptionalSPId a + +instance Member (Error SparError) r => MonadError SparError (SPImpl r) where + throwError = SPImpl . throw + catchError m handler = SPImpl $ catch (unSPImpl m) $ unSPImpl . handler + +-- | To learn more about polysemy tactics, read this: +-- * https://reasonablypolymorphic.com/blog/freer-higher-order-effects/ +-- * https://reasonablypolymorphic.com/blog/tactics/ +saml2ToSaml2WebSso :: + forall r a. + Members + '[ AReqIDStore, + AssIDStore, + Error SparError, + IdPEffect.IdP, + Input Opts, + Logger String, + Embed IO, + Final IO + ] + r => + Sem (SAML2 ': r) a -> + Sem r a +saml2ToSaml2WebSso = + interpretH $ \case + AuthReq n ma i -> do + get_a <- runT ma + ins <- getInspectorT + x <- raise $ unSPImpl $ SAML.authreq @_ @SparError n (inspectOrBomb ins get_a) i + s <- getInitialStateT + pure $ x <$ s + AuthResp mitlt ma mb mc ab -> do + get_a <- runT ma + get_b <- runT mb + get_c <- bindT $ uncurry mc + ins <- getInspectorT + s <- getInitialStateT + x <- raise $ unSPImpl $ SAML.authresp mitlt (inspectOrBomb ins get_a) (inspectOrBomb ins get_b) (\x y -> inspectOrBomb ins $ get_c $ (x, y) <$ s) ab + pure $ x <$ s + Meta t ma mb -> do + get_a <- runT ma + get_b <- runT mb + ins <- getInspectorT + x <- raise $ unSPImpl $ SAML.meta t (inspectOrBomb ins get_a) (inspectOrBomb ins get_b) + s <- getInitialStateT + pure $ x <$ s + ToggleCookie sbs mp -> do + liftT $ unSPImpl $ SAML.toggleCookie sbs mp + +inspectOrBomb :: + Members + '[ AReqIDStore, + AssIDStore, + Error SparError, + IdPEffect.IdP, + Logger String, + Input Opts, + Embed IO, + Final IO + ] + r => + Inspector f -> + Sem (SAML2 : r) (f b) -> + SPImpl r b +inspectOrBomb ins get_a = do + fa <- SPImpl $ saml2ToSaml2WebSso get_a + maybe + (SPImpl . throw @SparError $ SAML.CustomError $ SparInternalError "saml2ToSaml2WebSso called with an uninspectable weaving functor") + pure + $ inspect ins fa diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings.hs b/services/spar/src/Spar/Sem/SamlProtocolSettings.hs new file mode 100644 index 00000000000..ea545706614 --- /dev/null +++ b/services/spar/src/Spar/Sem/SamlProtocolSettings.hs @@ -0,0 +1,13 @@ +module Spar.Sem.SamlProtocolSettings where + +import Data.Id (TeamId) +import Imports +import Polysemy +import qualified SAML2.WebSSO.Types as SAML +import qualified URI.ByteString as URI + +data SamlProtocolSettings m a where + SpIssuer :: Maybe TeamId -> SamlProtocolSettings m SAML.Issuer + ResponseURI :: Maybe TeamId -> SamlProtocolSettings m URI.URI + +makeSem ''SamlProtocolSettings diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs b/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs new file mode 100644 index 00000000000..138ba41640f --- /dev/null +++ b/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs @@ -0,0 +1,18 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +module Spar.Sem.SamlProtocolSettings.Servant where + +import Imports +import Polysemy +import qualified SAML2.WebSSO as SAML +import Spar.Sem.SamlProtocolSettings +import Wire.API.Routes.Public.Spar + +-- TODO(sandy): Why is this instance not provided by SAML? Very rude! +instance SAML.HasConfig ((->) SAML.Config) where + getConfig = id + +sparRouteToServant :: SAML.Config -> Sem (SamlProtocolSettings ': r) a -> Sem r a +sparRouteToServant cfg = interpret $ \x -> case x of + SpIssuer mitlt -> pure $ sparSPIssuer mitlt cfg + ResponseURI mitlt -> pure $ sparResponseURI mitlt cfg diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs index b37ba065a80..a294653fe3c 100644 --- a/services/spar/test-integration/Test/Spar/DataSpec.hs +++ b/services/spar/test-integration/Test/Spar/DataSpec.hs @@ -230,9 +230,9 @@ spec = do testSPStoreID :: forall (a :: Type). (Typeable a) => - (SAML.ID a -> SAML.Time -> Sem RealInterpretation ()) -> - (SAML.ID a -> Sem RealInterpretation ()) -> - (SAML.ID a -> Sem RealInterpretation Bool) -> + (SAML.ID a -> SAML.Time -> Sem CanonicalEffs ()) -> + (SAML.ID a -> Sem CanonicalEffs ()) -> + (SAML.ID a -> Sem CanonicalEffs Bool) -> SpecWith TestEnv testSPStoreID store unstore isalive = do describe ("SPStoreID @" <> show (typeRep @a)) $ do diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index a4f799881c8..c66bbdd5bef 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -117,7 +117,7 @@ module Util.Core ssoToUidSpar, runSimpleSP, runSpar, - type RealInterpretation, + type CanonicalEffs, getSsoidViaSelf, getSsoidViaSelf', getUserIdViaRef, @@ -168,35 +168,20 @@ import Network.HTTP.Client.MultipartFormData import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp.Internal as Warp import qualified Options.Applicative as OPA -import Polysemy -import Polysemy.Error (runError) -import Polysemy.Input import SAML2.WebSSO as SAML import qualified SAML2.WebSSO.API.Example as SAML import SAML2.WebSSO.Test.Lenses (userRefL) import SAML2.WebSSO.Test.MockResponse import SAML2.WebSSO.Test.Util (SampleIdP (..), makeSampleIdPMetadata) -import Spar.App (liftSem, type RealInterpretation) +import Spar.App (liftSem) import qualified Spar.App as Spar -import Spar.Error (SparError) +import Spar.CanonicalInterpreter import qualified Spar.Intra.BrigApp as Intra import qualified Spar.Options import Spar.Run -import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra, ttlErrorToSparError) -import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) -import Spar.Sem.BindCookieStore.Cassandra (bindCookieStoreToCassandra) -import Spar.Sem.BrigAccess.Http (brigAccessToHttp) -import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) -import Spar.Sem.GalleyAccess.Http (galleyAccessToHttp) -import Spar.Sem.IdP.Cassandra -import Spar.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog, toLevel) -import Spar.Sem.Random.IO (randomToIO) +import Spar.Sem.Logger.TinyLog (toLevel) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.SAMLUserStore.Cassandra import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) -import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) -import Spar.Sem.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) import qualified System.Logger.Extended as Log import System.Random (randomRIO) import Test.Hspec hiding (it, pending, pendingWith, xit) @@ -1236,36 +1221,12 @@ runSimpleSP action = do runSpar :: (MonadReader TestEnv m, MonadIO m) => - Spar.Spar RealInterpretation a -> + Spar.Spar CanonicalEffs a -> m a -runSpar (Spar.Spar action) = do +runSpar action = do ctx <- (^. teSparEnv) <$> ask liftIO $ do - result <- - fmap join - . liftIO - . runFinal - . embedToFinal @IO - . randomToIO - . runInputConst (Spar.sparCtxLogger ctx) - . runInputConst (Spar.sparCtxOpts ctx) - . loggerToTinyLog (Spar.sparCtxLogger ctx) - . stringLoggerToTinyLog - . runError @SparError - . ttlErrorToSparError - . galleyAccessToHttp (Spar.sparCtxHttpManager ctx) (Spar.sparCtxHttpGalley ctx) - . brigAccessToHttp (Spar.sparCtxHttpManager ctx) (Spar.sparCtxHttpBrig ctx) - . interpretClientToIO (Spar.sparCtxCas ctx) - . samlUserStoreToCassandra @Cas.Client - . idPToCassandra @Cas.Client - . defaultSsoCodeToCassandra - . scimTokenStoreToCassandra - . scimUserTimesStoreToCassandra - . scimExternalIdStoreToCassandra - . aReqIDStoreToCassandra - . assIDStoreToCassandra - . bindCookieStoreToCassandra - $ runExceptT action + result <- runSparToIO ctx action either (throwIO . ErrorCall . show) pure result getSsoidViaSelf :: HasCallStack => UserId -> TestSpar UserSSOId