diff --git a/changelog.d/2-features/pr-1755 b/changelog.d/2-features/pr-1755 new file mode 100644 index 0000000000..81624c4022 --- /dev/null +++ b/changelog.d/2-features/pr-1755 @@ -0,0 +1,3 @@ +Support using a single IDP with a single EntityID (aka issuer ID) to set up two teams. +Sets up a migration, and makes teamID + EntityID unique, rather than relying on EntityID to be unique. +Required to support multiple teams in environments where the IDP software cannot present anything but one EntityID (E.G.: DualShield). \ No newline at end of file diff --git a/docs/reference/cassandra-schema.cql b/docs/reference/cassandra-schema.cql index cd0b9d85d5..e9383b3618 100644 --- a/docs/reference/cassandra-schema.cql +++ b/docs/reference/cassandra-schema.cql @@ -1513,6 +1513,7 @@ CREATE TABLE spar_test.issuer_idp ( CREATE TABLE spar_test.idp ( idp uuid PRIMARY KEY, + api_version int, extra_public_keys list, issuer text, old_issuers list, @@ -1681,6 +1682,27 @@ CREATE TABLE spar_test.team_idp ( AND read_repair_chance = 0.0 AND speculative_retry = '99PERCENTILE'; +CREATE TABLE spar_test.issuer_idp_v2 ( + issuer text, + team uuid, + idp uuid, + PRIMARY KEY (issuer, team) +) WITH CLUSTERING ORDER BY (team ASC) + AND bloom_filter_fp_chance = 0.1 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.LeveledCompactionStrategy'} + AND compression = {'chunk_length_in_kb': '64', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND crc_check_chance = 1.0 + AND dclocal_read_repair_chance = 0.1 + AND default_time_to_live = 0 + AND gc_grace_seconds = 864000 + AND max_index_interval = 2048 + AND memtable_flush_period_in_ms = 0 + AND min_index_interval = 128 + AND read_repair_chance = 0.0 + AND speculative_retry = '99PERCENTILE'; + CREATE TABLE spar_test.scim_user_times ( uid uuid PRIMARY KEY, created_at timestamp, diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs index ad54f2aa87..5492a0be76 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs @@ -53,8 +53,10 @@ type API = type APISSO = "metadata" :> SAML.APIMeta + :<|> "metadata" :> Capture "team" TeamId :> SAML.APIMeta :<|> "initiate-login" :> APIAuthReqPrecheck :<|> "initiate-login" :> APIAuthReq + :<|> APIAuthRespLegacy :<|> APIAuthResp :<|> "settings" :> SsoSettingsGet @@ -131,8 +133,16 @@ data DoInitiate = DoInitiateLogin | DoInitiateBind type WithSetBindCookie = Headers '[Servant.Header "Set-Cookie" SetBindCookie] +type APIAuthRespLegacy = + "finalize-login" + :> Header "Cookie" ST + -- (SAML.APIAuthResp from here on, except for response) + :> MultipartForm Mem SAML.AuthnResponseBody + :> Post '[PlainText] Void + type APIAuthResp = "finalize-login" + :> Capture "team" TeamId :> Header "Cookie" ST -- (SAML.APIAuthResp from here on, except for response) :> MultipartForm Mem SAML.AuthnResponseBody @@ -156,6 +166,7 @@ type IdpGetAll = Get '[JSON] IdPList type IdpCreate = ReqBodyCustomError '[RawXML, JSON] "wai-error" IdPMetadataInfo :> QueryParam' '[Optional, Strict] "replaces" SAML.IdPId + :> QueryParam' '[Optional, Strict] "api-version" WireIdPAPIVersion :> PostCreated '[JSON] IdP type IdpUpdate = @@ -176,11 +187,17 @@ type APIINTERNAL = :<|> "teams" :> Capture "team" TeamId :> DeleteNoContent :<|> "sso" :> "settings" :> ReqBody '[JSON] SsoSettings :> Put '[JSON] NoContent -sparSPIssuer :: SAML.HasConfig m => m SAML.Issuer -sparSPIssuer = SAML.Issuer <$> SAML.getSsoURI (Proxy @APISSO) (Proxy @APIAuthResp) - -sparResponseURI :: SAML.HasConfig m => m URI.URI -sparResponseURI = SAML.getSsoURI (Proxy @APISSO) (Proxy @APIAuthResp) +sparSPIssuer :: SAML.HasConfig m => Maybe TeamId -> m SAML.Issuer +sparSPIssuer Nothing = + SAML.Issuer <$> SAML.getSsoURI (Proxy @APISSO) (Proxy @APIAuthRespLegacy) +sparSPIssuer (Just tid) = + SAML.Issuer <$> SAML.getSsoURI' (Proxy @APISSO) (Proxy @APIAuthResp) tid + +sparResponseURI :: SAML.HasConfig m => Maybe TeamId -> m URI.URI +sparResponseURI Nothing = + SAML.getSsoURI (Proxy @APISSO) (Proxy @APIAuthRespLegacy) +sparResponseURI (Just tid) = + SAML.getSsoURI' (Proxy @APISSO) (Proxy @APIAuthResp) tid -- SCIM diff --git a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs index 868c59ab6f..f3d88cfd95 100644 --- a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs +++ b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs @@ -17,10 +17,14 @@ module Wire.API.User.IdentityProvider where +import qualified Cassandra as Cql import Control.Lens (makeLenses, (.~), (?~)) import Control.Monad.Except import Data.Aeson import Data.Aeson.TH +import qualified Data.Attoparsec.ByteString as AP +import qualified Data.Binary.Builder as BSB +import qualified Data.ByteString.Conversion as BSC import Data.HashMap.Strict.InsOrd (InsOrdHashMap) import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap import Data.Id (TeamId) @@ -33,6 +37,7 @@ import SAML2.WebSSO (IdPConfig) import qualified SAML2.WebSSO as SAML import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Servant.API as Servant hiding (MkLink, URI (..)) +import Wire.API.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) import Wire.API.User.Orphans (samlSchemaOptions) -- | The identity provider type used in Spar. @@ -43,6 +48,7 @@ data WireIdP = WireIdP -- | list of issuer names that this idp has replaced, most recent first. this is used -- for finding users that are still stored under the old issuer, see -- 'findUserWithOldIssuer', 'moveUserToNewIssuer'. + _wiApiVersion :: Maybe WireIdPAPIVersion, _wiOldIssuers :: [SAML.Issuer], -- | the issuer that has replaced this one. this is set iff a new issuer is created -- with the @"replaces"@ query parameter, and it is used to decide whether users not @@ -51,10 +57,61 @@ data WireIdP = WireIdP } deriving (Eq, Show, Generic) +data WireIdPAPIVersion + = -- | initial API + WireIdPAPIV1 + | -- | support for different SP entityIDs per team + WireIdPAPIV2 + deriving stock (Eq, Show, Enum, Bounded, Generic) + deriving (Arbitrary) via (GenericUniform WireIdPAPIVersion) + +defWireIdPAPIVersion :: WireIdPAPIVersion +defWireIdPAPIVersion = WireIdPAPIV1 + makeLenses ''WireIdP +deriveJSON deriveJSONOptions ''WireIdPAPIVersion deriveJSON deriveJSONOptions ''WireIdP +instance BSC.ToByteString WireIdPAPIVersion where + builder = + BSB.fromByteString . \case + WireIdPAPIV1 -> "v1" + WireIdPAPIV2 -> "v2" + +instance BSC.FromByteString WireIdPAPIVersion where + parser = + (AP.string "v1" >> pure WireIdPAPIV1) + <|> (AP.string "v2" >> pure WireIdPAPIV2) + +instance FromHttpApiData WireIdPAPIVersion where + parseQueryParam txt = maybe err Right $ BSC.fromByteString' (cs txt) + where + err = Left $ "FromHttpApiData WireIdPAPIVersion: " <> txt + +instance ToHttpApiData WireIdPAPIVersion where + toQueryParam = cs . BSC.toByteString' + +instance ToParamSchema WireIdPAPIVersion where + toParamSchema Proxy = + mempty + { _paramSchemaDefault = Just "v2", + _paramSchemaType = Just SwaggerString, + _paramSchemaEnum = Just (String . toQueryParam <$> [(minBound :: WireIdPAPIVersion) ..]) + } + +instance Cql.Cql WireIdPAPIVersion where + ctype = Cql.Tagged Cql.IntColumn + + toCql WireIdPAPIV1 = Cql.CqlInt 1 + toCql WireIdPAPIV2 = Cql.CqlInt 2 + + fromCql (Cql.CqlInt i) = case i of + 1 -> return WireIdPAPIV1 + 2 -> return WireIdPAPIV2 + n -> Left $ "Unexpected ClientCapability value: " ++ show n + fromCql _ = Left "ClientCapability value: int expected" + -- | A list of 'IdP's, returned by some endpoints. Wrapped into an object to -- allow extensibility later on. data IdPList = IdPList @@ -104,6 +161,9 @@ instance ToJSON IdPMetadataInfo where instance ToSchema IdPList where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions +instance ToSchema WireIdPAPIVersion where + declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions + instance ToSchema WireIdP where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs index 642dfe1f87..d79ed2a670 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs @@ -40,6 +40,7 @@ import qualified Wire.API.User as User import qualified Wire.API.User.Activation as User.Activation import qualified Wire.API.User.Auth as User.Auth import qualified Wire.API.User.Identity as User.Identity +import qualified Wire.API.User.IdentityProvider as User.IdentityProvider import qualified Wire.API.User.Password as User.Password import qualified Wire.API.User.Profile as User.Profile import qualified Wire.API.User.Search as User.Search @@ -84,7 +85,8 @@ tests = testRoundTrip @Team.Role.Role, testRoundTrip @User.Search.TeamUserSearchSortBy, testRoundTrip @User.Search.TeamUserSearchSortOrder, - testRoundTrip @User.Search.RoleFilter + testRoundTrip @User.Search.RoleFilter, + testRoundTrip @User.IdentityProvider.WireIdPAPIVersion -- FUTUREWORK: -- testCase "Call.Config.TurnUsername (doesn't have FromByteString)" ... -- testCase "User.Activation.ActivationTarget (doesn't have FromByteString)" ... diff --git a/services/spar/schema/src/Main.hs b/services/spar/schema/src/Main.hs index d039eb3154..4084b5c10a 100644 --- a/services/spar/schema/src/Main.hs +++ b/services/spar/schema/src/Main.hs @@ -29,6 +29,7 @@ import qualified V11 import qualified V12 import qualified V13 import qualified V14 +import qualified V15 import qualified V2 import qualified V3 import qualified V4 @@ -61,7 +62,8 @@ main = do V11.migration, V12.migration, V13.migration, - V14.migration + V14.migration, + V15.migration -- When adding migrations here, don't forget to update -- 'schemaVersion' in Spar.Data diff --git a/services/spar/schema/src/V15.hs b/services/spar/schema/src/V15.hs new file mode 100644 index 0000000000..6ead409623 --- /dev/null +++ b/services/spar/schema/src/V15.hs @@ -0,0 +1,43 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2020 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module V15 + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = Migration 15 "Optionally index IdP by teamid (in addition to entityID); add idp api version." $ do + void $ + schema' + [r| + CREATE TABLE if not exists issuer_idp_v2 + ( issuer text + , team uuid + , idp uuid + , PRIMARY KEY (issuer, team) + ) with compaction = {'class': 'LeveledCompactionStrategy'}; + |] + void $ + schema' + [r| + ALTER TABLE idp ADD api_version int; + |] diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 884c3ef17e..ec6623400f 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: 2afa3a03f475aac5d63ab87ded5843f404fee3d8506ac70390ee37dde18f22d4 +-- hash: 6a8deefc6739b8a56d89eae84bd4333254d31f2b1bf1ab22b830d7d120992bbf name: spar version: 0.1 @@ -364,6 +364,7 @@ executable spar-schema V12 V13 V14 + V15 V2 V3 V4 diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 7afadc45b3..9b5b6923de 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -85,10 +85,12 @@ api opts = apiSSO :: Opts -> ServerT APISSO Spar apiSSO opts = - SAML.meta appName sparSPIssuer sparResponseURI + SAML.meta appName (sparSPIssuer Nothing) (sparResponseURI Nothing) + :<|> (\tid -> SAML.meta appName (sparSPIssuer (Just tid)) (sparResponseURI (Just tid))) :<|> authreqPrecheck :<|> authreq (maxttlAuthreqDiffTime opts) DoInitiateLogin - :<|> authresp + :<|> authresp Nothing + :<|> authresp . Just :<|> ssoSettings apiIDP :: ServerT APIIDP Spar @@ -130,7 +132,13 @@ authreq _ DoInitiateLogin (Just _) _ _ _ = throwSpar SparInitLoginWithAuth authreq _ DoInitiateBind Nothing _ _ _ = throwSpar SparInitBindWithoutAuth authreq authreqttl _ zusr msucc merr idpid = do vformat <- validateAuthreqParams msucc merr - form@(SAML.FormRedirect _ ((^. SAML.rqID) -> reqid)) <- SAML.authreq authreqttl sparSPIssuer idpid + form@(SAML.FormRedirect _ ((^. SAML.rqID) -> reqid)) <- do + idp :: IdP <- wrapMonadClient (Data.getIdPConfig idpid) >>= maybe (throwSpar (SparIdPNotFound (cs $ show idpid))) pure + let mbtid :: Maybe TeamId + mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . wiApiVersion) of + WireIdPAPIV1 -> Nothing + WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . wiTeam + SAML.authreq authreqttl (sparSPIssuer mbtid) idpid wrapMonadClient $ Data.storeVerdictFormat authreqttl reqid vformat cky <- initializeBindCookie zusr authreqttl SAML.logger SAML.Debug $ "setting bind cookie: " <> show cky @@ -168,15 +176,15 @@ validateRedirectURL uri = do unless ((SBS.length $ URI.serializeURIRef' uri) <= redirectURLMaxLength) $ do throwSpar $ SparBadInitiateLoginQueryParams "url-too-long" -authresp :: Maybe ST -> SAML.AuthnResponseBody -> Spar Void -authresp ckyraw arbody = logErrors $ SAML.authresp sparSPIssuer sparResponseURI go arbody +authresp :: Maybe TeamId -> Maybe ST -> SAML.AuthnResponseBody -> Spar Void +authresp mbtid ckyraw arbody = logErrors $ SAML.authresp mbtid (sparSPIssuer mbtid) (sparResponseURI mbtid) go arbody where cky :: Maybe BindCookie cky = ckyraw >>= bindCookieFromHeader go :: SAML.AuthnResponse -> SAML.AccessVerdict -> Spar Void go resp verdict = do - result :: SAML.ResponseVerdict <- verdictHandler cky resp verdict + result :: SAML.ResponseVerdict <- verdictHandler cky mbtid resp verdict throwError $ SAML.CustomServant result logErrors :: Spar Void -> Spar Void @@ -208,7 +216,7 @@ idpGetRaw zusr idpid = do _ <- authorizeIdP zusr idp wrapMonadClient (Data.getIdPRawMetadata idpid) >>= \case Just txt -> pure $ RawIdPMetadata txt - Nothing -> throwSpar SparIdPNotFound + Nothing -> throwSpar $ SparIdPNotFound (cs $ show idpid) idpGetAll :: Maybe UserId -> Spar IdPList idpGetAll zusr = withDebugLog "idpGetAll" (const Nothing) $ do @@ -269,21 +277,25 @@ idpDelete zusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (cons updateReplacingIdP :: IdP -> Spar () updateReplacingIdP idp = forM_ (idp ^. SAML.idpExtraInfo . wiOldIssuers) $ \oldIssuer -> do wrapMonadClient $ do - iid <- Data.getIdPIdByIssuer oldIssuer - mapM_ (Data.clearReplacedBy . Data.Replaced) iid + Data.getIdPIdByIssuer oldIssuer (idp ^. SAML.idpExtraInfo . wiTeam) >>= \case + Data.GetIdPFound iid -> Data.clearReplacedBy $ Data.Replaced iid + Data.GetIdPNotFound -> pure () + Data.GetIdPDanglingId _ -> pure () + Data.GetIdPNonUnique _ -> pure () + Data.GetIdPWrongTeam _ -> pure () -- | This handler only does the json parsing, and leaves all authorization checks and -- application logic to 'idpCreateXML'. -idpCreate :: Maybe UserId -> IdPMetadataInfo -> Maybe SAML.IdPId -> Spar IdP -idpCreate zusr (IdPMetadataValue raw xml) midpid = idpCreateXML zusr raw xml midpid +idpCreate :: Maybe UserId -> IdPMetadataInfo -> Maybe SAML.IdPId -> Maybe WireIdPAPIVersion -> Spar IdP +idpCreate zusr (IdPMetadataValue raw xml) midpid apiversion = idpCreateXML zusr raw xml midpid apiversion -- | We generate a new UUID for each IdP used as IdPConfig's path, thereby ensuring uniqueness. -idpCreateXML :: Maybe UserId -> Text -> SAML.IdPMetadata -> Maybe SAML.IdPId -> Spar IdP -idpCreateXML zusr raw idpmeta mReplaces = withDebugLog "idpCreate" (Just . show . (^. SAML.idpId)) $ do +idpCreateXML :: Maybe UserId -> Text -> SAML.IdPMetadata -> Maybe SAML.IdPId -> Maybe WireIdPAPIVersion -> Spar IdP +idpCreateXML zusr raw idpmeta mReplaces (fromMaybe defWireIdPAPIVersion -> apiversion) = withDebugLog "idpCreate" (Just . show . (^. SAML.idpId)) $ do teamid <- Brig.getZUsrCheckPerm zusr CreateUpdateDeleteIdp Galley.assertSSOEnabled teamid assertNoScimOrNoIdP teamid - idp <- validateNewIdP idpmeta teamid mReplaces + idp <- validateNewIdP apiversion idpmeta teamid mReplaces wrapMonadClient $ Data.storeIdPRawMetadata (idp ^. SAML.idpId) raw SAML.storeIdPConfig idp forM_ mReplaces $ \replaces -> wrapMonadClient $ do @@ -322,23 +334,61 @@ assertNoScimOrNoIdP teamid = do validateNewIdP :: forall m. (HasCallStack, m ~ Spar) => + WireIdPAPIVersion -> SAML.IdPMetadata -> TeamId -> Maybe SAML.IdPId -> m IdP -validateNewIdP _idpMetadata teamId mReplaces = do +validateNewIdP apiversion _idpMetadata teamId mReplaces = withDebugLog "validateNewIdP" (Just . show . (^. SAML.idpId)) $ do _idpId <- SAML.IdPId <$> SAML.createUUID oldIssuers :: [SAML.Issuer] <- case mReplaces of Nothing -> pure [] Just replaces -> do - idp <- wrapMonadClient (Data.getIdPConfig replaces) >>= maybe (throwSpar SparIdPNotFound) pure + idp <- wrapMonadClient (Data.getIdPConfig replaces) >>= maybe (throwSpar (SparIdPNotFound (cs $ show mReplaces))) pure pure $ (idp ^. SAML.idpMetadata . SAML.edIssuer) : (idp ^. SAML.idpExtraInfo . wiOldIssuers) let requri = _idpMetadata ^. SAML.edRequestURI - _idpExtraInfo = WireIdP teamId oldIssuers Nothing + _idpExtraInfo = WireIdP teamId (Just apiversion) oldIssuers Nothing enforceHttps requri - wrapMonadClient (Data.getIdPIdByIssuer (_idpMetadata ^. SAML.edIssuer)) >>= \case - Nothing -> pure () - Just _ -> throwSpar SparNewIdPAlreadyInUse + idp <- wrapMonadClient (Data.getIdPConfigByIssuer (_idpMetadata ^. SAML.edIssuer) teamId) + SAML.logger SAML.Debug $ show (apiversion, _idpMetadata, teamId, mReplaces) + SAML.logger SAML.Debug $ show (_idpId, oldIssuers, idp) + + let handleIdPClash :: Either SAML.IdPId IdP -> m () + handleIdPClash = case apiversion of + WireIdPAPIV1 -> const $ do + throwSpar $ SparNewIdPAlreadyInUse "you can't create an IdP with api-version v1 if the issuer is already in use on the wire instance." + WireIdPAPIV2 -> \case + (Right idp') -> do + guardSameTeam idp' + guardReplaceeV2 + (Left id') -> do + idp' <- do + let err = throwSpar $ SparIdPNotFound (cs $ show id') -- database inconsistency + wrapMonadClient (Data.getIdPConfig id') >>= maybe err pure + handleIdPClash (Right idp') + + guardSameTeam :: IdP -> m () + guardSameTeam idp' = do + when ((idp' ^. SAML.idpExtraInfo . wiTeam) == teamId) $ do + throwSpar $ SparNewIdPAlreadyInUse "if the exisitng IdP is registered for a team, the new one can't have it." + + guardReplaceeV2 :: m () + guardReplaceeV2 = forM_ mReplaces $ \rid -> do + ridp <- do + let err = throwSpar $ SparIdPNotFound (cs $ show rid) -- database inconsistency + wrapMonadClient (Data.getIdPConfig rid) >>= maybe err pure + when (fromMaybe defWireIdPAPIVersion (ridp ^. SAML.idpExtraInfo . wiApiVersion) /= WireIdPAPIV2) $ do + throwSpar $ + SparNewIdPAlreadyInUse + (cs $ "api-version mismatch: " <> show ((ridp ^. SAML.idpExtraInfo . wiApiVersion), WireIdPAPIV2)) + + case idp of + Data.GetIdPFound idp' {- same team -} -> handleIdPClash (Right idp') + Data.GetIdPNotFound -> pure () + res@(Data.GetIdPDanglingId _) -> throwSpar . SparIdPNotFound . cs . show $ res -- database inconsistency + res@(Data.GetIdPNonUnique _) -> throwSpar . SparIdPNotFound . cs . show $ res -- impossible + Data.GetIdPWrongTeam id' {- different team -} -> handleIdPClash (Left id') + pure SAML.IdPConfig {..} -- | FUTUREWORK: 'idpUpdateXML' is only factored out of this function for symmetry with @@ -369,7 +419,7 @@ validateIdPUpdate :: SAML.IdPMetadata -> SAML.IdPId -> m (TeamId, IdP) -validateIdPUpdate zusr _idpMetadata _idpId = do +validateIdPUpdate zusr _idpMetadata _idpId = withDebugLog "validateNewIdP" (Just . show . (_2 %~ (^. SAML.idpId))) $ do previousIdP <- wrapMonadClient (Data.getIdPConfig _idpId) >>= \case Nothing -> throwError errUnknownIdPId @@ -383,10 +433,13 @@ validateIdPUpdate zusr _idpMetadata _idpId = do if previousIssuer == newIssuer then pure $ previousIdP ^. SAML.idpExtraInfo else do - foundConfig <- wrapMonadClient (Data.getIdPConfigByIssuer newIssuer) - let notInUseByOthers = case foundConfig of - Nothing -> True - Just c -> c ^. SAML.idpId == _idpId + foundConfig <- wrapMonadClient (Data.getIdPConfigByIssuerAllowOld newIssuer (Just teamId)) + notInUseByOthers <- case foundConfig of + Data.GetIdPFound c -> pure $ c ^. SAML.idpId == _idpId + Data.GetIdPNotFound -> pure True + res@(Data.GetIdPDanglingId _) -> throwSpar . SparIdPNotFound . cs . show $ res -- impossible + res@(Data.GetIdPNonUnique _) -> throwSpar . SparIdPNotFound . cs . show $ res -- impossible (because team id was used in lookup) + Data.GetIdPWrongTeam _ -> pure False if notInUseByOthers then pure $ (previousIdP ^. SAML.idpExtraInfo) & wiOldIssuers %~ nub . (previousIssuer :) else throwSpar SparIdPIssuerInUse @@ -447,7 +500,7 @@ internalPutSsoSettings SsoSettings {defaultSsoCode = Just code} = do -- this will return a 404, which is not quite right, -- but it's an internal endpoint and the message clearly says -- "Could not find IdP". - throwSpar SparIdPNotFound + throwSpar $ SparIdPNotFound mempty Just _ -> do wrapMonadClient $ Data.storeDefaultSsoCode code pure NoContent diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index c3914ab363..2e243d33d6 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -146,15 +146,22 @@ instance SPStoreID Assertion Spar where instance SPStoreIdP SparError Spar where type IdPConfigExtra Spar = WireIdP + type IdPConfigSPId Spar = TeamId storeIdPConfig :: IdP -> Spar () storeIdPConfig idp = wrapMonadClient $ Data.storeIdPConfig idp getIdPConfig :: IdPId -> Spar IdP - getIdPConfig = (>>= maybe (throwSpar SparIdPNotFound) pure) . wrapMonadClientWithEnv . Data.getIdPConfig + getIdPConfig = (>>= maybe (throwSpar (SparIdPNotFound mempty)) pure) . wrapMonadClientWithEnv . Data.getIdPConfig - getIdPConfigByIssuer :: Issuer -> Spar IdP - getIdPConfigByIssuer = (>>= maybe (throwSpar SparIdPNotFound) pure) . wrapMonadClientWithEnv . Data.getIdPConfigByIssuer + getIdPConfigByIssuerOptionalSPId :: Issuer -> Maybe TeamId -> Spar IdP + getIdPConfigByIssuerOptionalSPId issuer mbteam = do + wrapMonadClientWithEnv (Data.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) -- | 'wrapMonadClient' with an 'Env' in a 'ReaderT', and exceptions. If you -- don't need either of those, 'wrapMonadClient' will suffice. @@ -229,9 +236,9 @@ getUserByScimExternalId tid email = do -- FUTUREWORK: once we support , brig will refuse to delete -- users that have an sso id, unless the request comes from spar. then we can make users -- undeletable in the team admin page, and ask admins to go talk to their IdP system. -createSamlUserWithId :: UserId -> SAML.UserRef -> Spar () -createSamlUserWithId buid suid = do - teamid <- (^. idpExtraInfo . wiTeam) <$> getIdPConfigByIssuer (suid ^. uidTenant) +createSamlUserWithId :: IdP -> UserId -> SAML.UserRef -> Spar () +createSamlUserWithId idp buid suid = do + let teamid = idp ^. idpExtraInfo . wiTeam uname <- either (throwSpar . SparBadUserName . cs) pure $ Intra.mkUserName Nothing (UrefOnly suid) buid' <- Intra.createBrigUserSAML suid buid teamid uname ManagedByWire assert (buid == buid') $ pure () @@ -239,19 +246,19 @@ createSamlUserWithId buid suid = do -- | If the team has no scim token, call 'createSamlUser'. Otherwise, raise "invalid -- credentials". -autoprovisionSamlUser :: SAML.UserRef -> Spar UserId -autoprovisionSamlUser suid = do +autoprovisionSamlUser :: Maybe TeamId -> SAML.UserRef -> Spar UserId +autoprovisionSamlUser mbteam suid = do buid <- Id <$> liftIO UUID.nextRandom - autoprovisionSamlUserWithId buid suid + autoprovisionSamlUserWithId mbteam buid suid pure buid -- | Like 'autoprovisionSamlUser', but for an already existing 'UserId'. -autoprovisionSamlUserWithId :: UserId -> SAML.UserRef -> Spar () -autoprovisionSamlUserWithId buid suid = do - idp <- getIdPConfigByIssuer (suid ^. uidTenant) +autoprovisionSamlUserWithId :: Maybe TeamId -> UserId -> SAML.UserRef -> Spar () +autoprovisionSamlUserWithId mbteam buid suid = do + idp <- getIdPConfigByIssuerOptionalSPId (suid ^. uidTenant) mbteam guardReplacedIdP idp guardScimTokens idp - createSamlUserWithId buid suid + createSamlUserWithId idp buid suid validateEmailIfExists buid suid where -- Replaced IdPs are not allowed to create new wire accounts. @@ -294,7 +301,13 @@ bindUser buid userref = do oldStatus <- do let err :: Spar a err = throwSpar . SparBindFromWrongOrNoTeam . cs . show $ buid - teamid :: TeamId <- (^. idpExtraInfo . wiTeam) <$> getIdPConfigByIssuer (userref ^. uidTenant) + teamid :: TeamId <- + wrapMonadClient (Data.getIdPConfigByIssuerAllowOld (userref ^. uidTenant) Nothing) >>= \case + Data.GetIdPFound idp -> pure $ idp ^. idpExtraInfo . wiTeam + Data.GetIdPNotFound -> err + Data.GetIdPDanglingId _ -> err -- database inconsistency + Data.GetIdPNonUnique is -> throwSpar $ SparUserRefInMultipleTeams (cs $ show (buid, is)) + Data.GetIdPWrongTeam _ -> err -- impossible acc <- Intra.getBrigUserAccount Intra.WithPendingInvitations buid >>= maybe err pure teamid' :: TeamId <- userTeam (accountUser acc) & maybe err pure unless (teamid' == teamid) err @@ -348,21 +361,24 @@ instance Intra.MonadSparToGalley Spar where -- signed in-response-to info in the assertions matches the unsigned in-response-to field in the -- 'SAML.Response', and fills in the response id in the header if missing, we can just go for the -- latter. -verdictHandler :: HasCallStack => Maybe BindCookie -> SAML.AuthnResponse -> SAML.AccessVerdict -> Spar SAML.ResponseVerdict -verdictHandler cky aresp verdict = do +verdictHandler :: HasCallStack => Maybe BindCookie -> Maybe TeamId -> SAML.AuthnResponse -> SAML.AccessVerdict -> Spar SAML.ResponseVerdict +verdictHandler cky mbteam aresp verdict = do -- [3/4.1.4.2] -- [...] If the containing message is in response to an , then -- the InResponseTo attribute MUST match the request's ID. + SAML.logger SAML.Debug $ "entering verdictHandler: " <> show (fromBindCookie <$> cky, aresp, verdict) reqid <- either (throwSpar . SparNoRequestRefInResponse . cs) pure $ SAML.rspInResponseTo aresp format :: Maybe VerdictFormat <- wrapMonadClient $ Data.getVerdictFormat reqid - case format of + resp <- case format of Just (VerdictFormatWeb) -> - verdictHandlerResult cky verdict >>= verdictHandlerWeb + verdictHandlerResult cky mbteam verdict >>= verdictHandlerWeb Just (VerdictFormatMobile granted denied) -> - verdictHandlerResult cky verdict >>= verdictHandlerMobile granted denied - Nothing -> throwSpar SparNoSuchRequest - --- (this shouldn't happen too often, see 'storeVerdictFormat') + verdictHandlerResult cky mbteam verdict >>= verdictHandlerMobile granted denied + Nothing -> + -- (this shouldn't happen too often, see 'storeVerdictFormat') + throwSpar SparNoSuchRequest + SAML.logger SAML.Debug $ "leaving verdictHandler: " <> show resp + pure resp data VerdictHandlerResult = VerifyHandlerGranted {_vhrCookie :: SetCookie, _vhrUserId :: UserId} @@ -370,10 +386,11 @@ data VerdictHandlerResult | VerifyHandlerError {_vhrLabel :: ST, _vhrMessage :: ST} deriving (Eq, Show) -verdictHandlerResult :: HasCallStack => Maybe BindCookie -> SAML.AccessVerdict -> Spar VerdictHandlerResult -verdictHandlerResult bindCky verdict = do - result <- catchVerdictErrors $ verdictHandlerResultCore bindCky verdict - SAML.logger SAML.Debug (show result) +verdictHandlerResult :: HasCallStack => Maybe BindCookie -> Maybe TeamId -> SAML.AccessVerdict -> Spar VerdictHandlerResult +verdictHandlerResult bindCky mbteam verdict = do + SAML.logger SAML.Debug $ "entering verdictHandlerResult: " <> show (fromBindCookie <$> bindCky) + result <- catchVerdictErrors $ verdictHandlerResultCore bindCky mbteam verdict + SAML.logger SAML.Debug $ "leaving verdictHandlerResult" <> show result pure result catchVerdictErrors :: Spar VerdictHandlerResult -> Spar VerdictHandlerResult @@ -390,9 +407,9 @@ catchVerdictErrors = (`catchError` hndlr) -- | If a user attempts to login presenting a new IdP issuer, but there is no entry in -- @"spar.user"@ for her: lookup @"old_issuers"@ from @"spar.idp"@ for the new IdP, and -- traverse the old IdPs in search for the old entry. Return that old entry. -findUserWithOldIssuer :: SAML.UserRef -> Spar (Maybe (SAML.UserRef, UserId)) -findUserWithOldIssuer (SAML.UserRef issuer subject) = do - idp <- getIdPConfigByIssuer issuer +findUserWithOldIssuer :: Maybe TeamId -> SAML.UserRef -> Spar (Maybe (SAML.UserRef, UserId)) +findUserWithOldIssuer mbteam (SAML.UserRef issuer subject) = do + idp <- getIdPConfigByIssuerOptionalSPId issuer mbteam let tryFind :: Maybe (SAML.UserRef, UserId) -> Issuer -> Spar (Maybe (SAML.UserRef, UserId)) tryFind found@(Just _) _ = pure found tryFind Nothing oldIssuer = (uref,) <$$> getUserByUref uref @@ -408,8 +425,8 @@ moveUserToNewIssuer oldUserRef newUserRef uid = do Intra.setBrigUserVeid uid (UrefOnly newUserRef) wrapMonadClient $ Data.deleteSAMLUser uid oldUserRef -verdictHandlerResultCore :: HasCallStack => Maybe BindCookie -> SAML.AccessVerdict -> Spar VerdictHandlerResult -verdictHandlerResultCore bindCky = \case +verdictHandlerResultCore :: HasCallStack => Maybe BindCookie -> Maybe TeamId -> SAML.AccessVerdict -> Spar VerdictHandlerResult +verdictHandlerResultCore bindCky mbteam = \case SAML.AccessDenied reasons -> do pure $ VerifyHandlerDenied reasons SAML.AccessGranted userref -> do @@ -422,12 +439,12 @@ verdictHandlerResultCore bindCky = \case viaSparCassandraOldIssuer <- if isJust viaSparCassandra then pure Nothing - else findUserWithOldIssuer userref + else findUserWithOldIssuer mbteam userref case (viaBindCookie, viaSparCassandra, viaSparCassandraOldIssuer) of -- This is the first SSO authentication, so we auto-create a user. We know the user -- has not been created via SCIM because then we would've ended up in the - -- "reauthentication" branch, so we pass 'ManagedByWire'. - (Nothing, Nothing, Nothing) -> autoprovisionSamlUser userref + -- "reauthentication" branch. + (Nothing, Nothing, Nothing) -> autoprovisionSamlUser mbteam userref -- If the user is only found under an old (previous) issuer, move it here. (Nothing, Nothing, Just (oldUserRef, uid)) -> moveUserToNewIssuer oldUserRef userref uid >> pure uid -- SSO re-authentication (the most common case). diff --git a/services/spar/src/Spar/Data.hs b/services/spar/src/Spar/Data.hs index 3f0658518b..bbe5c2ca8c 100644 --- a/services/spar/src/Spar/Data.hs +++ b/services/spar/src/Spar/Data.hs @@ -56,9 +56,12 @@ module Spar.Data Replacing (..), setReplacedBy, clearReplacedBy, + GetIdPResult (..), getIdPConfig, getIdPConfigByIssuer, + getIdPConfigByIssuerAllowOld, getIdPIdByIssuer, + getIdPIdByIssuerAllowOld, getIdPConfigsByTeam, deleteIdPConfig, deleteTeam, @@ -120,7 +123,7 @@ import qualified Prelude -- | A lower bound: @schemaVersion <= whatWeFoundOnCassandra@, not @==@. schemaVersion :: Int32 -schemaVersion = 14 +schemaVersion = 15 ---------------------------------------------------------------------- -- helpers @@ -322,6 +325,11 @@ getSAMLSomeUsersByIssuer issuer = sel :: PrepQuery R (Identity SAML.Issuer) (SAML.NameID, UserId) sel = "SELECT sso_id, uid FROM user_v2 WHERE issuer = ? LIMIT 2000" +-- | Lookup a brig 'UserId' by IdP issuer and NameID. +-- +-- NB: It is not allowed for two distinct wire users from two different teams to have the same +-- 'UserRef'. RATIONALE: this allows us to implement 'getSAMLUser' without adding 'TeamId' to +-- 'UserRef' (which in turn would break the (admittedly leaky) abstarctions of saml2-web-sso). getSAMLUser :: (HasCallStack, MonadClient m) => SAML.UserRef -> m (Maybe UserId) getSAMLUser uref = do mbUid <- getSAMLUserNew uref @@ -409,7 +417,7 @@ lookupBindCookie (cs . fromBindCookie -> ckyval :: ST) = ---------------------------------------------------------------------- -- idp -type IdPConfigRow = (SAML.IdPId, SAML.Issuer, URI, SignedCertificate, [SignedCertificate], TeamId, [SAML.Issuer], Maybe SAML.IdPId) +type IdPConfigRow = (SAML.IdPId, SAML.Issuer, URI, SignedCertificate, [SignedCertificate], TeamId, Maybe WireIdPAPIVersion, [SAML.Issuer], Maybe SAML.IdPId) -- FUTUREWORK: should be called 'insertIdPConfig' for consistency. -- FUTUREWORK: enforce that wiReplacedby is Nothing, or throw an error. there is no @@ -431,12 +439,14 @@ storeIdPConfig idp = retry x5 . batch $ do NL.tail (idp ^. SAML.idpMetadata . SAML.edCertAuthnResponse), -- (the 'List1' is split up into head and tail to make migration from one-element-only easier.) idp ^. SAML.idpExtraInfo . wiTeam, + idp ^. SAML.idpExtraInfo . wiApiVersion, idp ^. SAML.idpExtraInfo . wiOldIssuers, idp ^. SAML.idpExtraInfo . wiReplacedBy ) addPrepQuery byIssuer ( idp ^. SAML.idpId, + idp ^. SAML.idpExtraInfo . wiTeam, idp ^. SAML.idpMetadata . SAML.edIssuer ) addPrepQuery @@ -446,9 +456,12 @@ storeIdPConfig idp = retry x5 . batch $ do ) where ins :: PrepQuery W IdPConfigRow () - ins = "INSERT INTO idp (idp, issuer, request_uri, public_key, extra_public_keys, team, old_issuers, replaced_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - byIssuer :: PrepQuery W (SAML.IdPId, SAML.Issuer) () - byIssuer = "INSERT INTO issuer_idp (idp, issuer) VALUES (?, ?)" + ins = "INSERT INTO idp (idp, issuer, request_uri, public_key, extra_public_keys, team, api_version, old_issuers, replaced_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + + -- FUTUREWORK: migrate `spar.issuer_idp` away, `spar.issuer_idp_v2` is enough. + byIssuer :: PrepQuery W (SAML.IdPId, TeamId, SAML.Issuer) () + byIssuer = "INSERT INTO issuer_idp_v2 (idp, team, issuer) VALUES (?, ?, ?)" + byTeam :: PrepQuery W (SAML.IdPId, TeamId) () byTeam = "INSERT INTO team_idp (idp, team) VALUES (?, ?)" @@ -497,37 +510,121 @@ getIdPConfig idpid = certsTail, -- extras teamId, + apiVersion, oldIssuers, replacedBy ) = do let _edCertAuthnResponse = certsHead NL.:| certsTail _idpMetadata = SAML.IdPMetadata {..} - _idpExtraInfo = WireIdP teamId oldIssuers replacedBy + _idpExtraInfo = WireIdP teamId apiVersion oldIssuers replacedBy pure $ SAML.IdPConfig {..} sel :: PrepQuery R (Identity SAML.IdPId) IdPConfigRow - sel = "SELECT idp, issuer, request_uri, public_key, extra_public_keys, team, old_issuers, replaced_by FROM idp WHERE idp = ?" + sel = "SELECT idp, issuer, request_uri, public_key, extra_public_keys, team, api_version, old_issuers, replaced_by FROM idp WHERE idp = ?" + +data GetIdPResult a + = GetIdPFound a + | GetIdPNotFound + | -- | IdPId has been found, but no IdPConfig matching that Id. (Database + -- inconsistency or race condition.) + GetIdPDanglingId SAML.IdPId + | -- | You were looking for an idp by just providing issuer, not teamid, and `issuer_idp_v2` + -- has more than one entry (for different teams). + GetIdPNonUnique [SAML.IdPId] + | -- | An IdP was found, but it lives in another team than the one you were looking for. + -- This should be handled similarly to NotFound in most cases. + GetIdPWrongTeam SAML.IdPId + deriving (Eq, Show) + +-- | (There are probably category theoretical models for what we're doing here, but it's more +-- straight-forward to just handle the one instance we need.) +mapGetIdPResult :: (HasCallStack, MonadClient m) => GetIdPResult SAML.IdPId -> m (GetIdPResult IdP) +mapGetIdPResult (GetIdPFound i) = getIdPConfig i <&> maybe (GetIdPDanglingId i) GetIdPFound +mapGetIdPResult GetIdPNotFound = pure GetIdPNotFound +mapGetIdPResult (GetIdPDanglingId i) = pure (GetIdPDanglingId i) +mapGetIdPResult (GetIdPNonUnique is) = pure (GetIdPNonUnique is) +mapGetIdPResult (GetIdPWrongTeam i) = pure (GetIdPWrongTeam i) +-- | See 'getIdPIdByIssuer'. getIdPConfigByIssuer :: (HasCallStack, MonadClient m) => SAML.Issuer -> - m (Maybe IdP) -getIdPConfigByIssuer issuer = do - getIdPIdByIssuer issuer >>= \case - Nothing -> pure Nothing - Just idpid -> getIdPConfig idpid + TeamId -> + m (GetIdPResult IdP) +getIdPConfigByIssuer issuer = + getIdPIdByIssuer issuer >=> mapGetIdPResult +-- | See 'getIdPIdByIssuerAllowOld'. +getIdPConfigByIssuerAllowOld :: + (HasCallStack, MonadClient m) => + SAML.Issuer -> + Maybe TeamId -> + m (GetIdPResult IdP) +getIdPConfigByIssuerAllowOld issuer = do + getIdPIdByIssuerAllowOld issuer >=> mapGetIdPResult + +-- | Lookup idp in table `issuer_idp_v2` (using both issuer entityID and teamid); if nothing +-- is found there or if teamid is 'Nothing', lookup under issuer in `issuer_idp`. getIdPIdByIssuer :: (HasCallStack, MonadClient m) => SAML.Issuer -> - m (Maybe SAML.IdPId) -getIdPIdByIssuer issuer = do - retry x1 (query1 sel $ params Quorum (Identity issuer)) <&> \case - Nothing -> Nothing - Just (Identity idpid) -> Just idpid + TeamId -> + m (GetIdPResult SAML.IdPId) +getIdPIdByIssuer issuer = getIdPIdByIssuerAllowOld issuer . Just + +-- | Like 'getIdPIdByIssuer', but do not require a 'TeamId'. If none is provided, see if a +-- single solution can be found without. +getIdPIdByIssuerAllowOld :: + (HasCallStack, MonadClient m) => + SAML.Issuer -> + Maybe TeamId -> + m (GetIdPResult SAML.IdPId) +getIdPIdByIssuerAllowOld issuer mbteam = do + mbv2 <- maybe (pure Nothing) (getIdPIdByIssuerWithTeam issuer) mbteam + mbv1v2 <- maybe (getIdPIdByIssuerWithoutTeam issuer) (pure . GetIdPFound) mbv2 + case (mbv1v2, mbteam) of + (GetIdPFound idpid, Just team) -> do + getIdPConfig idpid >>= \case + Nothing -> do + pure $ GetIdPDanglingId idpid + Just idp -> + pure $ + if idp ^. SAML.idpExtraInfo . wiTeam /= team + then GetIdPWrongTeam idpid + else mbv1v2 + _ -> pure mbv1v2 + +-- | Find 'IdPId' without team. Search both `issuer_idp` and `issuer_idp_v2`; in the latter, +-- make sure the result is unique (no two IdPs for two different teams). +getIdPIdByIssuerWithoutTeam :: + (HasCallStack, MonadClient m) => + SAML.Issuer -> + m (GetIdPResult SAML.IdPId) +getIdPIdByIssuerWithoutTeam issuer = do + (runIdentity <$$> retry x1 (query1 sel $ params Quorum (Identity issuer))) >>= \case + Just idpid -> pure $ GetIdPFound idpid + Nothing -> + (runIdentity <$$> retry x1 (query selv2 $ params Quorum (Identity issuer))) >>= \case + [] -> pure GetIdPNotFound + [idpid] -> pure $ GetIdPFound idpid + idpids@(_ : _ : _) -> pure $ GetIdPNonUnique idpids where sel :: PrepQuery R (Identity SAML.Issuer) (Identity SAML.IdPId) sel = "SELECT idp FROM issuer_idp WHERE issuer = ?" + selv2 :: PrepQuery R (Identity SAML.Issuer) (Identity SAML.IdPId) + selv2 = "SELECT idp FROM issuer_idp_v2 WHERE issuer = ?" + +getIdPIdByIssuerWithTeam :: + (HasCallStack, MonadClient m) => + SAML.Issuer -> + TeamId -> + m (Maybe SAML.IdPId) +getIdPIdByIssuerWithTeam issuer tid = do + runIdentity <$$> retry x1 (query1 sel $ params Quorum (issuer, tid)) + where + sel :: PrepQuery R (SAML.Issuer, TeamId) (Identity SAML.IdPId) + sel = "SELECT idp FROM issuer_idp_v2 WHERE issuer = ? and team = ?" + getIdPConfigsByTeam :: (HasCallStack, MonadClient m) => TeamId -> @@ -551,14 +648,21 @@ deleteIdPConfig idp issuer team = retry x5 . batch $ do addPrepQuery delDefaultIdp (Identity idp) addPrepQuery delIdp (Identity idp) addPrepQuery delIssuerIdp (Identity issuer) + addPrepQuery delIssuerIdpV2 (Identity issuer) addPrepQuery delTeamIdp (team, idp) where delDefaultIdp :: PrepQuery W (Identity SAML.IdPId) () delDefaultIdp = "DELETE FROM default_idp WHERE partition_key_always_default = 'default' AND idp = ?" + delIdp :: PrepQuery W (Identity SAML.IdPId) () delIdp = "DELETE FROM idp WHERE idp = ?" + delIssuerIdp :: PrepQuery W (Identity SAML.Issuer) () delIssuerIdp = "DELETE FROM issuer_idp WHERE issuer = ?" + + delIssuerIdpV2 :: PrepQuery W (Identity SAML.Issuer) () + delIssuerIdpV2 = "DELETE FROM issuer_idp_v2 WHERE issuer = ?" + delTeamIdp :: PrepQuery W (TeamId, SAML.IdPId) () delTeamIdp = "DELETE FROM team_idp WHERE team = ? and idp = ?" diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs index 960b7de809..ddbc57321c 100644 --- a/services/spar/src/Spar/Error.hs +++ b/services/spar/src/Spar/Error.hs @@ -68,7 +68,7 @@ throwSpar :: MonadError SparError m => SparCustomError -> m a throwSpar = throwError . SAML.CustomError data SparCustomError - = SparIdPNotFound + = SparIdPNotFound LT | SparSamlCredentialsNotFound | SparMissingZUsr | SparNotInTeam @@ -84,6 +84,7 @@ data SparCustomError | SparBindFromWrongOrNoTeam LT | SparBindFromBadAccountStatus LT | SparBindUserRefTaken + | SparUserRefInMultipleTeams LT | SparBadUserName LT | SparCannotCreateUsersOnReplacedIdP LT | SparCouldNotParseRfcResponse LT LT @@ -93,7 +94,7 @@ data SparCustomError | SparCassandraTTLError TTLError | SparNewIdPBadMetadata LT | SparNewIdPPubkeyMismatch - | SparNewIdPAlreadyInUse + | SparNewIdPAlreadyInUse LT | SparNewIdPWantHttps LT | SparIdPHasBoundUsers | SparIdPIssuerInUse @@ -140,6 +141,7 @@ renderSparError (SAML.CustomError (SparBadInitiateLoginQueryParams label)) = Rig renderSparError (SAML.CustomError (SparBindFromWrongOrNoTeam msg)) = Right $ Wai.mkError status403 "bad-team" ("Forbidden: wrong user team " <> msg) renderSparError (SAML.CustomError (SparBindFromBadAccountStatus msg)) = Right $ Wai.mkError status403 "bad-account-status" ("Forbidden: user has account status " <> msg <> "; only Active, PendingInvitation are supported") renderSparError (SAML.CustomError SparBindUserRefTaken) = Right $ Wai.mkError status403 "subject-id-taken" "Forbidden: SubjectID is used by another wire user. If you have an old user bound to this IdP, unbind or delete that user." +renderSparError (SAML.CustomError (SparUserRefInMultipleTeams msg)) = Right $ Wai.mkError status403 "bad-team" ("Forbidden: multiple teams for same UserRef " <> msg) renderSparError (SAML.CustomError (SparBadUserName msg)) = Right $ Wai.mkError status400 "bad-username" ("Bad UserName in SAML response, except len [1, 128]: " <> msg) renderSparError (SAML.CustomError (SparCannotCreateUsersOnReplacedIdP replacingIdPId)) = Right $ Wai.mkError status400 "cannont-provision-on-replaced-idp" ("This IdP has been replaced, users can only be auto-provisioned on the replacing IdP " <> replacingIdPId) -- RFC-specific errors @@ -158,7 +160,8 @@ renderSparError SAML.BadSamlResponseIssuerMissing = Right $ Wai.mkError status40 renderSparError SAML.BadSamlResponseNoAssertions = Right $ Wai.mkError status400 "bad-response-saml" ("Bad response: no assertions in AuthnResponse") renderSparError SAML.BadSamlResponseAssertionWithoutID = Right $ Wai.mkError status400 "bad-response-saml" ("Bad response: assertion without ID") renderSparError (SAML.BadSamlResponseInvalidSignature msg) = Right $ Wai.mkError status400 "bad-response-signature" (cs msg) -renderSparError (SAML.CustomError SparIdPNotFound) = Right $ Wai.mkError status404 "not-found" "Could not find IdP." +renderSparError (SAML.CustomError (SparIdPNotFound "")) = Right $ Wai.mkError status404 "not-found" "Could not find IdP." +renderSparError (SAML.CustomError (SparIdPNotFound msg)) = Right $ Wai.mkError status404 "not-found" ("Could not find IdP: " <> msg) renderSparError (SAML.CustomError SparSamlCredentialsNotFound) = Right $ Wai.mkError status404 "not-found" "Could not find SAML credentials, and auto-provisioning is disabled." renderSparError (SAML.CustomError SparMissingZUsr) = Right $ Wai.mkError status400 "client-error" "[header] 'Z-User' required" renderSparError (SAML.CustomError SparNotInTeam) = Right $ Wai.mkError status403 "no-team-member" "Requesting user is not a team member or not a member of this team." @@ -172,7 +175,7 @@ renderSparError (SAML.InvalidCert msg) = Right $ Wai.mkError status500 "invalid- -- Errors related to IdP creation renderSparError (SAML.CustomError (SparNewIdPBadMetadata msg)) = Right $ Wai.mkError status400 "invalid-metadata" msg renderSparError (SAML.CustomError SparNewIdPPubkeyMismatch) = Right $ Wai.mkError status400 "key-mismatch" "public keys in body, metadata do not match" -renderSparError (SAML.CustomError SparNewIdPAlreadyInUse) = Right $ Wai.mkError status400 "idp-already-in-use" "an idp issuer can only be used within one team" +renderSparError (SAML.CustomError (SparNewIdPAlreadyInUse msg)) = Right $ Wai.mkError status400 "idp-already-in-use" msg renderSparError (SAML.CustomError (SparNewIdPWantHttps msg)) = Right $ Wai.mkError status400 "idp-must-be-https" ("an idp request uri must be https, not http or other: " <> msg) renderSparError (SAML.CustomError SparIdPHasBoundUsers) = Right $ Wai.mkError status412 "idp-has-bound-users" "an idp can only be deleted if it is empty" renderSparError (SAML.CustomError SparIdPIssuerInUse) = Right $ Wai.mkError status400 "idp-issuer-in-use" "The issuer of your IdP is already in use. Remove the entry in the team that uses it, or construct a new IdP issuer." diff --git a/services/spar/src/Spar/Options.hs b/services/spar/src/Spar/Options.hs index 27bfdff16b..55d040520d 100644 --- a/services/spar/src/Spar/Options.hs +++ b/services/spar/src/Spar/Options.hs @@ -53,7 +53,10 @@ getOpts = do deriveOpts :: OptsRaw -> IO Opts deriveOpts raw = do derived <- do - let respuri = runWithConfig raw sparResponseURI + let respuri = + -- respuri is only needed for 'derivedOptsBindCookiePath'; we want the prefix of the + -- V2 path that includes the team id. + runWithConfig raw (sparResponseURI Nothing) derivedOptsBindCookiePath = URI.uriPath respuri -- We could also make this selectable in the config file, but it seems easier to derive it from -- the SAML base uri. diff --git a/services/spar/test-integration/Spec.hs b/services/spar/test-integration/Spec.hs index 32c0b1bf92..f74c9dc513 100644 --- a/services/spar/test-integration/Spec.hs +++ b/services/spar/test-integration/Spec.hs @@ -29,7 +29,7 @@ -- the solution: https://github.com/hspec/hspec/pull/397. module Main where -import Control.Lens ((^.)) +import Control.Lens ((.~), (^.)) import Data.String.Conversions import Data.Text (pack) import Imports @@ -53,10 +53,14 @@ import Wire.API.User.Scim main :: IO () main = do (wireArgs, hspecArgs) <- partitionArgs <$> getArgs - env <- withArgs wireArgs mkEnvFromOptions + let env = withArgs wireArgs mkEnvFromOptions withArgs hspecArgs . hspec $ do - beforeAll (pure env) . afterAll destroyEnv $ mkspec - mkspec' env + for_ [minBound ..] $ \idpApiVersion -> do + describe (show idpApiVersion) . beforeAll (env <&> teWireIdPAPIVersion .~ idpApiVersion) . afterAll destroyEnv $ do + mkspecMisc + mkspecSaml + mkspecScim + mkspecHscimAcceptance env destroyEnv partitionArgs :: [String] -> ([String], [String]) partitionArgs = go [] [] @@ -66,23 +70,30 @@ partitionArgs = go [] [] go wireArgs hspecArgs (x : xs) = go wireArgs (hspecArgs <> [x]) xs go wireArgs hspecArgs [] = (wireArgs, hspecArgs) -mkspec :: SpecWith TestEnv -mkspec = do +mkspecMisc :: SpecWith TestEnv +mkspecMisc = do describe "Logging" Test.LoggingSpec.spec describe "Metrics" Test.MetricsSpec.spec + +mkspecSaml :: SpecWith TestEnv +mkspecSaml = do describe "Spar.API" Test.Spar.APISpec.spec describe "Spar.App" Test.Spar.AppSpec.spec describe "Spar.Data" Test.Spar.DataSpec.spec describe "Spar.Intra.Brig" Test.Spar.Intra.BrigSpec.spec + +mkspecScim :: SpecWith TestEnv +mkspecScim = do describe "Spar.Scim.Auth" Test.Spar.Scim.AuthSpec.spec describe "Spar.Scim.User" Test.Spar.Scim.UserSpec.spec -mkspec' :: TestEnv -> Spec -mkspec' env = do +mkspecHscimAcceptance :: IO TestEnv -> (TestEnv -> IO ()) -> Spec +mkspecHscimAcceptance mkenv _destroyenv = do describe "hscim acceptance tests" $ microsoftAzure @SparTag AcceptanceConfig {..} where scimAppAndConfig = do + env <- mkenv (app, _) <- mkApp (env ^. teOpts) scimAuthToken <- toHeader . fst <$> registerIdPAndScimToken `runReaderT` env let queryConfig = AcceptanceQueryConfig {..} diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs index 1103a4ce24..486c67cda2 100644 --- a/services/spar/test-integration/Test/Spar/APISpec.hs +++ b/services/spar/test-integration/Test/Spar/APISpec.hs @@ -54,6 +54,7 @@ import SAML2.WebSSO edCertAuthnResponse, edIssuer, edRequestURI, + fromIssuer, getUserRef, idPIdToST, idpExtraInfo, @@ -61,6 +62,7 @@ import SAML2.WebSSO idpMetadata, mkNameID, parseFromDocument, + rqIssuer, (-/), ) import qualified SAML2.WebSSO as SAML @@ -70,6 +72,7 @@ import SAML2.WebSSO.Test.Util import qualified Spar.Data as Data import qualified Spar.Intra.Brig as Intra import Text.XML.DSig (SignPrivCreds, mkSignCredsWithCert) +import qualified URI.ByteString as URI import URI.ByteString.QQ (uri) import Util.Core import qualified Util.Scim as ScimT @@ -79,6 +82,7 @@ import qualified Web.Scim.Schema.User as Scim import Wire.API.Cookie import Wire.API.Routes.Public.Spar import Wire.API.User.IdentityProvider +import qualified Wire.API.User.Saml as WireAPI (saml) import Wire.API.User.Scim spec :: SpecWith TestEnv @@ -122,7 +126,7 @@ specMisc = do pure $ nonfresh & edIssuer .~ issuer env <- ask uid <- fst <$> call (createUserWithTeam (env ^. teBrig) (env ^. teGalley)) - resp <- call $ callIdpCreate' (env ^. teSpar) (Just uid) somemeta + resp <- call $ callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid) somemeta liftIO $ statusCode resp `shouldBe` if isHttps then 201 else 400 it "does not trigger on https urls" $ check True it "does trigger on http urls" $ check False @@ -130,17 +134,25 @@ specMisc = do specMetadata :: SpecWith TestEnv specMetadata = do describe "metadata" $ do - it "metadata" $ do - env <- ask - get ((env ^. teSpar) . path "/sso/metadata" . expect2xx) - `shouldRespondWith` ( \(responseBody -> Just (cs -> bdy)) -> - all - (`isInfixOf` bdy) - [ "md:SPSSODescriptor", - "validUntil", - "WantAssertionsSigned=\"true\"" - ] - ) + let mkit :: String -> String -> SpecWith TestEnv + mkit mdpath finalizepath = do + it ("metadata (" <> mdpath <> ")") $ do + env <- ask + let sparHost = env ^. teOpts . to WireAPI.saml . SAML.cfgSPSsoURI . to (cs . SAML.renderURI) + fragments = + [ "md:SPSSODescriptor", + "validUntil", + "WantAssertionsSigned=\"true\"", + " sparHost + <> finalizepath + <> "\" index=\"0\" isDefault=\"true\"/>" + ] + get ((env ^. teSpar) . path (cs mdpath) . expect2xx) + `shouldRespondWith` (\(responseBody -> Just (cs -> bdy)) -> all (`isInfixOf` bdy) fragments) + + mkit "/sso/metadata" "/finalize-login" + mkit "/sso/metadata/208f5cc4-14cd-11ec-b969-db4fdf0173d5" "/finalize-login/208f5cc4-14cd-11ec-b969-db4fdf0173d5" specInitiateLogin :: SpecWith TestEnv specInitiateLogin = do @@ -186,11 +198,11 @@ specFinalizeLogin = do describe "POST /sso/finalize-login" $ do context "access denied" $ do it "responds with a very peculiar 'forbidden' HTTP response" $ do - (_, _, idp, (_, privcreds)) <- registerTestIdPWithMeta + (_, tid, idp, (_, privcreds)) <- registerTestIdPWithMeta authnreq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp spmeta authnreq False - sparresp <- submitAuthnResponse authnresp + sparresp <- submitAuthnResponse tid authnresp liftIO $ do -- import Text.XML -- putStrLn $ unlines @@ -224,20 +236,49 @@ specFinalizeLogin = do hasPersistentCookieHeader sparresp `shouldBe` Right () context "happy flow" $ do it "responds with a very peculiar 'allowed' HTTP response" $ do - (_, _, idp, (_, privcreds)) <- registerTestIdPWithMeta - spmeta <- getTestSPMetadata + env <- ask + let apiVersion = env ^. teWireIdPAPIVersion + (_, tid, idp, (_, privcreds)) <- registerTestIdPWithMeta + liftIO $ fromMaybe defWireIdPAPIVersion (idp ^. idpExtraInfo . wiApiVersion) `shouldBe` apiVersion + spmeta <- getTestSPMetadata tid authnreq <- negotiateAuthnRequest idp + let audiencePath = case apiVersion of + WireIdPAPIV1 -> "/sso/finalize-login" + WireIdPAPIV2 -> "/sso/finalize-login/" <> toByteString' tid + liftIO $ authnreq ^. rqIssuer . fromIssuer . to URI.uriPath `shouldBe` audiencePath authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp spmeta authnreq True - loginSuccess =<< submitAuthnResponse authnresp + loginSuccess =<< submitAuthnResponse tid authnresp + context "happy flow (two teams, fixed IdP entityID)" $ do + it "works" $ do + skipIdPAPIVersions + [ WireIdPAPIV1 + -- (In fact, to get this to work was the reason to introduce 'WireIdPAPIVesion'.) + ] + env <- ask + (_, tid1, idp1, (IdPMetadataValue _ metadata, privcreds)) <- registerTestIdPWithMeta + (tid2, idp2) <- liftIO . runHttpT (env ^. teMgr) $ do + (owner2, tid2) <- createUserWithTeam (env ^. teBrig) (env ^. teGalley) + idp2 :: IdP <- callIdpCreate (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner2) metadata + pure (tid2, idp2) + do + spmeta <- getTestSPMetadata tid1 + authnreq <- negotiateAuthnRequest idp1 + authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp1 spmeta authnreq True + loginSuccess =<< submitAuthnResponse tid1 authnresp + do + spmeta <- getTestSPMetadata tid2 + authnreq <- negotiateAuthnRequest idp2 + authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp2 spmeta authnreq True + loginSuccess =<< submitAuthnResponse tid2 authnresp context "user is created once, then deleted in team settings, then can login again." $ do it "responds with 'allowed'" $ do (ownerid, teamid, idp, (_, privcreds)) <- registerTestIdPWithMeta - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata teamid -- first login newUserAuthnResp :: SignedAuthnResponse <- do authnreq <- negotiateAuthnRequest idp authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp spmeta authnreq True - loginSuccess =<< submitAuthnResponse authnresp + loginSuccess =<< submitAuthnResponse teamid authnresp pure $ authnresp let newUserRef@(UserRef _ subj) = either (error . show) (^. userRefL) $ @@ -270,7 +311,7 @@ specFinalizeLogin = do do authnreq <- negotiateAuthnRequest idp authnresp <- runSimpleSP $ mkAuthnResponseWithSubj subj privcreds idp spmeta authnreq True - loginSuccess =<< submitAuthnResponse authnresp + loginSuccess =<< submitAuthnResponse teamid authnresp context "unknown user" $ do it "creates the user" $ do pending @@ -285,9 +326,9 @@ specFinalizeLogin = do pending context "unknown IdP Issuer" $ do it "rejects" $ do - (_, _, idp, (_, privcreds)) <- registerTestIdPWithMeta + (_, teamid, idp, (_, privcreds)) <- registerTestIdPWithMeta authnreq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata teamid authnresp <- runSimpleSP $ mkAuthnResponse @@ -296,7 +337,7 @@ specFinalizeLogin = do spmeta authnreq True - sparresp <- submitAuthnResponse authnresp + sparresp <- submitAuthnResponse teamid authnresp let shouldContainInBase64 :: String -> String -> Expectation shouldContainInBase64 hay needle = cs hay'' `shouldContain` needle where @@ -314,7 +355,7 @@ specFinalizeLogin = do statusCode sparresp `shouldBe` 404 -- body should contain the error label in the title, the verbatim haskell error, and the request: (cs . fromJust . responseBody $ sparresp) `shouldContain` "wire:sso:error:not-found" - (cs . fromJust . responseBody $ sparresp) `shouldContainInBase64` "CustomError SparIdPNotFound" + (cs . fromJust . responseBody $ sparresp) `shouldContainInBase64` "CustomError (SparIdPNotFound" (cs . fromJust . responseBody $ sparresp) `shouldContainInBase64` "Input {iName = \"SAMLResponse\"" -- TODO(arianvp): Ask Matthias what this even means context "AuthnResponse does not match any request" $ do @@ -327,7 +368,7 @@ specFinalizeLogin = do context "IdP changes response format" $ do it "treats NameId case-insensitively" $ do (_ownerid, tid, idp, (_, privcreds)) <- registerTestIdPWithMeta - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid let loginSuccess :: HasCallStack => ResponseLBS -> TestSpar () loginSuccess sparresp = liftIO $ do @@ -336,7 +377,7 @@ specFinalizeLogin = do let loginWithSubject subj = do authnreq <- negotiateAuthnRequest idp authnresp <- runSimpleSP $ mkAuthnResponseWithSubj subj privcreds idp spmeta authnreq True - loginSuccess =<< submitAuthnResponse authnresp + loginSuccess =<< submitAuthnResponse tid authnresp ssoid <- getSsoidViaAuthResp authnresp ssoToUidSpar tid ssoid @@ -482,20 +523,21 @@ specBindingUsers = describe "binding existing users to sso identities" $ do reBindSame' tweakcookies uid idp privCreds subj = do (authnReq, Just (SetBindCookie (SimpleSetCookie bindCky))) <- do negotiateAuthnRequest' DoInitiateBind idp (header "Z-User" $ toByteString' uid) - spmeta <- getTestSPMetadata + let tid = idp ^. idpExtraInfo . wiTeam + spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ mkAuthnResponseWithSubj subj privCreds idp spmeta authnReq True let cookiehdr = case tweakcookies [(Cky.setCookieName bindCky, Cky.setCookieValue bindCky)] of Just val -> header "Cookie" . cs . LB.toLazyByteString . Cky.renderCookies $ val Nothing -> id sparAuthnResp :: ResponseLBS <- - submitAuthnResponse' cookiehdr authnResp + submitAuthnResponse' cookiehdr tid authnResp pure (authnResp, sparAuthnResp) reBindDifferent :: HasCallStack => UserId -> TestSpar (SignedAuthnResponse, ResponseLBS) reBindDifferent uid = do env <- ask (SampleIdP metadata privcreds _ _) <- makeSampleIdPMetadata - idp <- call $ callIdpCreate (env ^. teSpar) (Just uid) metadata + idp <- call $ callIdpCreate (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid) metadata (_, authnResp, sparAuthnResp) <- initialBind uid idp privcreds pure (authnResp, sparAuthnResp) context "initial bind" $ do @@ -600,10 +642,10 @@ testGetPutDelete whichone = do -- the team, which is the original owner.) mkSsoOwner :: UserId -> TeamId -> IdP -> SignPrivCreds -> TestSpar UserId mkSsoOwner firstOwner tid idp privcreds = do - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid authnreq <- negotiateAuthnRequest idp authnresp <- runSimpleSP $ mkAuthnResponse privcreds idp spmeta authnreq True - loginresp <- submitAuthnResponse authnresp + loginresp <- submitAuthnResponse tid authnresp liftIO $ responseStatus loginresp `shouldBe` status200 [ssoOwner] <- filter (/= firstOwner) <$> getTeamMembers firstOwner tid promoteTeamMember firstOwner tid ssoOwner @@ -754,7 +796,7 @@ specCRUDIdentityProvider = do env <- ask (owner1, _, (^. idpId) -> idpid1, (IdPMetadataValue _ idpmeta1, _)) <- registerTestIdPWithMeta (SampleIdP idpmeta2 _ _ _) <- makeSampleIdPMetadata - _ <- call $ callIdpCreate (env ^. teSpar) (Just owner1) idpmeta2 + _ <- call $ callIdpCreate (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 let idpmeta1' = idpmeta1 & edIssuer .~ (idpmeta2 ^. edIssuer) callIdpUpdate' (env ^. teSpar) (Just owner1) idpid1 (IdPMetadataValue (cs $ SAML.encode idpmeta1') undefined) `shouldRespondWith` checkErrHspec 400 "idp-issuer-in-use" @@ -904,12 +946,13 @@ specCRUDIdentityProvider = do check :: HasCallStack => Bool -> Int -> String -> Either String () -> TestSpar () check useNewPrivKey expectedStatus expectedHtmlTitle expectedCookie = do (idp, oldPrivKey, newPrivKey) <- initidp + let tid = idp ^. idpExtraInfo . wiTeam env <- ask (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. idpId) - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid let privkey = if useNewPrivKey then newPrivKey else oldPrivKey idpresp <- runSimpleSP $ mkAuthnResponse privkey idp spmeta authnreq True - sparresp <- submitAuthnResponse idpresp + sparresp <- submitAuthnResponse tid idpresp liftIO $ do statusCode sparresp `shouldBe` expectedStatus let bdy = maybe "" (cs @LBS @String) (responseBody sparresp) @@ -933,7 +976,7 @@ specCRUDIdentityProvider = do env <- ask (uid, _tid) <- call $ createUserWithTeamDisableSSO (env ^. teBrig) (env ^. teGalley) (SampleIdP metadata _ _ _) <- makeSampleIdPMetadata - callIdpCreate' (env ^. teSpar) (Just uid) metadata + callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid) metadata `shouldRespondWith` checkErrHspec 403 "sso-disabled" context "bad xml" $ do it "responds with a 'client error'" $ do @@ -944,14 +987,14 @@ specCRUDIdentityProvider = do it "responds with 'client error'" $ do env <- ask (SampleIdP idpmeta _ _ _) <- makeSampleIdPMetadata - callIdpCreate' (env ^. teSpar) Nothing idpmeta + callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) Nothing idpmeta `shouldRespondWith` checkErrHspec 400 "client-error" context "zuser has no team" $ do it "responds with 'no team member'" $ do env <- ask (uid, _) <- call $ createRandomPhoneUser (env ^. teBrig) (SampleIdP idpmeta _ _ _) <- makeSampleIdPMetadata - callIdpCreate' (env ^. teSpar) (Just uid) idpmeta + callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid) idpmeta `shouldRespondWith` checkErrHspec 403 "no-team-member" context "zuser is a team member, but not a team owner" $ do it "responds with 'insufficient-permissions' and a helpful message" $ do @@ -960,7 +1003,7 @@ specCRUDIdentityProvider = do newmember <- let perms = Galley.noPermissions in call $ createTeamMember (env ^. teBrig) (env ^. teGalley) tid perms - callIdpCreate' (env ^. teSpar) (Just newmember) (idp ^. idpMetadata) + callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just newmember) (idp ^. idpMetadata) `shouldRespondWith` checkErrHspec 403 "insufficient-permissions" context "idp (identified by issuer) is in use by other team" $ do it "rejects" $ do @@ -968,21 +1011,31 @@ specCRUDIdentityProvider = do (SampleIdP newMetadata _ _ _) <- makeSampleIdPMetadata (uid1, _) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) (uid2, _) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) - resp1 <- call $ callIdpCreate' (env ^. teSpar) (Just uid1) newMetadata - resp2 <- call $ callIdpCreate' (env ^. teSpar) (Just uid1) newMetadata - resp3 <- call $ callIdpCreate' (env ^. teSpar) (Just uid2) newMetadata + -- first idp + resp1 <- call $ callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid1) newMetadata + -- same idp issuer, same team + resp2 <- call $ callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid1) newMetadata + -- same idp issuer, different team + resp3 <- call $ callIdpCreate' (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just uid2) newMetadata liftIO $ do statusCode resp1 `shouldBe` 201 - statusCode resp2 `shouldBe` 400 - responseJsonEither resp2 `shouldBe` Right (TestErrorLabel "idp-already-in-use") - statusCode resp3 `shouldBe` 400 - responseJsonEither resp3 `shouldBe` Right (TestErrorLabel "idp-already-in-use") + do + -- always fail if we're trying same (SP entityID, IdP entityID, team) + statusCode resp2 `shouldBe` 400 + responseJsonEither resp2 `shouldBe` Right (TestErrorLabel "idp-already-in-use") + case env ^. teWireIdPAPIVersion of + -- fail in the old api only if we're trying same (SP entityID, IdP entityID) on different teams + WireIdPAPIV1 -> do + statusCode resp3 `shouldBe` 400 + responseJsonEither resp3 `shouldBe` Right (TestErrorLabel "idp-already-in-use") + WireIdPAPIV2 -> do + statusCode resp3 `shouldBe` 201 context "client is owner with email" $ do it "responds with 2xx; makes IdP available for GET /identity-providers/" $ do env <- ask (owner, _) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) (SampleIdP metadata _ _ _) <- makeSampleIdPMetadata - idp <- call $ callIdpCreate (env ^. teSpar) (Just owner) metadata + idp <- call $ callIdpCreate (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner) metadata idp' <- call $ callIdpGet (env ^. teSpar) (Just owner) (idp ^. idpId) rawmeta <- call $ callIdpGetRaw (env ^. teSpar) (Just owner) (idp ^. idpId) liftIO $ do @@ -1017,7 +1070,7 @@ specCRUDIdentityProvider = do issuer2 <- makeIssuer idp2 <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) idp1' <- call $ callIdpGet (env ^. teSpar) (Just owner1) (idp1 ^. SAML.idpId) idp2' <- call $ callIdpGet (env ^. teSpar) (Just owner1) (idp2 ^. SAML.idpId) liftIO $ do @@ -1047,7 +1100,7 @@ specCRUDIdentityProvider = do issuer2 <- makeIssuer _ <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) newuref <- tryLogin privkey1 idp1 userSubject newuid <- getUserIdViaRef' newuref liftIO $ do @@ -1066,7 +1119,7 @@ specCRUDIdentityProvider = do issuer2 <- makeIssuer idp2 <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) newuref <- tryLogin privkey2 idp2 userSubject newuid <- getUserIdViaRef' newuref liftIO $ do @@ -1083,7 +1136,7 @@ specCRUDIdentityProvider = do issuer2 <- makeIssuer idp2 <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) newuref <- tryLogin privkey2 idp2 userSubject newuid <- getUserIdViaRef' newuref liftIO $ do @@ -1105,7 +1158,7 @@ specDeleteCornerCases = describe "delete corner cases" $ do uref `shouldBe` (SAML.UserRef issuer1 userSubject) idp2 <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) call $ callIdpDelete (env ^. teSpar) (pure owner1) (idp2 ^. idpId) uref' <- tryLogin privkey1 idp1 userSubject uid' <- getUserIdViaRef' uref' @@ -1119,7 +1172,7 @@ specDeleteCornerCases = describe "delete corner cases" $ do issuer2 <- makeIssuer idp2 <- let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 - in call $ callIdpCreateReplace (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + in call $ callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) call $ callIdpDelete (env ^. teSpar) (pure owner1) (idp2 ^. idpId) let userSubject = SAML.unspecifiedNameID "bloob" uref <- tryLogin privkey1 idp1 userSubject @@ -1166,10 +1219,11 @@ specDeleteCornerCases = describe "delete corner cases" $ do createViaSamlResp :: HasCallStack => IdP -> SignPrivCreds -> SAML.UserRef -> TestSpar ResponseLBS createViaSamlResp idp privCreds (SAML.UserRef _ subj) = do + let tid = idp ^. idpExtraInfo . wiTeam authnReq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ mkAuthnResponseWithSubj subj privCreds idp spmeta authnReq True - createResp <- submitAuthnResponse authnResp + createResp <- submitAuthnResponse tid authnResp liftIO $ responseStatus createResp `shouldBe` status200 pure createResp @@ -1192,7 +1246,7 @@ specScimAndSAML = do it "SCIM and SAML work together and SCIM-created users can login" $ do env <- ask -- create a user via scim - (tok, (_, _, idp, (_, privcreds))) <- ScimT.registerIdPAndScimTokenWithMeta + (tok, (_, tid, idp, (_, privcreds))) <- ScimT.registerIdPAndScimTokenWithMeta (usr, subj) <- ScimT.randomScimUserWithSubject scimStoredUser <- ScimT.createUser tok usr let userid :: UserId = ScimT.scimUserId scimStoredUser @@ -1208,9 +1262,9 @@ specScimAndSAML = do liftIO $ ('r', preview veidUref <$$> (Intra.veidFromUserSSOId <$> userssoid)) `shouldBe` ('r', Just (Right (Just userref))) -- login a user for the first time with the scim-supplied credentials authnreq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid authnresp :: SignedAuthnResponse <- runSimpleSP $ mkAuthnResponseWithSubj subject privcreds idp spmeta authnreq True - sparresp :: ResponseLBS <- submitAuthnResponse authnresp + sparresp :: ResponseLBS <- submitAuthnResponse tid authnresp -- user should receive a cookie liftIO $ statusCode sparresp `shouldBe` 200 setcky :: SAML.SimpleSetCookie "zuid" <- @@ -1250,7 +1304,7 @@ specScimAndSAML = do mkNameID subj (Just "https://federation.foobar.com/nidp/saml2/metadata") (Just "https://prod-nginz-https.wire.com/sso/finalize-login") Nothing authnreq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . wiTeam) authnresp :: SignedAuthnResponse <- runSimpleSP $ mkAuthnResponseWithSubj subjectWithQualifier privcreds idp spmeta authnreq True ssoid <- getSsoidViaAuthResp authnresp @@ -1385,7 +1439,7 @@ specSparUserMigration = do env <- ask (_ownerid, tid, idp, (_, privcreds)) <- registerTestIdPWithMeta - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid (issuer, subject) <- do suffix <- cs <$> replicateM 7 (getRandomR ('a', 'z')) @@ -1408,7 +1462,7 @@ specSparUserMigration = do mbUserId <- do authnreq <- negotiateAuthnRequest idp authnresp <- runSimpleSP $ mkAuthnResponseWithSubj subject privcreds idp spmeta authnreq True - sparresp <- submitAuthnResponse authnresp + sparresp <- submitAuthnResponse tid authnresp liftIO $ statusCode sparresp `shouldBe` 200 ssoid <- getSsoidViaAuthResp authnresp ssoToUidSpar tid ssoid diff --git a/services/spar/test-integration/Test/Spar/AppSpec.hs b/services/spar/test-integration/Test/Spar/AppSpec.hs index 4e8b84468c..a357d2b409 100644 --- a/services/spar/test-integration/Test/Spar/AppSpec.hs +++ b/services/spar/test-integration/Test/Spar/AppSpec.hs @@ -43,6 +43,7 @@ import URI.ByteString.QQ (uri) import Util import Web.Cookie import Wire.API.User.IdentityProvider (IdP) +import qualified Wire.API.User.IdentityProvider as User spec :: SpecWith TestEnv spec = describe "accessVerdict" $ do @@ -152,7 +153,7 @@ requestAccessVerdict idp isGranted mkAuthnReq = do raw <- mkAuthnReq (idp ^. SAML.idpId) bdy <- maybe (error "authreq") pure $ responseBody raw either (error . show) pure $ Servant.mimeUnrender (Servant.Proxy @SAML.HTML) bdy - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . User.wiTeam) (privKey, _, _) <- DSig.mkSignCredsWithCert Nothing 96 authnresp :: SAML.AuthnResponse <- do case authnreq of @@ -165,7 +166,12 @@ requestAccessVerdict idp isGranted mkAuthnReq = do if isGranted then SAML.AccessGranted uref else SAML.AccessDenied [DeniedNoBearerConfSubj, DeniedNoAuthnStatement] - outcome :: ResponseVerdict <- runSpar $ Spar.verdictHandler Nothing authnresp verdict + outcome :: ResponseVerdict <- do + mbteam <- + asks (^. teWireIdPAPIVersion) <&> \case + User.WireIdPAPIV1 -> Nothing + User.WireIdPAPIV2 -> Just (idp ^. SAML.idpExtraInfo . User.wiTeam) + runSpar $ Spar.verdictHandler Nothing mbteam authnresp verdict let loc :: URI.URI loc = maybe (error "no location") (either error id . SAML.parseURI' . cs) diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs index bc4d8f418a..0662956fc6 100644 --- a/services/spar/test-integration/Test/Spar/DataSpec.hs +++ b/services/spar/test-integration/Test/Spar/DataSpec.hs @@ -170,22 +170,24 @@ spec = do it "getIdPConfigByIssuer works" $ do idp <- makeTestIdP () <- runSparCass $ Data.storeIdPConfig idp - midp <- runSparCass $ Data.getIdPConfigByIssuer (idp ^. idpMetadata . edIssuer) - liftIO $ midp `shouldBe` Just idp + midp <- runSparCass $ Data.getIdPConfigByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + liftIO $ midp `shouldBe` GetIdPFound idp it "getIdPIdByIssuer works" $ do idp <- makeTestIdP () <- runSparCass $ Data.storeIdPConfig idp - midp <- runSparCass $ Data.getIdPIdByIssuer (idp ^. idpMetadata . edIssuer) - liftIO $ midp `shouldBe` Just (idp ^. idpId) + midp <- runSparCass $ Data.getIdPIdByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + liftIO $ midp `shouldBe` GetIdPFound (idp ^. idpId) it "getIdPConfigsByTeam works" $ do + skipIdPAPIVersions [WireIdPAPIV1] teamid <- nextWireId - idp <- makeTestIdP <&> idpExtraInfo .~ (WireIdP teamid [] Nothing) + idp <- makeTestIdP <&> idpExtraInfo .~ (WireIdP teamid Nothing [] Nothing) () <- runSparCass $ Data.storeIdPConfig idp idps <- runSparCass $ Data.getIdPConfigsByTeam teamid liftIO $ idps `shouldBe` [idp] it "deleteIdPConfig works" $ do teamid <- nextWireId - idp <- makeTestIdP <&> idpExtraInfo .~ (WireIdP teamid [] Nothing) + idpApiVersion <- asks (^. teWireIdPAPIVersion) + idp <- makeTestIdP <&> idpExtraInfo .~ (WireIdP teamid (Just idpApiVersion) [] Nothing) () <- runSparCass $ Data.storeIdPConfig idp do midp <- runSparCass $ Data.getIdPConfig (idp ^. idpId) @@ -195,11 +197,11 @@ spec = do midp <- runSparCass $ Data.getIdPConfig (idp ^. idpId) liftIO $ midp `shouldBe` Nothing do - midp <- runSparCass $ Data.getIdPConfigByIssuer (idp ^. idpMetadata . edIssuer) - liftIO $ midp `shouldBe` Nothing + midp <- runSparCass $ Data.getIdPConfigByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + liftIO $ midp `shouldBe` GetIdPNotFound do - midp <- runSparCass $ Data.getIdPIdByIssuer (idp ^. idpMetadata . edIssuer) - liftIO $ midp `shouldBe` Nothing + midp <- runSparCass $ Data.getIdPIdByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + liftIO $ midp `shouldBe` GetIdPNotFound do idps <- runSparCass $ Data.getIdPConfigsByTeam teamid liftIO $ idps `shouldBe` [] @@ -302,8 +304,8 @@ testDeleteTeam = it "cleans up all the right tables after deletion" $ do -- The config from 'issuer_idp': do let issuer = idp ^. SAML.idpMetadata . SAML.edIssuer - mbIdp <- runSparCass $ Data.getIdPIdByIssuer issuer - liftIO $ mbIdp `shouldBe` Nothing + mbIdp <- runSparCass $ Data.getIdPIdByIssuer issuer (idp ^. SAML.idpExtraInfo . wiTeam) + liftIO $ mbIdp `shouldBe` GetIdPNotFound -- The config from 'team_idp': do idps <- runSparCass $ Data.getIdPConfigsByTeam tid diff --git a/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs b/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs index 2e9056cea2..5be4bc24a2 100644 --- a/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs @@ -133,9 +133,10 @@ testNumIdPs = do let addSomeIdP :: TestSpar () addSomeIdP = do - spar <- asks (view teSpar) + let spar = env ^. teSpar + apiversion = env ^. teWireIdPAPIVersion SAML.SampleIdP metadata _ _ _ <- SAML.makeSampleIdPMetadata - void $ call $ Util.callIdpCreate spar (Just owner) metadata + void $ call $ Util.callIdpCreate apiversion spar (Just owner) metadata createToken owner (CreateScimToken "eins" (Just defPassword)) >>= deleteToken owner . stiId . createScimTokenResponseInfo diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 8b742be0a6..4ade02f3eb 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -78,6 +78,7 @@ import qualified Web.Scim.Schema.User as Scim.User import qualified Wire.API.Team.Export as CsvExport import Wire.API.Team.Invitation (Invitation (..)) import Wire.API.User.IdentityProvider (IdP) +import qualified Wire.API.User.IdentityProvider as User import Wire.API.User.RichInfo import qualified Wire.API.User.Saml as Spar.Types import qualified Wire.API.User.Scim as Spar.Types @@ -685,11 +686,12 @@ testScimCreateVsUserRef = do createViaSamlResp :: HasCallStack => IdP -> SAML.SignPrivCreds -> SAML.UserRef -> TestSpar ResponseLBS createViaSamlResp idp privCreds (SAML.UserRef _ subj) = do authnReq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + let tid = idp ^. SAML.idpExtraInfo . User.wiTeam + spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ SAML.mkAuthnResponseWithSubj subj privCreds idp spmeta authnReq True - submitAuthnResponse authnResp IdP -> SAML.SignPrivCreds -> SAML.UserRef -> TestSpar () createViaSamlFails idp privCreds uref = do diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index a31d8cab8b..03bda6dc4b 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -253,6 +253,7 @@ mkEnv _teTstOpts _teOpts = do _teGalley = endpointToReq (cfgGalley _teTstOpts) _teSpar = endpointToReq (cfgSpar _teTstOpts) _teSparEnv = Spar.Env {..} + _teWireIdPAPIVersion = WireIdPAPIV2 sparCtxOpts = _teOpts sparCtxCas = _teCql sparCtxHttpManager = _teMgr @@ -497,7 +498,7 @@ deleteUserOnBrig :: m () deleteUserOnBrig brigreq uid = do deleteUserNoWait brigreq uid - recoverAll (exponentialBackoff 30000 <> limitRetries 5) $ \_ -> do + recoverAll (exponentialBackoff 500000 <> limitRetries 5) $ \_ -> do profile <- getSelfProfile brigreq uid liftIO $ selfUser profile `shouldSatisfy` Brig.userDeleted @@ -520,8 +521,8 @@ deleteUserNoWait brigreq uid = nextWireId :: MonadIO m => m (Id a) nextWireId = Id <$> liftIO UUID.nextRandom -nextWireIdP :: MonadIO m => m WireIdP -nextWireIdP = WireIdP <$> (Id <$> liftIO UUID.nextRandom) <*> pure [] <*> pure Nothing +nextWireIdP :: MonadIO m => WireIdPAPIVersion -> m WireIdP +nextWireIdP version = WireIdP <$> (Id <$> liftIO UUID.nextRandom) <*> pure (Just version) <*> pure [] <*> pure Nothing nextSAMLID :: MonadIO m => m (ID a) nextSAMLID = mkID . UUID.toText <$> liftIO UUID.nextRandom @@ -736,18 +737,26 @@ call req = ask >>= \env -> liftIO $ runHttpT (env ^. teMgr) req ping :: (Request -> Request) -> Http () ping req = void . get $ req . path "/i/status" . expect2xx -makeTestIdP :: (HasCallStack, MonadRandom m, MonadIO m) => m (IdPConfig WireIdP) +makeTestIdP :: (HasCallStack, MonadReader TestEnv m, MonadRandom m, MonadIO m) => m (IdPConfig WireIdP) makeTestIdP = do + apiversion <- asks (^. teWireIdPAPIVersion) SampleIdP md _ _ _ <- makeSampleIdPMetadata IdPConfig <$> (IdPId <$> liftIO UUID.nextRandom) <*> (pure md) - <*> nextWireIdP + <*> nextWireIdP apiversion -getTestSPMetadata :: (HasCallStack, MonadReader TestEnv m, MonadIO m) => m SPMetadata -getTestSPMetadata = do +getTestSPMetadata :: (HasCallStack, MonadReader TestEnv m, MonadIO m) => TeamId -> m SPMetadata +getTestSPMetadata tid = do env <- ask - resp <- call . get $ (env ^. teSpar) . path "/sso/metadata" . expect2xx + resp <- + call . get $ + (env ^. teSpar) + . ( case env ^. teWireIdPAPIVersion of + WireIdPAPIV1 -> path "/sso/metadata" + WireIdPAPIV2 -> paths ["/sso/metadata", toByteString' tid] + ) + . expect2xx raw <- maybe (crash_ "no body") (pure . cs) $ responseBody resp either (crash_ . show) pure (SAML.decode raw) where @@ -774,7 +783,7 @@ registerTestIdPWithMeta = do -- | Helper for 'registerTestIdP'. registerTestIdPFrom :: - (HasCallStack, MonadIO m) => + (HasCallStack, MonadIO m, MonadReader TestEnv m) => IdPMetadata -> Manager -> BrigReq -> @@ -782,9 +791,10 @@ registerTestIdPFrom :: SparReq -> m (UserId, TeamId, IdP) registerTestIdPFrom metadata mgr brig galley spar = do + apiVersion <- asks (^. teWireIdPAPIVersion) liftIO . runHttpT mgr $ do (uid, tid) <- createUserWithTeam brig galley - (uid,tid,) <$> callIdpCreate spar (Just uid) metadata + (uid,tid,) <$> callIdpCreate apiVersion spar (Just uid) metadata getCookie :: KnownSymbol name => proxy name -> ResponseLBS -> Either String (SAML.SimpleSetCookie name) getCookie proxy rsp = do @@ -838,10 +848,11 @@ isSetBindCookie (SetBindCookie (SimpleSetCookie cky)) = do tryLogin :: HasCallStack => SignPrivCreds -> IdP -> NameID -> TestSpar SAML.UserRef tryLogin privkey idp userSubject = do env <- ask - spmeta <- getTestSPMetadata + let tid = idp ^. idpExtraInfo . wiTeam + spmeta <- getTestSPMetadata tid (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. SAML.idpId) idpresp <- runSimpleSP $ mkAuthnResponseWithSubj userSubject privkey idp spmeta authnreq True - sparresp <- submitAuthnResponse idpresp + sparresp <- submitAuthnResponse tid idpresp liftIO $ do statusCode sparresp `shouldBe` 200 let bdy = maybe "" (cs @LBS @String) (responseBody sparresp) @@ -852,10 +863,11 @@ tryLogin privkey idp userSubject = do tryLoginFail :: HasCallStack => SignPrivCreds -> IdP -> NameID -> String -> TestSpar () tryLoginFail privkey idp userSubject bodyShouldContain = do env <- ask - spmeta <- getTestSPMetadata + let tid = idp ^. idpExtraInfo . wiTeam + spmeta <- getTestSPMetadata tid (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. SAML.idpId) idpresp <- runSimpleSP $ mkAuthnResponseWithSubj userSubject privkey idp spmeta authnreq True - sparresp <- submitAuthnResponse idpresp + sparresp <- submitAuthnResponse tid idpresp liftIO $ do let bdy = maybe "" (cs @LBS @String) (responseBody sparresp) bdy `shouldContain` bodyShouldContain @@ -899,6 +911,7 @@ negotiateAuthnRequest' (doInitiatePath -> doInit) idp modreq = do submitAuthnResponse :: (HasCallStack, MonadIO m, MonadReader TestEnv m) => + TeamId -> SignedAuthnResponse -> m ResponseLBS submitAuthnResponse = submitAuthnResponse' id @@ -906,13 +919,18 @@ submitAuthnResponse = submitAuthnResponse' id submitAuthnResponse' :: (HasCallStack, MonadIO m, MonadReader TestEnv m) => (Request -> Request) -> + TeamId -> SignedAuthnResponse -> m ResponseLBS -submitAuthnResponse' reqmod (SignedAuthnResponse authnresp) = do +submitAuthnResponse' reqmod tid (SignedAuthnResponse authnresp) = do env <- ask req :: Request <- formDataBody [partLBS "SAMLResponse" . EL.encode . XML.renderLBS XML.def $ authnresp] empty - call $ post' req (reqmod . (env ^. teSpar) . path "/sso/finalize-login/") + let p = + case env ^. teWireIdPAPIVersion of + WireIdPAPIV1 -> path "/sso/finalize-login/" + WireIdPAPIV2 -> paths ["/sso/finalize-login", toByteString' tid] + call $ post' req (reqmod . (env ^. teSpar) . p) loginSsoUserFirstTime :: (HasCallStack, MonadIO m, MonadReader TestEnv m) => @@ -938,10 +956,11 @@ loginCreatedSsoUser :: m (UserId, Cookie) loginCreatedSsoUser nameid idp privCreds = do env <- ask + let tid = idp ^. idpExtraInfo . wiTeam authnReq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata + spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ mkAuthnResponseWithSubj nameid privCreds idp spmeta authnReq True - sparAuthnResp <- submitAuthnResponse authnResp + sparAuthnResp <- submitAuthnResponse tid authnResp let wireCookie = maybe (error (show sparAuthnResp)) id . lookup "Set-Cookie" $ responseHeaders sparAuthnResp accessResp :: ResponseLBS <- @@ -1051,18 +1070,26 @@ callIdpGetAll' :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> m Respo callIdpGetAll' sparreq_ muid = do get $ sparreq_ . maybe id zUser muid . path "/identity-providers" -callIdpCreate :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> SAML.IdPMetadata -> m IdP -callIdpCreate sparreq_ muid metadata = do - resp <- callIdpCreate' (sparreq_ . expect2xx) muid metadata +callIdpCreate :: (MonadIO m, MonadHttp m) => WireIdPAPIVersion -> SparReq -> Maybe UserId -> SAML.IdPMetadata -> m IdP +callIdpCreate apiversion sparreq_ muid metadata = do + resp <- callIdpCreate' apiversion (sparreq_ . expect2xx) muid metadata either (liftIO . throwIO . ErrorCall . show) pure $ responseJsonEither @IdP resp -callIdpCreate' :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> SAML.IdPMetadata -> m ResponseLBS -callIdpCreate' sparreq_ muid metadata = do +callIdpCreate' :: (MonadIO m, MonadHttp m) => WireIdPAPIVersion -> SparReq -> Maybe UserId -> SAML.IdPMetadata -> m ResponseLBS +callIdpCreate' apiversion sparreq_ muid metadata = do + explicitQueryParam <- do + -- `&api-version=v1` is implicit and can be omitted from the query, but we want to test + -- both, and not spend extra time on it. + liftIO $ randomRIO (True, False) post $ sparreq_ . maybe id zUser muid . path "/identity-providers/" + . ( case apiversion of + WireIdPAPIV1 -> Bilge.query [("api-version", Just "v1") | explicitQueryParam] + WireIdPAPIV2 -> Bilge.query [("api-version", Just "v2")] + ) . body (RequestBodyLBS . cs $ SAML.encode metadata) . header "Content-Type" "application/xml" @@ -1081,20 +1108,34 @@ callIdpCreateRaw' sparreq_ muid ctyp metadata = do . body (RequestBodyLBS metadata) . header "Content-Type" ctyp -callIdpCreateReplace :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> IdPMetadata -> IdPId -> m IdP -callIdpCreateReplace sparreq_ muid metadata idpid = do - resp <- callIdpCreateReplace' (sparreq_ . expect2xx) muid metadata idpid +callIdpCreateReplace :: (MonadIO m, MonadHttp m) => WireIdPAPIVersion -> SparReq -> Maybe UserId -> IdPMetadata -> IdPId -> m IdP +callIdpCreateReplace apiversion sparreq_ muid metadata idpid = do + resp <- callIdpCreateReplace' apiversion (sparreq_ . expect2xx) muid metadata idpid either (liftIO . throwIO . ErrorCall . show) pure $ responseJsonEither @IdP resp -callIdpCreateReplace' :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> IdPMetadata -> IdPId -> m ResponseLBS -callIdpCreateReplace' sparreq_ muid metadata idpid = do +callIdpCreateReplace' :: (HasCallStack, MonadIO m, MonadHttp m) => WireIdPAPIVersion -> SparReq -> Maybe UserId -> IdPMetadata -> IdPId -> m ResponseLBS +callIdpCreateReplace' apiversion sparreq_ muid metadata idpid = do + explicitQueryParam <- do + -- `&api-version=v1` is implicit and can be omitted from the query, but we want to test + -- both, and not spend extra time on it. + liftIO $ randomRIO (True, False) post $ sparreq_ . maybe id zUser muid . path "/identity-providers/" + . Bilge.query + ( [ ( "api-version", + case apiversion of + WireIdPAPIV1 -> if explicitQueryParam then Just "v1" else Nothing + WireIdPAPIV2 -> Just "v2" + ), + ( "replaces", + Just . cs . idPIdToST $ idpid + ) + ] + ) . body (RequestBodyLBS . cs $ SAML.encode metadata) - . queryItem "replaces" (cs $ idPIdToST idpid) . header "Content-Type" "application/xml" callIdpUpdate' :: (MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> IdPId -> IdPMetadataInfo -> m ResponseLBS diff --git a/services/spar/test-integration/Util/Types.hs b/services/spar/test-integration/Util/Types.hs index a727e80382..67b5631d62 100644 --- a/services/spar/test-integration/Util/Types.hs +++ b/services/spar/test-integration/Util/Types.hs @@ -31,17 +31,19 @@ module Util.Types teSparEnv, teOpts, teTstOpts, + teWireIdPAPIVersion, Select, ResponseLBS, IntegrationConfig (..), TestErrorLabel (..), + skipIdPAPIVersions, ) where import Bilge import Cassandra as Cas import Control.Exception -import Control.Lens (makeLenses) +import Control.Lens (makeLenses, (^.)) import Crypto.Random.Types (MonadRandom (..)) import Data.Aeson import qualified Data.Aeson as Aeson @@ -51,7 +53,9 @@ import Imports import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Spar.API () import qualified Spar.App as Spar +import Test.Hspec (pendingWith) import Util.Options +import Wire.API.User.IdentityProvider (WireIdPAPIVersion) import Wire.API.User.Saml type BrigReq = Request -> Request @@ -76,7 +80,14 @@ data TestEnv = TestEnv -- | spar config _teOpts :: Opts, -- | integration test config - _teTstOpts :: IntegrationConfig + _teTstOpts :: IntegrationConfig, + -- | If True, run tests against legacy SAML API where team is derived from idp issuer + -- instead of teamid. See Section "using the same IdP (same entityID, or Issuer) with + -- different teams" in "/docs/reference/spar-braindump.md" for more details. + -- + -- NB: this has no impact on the tested spar code; the rest API supports both legacy and + -- multi-sp mode. this falg merely determines how the rest API is used. + _teWireIdPAPIVersion :: WireIdPAPIVersion } type Select = TestEnv -> (Request -> Request) @@ -108,3 +119,8 @@ _unitTestTestErrorLabel = do unless (val == Right "not-found") $ throwIO . ErrorCall . show $ val + +skipIdPAPIVersions :: (MonadIO m, MonadReader TestEnv m) => [WireIdPAPIVersion] -> m () +skipIdPAPIVersions skip = do + asks (^. teWireIdPAPIVersion) >>= \vers -> when (vers `elem` skip) . liftIO $ do + pendingWith $ "skipping " <> show vers <> " for this test case (behavior covered by other versions)" diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index f80bac9baa..f48e5722fc 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -41,7 +41,7 @@ instance Arbitrary IdPList where pure $ IdPList {..} instance Arbitrary WireIdP where - arbitrary = WireIdP <$> arbitrary <*> arbitrary <*> arbitrary + arbitrary = WireIdP <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary deriving instance Arbitrary ScimToken diff --git a/stack.yaml b/stack.yaml index 7eb20da383..33dd374754 100644 --- a/stack.yaml +++ b/stack.yaml @@ -70,7 +70,7 @@ extra-deps: # a version > 1.0.0 of wai-middleware-prometheus is available # (required: https://github.com/fimad/prometheus-haskell/pull/45) - git: https://github.com/wireapp/saml2-web-sso - commit: f56b5ffc10ec5ceab9a508cbb9f8fbaa017bbf2c # https://github.com/wireapp/saml2-web-sso/pull/74 (Apr 30, 2021) + commit: 60398f375987b74d6b855b5d225e45dc3a96ac06 # https://github.com/wireapp/saml2-web-sso/pull/75 (Sep 10, 2021) - git: https://github.com/kim/hs-collectd commit: 885da222be2375f78c7be36127620ed772b677c9 diff --git a/stack.yaml.lock b/stack.yaml.lock index 3cd6d32d24..6fc195e0df 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -23,11 +23,11 @@ packages: git: https://github.com/wireapp/saml2-web-sso pantry-tree: size: 4887 - sha256: 12be9a699749b9ebe63fb2e04113c2f2160a63494e8a2ba005792a02d0571f47 - commit: f56b5ffc10ec5ceab9a508cbb9f8fbaa017bbf2c + sha256: 29e0138ca6bc33500b87cd6c06bbd899fe4ddaadcfd5117b211e7769f9f80161 + commit: 60398f375987b74d6b855b5d225e45dc3a96ac06 original: git: https://github.com/wireapp/saml2-web-sso - commit: f56b5ffc10ec5ceab9a508cbb9f8fbaa017bbf2c + commit: 60398f375987b74d6b855b5d225e45dc3a96ac06 - completed: name: collectd version: 0.0.0.2