From 4e0551b4f572c727c7288677d5c6548ead02022d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 14:25:08 +0000 Subject: [PATCH 01/23] endpoint to get raw user data --- .../src/Wire/API/Routes/Internal/Brig.hs | 9 ++++++ services/brig/src/Brig/API/Internal.hs | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 7ef9eead11..5a9ace9b88 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -444,6 +444,15 @@ type AccountAPI = Bool :> Get '[Servant.JSON] [User] ) + :<|> Named + "iGetUsersRaw" + ( "users" + :> "raw" + :> QueryParam' [Optional, Strict] "ids" (CommaSeparatedList UserId) + :> QueryParam' [Optional, Strict] "handles" (CommaSeparatedList Handle) + :> QueryParam' [Optional, Strict] "email" (CommaSeparatedList EmailAddress) + :> Get '[Servant.JSON] [User] + ) :<|> Named "iGetUserContacts" ( "users" diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 9967a35dfb..ed74e52ef6 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -276,6 +276,7 @@ accountAPI = :<|> Named @"iPutUserStatus" changeAccountStatusH :<|> Named @"iGetUserStatus" getAccountStatusH :<|> Named @"iGetUsersByVariousKeys" listActivatedAccountsH + :<|> Named @"iGetUsersRaw" listUsersRawH :<|> Named @"iGetUserContacts" getContactListH :<|> Named @"iGetUserActivationCode" getActivationCode :<|> Named @"iGetUserPasswordResetCode" getPasswordResetCodeH @@ -762,6 +763,36 @@ listActivatedAccountsH } pure $ filter (\u -> u.userStatus /= Deleted) $ others <> byEmails +-- | Diagnostic lookup of user records without the normal status, identity, or expired-invitation filtering. +listUsersRawH :: + ( Member (Input (Local ())) r, + Member UserSubsystem r + ) => + Maybe (CommaSeparatedList UserId) -> + Maybe (CommaSeparatedList Handle) -> + Maybe (CommaSeparatedList EmailAddress) -> + Handler r [User] +listUsersRawH + (maybe [] fromCommaSeparatedList -> uids) + (maybe [] fromCommaSeparatedList -> handles) + (maybe [] fromCommaSeparatedList -> emails) = do + when (length uids + length handles + length emails == 0) $ do + throwStd (notFound "no user keys") + lift $ liftSem do + loc <- input + byEmails <- getAccountsByEmailNoFilter $ loc $> emails + ( getAccountsBy $ + loc + $> def + { includePendingInvitations = WithPendingInvitations, + includeUsersWithExpiredInvitations = True, + includeUsersWithoutIdentity = True, + getByUserId = uids, + getByHandle = handles + } + ) + <&> (<> byEmails) + getActivationCode :: ( Member ActivationCodeStore r, Member (Embed IO) r From 2f62f273a8efcb25c86b49eb7dd75b424ea0240b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 14:25:50 +0000 Subject: [PATCH 02/23] test team invite when SCIM expired creates no duplicates --- integration/test/API/BrigInternal.hs | 5 ++ integration/test/Test/Spar.hs | 80 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index 4a0051e890..bd9a7abf58 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -76,6 +76,11 @@ getUsersId domain ids = do req <- baseRequest domain Brig Unversioned "/i/users" submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)] +getUsersIdRaw :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response +getUsersIdRaw domain ids = do + req <- baseRequest domain Brig Unversioned "/i/users/raw" + submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)] + getUsersByEmail :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response getUsersByEmail domain emails = do req <- baseRequest domain Brig Unversioned "/i/users" diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index fff111393d..4b46cdb499 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -26,6 +26,7 @@ import API.GalleyInternal (setTeamFeatureStatus) import qualified API.Nginz as Nginz import API.Spar import API.SparInternal +import Control.Concurrent (threadDelay) import Control.Lens (to, (^.)) import qualified Data.Aeson as A import qualified Data.Aeson.KeyMap as KeyMap @@ -52,6 +53,85 @@ import qualified Time.System as Hourglass ---------------------------------------------------------------------- -- scim stuff +testScimInvitationThenManualInvitationNoSaml :: (HasCallStack) => App () +testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do + (owner, _tid, _) <- createTeam testDomain 1 + token <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString + (email, scid) <- createScimInvitationUser testDomain token + -- This test backend uses a 2-second invitation TTL. Cross that boundary, + -- then immediately send the manual invitation before cleanup can run. + liftIO $ threadDelay 2_100_000 + iid <- acceptManualInvitation testDomain owner email + users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + case users of + [_] -> pure () + [user1, user2] -> do + email1 <- user1 %. "email" >>= asString + email2 <- user2 %. "email" >>= asString + when (email1 == email2) $ do + id1 <- user1 %. "id" >>= asString + id2 <- user2 %. "id" >>= asString + id1 `shouldMatch` id2 + _ -> fail "more than 2 users not expected" + +testScimInvitationThenManualInvitationSamlEmailValidation :: (HasCallStack) => App () +testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do + (owner, tid, _) <- createTeam testDomain 1 + token <- createSamlScimToken owner tid True + (email, scid) <- createScimInvitationUser testDomain token + iid <- acceptManualInvitation testDomain owner email + users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + case users of + [_] -> pure () + [user1, user2] -> do + email1 <- user1 %. "email" >>= asString + email2 <- user2 %. "email" >>= asString + when (email1 == email2) $ do + id1 <- user1 %. "id" >>= asString + id2 <- user2 %. "id" >>= asString + id1 `shouldMatch` id2 + _ -> fail "more than 2 users not expected" + +testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () +testScimInvitationThenManualInvitationSamlEmailAutoActivation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do + (owner, tid, _) <- createTeam testDomain 1 + token <- createSamlScimToken owner tid False + (email, _) <- createScimInvitationUser testDomain token + -- account is auto activated so the team invitation is not possible + postInvitation owner (def {email = Just email}) >>= assertStatus 409 + +scimInvitationTestOverrides :: ServiceOverrides +scimInvitationTestOverrides = + def + { brigCfg = + setField "optSettings.setTeamInvitationTimeout" (2 :: Int) + . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) + } + +createSamlScimToken :: (HasCallStack, MakesValue user) => user -> String -> Bool -> App String +createSamlScimToken user team validateEmails = do + assertSuccess =<< setTeamFeatureStatus user team "sso" "enabled" + assertSuccess =<< setTeamFeatureStatus user team "validateSAMLemails" (if validateEmails then "enabled" else "disabled") + (idp, _) <- registerTestIdPWithMetaWithPrivateCreds user + idpId <- asString $ idp.json %. "id" + createScimToken user (def {idp = Just idpId}) >>= getJSON 200 >>= (%. "token") >>= asString + +createScimInvitationUser :: (HasCallStack, MakesValue domain) => domain -> String -> App (String, String) +createScimInvitationUser testDomain token = do + email <- randomEmail + externalId <- randomExternalId + scimUser <- randomScimUserWithEmail externalId email + scimUserId <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString + pure (email, scimUserId) + +acceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> App String +acceptManualInvitation testDomain inviter email = do + invitation <- postInvitation inviter (def {email = Just email}) >>= getJSON 201 + invitationId <- invitation %. "id" >>= asString + code <- getInvitationCode inviter invitation >>= getJSON 200 >>= (%. "code") >>= asString + registerUserWith testDomain email code "Alice" >>= assertStatus 201 + pure invitationId + testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do (owner, tid, _) <- createTeam OwnDomain 1 From ec381c7549471785970b53243a142b05bae481ad Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 14:43:29 +0000 Subject: [PATCH 03/23] shorter timeout --- integration/test/Test/Spar.hs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 4b46cdb499..9b2edc43e3 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -58,9 +58,8 @@ testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitatio (owner, _tid, _) <- createTeam testDomain 1 token <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString (email, scid) <- createScimInvitationUser testDomain token - -- This test backend uses a 2-second invitation TTL. Cross that boundary, - -- then immediately send the manual invitation before cleanup can run. - liftIO $ threadDelay 2_100_000 + -- wait for invitation to expire + liftIO $ threadDelay 1_100_000 iid <- acceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList case users of @@ -104,7 +103,7 @@ scimInvitationTestOverrides :: ServiceOverrides scimInvitationTestOverrides = def { brigCfg = - setField "optSettings.setTeamInvitationTimeout" (2 :: Int) + setField "optSettings.setTeamInvitationTimeout" (1 :: Int) . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } From 000e704bf2415bbbfaa5c729e49d280ccbe739cf Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 14:56:31 +0000 Subject: [PATCH 04/23] this is the real reproduction of the issue --- integration/test/Test/Spar.hs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 9b2edc43e3..3c0d35aa2c 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -91,6 +91,21 @@ testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend id1 `shouldMatch` id2 _ -> fail "more than 2 users not expected" +testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () +testScimSamlEmailVerificationExpiryThenManualInvitation = withModifiedBackend samlEmailVerificationExpiryOverrides $ \testDomain -> do + (owner, tid, _) <- createTeam testDomain 1 + token <- createSamlScimToken owner tid True + (email, scid) <- createScimInvitationUser testDomain token + + -- wait until email verification expires + eventually $ getActivationCode testDomain email >>= assertStatus 404 + + iid <- acceptManualInvitation testDomain owner email + users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + -- this leads to 2 active accounts with the same email, + -- but the SSO account's email stays unvalidated + printJSON users + testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () testScimInvitationThenManualInvitationSamlEmailAutoActivation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do (owner, tid, _) <- createTeam testDomain 1 @@ -107,6 +122,14 @@ scimInvitationTestOverrides = . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } +samlEmailVerificationExpiryOverrides :: ServiceOverrides +samlEmailVerificationExpiryOverrides = + def + { brigCfg = + setField "optSettings.setActivationTimeout" (1 :: Int) + . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) + } + createSamlScimToken :: (HasCallStack, MakesValue user) => user -> String -> Bool -> App String createSamlScimToken user team validateEmails = do assertSuccess =<< setTeamFeatureStatus user team "sso" "enabled" From ba126699fc06b585a2fb28b4742a81b46d893035 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 31 Jul 2026 11:44:29 +0000 Subject: [PATCH 05/23] wip --- integration/test/Test/Spar.hs | 53 +++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 3c0d35aa2c..67a52092ce 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -60,8 +60,9 @@ testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitatio (email, scid) <- createScimInvitationUser testDomain token -- wait for invitation to expire liftIO $ threadDelay 1_100_000 - iid <- acceptManualInvitation testDomain owner email + iid <- sendAndAcceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + printJSON users case users of [_] -> pure () [user1, user2] -> do @@ -78,8 +79,9 @@ testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend (owner, tid, _) <- createTeam testDomain 1 token <- createSamlScimToken owner tid True (email, scid) <- createScimInvitationUser testDomain token - iid <- acceptManualInvitation testDomain owner email + iid <- sendAndAcceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + printJSON users case users of [_] -> pure () [user1, user2] -> do @@ -92,18 +94,41 @@ testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend _ -> fail "more than 2 users not expected" testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () -testScimSamlEmailVerificationExpiryThenManualInvitation = withModifiedBackend samlEmailVerificationExpiryOverrides $ \testDomain -> do +testScimSamlEmailVerificationExpiryThenManualInvitation = do + let settings = + def + { brigCfg = + setField "optSettings.setActivationTimeout" (1 :: Int) + . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) + } + + + withModifiedBackend settings $ \testDomain -> do + (owner, tid, _) <- createTeam testDomain 1 + token <- createSamlScimToken owner tid True + (email, scid) <- createScimInvitationUser testDomain token + + -- wait until email verification expires + eventually $ getActivationCode testDomain email >>= assertStatus 404 + + iid <- sendAndAcceptManualInvitation testDomain owner email + users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + -- this leads to 2 active accounts with the same email, + -- but the SSO account's email stays unvalidated + -- note: we should also test what happens if the verification is still active + printJSON users + +testScimSamlEmailVerificationThenManualInvitation :: (HasCallStack) => App () +testScimSamlEmailVerificationThenManualInvitation = do + let testDomain = OwnDomain (owner, tid, _) <- createTeam testDomain 1 token <- createSamlScimToken owner tid True (email, scid) <- createScimInvitationUser testDomain token - - -- wait until email verification expires - eventually $ getActivationCode testDomain email >>= assertStatus 404 - - iid <- acceptManualInvitation testDomain owner email + iid <- sendAndAcceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList -- this leads to 2 active accounts with the same email, -- but the SSO account's email stays unvalidated + -- note: we should also test what happens if the verification is still active printJSON users testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () @@ -122,14 +147,6 @@ scimInvitationTestOverrides = . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } -samlEmailVerificationExpiryOverrides :: ServiceOverrides -samlEmailVerificationExpiryOverrides = - def - { brigCfg = - setField "optSettings.setActivationTimeout" (1 :: Int) - . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) - } - createSamlScimToken :: (HasCallStack, MakesValue user) => user -> String -> Bool -> App String createSamlScimToken user team validateEmails = do assertSuccess =<< setTeamFeatureStatus user team "sso" "enabled" @@ -146,8 +163,8 @@ createScimInvitationUser testDomain token = do scimUserId <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString pure (email, scimUserId) -acceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> App String -acceptManualInvitation testDomain inviter email = do +sendAndAcceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> App String +sendAndAcceptManualInvitation testDomain inviter email = do invitation <- postInvitation inviter (def {email = Just email}) >>= getJSON 201 invitationId <- invitation %. "id" >>= asString code <- getInvitationCode inviter invitation >>= getJSON 200 >>= (%. "code") >>= asString From 5d0954ef2307e5fc96d9bea681fb81068d9bb4af Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 31 Jul 2026 15:24:58 +0200 Subject: [PATCH 06/23] wip --- integration/test/Test/Spar.hs | 30 +++++------ .../src/Wire/API/Routes/Internal/Brig.hs | 6 +-- libs/wire-api/src/Wire/API/User.hs | 35 +++++++++++++ services/brig/src/Brig/API/Internal.hs | 50 ++++++++----------- 4 files changed, 75 insertions(+), 46 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 67a52092ce..5ed92205d6 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -66,12 +66,13 @@ testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitatio case users of [_] -> pure () [user1, user2] -> do + -- The SAML/SCIM account remains active because it has an SSO identity. + -- Its email is only stored as `email_unvalidated`; neither an active nor + -- an expired email activation code claims the email key. Consequently, + -- the manual account can claim the email and both accounts exist. email1 <- user1 %. "email" >>= asString - email2 <- user2 %. "email" >>= asString - when (email1 == email2) $ do - id1 <- user1 %. "id" >>= asString - id2 <- user2 %. "id" >>= asString - id1 `shouldMatch` id2 + email2 <- user2 %. "email_unvalidated" >>= asString + email1 `shouldMatch` email2 _ -> fail "more than 2 users not expected" testScimInvitationThenManualInvitationSamlEmailValidation :: (HasCallStack) => App () @@ -94,7 +95,7 @@ testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend _ -> fail "more than 2 users not expected" testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () -testScimSamlEmailVerificationExpiryThenManualInvitation = do +testScimSamlEmailVerificationExpiryThenManualInvitation = do let settings = def { brigCfg = @@ -102,7 +103,6 @@ testScimSamlEmailVerificationExpiryThenManualInvitation = do . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } - withModifiedBackend settings $ \testDomain -> do (owner, tid, _) <- createTeam testDomain 1 token <- createSamlScimToken owner tid True @@ -113,22 +113,24 @@ testScimSamlEmailVerificationExpiryThenManualInvitation = do iid <- sendAndAcceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - -- this leads to 2 active accounts with the same email, - -- but the SSO account's email stays unvalidated - -- note: we should also test what happens if the verification is still active + -- The email activation code has expired, but this does not deactivate the + -- SAML/SCIM account. It remains active because of its SSO identity, while + -- the email remains unvalidated. Since an activation code does not claim + -- the email key, the manual account can claim it and both accounts exist. printJSON users testScimSamlEmailVerificationThenManualInvitation :: (HasCallStack) => App () -testScimSamlEmailVerificationThenManualInvitation = do +testScimSamlEmailVerificationThenManualInvitation = do let testDomain = OwnDomain (owner, tid, _) <- createTeam testDomain 1 token <- createSamlScimToken owner tid True (email, scid) <- createScimInvitationUser testDomain token iid <- sendAndAcceptManualInvitation testDomain owner email users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - -- this leads to 2 active accounts with the same email, - -- but the SSO account's email stays unvalidated - -- note: we should also test what happens if the verification is still active + -- The SAML/SCIM account remains active because of its SSO identity, but its + -- email is only stored as `email_unvalidated`. Even while the activation + -- code is still valid, it does not claim the email key. The manual account + -- can therefore claim the email and both accounts exist. printJSON users testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 5a9ace9b88..6ce8efee2a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -448,10 +448,8 @@ type AccountAPI = "iGetUsersRaw" ( "users" :> "raw" - :> QueryParam' [Optional, Strict] "ids" (CommaSeparatedList UserId) - :> QueryParam' [Optional, Strict] "handles" (CommaSeparatedList Handle) - :> QueryParam' [Optional, Strict] "email" (CommaSeparatedList EmailAddress) - :> Get '[Servant.JSON] [User] + :> QueryParam' [Required, Strict] "ids" (CommaSeparatedList UserId) + :> Get '[Servant.JSON] [RawUser] ) :<|> Named "iGetUserContacts" diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 161040a456..43e3a99722 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -36,6 +36,7 @@ module Wire.API.User SelfProfile (..), -- User (should not be here) User (..), + RawUser (..), UserType (..), isSamlUser, userId, @@ -727,6 +728,40 @@ userObjectSchema = <* (fromMaybe False <$> (\u -> if userDeleted u then Just True else Nothing) .= maybe_ (optField "deleted" schema)) <*> userSearchable .= (fromMaybe True <$> optField "searchable" schema) +-- | Stored account data exposed by the diagnostic users endpoint. +-- +-- This intentionally keeps the storage-level activation flag separate from +-- 'AccountStatus' and from the derived 'UserIdentity'. +data RawUser = RawUser + { rawUserId :: UserId, + rawUserName :: Name, + rawUserEmail :: Maybe EmailAddress, + rawUserEmailUnvalidated :: Maybe EmailAddress, + rawUserSSOId :: Maybe A.Value, + rawUserActivated :: Bool, + rawUserStatus :: Maybe AccountStatus, + rawUserHandle :: Maybe Handle, + rawUserTeamId :: Maybe TeamId, + rawUserManagedBy :: Maybe ManagedBy + } + deriving stock (Eq, Ord, Show, Generic) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema RawUser) + +instance ToSchema RawUser where + schema = + object $ + RawUser + <$> rawUserId .= field "id" schema + <*> rawUserName .= field "name" schema + <*> rawUserEmail .= maybe_ (optField "email" schema) + <*> rawUserEmailUnvalidated .= maybe_ (optField "email_unvalidated" schema) + <*> rawUserSSOId .= maybe_ (optField "sso_id" schema) + <*> rawUserActivated .= field "activated" schema + <*> rawUserStatus .= maybe_ (optField "status" schema) + <*> rawUserHandle .= maybe_ (optField "handle" schema) + <*> rawUserTeamId .= maybe_ (optField "team" schema) + <*> rawUserManagedBy .= maybe_ (optField "managed_by" schema) + userEmail :: User -> Maybe EmailAddress userEmail = emailIdentity <=< userIdentity diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index ed74e52ef6..7d554f97e0 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -42,6 +42,7 @@ import Brig.User.Search.Index qualified as Search import Control.Error hiding (bool) import Control.Lens (preview, to, _Just) import Control.Lens.Extras (is) +import Data.Aeson qualified as A import Data.ByteString.Conversion (toByteString) import Data.Code qualified as Code import Data.CommaSeparatedList @@ -129,7 +130,7 @@ import Wire.Sem.Concurrency import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.SparAPIAccess (SparAPIAccess) -import Wire.StoredUser (StoredUser (emailUnvalidated)) +import Wire.StoredUser (StoredUser (..)) import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.UserGroupSubsystem @@ -765,33 +766,26 @@ listActivatedAccountsH -- | Diagnostic lookup of user records without the normal status, identity, or expired-invitation filtering. listUsersRawH :: - ( Member (Input (Local ())) r, - Member UserSubsystem r - ) => - Maybe (CommaSeparatedList UserId) -> - Maybe (CommaSeparatedList Handle) -> - Maybe (CommaSeparatedList EmailAddress) -> - Handler r [User] -listUsersRawH - (maybe [] fromCommaSeparatedList -> uids) - (maybe [] fromCommaSeparatedList -> handles) - (maybe [] fromCommaSeparatedList -> emails) = do - when (length uids + length handles + length emails == 0) $ do - throwStd (notFound "no user keys") - lift $ liftSem do - loc <- input - byEmails <- getAccountsByEmailNoFilter $ loc $> emails - ( getAccountsBy $ - loc - $> def - { includePendingInvitations = WithPendingInvitations, - includeUsersWithExpiredInvitations = True, - includeUsersWithoutIdentity = True, - getByUserId = uids, - getByHandle = handles - } - ) - <&> (<> byEmails) + (Member UserStore r) => + CommaSeparatedList UserId -> + Handler r [RawUser] +listUsersRawH (fromCommaSeparatedList -> uids) = + lift . liftSem $ catMaybes <$> traverse (fmap (fmap rawUserFromStored) . UserStore.getUser) uids + +rawUserFromStored :: StoredUser -> RawUser +rawUserFromStored user = + RawUser + { rawUserId = user.id, + rawUserName = user.name, + rawUserEmail = user.email, + rawUserEmailUnvalidated = user.emailUnvalidated, + rawUserSSOId = A.toJSON <$> user.ssoId, + rawUserActivated = user.activated, + rawUserStatus = user.status, + rawUserHandle = user.handle, + rawUserTeamId = user.teamId, + rawUserManagedBy = user.managedBy + } getActivationCode :: ( Member ActivationCodeStore r, From ca0025d04e012aa8b9125297703f97493df56c3b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 31 Jul 2026 18:26:35 +0200 Subject: [PATCH 07/23] this test now reveals what happens --- integration/test/Test/Spar.hs | 181 ++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 85 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 5ed92205d6..3c46880395 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -57,95 +57,101 @@ testScimInvitationThenManualInvitationNoSaml :: (HasCallStack) => App () testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do (owner, _tid, _) <- createTeam testDomain 1 token <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString - (email, scid) <- createScimInvitationUser testDomain token + (email, scid, handle) <- createScimInvitationUser testDomain token -- wait for invitation to expire - liftIO $ threadDelay 1_100_000 - iid <- sendAndAcceptManualInvitation testDomain owner email + liftIO $ threadDelay 2_100_000 + iid <- sendAndAcceptManualInvitation testDomain owner email handle users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList printJSON users - case users of - [_] -> pure () - [user1, user2] -> do - -- The SAML/SCIM account remains active because it has an SSO identity. - -- Its email is only stored as `email_unvalidated`; neither an active nor - -- an expired email activation code claims the email key. Consequently, - -- the manual account can claim the email and both accounts exist. - email1 <- user1 %. "email" >>= asString - email2 <- user2 %. "email_unvalidated" >>= asString - email1 `shouldMatch` email2 - _ -> fail "more than 2 users not expected" - -testScimInvitationThenManualInvitationSamlEmailValidation :: (HasCallStack) => App () -testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do - (owner, tid, _) <- createTeam testDomain 1 - token <- createSamlScimToken owner tid True - (email, scid) <- createScimInvitationUser testDomain token - iid <- sendAndAcceptManualInvitation testDomain owner email - users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - printJSON users - case users of - [_] -> pure () - [user1, user2] -> do - email1 <- user1 %. "email" >>= asString - email2 <- user2 %. "email" >>= asString - when (email1 == email2) $ do - id1 <- user1 %. "id" >>= asString - id2 <- user2 %. "id" >>= asString - id1 `shouldMatch` id2 - _ -> fail "more than 2 users not expected" - -testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () -testScimSamlEmailVerificationExpiryThenManualInvitation = do - let settings = - def - { brigCfg = - setField "optSettings.setActivationTimeout" (1 :: Int) - . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) - } - - withModifiedBackend settings $ \testDomain -> do - (owner, tid, _) <- createTeam testDomain 1 - token <- createSamlScimToken owner tid True - (email, scid) <- createScimInvitationUser testDomain token - - -- wait until email verification expires - eventually $ getActivationCode testDomain email >>= assertStatus 404 - - iid <- sendAndAcceptManualInvitation testDomain owner email - users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - -- The email activation code has expired, but this does not deactivate the - -- SAML/SCIM account. It remains active because of its SSO identity, while - -- the email remains unvalidated. Since an activation code does not claim - -- the email key, the manual account can claim it and both accounts exist. - printJSON users - -testScimSamlEmailVerificationThenManualInvitation :: (HasCallStack) => App () -testScimSamlEmailVerificationThenManualInvitation = do - let testDomain = OwnDomain - (owner, tid, _) <- createTeam testDomain 1 - token <- createSamlScimToken owner tid True - (email, scid) <- createScimInvitationUser testDomain token - iid <- sendAndAcceptManualInvitation testDomain owner email - users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - -- The SAML/SCIM account remains active because of its SSO identity, but its - -- email is only stored as `email_unvalidated`. Even while the activation - -- code is still valid, it does not claim the email key. The manual account - -- can therefore claim the email and both accounts exist. - printJSON users - -testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () -testScimInvitationThenManualInvitationSamlEmailAutoActivation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do - (owner, tid, _) <- createTeam testDomain 1 - token <- createSamlScimToken owner tid False - (email, _) <- createScimInvitationUser testDomain token - -- account is auto activated so the team invitation is not possible - postInvitation owner (def {email = Just email}) >>= assertStatus 409 + -- The manual account is active and owns the email, while the expired + -- non-SAML SCIM account still has status `pending-invitation` and retains + -- its handle. `activated: true` on the SCIM row is intentional: it keeps + -- the SCIM identity representable and does not mean that the invitation was + -- accepted. This confirms that asynchronous cleanup had not removed the + -- expired account before the manual invitation was accepted. + -- case users of + -- [_] -> pure () + -- [user1, user2] -> do + -- -- The SAML/SCIM account remains active because it has an SSO identity. + -- -- Its email is only stored as `email_unvalidated`; neither an active nor + -- -- an expired email activation code claims the email key. Consequently, + -- -- the manual account can claim the email and both accounts exist. + -- email1 <- user1 %. "email" >>= asString + -- email2 <- user2 %. "email_unvalidated" >>= asString + -- email1 `shouldMatch` email2 + -- _ -> fail "more than 2 users not expected" + +-- testScimInvitationThenManualInvitationSamlEmailValidation :: (HasCallStack) => App () +-- testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do +-- (owner, tid, _) <- createTeam testDomain 1 +-- token <- createSamlScimToken owner tid True +-- (email, scid, handle) <- createScimInvitationUser testDomain token +-- iid <- sendAndAcceptManualInvitation testDomain owner email handle +-- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList +-- printJSON users +-- case users of +-- [_] -> pure () +-- [user1, user2] -> do +-- email1 <- user1 %. "email" >>= asString +-- email2 <- user2 %. "email" >>= asString +-- when (email1 == email2) $ do +-- id1 <- user1 %. "id" >>= asString +-- id2 <- user2 %. "id" >>= asString +-- id1 `shouldMatch` id2 +-- _ -> fail "more than 2 users not expected" +-- +-- testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () +-- testScimSamlEmailVerificationExpiryThenManualInvitation = do +-- let settings = +-- def +-- { brigCfg = +-- setField "optSettings.setActivationTimeout" (1 :: Int) +-- . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) +-- } +-- +-- withModifiedBackend settings $ \testDomain -> do +-- (owner, tid, _) <- createTeam testDomain 1 +-- token <- createSamlScimToken owner tid True +-- (email, scid, handle) <- createScimInvitationUser testDomain token +-- +-- -- wait until email verification expires +-- eventually $ getActivationCode testDomain email >>= assertStatus 404 +-- +-- iid <- sendAndAcceptManualInvitation testDomain owner email handle +-- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList +-- -- The email activation code has expired, but this does not deactivate the +-- -- SAML/SCIM account. It remains active because of its SSO identity, while +-- -- the email remains unvalidated. Since an activation code does not claim +-- -- the email key, the manual account can claim it and both accounts exist. +-- printJSON users +-- +-- testScimSamlEmailVerificationThenManualInvitation :: (HasCallStack) => App () +-- testScimSamlEmailVerificationThenManualInvitation = do +-- let testDomain = OwnDomain +-- (owner, tid, _) <- createTeam testDomain 1 +-- token <- createSamlScimToken owner tid True +-- (email, scid, handle) <- createScimInvitationUser testDomain token +-- iid <- sendAndAcceptManualInvitation testDomain owner email handle +-- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList +-- -- The SAML/SCIM account remains active because of its SSO identity, but its +-- -- email is only stored as `email_unvalidated`. Even while the activation +-- -- code is still valid, it does not claim the email key. The manual account +-- -- can therefore claim the email and both accounts exist. +-- printJSON users +-- +-- testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () +-- testScimInvitationThenManualInvitationSamlEmailAutoActivation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do +-- (owner, tid, _) <- createTeam testDomain 1 +-- token <- createSamlScimToken owner tid False +-- (email, _, _) <- createScimInvitationUser testDomain token +-- -- account is auto activated so the team invitation is not possible +-- postInvitation owner (def {email = Just email}) >>= assertStatus 409 scimInvitationTestOverrides :: ServiceOverrides scimInvitationTestOverrides = def { brigCfg = - setField "optSettings.setTeamInvitationTimeout" (1 :: Int) + setField "optSettings.setTeamInvitationTimeout" (2 :: Int) . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } @@ -157,20 +163,25 @@ createSamlScimToken user team validateEmails = do idpId <- asString $ idp.json %. "id" createScimToken user (def {idp = Just idpId}) >>= getJSON 200 >>= (%. "token") >>= asString -createScimInvitationUser :: (HasCallStack, MakesValue domain) => domain -> String -> App (String, String) +createScimInvitationUser :: (HasCallStack, MakesValue domain) => domain -> String -> App (String, String, String) createScimInvitationUser testDomain token = do email <- randomEmail externalId <- randomExternalId scimUser <- randomScimUserWithEmail externalId email scimUserId <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString - pure (email, scimUserId) + handle <- scimUser %. "userName" >>= asString + pure (email, scimUserId, handle) -sendAndAcceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> App String -sendAndAcceptManualInvitation testDomain inviter email = do +sendAndAcceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> String -> App String +sendAndAcceptManualInvitation testDomain inviter email handle = do invitation <- postInvitation inviter (def {email = Just email}) >>= getJSON 201 invitationId <- invitation %. "id" >>= asString code <- getInvitationCode inviter invitation >>= getJSON 200 >>= (%. "code") >>= asString registerUserWith testDomain email code "Alice" >>= assertStatus 201 + user <- getUsersByEmail testDomain [email] >>= getJSON 200 >>= asList >>= assertOne + -- The SCIM account already owns the requested username, even though the + -- manual account was created through a separate invitation. + putHandle user handle >>= assertStatus 409 pure invitationId testSparUserCreationInvitationTimeout :: (HasCallStack) => App () From ccef966d1c7f92ed1a76576b77abeb3fb527dd83 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 10:29:51 +0200 Subject: [PATCH 08/23] refined the test --- integration/test/Test/Spar.hs | 173 +++++++++------------------------- 1 file changed, 45 insertions(+), 128 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 3c46880395..40bb3c126c 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -53,136 +53,53 @@ import qualified Time.System as Hourglass ---------------------------------------------------------------------- -- scim stuff -testScimInvitationThenManualInvitationNoSaml :: (HasCallStack) => App () -testScimInvitationThenManualInvitationNoSaml = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do - (owner, _tid, _) <- createTeam testDomain 1 - token <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString - (email, scid, handle) <- createScimInvitationUser testDomain token - -- wait for invitation to expire - liftIO $ threadDelay 2_100_000 - iid <- sendAndAcceptManualInvitation testDomain owner email handle - users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - printJSON users - -- The manual account is active and owns the email, while the expired - -- non-SAML SCIM account still has status `pending-invitation` and retains - -- its handle. `activated: true` on the SCIM row is intentional: it keeps - -- the SCIM identity representable and does not mean that the invitation was - -- accepted. This confirms that asynchronous cleanup had not removed the - -- expired account before the manual invitation was accepted. - -- case users of - -- [_] -> pure () - -- [user1, user2] -> do - -- -- The SAML/SCIM account remains active because it has an SSO identity. - -- -- Its email is only stored as `email_unvalidated`; neither an active nor - -- -- an expired email activation code claims the email key. Consequently, - -- -- the manual account can claim the email and both accounts exist. - -- email1 <- user1 %. "email" >>= asString - -- email2 <- user2 %. "email_unvalidated" >>= asString - -- email1 `shouldMatch` email2 - -- _ -> fail "more than 2 users not expected" - --- testScimInvitationThenManualInvitationSamlEmailValidation :: (HasCallStack) => App () --- testScimInvitationThenManualInvitationSamlEmailValidation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do --- (owner, tid, _) <- createTeam testDomain 1 --- token <- createSamlScimToken owner tid True --- (email, scid, handle) <- createScimInvitationUser testDomain token --- iid <- sendAndAcceptManualInvitation testDomain owner email handle --- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList --- printJSON users --- case users of --- [_] -> pure () --- [user1, user2] -> do --- email1 <- user1 %. "email" >>= asString --- email2 <- user2 %. "email" >>= asString --- when (email1 == email2) $ do --- id1 <- user1 %. "id" >>= asString --- id2 <- user2 %. "id" >>= asString --- id1 `shouldMatch` id2 --- _ -> fail "more than 2 users not expected" --- --- testScimSamlEmailVerificationExpiryThenManualInvitation :: (HasCallStack) => App () --- testScimSamlEmailVerificationExpiryThenManualInvitation = do --- let settings = --- def --- { brigCfg = --- setField "optSettings.setActivationTimeout" (1 :: Int) --- . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) --- } --- --- withModifiedBackend settings $ \testDomain -> do --- (owner, tid, _) <- createTeam testDomain 1 --- token <- createSamlScimToken owner tid True --- (email, scid, handle) <- createScimInvitationUser testDomain token --- --- -- wait until email verification expires --- eventually $ getActivationCode testDomain email >>= assertStatus 404 --- --- iid <- sendAndAcceptManualInvitation testDomain owner email handle --- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList --- -- The email activation code has expired, but this does not deactivate the --- -- SAML/SCIM account. It remains active because of its SSO identity, while --- -- the email remains unvalidated. Since an activation code does not claim --- -- the email key, the manual account can claim it and both accounts exist. --- printJSON users --- --- testScimSamlEmailVerificationThenManualInvitation :: (HasCallStack) => App () --- testScimSamlEmailVerificationThenManualInvitation = do --- let testDomain = OwnDomain --- (owner, tid, _) <- createTeam testDomain 1 --- token <- createSamlScimToken owner tid True --- (email, scid, handle) <- createScimInvitationUser testDomain token --- iid <- sendAndAcceptManualInvitation testDomain owner email handle --- users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList --- -- The SAML/SCIM account remains active because of its SSO identity, but its --- -- email is only stored as `email_unvalidated`. Even while the activation --- -- code is still valid, it does not claim the email key. The manual account --- -- can therefore claim the email and both accounts exist. --- printJSON users --- --- testScimInvitationThenManualInvitationSamlEmailAutoActivation :: (HasCallStack) => App () --- testScimInvitationThenManualInvitationSamlEmailAutoActivation = withModifiedBackend scimInvitationTestOverrides $ \testDomain -> do --- (owner, tid, _) <- createTeam testDomain 1 --- token <- createSamlScimToken owner tid False --- (email, _, _) <- createScimInvitationUser testDomain token --- -- account is auto activated so the team invitation is not possible --- postInvitation owner (def {email = Just email}) >>= assertStatus 409 - -scimInvitationTestOverrides :: ServiceOverrides -scimInvitationTestOverrides = - def - { brigCfg = - setField "optSettings.setTeamInvitationTimeout" (2 :: Int) - . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) - } +-- Given: +-- - SCIM invitation created for email +-- - SCIM invitation expired (clean up has not run) +-- When: +-- - Team invitation is created for same email +-- Then: +-- - The team invitation is created sucessfully +-- - The team invitation can be accepted +-- - The email is claimed for the new team user +-- - The handle that was previously used for the SCIM invitation +-- is available for the new team user +-- - No active (SCIM) user with the same email exists +testTeamInvitationWhenScimInvitationExpired :: (HasCallStack) => App () +testTeamInvitationWhenScimInvitationExpired = do + let settings = + def + { brigCfg = + -- timeout for both SCIM and team invitations + setField "optSettings.setTeamInvitationTimeout" (2 :: Int) + -- controls when asynchronous cleanup removes expired SCIM pending accounts + . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) + } + withModifiedBackend settings $ \testDomain -> do + (owner, _tid, _) <- createTeam testDomain 1 + token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString -createSamlScimToken :: (HasCallStack, MakesValue user) => user -> String -> Bool -> App String -createSamlScimToken user team validateEmails = do - assertSuccess =<< setTeamFeatureStatus user team "sso" "enabled" - assertSuccess =<< setTeamFeatureStatus user team "validateSAMLemails" (if validateEmails then "enabled" else "disabled") - (idp, _) <- registerTestIdPWithMetaWithPrivateCreds user - idpId <- asString $ idp.json %. "id" - createScimToken user (def {idp = Just idpId}) >>= getJSON 200 >>= (%. "token") >>= asString + -- create a SCIM user, SCIM invitation will be sent + email <- randomEmail + externalId <- randomExternalId + scimUser <- randomScimUserWithEmail externalId email + scid <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString + handle <- scimUser %. "userName" >>= asString -createScimInvitationUser :: (HasCallStack, MakesValue domain) => domain -> String -> App (String, String, String) -createScimInvitationUser testDomain token = do - email <- randomEmail - externalId <- randomExternalId - scimUser <- randomScimUserWithEmail externalId email - scimUserId <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString - handle <- scimUser %. "userName" >>= asString - pure (email, scimUserId, handle) - -sendAndAcceptManualInvitation :: (HasCallStack, MakesValue domain, MakesValue user) => domain -> user -> String -> String -> App String -sendAndAcceptManualInvitation testDomain inviter email handle = do - invitation <- postInvitation inviter (def {email = Just email}) >>= getJSON 201 - invitationId <- invitation %. "id" >>= asString - code <- getInvitationCode inviter invitation >>= getJSON 200 >>= (%. "code") >>= asString - registerUserWith testDomain email code "Alice" >>= assertStatus 201 - user <- getUsersByEmail testDomain [email] >>= getJSON 200 >>= asList >>= assertOne - -- The SCIM account already owns the requested username, even though the - -- manual account was created through a separate invitation. - putHandle user handle >>= assertStatus 409 - pure invitationId + -- wait for the SCIM invitation to expire + liftIO $ threadDelay 2_100_000 + + -- create a manual inivitation + invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201 + iid <- invitation %. "id" >>= asString + code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString + registerUserWith testDomain email code "Alice" >>= assertStatus 201 + user <- getUsersByEmail testDomain [email] >>= getJSON 200 >>= asList >>= assertOne + + putHandle user handle >>= assertSuccess + + users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList + undefined "make assertions" users testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do From 3ee012e504aa473fe7fadce1722d11960a3addd9 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 10:35:28 +0200 Subject: [PATCH 09/23] test implemented completed --- integration/test/Test/Spar.hs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 40bb3c126c..3c28c7fbb7 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -72,7 +72,7 @@ testTeamInvitationWhenScimInvitationExpired = do { brigCfg = -- timeout for both SCIM and team invitations setField "optSettings.setTeamInvitationTimeout" (2 :: Int) - -- controls when asynchronous cleanup removes expired SCIM pending accounts + -- controls when asynchronous cleanup removes expired SCIM pending accounts . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } withModifiedBackend settings $ \testDomain -> do @@ -91,15 +91,22 @@ testTeamInvitationWhenScimInvitationExpired = do -- create a manual inivitation invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201 - iid <- invitation %. "id" >>= asString code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString registerUserWith testDomain email code "Alice" >>= assertStatus 201 user <- getUsersByEmail testDomain [email] >>= getJSON 200 >>= asList >>= assertOne + manualUserId <- user %. "id" >>= asString putHandle user handle >>= assertSuccess - users <- getUsersIdRaw testDomain [iid, scid] >>= getJSON 200 >>= asList - undefined "make assertions" users + users <- getUsersIdRaw testDomain [manualUserId, scid] >>= getJSON 200 >>= asList + rawUser <- assertOne users + rawUser %. "id" `shouldMatch` manualUserId + rawUser %. "email" `shouldMatch` email + rawUser %. "handle" `shouldMatch` handle + rawUser %. "managed_by" `shouldMatch` "wire" + rawUser %. "status" `shouldMatch` "active" + activated <- rawUser %. "activated" >>= asBool + activated `shouldMatch` True testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do From 52852d731cdfeabf809c96bdb41d047825beeda3 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 10:43:42 +0200 Subject: [PATCH 10/23] new test for SCIM invitation pending --- integration/test/Test/Spar.hs | 46 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 3c28c7fbb7..b13a1772bb 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -72,18 +72,18 @@ testTeamInvitationWhenScimInvitationExpired = do { brigCfg = -- timeout for both SCIM and team invitations setField "optSettings.setTeamInvitationTimeout" (2 :: Int) - -- controls when asynchronous cleanup removes expired SCIM pending accounts + -- controls when asynchronous cleanup removes expired SCIM pending accountsts . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } - withModifiedBackend settings $ \testDomain -> do - (owner, _tid, _) <- createTeam testDomain 1 + withModifiedBackend settings $ \domain -> do + (owner, _tid, _) <- createTeam domain 1 token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString -- create a SCIM user, SCIM invitation will be sent email <- randomEmail externalId <- randomExternalId scimUser <- randomScimUserWithEmail externalId email - scid <- createScimUser testDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString + scid <- createScimUser domain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString handle <- scimUser %. "userName" >>= asString -- wait for the SCIM invitation to expire @@ -92,13 +92,13 @@ testTeamInvitationWhenScimInvitationExpired = do -- create a manual inivitation invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201 code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString - registerUserWith testDomain email code "Alice" >>= assertStatus 201 - user <- getUsersByEmail testDomain [email] >>= getJSON 200 >>= asList >>= assertOne + registerUserWith domain email code "Alice" >>= assertStatus 201 + user <- getUsersByEmail domain [email] >>= getJSON 200 >>= asList >>= assertOne manualUserId <- user %. "id" >>= asString putHandle user handle >>= assertSuccess - users <- getUsersIdRaw testDomain [manualUserId, scid] >>= getJSON 200 >>= asList + users <- getUsersIdRaw domain [manualUserId, scid] >>= getJSON 200 >>= asList rawUser <- assertOne users rawUser %. "id" `shouldMatch` manualUserId rawUser %. "email" `shouldMatch` email @@ -108,6 +108,38 @@ testTeamInvitationWhenScimInvitationExpired = do activated <- rawUser %. "activated" >>= asBool activated `shouldMatch` True +-- Given: +-- - SCIM invitation created for email +-- - SCIM invitation stays in pending +-- When: +-- - Team invitation is created for same email +-- Then: +-- - The request will be rejected with a conflict +testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App () +testTeamInvitationWhenScimInvitationPending = do + (owner, _tid, _) <- createTeam OwnDomain 1 + token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString + + -- create a SCIM user, SCIM invitation will be sent + email <- randomEmail + externalId <- randomExternalId + scimUser <- randomScimUserWithEmail externalId email + scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString + handle <- scimUser %. "userName" >>= asString + + -- The SCIM invitation is still pending, so creating a second team + -- invitation for the same email must be rejected. + postInvitation owner (def {email = Just email}) >>= assertStatus 409 + + users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList + user <- assertOne users + user %. "email" `shouldMatch` email + user %. "handle" `shouldMatch` handle + user %. "managed_by" `shouldMatch` "scim" + user %. "status" `shouldMatch` "pending-invitation" + activated <- user %. "activated" >>= asBool + activated `shouldMatch` True + testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do (owner, tid, _) <- createTeam OwnDomain 1 From 4331aea048bc463792da58c1ba1bed142b3894e0 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 10:48:30 +0200 Subject: [PATCH 11/23] test for existing SCIM account --- integration/test/Test/Spar.hs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index b13a1772bb..62f908c8d8 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -140,6 +140,41 @@ testTeamInvitationWhenScimInvitationPending = do activated <- user %. "activated" >>= asBool activated `shouldMatch` True +-- Given: +-- - SCIM invitation created for email +-- - SCIM invitation accepted +-- When: +-- - Team invitation is created for same email +-- Then: +-- - The request will be rejected with a conflict +testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App () +testTeamInvitationWhenScimAccountExists = do + (owner, tid, _) <- createTeam OwnDomain 1 + token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString + + -- create a SCIM user, SCIM invitation will be sent + email <- randomEmail + externalId <- randomExternalId + scimUser <- randomScimUserWithEmail externalId email + scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString + handle <- scimUser %. "userName" >>= asString + + -- Accept the SCIM invitation so the SCIM-managed account is active. + registerInvitedUser OwnDomain tid email + + -- An active SCIM account already owns the email, so a second team + -- invitation for the same email must be rejected. + postInvitation owner (def {email = Just email}) >>= assertStatus 409 + + users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList + user <- assertOne users + user %. "email" `shouldMatch` email + user %. "handle" `shouldMatch` handle + user %. "managed_by" `shouldMatch` "scim" + user %. "status" `shouldMatch` "active" + activated <- user %. "activated" >>= asBool + activated `shouldMatch` True + testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do (owner, tid, _) <- createTeam OwnDomain 1 From f56740d3ef14fd6b2eb6e9e6dd28557e0cfe3ab2 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 11:48:13 +0200 Subject: [PATCH 12/23] inlined the BDD descriptions --- integration/test/Test/Spar.hs | 50 ++++++++++------------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 62f908c8d8..18e9f9cb0e 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -53,18 +53,6 @@ import qualified Time.System as Hourglass ---------------------------------------------------------------------- -- scim stuff --- Given: --- - SCIM invitation created for email --- - SCIM invitation expired (clean up has not run) --- When: --- - Team invitation is created for same email --- Then: --- - The team invitation is created sucessfully --- - The team invitation can be accepted --- - The email is claimed for the new team user --- - The handle that was previously used for the SCIM invitation --- is available for the new team user --- - No active (SCIM) user with the same email exists testTeamInvitationWhenScimInvitationExpired :: (HasCallStack) => App () testTeamInvitationWhenScimInvitationExpired = do let settings = @@ -72,32 +60,36 @@ testTeamInvitationWhenScimInvitationExpired = do { brigCfg = -- timeout for both SCIM and team invitations setField "optSettings.setTeamInvitationTimeout" (2 :: Int) - -- controls when asynchronous cleanup removes expired SCIM pending accountsts + -- Controls when asynchronous cleanup removes expired SCIM pending accounts. . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int) } withModifiedBackend settings $ \domain -> do (owner, _tid, _) <- createTeam domain 1 token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString - -- create a SCIM user, SCIM invitation will be sent + -- Create a SCIM user and let its invitation expire. Cleanup is deliberately + -- delayed so the expired pending account still exists at this point. email <- randomEmail externalId <- randomExternalId scimUser <- randomScimUserWithEmail externalId email scid <- createScimUser domain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString handle <- scimUser %. "userName" >>= asString - -- wait for the SCIM invitation to expire + -- Wait until the SCIM invitation has expired. liftIO $ threadDelay 2_100_000 - -- create a manual inivitation + -- Create and accept a manual team invitation for the same email. This is + -- expected to succeed after the expired SCIM account has been cleaned up. invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201 code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString registerUserWith domain email code "Alice" >>= assertStatus 201 user <- getUsersByEmail domain [email] >>= getJSON 200 >>= asList >>= assertOne manualUserId <- user %. "id" >>= asString + -- The handle previously held by the SCIM account is available again. putHandle user handle >>= assertSuccess + -- Only the active manual account remains; it owns both the email and handle. users <- getUsersIdRaw domain [manualUserId, scid] >>= getJSON 200 >>= asList rawUser <- assertOne users rawUser %. "id" `shouldMatch` manualUserId @@ -108,27 +100,20 @@ testTeamInvitationWhenScimInvitationExpired = do activated <- rawUser %. "activated" >>= asBool activated `shouldMatch` True --- Given: --- - SCIM invitation created for email --- - SCIM invitation stays in pending --- When: --- - Team invitation is created for same email --- Then: --- - The request will be rejected with a conflict testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App () testTeamInvitationWhenScimInvitationPending = do (owner, _tid, _) <- createTeam OwnDomain 1 token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString - -- create a SCIM user, SCIM invitation will be sent + -- Create a SCIM user; this sends a SCIM invitation that remains pending. email <- randomEmail externalId <- randomExternalId scimUser <- randomScimUserWithEmail externalId email scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString handle <- scimUser %. "userName" >>= asString - -- The SCIM invitation is still pending, so creating a second team - -- invitation for the same email must be rejected. + -- The SCIM invitation is still pending. A second team invitation for the + -- same email must be rejected with a conflict. postInvitation owner (def {email = Just email}) >>= assertStatus 409 users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList @@ -140,19 +125,12 @@ testTeamInvitationWhenScimInvitationPending = do activated <- user %. "activated" >>= asBool activated `shouldMatch` True --- Given: --- - SCIM invitation created for email --- - SCIM invitation accepted --- When: --- - Team invitation is created for same email --- Then: --- - The request will be rejected with a conflict testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App () testTeamInvitationWhenScimAccountExists = do (owner, tid, _) <- createTeam OwnDomain 1 token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString - -- create a SCIM user, SCIM invitation will be sent + -- Create a SCIM user and accept the resulting SCIM invitation below. email <- randomEmail externalId <- randomExternalId scimUser <- randomScimUserWithEmail externalId email @@ -162,8 +140,8 @@ testTeamInvitationWhenScimAccountExists = do -- Accept the SCIM invitation so the SCIM-managed account is active. registerInvitedUser OwnDomain tid email - -- An active SCIM account already owns the email, so a second team - -- invitation for the same email must be rejected. + -- An active SCIM account already owns the email. A second team invitation + -- for the same email must therefore be rejected with a conflict. postInvitation owner (def {email = Just email}) >>= assertStatus 409 users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList From 2ee9a6b43c9cac08e966352396932318026ff803 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 11:56:42 +0200 Subject: [PATCH 13/23] assert other team can still invite email --- integration/test/Test/Spar.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 18e9f9cb0e..f3cd1f4afb 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -103,6 +103,7 @@ testTeamInvitationWhenScimInvitationExpired = do testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App () testTeamInvitationWhenScimInvitationPending = do (owner, _tid, _) <- createTeam OwnDomain 1 + (otherOwner, _otherTid, _) <- createTeam OwnDomain 1 token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString -- Create a SCIM user; this sends a SCIM invitation that remains pending. @@ -113,9 +114,13 @@ testTeamInvitationWhenScimInvitationPending = do handle <- scimUser %. "userName" >>= asString -- The SCIM invitation is still pending. A second team invitation for the - -- same email must be rejected with a conflict. + -- same email and team must be rejected with a conflict. postInvitation owner (def {email = Just email}) >>= assertStatus 409 + -- The email must still be invit-able by a different team; otherwise a + -- pending SCIM invitation could be used for an email-registration DoS. + postInvitation otherOwner (def {email = Just email}) >>= assertStatus 201 + users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList user <- assertOne users user %. "email" `shouldMatch` email From 63a910014ef5f01d3de559a1a6e3763fc2f262af Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 12:30:35 +0200 Subject: [PATCH 14/23] reject team invitation when a pending SCIM invitation exists --- libs/types-common/src/Data/Id.hs | 5 ++++ .../TeamInvitationSubsystem/Interpreter.hs | 29 ++++++++++++++++++- services/brig/src/Brig/Data/User.hs | 5 ---- services/brig/src/Brig/Team/API.hs | 1 - 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index a1c0aedf91..d9d7156640 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -41,6 +41,7 @@ module Data.Id parseIdFromText, idToText, idToString, + invitationIdToUserId, idObjectSchema, IdObject (..), @@ -102,6 +103,10 @@ import System.Logger (ToBytes) import Test.QuickCheck import Test.QuickCheck.Instances () +-- | Pending invitation users reuse the invitation UUID as the user UUID. +invitationIdToUserId :: InvitationId -> UserId +invitationIdToUserId = Id . toUUID + data IdTag = Asset | Conversation diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index d7e7b9e468..8cad56fa36 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -54,10 +54,13 @@ import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) import Wire.Sem.Random qualified as Random +import Wire.StoredUser (StoredUser (managedBy, status, teamId)) import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Error import Wire.TeamSubsystem import Wire.UserKeyStore +import Wire.UserStore (UserStore) +import Wire.UserStore qualified as UserStore import Wire.UserSubsystem (UserSubsystem, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) data TeamInvitationSubsystemConfig = TeamInvitationSubsystemConfig @@ -75,6 +78,7 @@ runTeamInvitationSubsystem :: Member UserSubsystem r, Member Random r, Member InvitationStore r, + Member UserStore r, Member Now r, Member EmailSubsystem r, Member EnterpriseLoginSubsystem r, @@ -100,13 +104,16 @@ inviteUserImpl :: Member EmailSubsystem r, Member EnterpriseLoginSubsystem r, Member TeamSubsystem r, - Member UserKeyStore r + Member UserKeyStore r, + Member UserStore r ) => Local UserId -> TeamId -> InvitationRequest -> Sem r (Invitation, InvitationLocation) inviteUserImpl luid tid request = do + guardPendingScimInvitation request.inviteeEmail + let inviteeRole = fromMaybe defaultRole request.role let inviteePerms = Teams.rolePermissions inviteeRole @@ -131,6 +138,26 @@ inviteUserImpl luid tid request = do loc inv = InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId + guardPendingScimInvitation email = do + invitations <- Store.lookupInvitationsByEmail email + pendingScim <- or <$> traverse isPendingScimInvitation invitations + when pendingScim $ throw TeamInvitationEmailTaken + where + isPendingScimInvitation inv + | inv.teamId /= tid = pure False + | otherwise = do + -- The invitation store also contains ordinary team invitations, which do not + -- create a user until they are accepted. Check the user to distinguish those + -- invitations from a pending SCIM invitation, whose user already exists with + -- the invitation ID, managedBy = scim, and status = pending-invitation. + mUser <- UserStore.getUser (invitationIdToUserId inv.invitationId) + pure $ case mUser of + Just user -> + user.teamId == Just tid + && user.managedBy == Just ManagedByScim + && user.status == Just PendingInvitation + Nothing -> False + createInvitation' :: ( Member GalleyAPIAccess r, Member UserSubsystem r, diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 3a36c4c305..fe17628421 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -22,7 +22,6 @@ module Brig.Data.User ( -- * Creation newStoredUser, newStoredUserViaScim, - invitationIdToUserId, ) where @@ -41,10 +40,6 @@ import Wire.API.User import Wire.AuthenticationSubsystem.Config import Wire.StoredUser --- | Pending invitation users reuse the invitation UUID as the user UUID. -invitationIdToUserId :: InvitationId -> UserId -invitationIdToUserId = Id . toUUID - -- | Preconditions: -- -- 1. @newUserUUID u == Just inv || isNothing (newUserUUID u)@. diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index 323b260960..9f11eedd35 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -31,7 +31,6 @@ import Brig.API.User (createUserInviteViaScim) import Brig.API.User qualified as API import Brig.API.Util (logEmail, logInvitationCode) import Brig.App as App -import Brig.Data.User (invitationIdToUserId) import Brig.Template import Control.Lens (view, (^.)) import Control.Monad.Trans.Except From 3131d5fc673abe81b9de6670b759153384be4b42 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 15:22:24 +0200 Subject: [PATCH 15/23] scim pending user email table --- cassandra-schema.cql | 24 +++++++++++ services/brig/brig.cabal | 1 + services/brig/src/Brig/Schema/Run.hs | 4 +- .../Schema/V93_AddScimPendingUserEmail.hs | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 7d2e47e16e..c79723f599 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -1005,6 +1005,30 @@ CREATE TABLE brig_test.team_invitation_info ( AND read_repair = 'BLOCKING' AND speculative_retry = '99p'; +CREATE TABLE brig_test.team_scim_pending_user_email ( + team uuid, + email text, + user uuid, + PRIMARY KEY ((team, email), user) +) WITH CLUSTERING ORDER BY (user ASC) + AND additional_write_policy = '99p' + AND bloom_filter_fp_chance = 0.01 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND cdc = false + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'} + AND compression = {'chunk_length_in_kb': '16', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND memtable = 'default' + AND crc_check_chance = 1.0 + AND default_time_to_live = 0 + AND extensions = {} + 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 = 'BLOCKING' + AND speculative_retry = '99p'; + CREATE TABLE brig_test.unique_claims ( value text PRIMARY KEY, claims set diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index ecb51c1d05..e741240792 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -183,6 +183,7 @@ library Brig.Schema.V90_DomainRegistrationTeamIndex Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl Brig.Schema.V92_AddUserType + Brig.Schema.V93_AddScimPendingUserEmail Brig.Team.API Brig.Team.Template Brig.Template diff --git a/services/brig/src/Brig/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs index bef0e82ce3..560cf64f2e 100644 --- a/services/brig/src/Brig/Schema/Run.hs +++ b/services/brig/src/Brig/Schema/Run.hs @@ -67,6 +67,7 @@ import Brig.Schema.V89_UpdateDomainRegistrationSchema qualified as V89_UpdateDom import Brig.Schema.V90_DomainRegistrationTeamIndex qualified as V90_DomainRegistrationTeamIndex import Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl qualified as V91_UpdateDomainRegistrationSchema_AddWebappUrl import Brig.Schema.V92_AddUserType qualified as V92_AddUserType +import Brig.Schema.V93_AddScimPendingUserEmail qualified as V93_AddScimPendingUserEmail import Cassandra.MigrateSchema (migrateSchema) import Cassandra.Schema import Control.Exception (finally) @@ -140,7 +141,8 @@ migrations = V89_UpdateDomainRegistrationSchema.migration, V90_DomainRegistrationTeamIndex.migration, V91_UpdateDomainRegistrationSchema_AddWebappUrl.migration, - V92_AddUserType.migration + V92_AddUserType.migration, + V93_AddScimPendingUserEmail.migration -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in -- https://github.com/wireapp/wire-server/pull/964 ] diff --git a/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs new file mode 100644 index 0000000000..bca1d4ea90 --- /dev/null +++ b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 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 Brig.Schema.V93_AddScimPendingUserEmail + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 93 "Add lookup table for pending SCIM users by team and email" $ + schema' + [r| + CREATE TABLE team_scim_pending_user_email + ( team uuid + , email text + , user uuid + , primary key ((team, email), user) + ) + |] From 8b716db6cc5d2835816e00988c6561baba2a036c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 16:01:47 +0200 Subject: [PATCH 16/23] scim email team index create read delete ops --- .../src/Wire/InvitationStore.hs | 3 ++ .../src/Wire/InvitationStore/Cassandra.hs | 33 +++++++++++++++++++ .../test/unit/Wire/MiniBackend.hs | 9 +++++ .../Wire/MockInterpreters/InvitationStore.hs | 25 +++++++++++--- .../InterpreterSpec.hs | 2 ++ 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/InvitationStore.hs b/libs/wire-subsystems/src/Wire/InvitationStore.hs index 54873c400f..e875677cce 100644 --- a/libs/wire-subsystems/src/Wire/InvitationStore.hs +++ b/libs/wire-subsystems/src/Wire/InvitationStore.hs @@ -96,6 +96,9 @@ data InvitationStore :: Effect where LookupInvitation :: TeamId -> InvitationId -> InvitationStore m (Maybe StoredInvitation) LookupInvitationByCode :: InvitationCode -> InvitationStore m (Maybe StoredInvitation) LookupInvitationsByEmail :: EmailAddress -> InvitationStore m [StoredInvitation] + InsertPendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m () + LookupPendingScimUsers :: TeamId -> EmailAddress -> InvitationStore m [UserId] + DeletePendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m () -- | Range is page size, it defaults to 100 LookupInvitationsPaginated :: Maybe (Range 1 500 Int32) -> TeamId -> Maybe InvitationId -> InvitationStore m (PaginatedResult [StoredInvitation]) CountInvitations :: TeamId -> InvitationStore m Int64 diff --git a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs index e3ecf2d63b..fbafe1e291 100644 --- a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs @@ -44,6 +44,9 @@ interpretInvitationStoreToCassandra casClient = InsertInvitation newInv timeout -> embed $ insertInvitationImpl newInv timeout LookupInvitation tid iid -> embed $ lookupInvitationImpl tid iid LookupInvitationsByEmail email -> embed $ lookupInvitationsByEmailImpl email + InsertPendingScimUser tid email uid -> embed $ insertPendingScimUserImpl tid email uid + LookupPendingScimUsers tid email -> embed $ lookupPendingScimUsersImpl tid email + DeletePendingScimUser tid email uid -> embed $ deletePendingScimUserImpl tid email uid LookupInvitationByCode code -> embed $ lookupInvitationByCodeImpl code LookupInvitationsPaginated mSize tid miid -> embed $ lookupInvitationsPaginatedImpl mSize tid miid CountInvitations tid -> embed $ countInvitationsImpl tid @@ -152,6 +155,36 @@ lookupInvitationsByEmailImpl email = do SELECT team, role, id, created_at, created_by, email, name, code FROM team_invitation WHERE team = ? AND id = ? |] +insertPendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client () +insertPendingScimUserImpl team email uid = + retry x5 $ write cql (params LocalQuorum (team, email, uid)) + where + cql :: PrepQuery W (TeamId, EmailAddress, UserId) () + cql = + [sql| + INSERT INTO team_scim_pending_user_email (team, email, user) VALUES (?, ?, ?) + |] + +lookupPendingScimUsersImpl :: TeamId -> EmailAddress -> Client [UserId] +lookupPendingScimUsersImpl team email = + map runIdentity <$> retry x1 (query cql (params LocalQuorum (team, email))) + where + cql :: PrepQuery R (TeamId, EmailAddress) (Identity UserId) + cql = + [sql| + SELECT user FROM team_scim_pending_user_email WHERE team = ? AND email = ? + |] + +deletePendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client () +deletePendingScimUserImpl team email uid = + retry x5 $ write cql (params LocalQuorum (team, email, uid)) + where + cql :: PrepQuery W (TeamId, EmailAddress, UserId) () + cql = + [sql| + DELETE FROM team_scim_pending_user_email WHERE team = ? AND email = ? AND user = ? + |] + lookupInvitationImpl :: TeamId -> InvitationId -> Client (Maybe StoredInvitation) lookupInvitationImpl tid iid = fmap asRecord diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 1aa9f8bdf0..1324d919db 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -392,6 +392,7 @@ type StateEffects = State (Map (TeamId) [TeamCollaborator]), State (Map (TeamId, InvitationId) StoredInvitation), State (Map InvitationCode StoredInvitation), + State (Map (TeamId, EmailAddress) [UserId]), State (Map EmailKey (Maybe UserId, ActivationCode)), State [EmailKey], State [StoredUser], @@ -422,6 +423,7 @@ stateEffectsInterpreters MiniBackendParams {..} = . liftUserStoreState . liftBlockListStoreState . liftActivationCodeStoreState + . liftPendingScimUserStoreState . liftInvitationInfoStoreState . liftInvitationStoreState . liftTeamCollaboratorsStoreState @@ -515,6 +517,7 @@ data MiniBackend = MkMiniBackend activationCodes :: Map EmailKey (Maybe UserId, ActivationCode), invitationInfos :: Map InvitationCode StoredInvitation, invitations :: Map (TeamId, InvitationId) StoredInvitation, + pendingScimUsers :: Map (TeamId, EmailAddress) [UserId], teamIdps :: Map TeamId IdPList, teamCollaborators :: Map TeamId [TeamCollaborator], pushNotifications :: [Push], @@ -535,6 +538,7 @@ instance Default MiniBackend where activationCodes = mempty, invitationInfos = mempty, invitations = mempty, + pendingScimUsers = mempty, teamIdps = mempty, teamCollaborators = mempty, pushNotifications = mempty, @@ -818,6 +822,11 @@ liftInvitationStoreState = interpret \case Polysemy.State.Get -> gets (.invitations) Put newInvs -> modify $ \b -> b {invitations = newInvs} +liftPendingScimUserStoreState :: (Member (State MiniBackend) r) => Sem (State (Map (TeamId, EmailAddress) [UserId]) : r) a -> Sem r a +liftPendingScimUserStoreState = interpret \case + Polysemy.State.Get -> gets (.pendingScimUsers) + Put newUsers -> modify $ \b -> b {pendingScimUsers = newUsers} + liftTeamCollaboratorsStoreState :: (Member (State MiniBackend) r) => Sem (State (Map TeamId [TeamCollaborator]) : r) a -> Sem r a liftTeamCollaboratorsStoreState = interpret \case Polysemy.State.Get -> gets (.teamCollaborators) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs index 0f7b6f7aec..7bc540116f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs @@ -17,19 +17,20 @@ module Wire.MockInterpreters.InvitationStore where -import Data.Id (InvitationId, TeamId) +import Data.Id (InvitationId, TeamId, UserId) import Data.Map (alter, elems, (!?)) import Data.Map qualified as M import Imports hiding ((!?)) import Polysemy import Polysemy.State (State, get, gets, modify) -import Wire.API.User (InvitationCode (..)) +import Wire.API.User (EmailAddress, InvitationCode (..)) import Wire.InvitationStore inMemoryInvitationStoreInterpreter :: forall r. ( Member (State (Map (TeamId, InvitationId) StoredInvitation)) r, - Member (State (Map (InvitationCode) StoredInvitation)) r + Member (State (Map (InvitationCode) StoredInvitation)) r, + Member (State (Map (TeamId, EmailAddress) [UserId])) r ) => InterpreterFor InvitationStore r inMemoryInvitationStoreInterpreter = interpret \case @@ -49,7 +50,23 @@ inMemoryInvitationStoreInterpreter = interpret \case LookupInvitationsByEmail em -> let c i = guard (i.email == em) $> i in mapMaybe c . elems <$> get @(Map (TeamId, InvitationId) _) + InsertPendingScimUser tid email uid -> + modify @(Map (TeamId, EmailAddress) [UserId]) (M.insertWith (++) (tid, email) [uid]) + LookupPendingScimUsers tid email -> + gets @(Map (TeamId, EmailAddress) [UserId]) (fromMaybe [] . (!? (tid, email))) + DeletePendingScimUser tid email uid -> + modify @(Map (TeamId, EmailAddress) [UserId]) $ + M.alter + ( \case + Nothing -> Nothing + Just uids -> case filter (/= uid) uids of + [] -> Nothing + remaining -> Just remaining + ) + (tid, email) LookupInvitationsPaginated {} -> error "LookupInvitationsPaginated" - CountInvitations tid -> gets (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid')) + CountInvitations tid -> + gets @(Map (TeamId, InvitationId) StoredInvitation) + (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid')) DeleteInvitation _tid _invId -> error "DeleteInvitation" DeleteAllTeamInvitations _tid -> error "DeleteAllTeamInvitations" diff --git a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs index ce135460d0..ea21fd7983 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs @@ -75,6 +75,7 @@ type AllEffects = UserKeyStore, State (Map (TeamId, InvitationId) StoredInvitation), State (Map (InvitationCode) StoredInvitation), + State (Map (TeamId, EmailAddress) [UserId]), Now, State UTCTime, Error TeamInvitationSubsystemError, @@ -107,6 +108,7 @@ runAllEffects args = . interpretNowAsState . evalState mempty . evalState mempty + . evalState mempty . (evalState mempty . inMemoryUserKeyStoreInterpreter . raiseUnder) . inMemoryInvitationStoreInterpreter . evalState (mkStdGen 3) From 33a3b565d45cef1e35817b3718031c434dc7fbd5 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 17:29:51 +0200 Subject: [PATCH 17/23] check and clean up expired scim invitations --- integration/test/Test/Spar.hs | 21 +++-- .../TeamInvitationSubsystem/Interpreter.hs | 88 ++++++++++++++----- .../src/Wire/UserSubsystem/Interpreter.hs | 2 + services/brig/src/Brig/API/Internal.hs | 1 + services/brig/src/Brig/API/Public.hs | 2 + services/brig/src/Brig/API/User.hs | 12 ++- .../brig/src/Brig/InternalEvent/Process.hs | 2 + services/brig/src/Brig/Team/API.hs | 1 + 8 files changed, 96 insertions(+), 33 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index f3cd1f4afb..a71a3d9c47 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -89,17 +89,20 @@ testTeamInvitationWhenScimInvitationExpired = do -- The handle previously held by the SCIM account is available again. putHandle user handle >>= assertSuccess - -- Only the active manual account remains; it owns both the email and handle. - users <- getUsersIdRaw domain [manualUserId, scid] >>= getJSON 200 >>= asList - rawUser <- assertOne users - rawUser %. "id" `shouldMatch` manualUserId - rawUser %. "email" `shouldMatch` email - rawUser %. "handle" `shouldMatch` handle - rawUser %. "managed_by" `shouldMatch` "wire" - rawUser %. "status" `shouldMatch` "active" - activated <- rawUser %. "activated" >>= asBool + -- The raw endpoint also returns the tombstone for the deleted SCIM account. + manualUser <- getUsersIdRaw domain [manualUserId] >>= getJSON 200 >>= asList >>= assertOne + manualUser %. "id" `shouldMatch` manualUserId + manualUser %. "email" `shouldMatch` email + manualUser %. "handle" `shouldMatch` handle + manualUser %. "managed_by" `shouldMatch` "wire" + manualUser %. "status" `shouldMatch` "active" + activated <- manualUser %. "activated" >>= asBool activated `shouldMatch` True + deletedScimUser <- getUsersIdRaw domain [scid] >>= getJSON 200 >>= asList >>= assertOne + deletedScimUser %. "id" `shouldMatch` scid + deletedScimUser %. "status" `shouldMatch` "deleted" + testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App () testTeamInvitationWhenScimInvitationPending = do (owner, _tid, _) <- createTeam OwnDomain 1 diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index 8cad56fa36..ceb89cb2e4 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -54,14 +54,14 @@ import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) import Wire.Sem.Random qualified as Random -import Wire.StoredUser (StoredUser (managedBy, status, teamId)) +import Wire.StoredUser (StoredUser (email, managedBy, status, teamId)) import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Error import Wire.TeamSubsystem import Wire.UserKeyStore import Wire.UserStore (UserStore) import Wire.UserStore qualified as UserStore -import Wire.UserSubsystem (UserSubsystem, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) +import Wire.UserSubsystem (UserSubsystem, getAccountNoFilter, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) data TeamInvitationSubsystemConfig = TeamInvitationSubsystemConfig { maxTeamSize :: Word32, @@ -92,6 +92,12 @@ runTeamInvitationSubsystem cfg = interpret $ \case InternalCreateInvitation tid mExpectedInvId role mbInviterUid inviterEmail invRequest -> runInputConst cfg $ createInvitation' tid mExpectedInvId role mbInviterUid inviterEmail invRequest +data ScimInvitationState + = ScimInvitationConflict + | ScimInvitationExpired UserId + | ScimInvitationStale UserId + deriving (Eq, Show) + inviteUserImpl :: ( Member (Error TeamInvitationSubsystemError) r, Member GalleyAPIAccess r, @@ -112,12 +118,11 @@ inviteUserImpl :: InvitationRequest -> Sem r (Invitation, InvitationLocation) inviteUserImpl luid tid request = do - guardPendingScimInvitation request.inviteeEmail - let inviteeRole = fromMaybe defaultRole request.role let inviteePerms = Teams.rolePermissions inviteeRole ensurePermissionToAddUser (tUnqualified luid) tid inviteePerms + reconcileScimInvitation request.inviteeEmail inviterEmail <- note TeamInvitationNoEmail =<< runMaybeT do @@ -138,25 +143,62 @@ inviteUserImpl luid tid request = do loc inv = InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId - guardPendingScimInvitation email = do - invitations <- Store.lookupInvitationsByEmail email - pendingScim <- or <$> traverse isPendingScimInvitation invitations - when pendingScim $ throw TeamInvitationEmailTaken - where - isPendingScimInvitation inv - | inv.teamId /= tid = pure False - | otherwise = do - -- The invitation store also contains ordinary team invitations, which do not - -- create a user until they are accepted. Check the user to distinguish those - -- invitations from a pending SCIM invitation, whose user already exists with - -- the invitation ID, managedBy = scim, and status = pending-invitation. - mUser <- UserStore.getUser (invitationIdToUserId inv.invitationId) - pure $ case mUser of - Just user -> - user.teamId == Just tid - && user.managedBy == Just ManagedByScim - && user.status == Just PendingInvitation - Nothing -> False + reconcileScimInvitation email = do + pendingScimUsers <- Store.lookupPendingScimUsers tid email + invitations <- + if null pendingScimUsers + then pure [] + else Store.lookupInvitationsByEmail email + invitationStates <- traverse (classifyScimUser email invitations) pendingScimUsers + + for_ invitationStates $ \case + ScimInvitationExpired uid -> cleanupExpiredScimUser email uid + ScimInvitationStale uid -> Store.deletePendingScimUser tid email uid + ScimInvitationConflict -> pure () + + when (ScimInvitationConflict `elem` invitationStates) $ + throw TeamInvitationEmailTaken + + classifyScimUser requestedEmail invitations uid = do + mStoredUser <- UserStore.getUser uid + case mStoredUser of + Nothing -> pure $ ScimInvitationStale uid + Just storedUser + | storedUser.teamId /= Just tid + || storedUser.email /= Just requestedEmail + || storedUser.managedBy /= Just ManagedByScim -> do + pure $ ScimInvitationStale uid + | otherwise -> + case storedUser.status of + Just PendingInvitation -> + if (not (any (invitationIsLive uid) invitations)) + then + -- Only a matching pending SCIM account can be cleaned + -- up when its invitation has expired. + pure $ ScimInvitationExpired uid + else + -- The SCIM invitation is still usable, so the existing + -- pending account must not be deleted or replaced. + pure ScimInvitationConflict + _ -> + -- An active SCIM account must continue to block a manual + -- invitation, even if the index was not removed on activation. + pure ScimInvitationConflict + + invitationIsLive uid inv = + inv.teamId == tid + && invitationIdToUserId inv.invitationId == uid + + cleanupExpiredScimUser requestedEmail uid = do + -- Delete the account synchronously so UserStore releases its handle before + -- the manual invitation is created. Keep the index entry if deletion fails. + mUser <- getAccountNoFilter (qualifyAs luid uid) + case mUser of + Nothing -> pure () + Just user -> do + UserStore.deleteUser user + deleteKeyForUser uid (mkEmailKey requestedEmail) + Store.deletePendingScimUser tid requestedEmail uid createInvitation' :: ( Member GalleyAPIAccess r, diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 7873ab84b3..d5cb2dfee6 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -1206,6 +1206,8 @@ acceptTeamInvitationImpl luid pw code = do unless added $ throw UserSubsystemTooManyTeamMembers updateUserTeam uid tid deleteInvitation inv.teamId inv.invitationId + for_ (userEmail . selfUser =<< mSelfProfile) $ \email -> + deletePendingScimUser tid email uid syncUserIndex uid generateUserEvent uid Nothing (teamUpdated uid tid) diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 7d554f97e0..be8d728187 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -631,6 +631,7 @@ createUserNoVerifySpar uData = deleteUserNoAuthH :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member TinyLog r, Member UserKeyStore r, diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 8b8d887043..8698543074 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -1511,6 +1511,7 @@ deleteSelfUser :: Member (Embed HttpClientIO) r, Member UserKeyStore r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member EmailSubsystem r, Member UserSubsystem r, @@ -1532,6 +1533,7 @@ deleteSelfUser lu body = do verifyDeleteUser :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member TinyLog r, Member UserKeyStore r, diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index f785d6513d..bbf1d2d86d 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -637,6 +637,7 @@ createUserInviteViaScim :: Member UserKeyStore r, Member UserStore r, Member UserSubsystem r, + Member InvitationStore r, Member (UserPendingActivationStore p) r, Member TinyLog r, Member (Input (Local ())) r @@ -657,7 +658,9 @@ createUserInviteViaScim (NewUserScimInvitation tid uid extId loc name email _) = pure $ addUTCTime (realToFrac ttl) now lift . liftSem $ UserPendingActivationStore.add (UserPendingActivation uid expiresAt) - lift . liftSem $ UserStore.createUser account Nothing + lift . liftSem $ do + UserStore.createUser account Nothing + InvitationStore.insertPendingScimUser tid email uid newStoredUserToUser . Qualified account <$> viewFederationDomain -- | docs/reference/user/registration.md {#RefRestrictRegistration}. @@ -1012,6 +1015,7 @@ deleteSelfUser :: Member (Embed HttpClientIO) r, Member UserKeyStore r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member EmailSubsystem r, Member VerificationCodeSubsystem r, @@ -1087,6 +1091,7 @@ deleteSelfUser luid@(tUnqualified -> uid) pwd = do verifyDeleteUser :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserKeyStore r, Member TinyLog r, Member UserStore r, @@ -1119,6 +1124,7 @@ ensureAccountDeleted :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member TinyLog r, + Member InvitationStore r, Member UserKeyStore r, Member UserStore r, Member Events r, @@ -1172,6 +1178,7 @@ deleteAccount :: Member UserKeyStore r, Member TinyLog r, Member UserStore r, + Member InvitationStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, @@ -1190,6 +1197,9 @@ deleteAccount user = do PropertySubsystem.onUserDeleted uid UserStore.deleteUser user + for_ (userEmail user) $ \email -> + for_ (userTeam user) $ \tid -> + InvitationStore.deletePendingScimUser tid email uid traverse_ (removeUserFromAllGroups uid) user.userTeam diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index af01888918..9d148ac70f 100644 --- a/services/brig/src/Brig/InternalEvent/Process.hs +++ b/services/brig/src/Brig/InternalEvent/Process.hs @@ -38,6 +38,7 @@ import Wire.API.UserEvent import Wire.AuthenticationSubsystem import Wire.ClientStore (ClientStore) import Wire.Events (Events) +import Wire.InvitationStore (InvitationStore) import Wire.NotificationSubsystem import Wire.PropertySubsystem import Wire.Sem.Concurrency @@ -59,6 +60,7 @@ onEvent :: Member (Input (Local ())) r, Member UserKeyStore r, Member UserStore r, + Member InvitationStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index 9f11eedd35..48430fbeba 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -146,6 +146,7 @@ createInvitationViaScim :: ( Member BlockListStore r, Member UserKeyStore r, Member UserStore r, + Member InvitationStore r, Member (UserPendingActivationStore p) r, Member TinyLog r, Member TeamInvitationSubsystem r, From 4c3d216102aaef20276f4f739d5f340e5587bffa Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 18:30:37 +0200 Subject: [PATCH 18/23] unit tests --- .../unit/Wire/MockInterpreters/UserStore.hs | 15 +- .../InterpreterSpec.hs | 331 +++++++++++++++++- 2 files changed, 339 insertions(+), 7 deletions(-) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 8cedb9a658..d30c854658 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs @@ -51,7 +51,16 @@ inMemoryUserStoreInterpreter :: Member (State (Map UserId Password)) r ) => InterpreterFor UserStore r -inMemoryUserStoreInterpreter = interpret $ \case +inMemoryUserStoreInterpreter = inMemoryUserStoreInterpreterWithDeleteHook (const $ pure ()) + +inMemoryUserStoreInterpreterWithDeleteHook :: + forall r. + ( Member (State [StoredUser]) r, + Member (State (Map UserId Password)) r + ) => + (UserId -> Sem r ()) -> + InterpreterFor UserStore r +inMemoryUserStoreInterpreterWithDeleteHook onDelete = interpret $ \case CreateUser new _ -> do modify (newStoredUserToStoredUser new :) forM_ new.password $ modify . Map.insert new.id @@ -127,7 +136,9 @@ inMemoryUserStoreInterpreter = interpret $ \case us <- get us' <- f us put us' - DeleteUser user -> modify @[StoredUser] $ filter (\u -> u.id /= User.userId user) + DeleteUser user -> do + onDelete (User.userId user) + modify @[StoredUser] $ filter (\u -> u.id /= User.userId user) LookupName uid -> (.name) <$$> gets @[StoredUser] (find $ \u -> u.id == uid) LookupHandle h -> lookupHandleImpl h GlimpseHandle h -> lookupHandleImpl h diff --git a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs index ea21fd7983..7de81bc367 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs @@ -41,9 +41,11 @@ import Test.QuickCheck import Wire.API.EnterpriseLogin import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Password (Password) import Wire.API.Team.Invitation import Wire.API.Team.Member import Wire.API.Team.Permission +import Wire.API.Team.Role (defaultRole) import Wire.API.User import Wire.EmailSubsystem import Wire.EnterpriseLoginSubsystem @@ -61,6 +63,7 @@ import Wire.TeamSubsystem import Wire.TeamSubsystem.GalleyAPI import Wire.UserKeyStore import Wire.UserStore (UserStore) +import Wire.UserStore qualified as UserStore import Wire.UserSubsystem import Wire.Util @@ -85,6 +88,7 @@ type AllEffects = State (Map EmailAddress [SentMail]), UserSubsystem, UserStore, + State [UserId], UserKeyStore ] @@ -95,11 +99,42 @@ data RunAllEffectsArgs = RunAllEffectsArgs } deriving (Eq, Show) +data InviteScenarioObservation = InviteScenarioObservation + { -- 'Nothing' means the manual invitation was created successfully. + invitationResult :: Maybe TeamInvitationSubsystemError, + -- User IDs passed to 'UserStore.DeleteUser' during reconciliation. + deletedUserIds :: [UserId], + -- The candidate user's record after reconciliation, if it still exists. + observedUser :: Maybe StoredUser, + -- User IDs still present in the pending SCIM index after reconciliation. + observedPendingScimUsers :: [UserId] + } + deriving (Eq, Show) + +data InviteScenarioInput = InviteScenarioInput + { invitationTeam :: TeamId, + inviter :: StoredUser, + otherUsers :: [StoredUser], + pendingScimUsers :: [(TeamId, EmailAddress, UserId)], + liveInvitations :: [InsertInvitation], + inviteeEmail :: EmailAddress, + observedUid :: UserId + } + deriving (Eq, Show) + runAllEffects :: RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a -runAllEffects args = +runAllEffects args = runAllEffectsWithUserKeys args.initialUsers args + +runAllEffectsWithUserKeys :: [StoredUser] -> RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a +runAllEffectsWithUserKeys initialUsers args = run - . runInMemoryUserKeyStoreIntepreterWithStoredUsers args.initialUsers - . runInMemoryUserStoreInterpreter args.initialUsers mempty + . runInMemoryUserKeyStoreIntepreterWithStoredUsers initialUsers + . evalState ([] :: [UserId]) + . evalState mempty + . evalState args.initialUsers + . inMemoryUserStoreInterpreterWithDeleteHook (\uid -> modify @[UserId] (uid :)) + . raiseUnder @(State [StoredUser]) + . raiseUnder @(State (Map UserId Password)) . inMemoryUserSubsystemInterpreter . evalState mempty . noopEmailSubsystemInterpreter @@ -118,6 +153,49 @@ runAllEffects args = . discardTinyLogs . enterpriseLoginSubsystemTestInterpreter args.constGuardResult +runInviteScenarioObserved :: + InviteScenarioInput -> + Either LocalErrors InviteScenarioObservation +runInviteScenarioObserved input = + runAllEffectsWithUserKeys [input.inviter] args . runTeamInvitationSubsystem config $ do + for_ input.liveInvitations $ \inv -> void $ insertInvitation inv 3_000_000 + for_ input.pendingScimUsers $ \(indexTeam, email, uid) -> + deleteKey (mkEmailKey email) >> insertPendingScimUser indexTeam email uid + result <- catch (inviteUser inviterLuid input.invitationTeam invitationRequest >> pure Nothing) (pure . Just) + deletedUsers <- get @[UserId] + observedUser <- UserStore.getUser input.observedUid + observedIndex <- lookupPendingScimUsers input.invitationTeam input.inviteeEmail + pure + InviteScenarioObservation + { invitationResult = result, + deletedUserIds = deletedUsers, + observedUser, + observedPendingScimUsers = observedIndex + } + where + inviterLuid = toLocalUnsafe testDomain input.inviter.id + inviterMember = mkTeamMember input.inviter.id fullPermissions Nothing UserLegalHoldDisabled + invitationRequest = + InvitationRequest + { locale = Nothing, + role = Nothing, + inviteeName = Nothing, + inviteeEmail = input.inviteeEmail, + allowExisting = False + } + config = + TeamInvitationSubsystemConfig + { maxTeamSize = 50, + teamInvitationTimeout = 3_000_000, + blockedDomains = HashSet.empty + } + args = + RunAllEffectsArgs + { teams = Map.singleton input.invitationTeam [inviterMember], + initialUsers = input.inviter : input.otherUsers, + constGuardResult = Nothing + } + data LocalErrors = ETeamMemberNotFound | ETeamNotFound @@ -140,8 +218,251 @@ runLocalErrors = fmap toLocalErrors . runError . runError . runError spec :: Spec spec = do - describe "InviteUser" $ do - prop "honors dommain config from `brig.domain_registration`" $ + focus $ describe "InviteUser" $ do + prop "rejects a manual invitation when a matching SCIM invitation is pending" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) + (code :: InvitationCode) -> + inviter0.id /= scimUser0.id ==> + let inviter :: StoredUser + inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } + + scimUser :: StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + emailUnvalidated = Nothing, + activated = False, + status = Just PendingInvitation, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } + + storedInvitation = + MkInsertInvitation + { invitationId = Id (toUUID scimUser.id), + teamId = tid, + role = defaultRole, + createdAt = defaultTime, + createdBy = Just inviter.id, + inviteeEmail = inviteeEmail, + inviteeName = Nothing, + code = code + } + + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [storedInvitation], + inviteeEmail, + observedUid = scimUser.id + } + in counterexample (show (inviter, scimUser, storedInvitation)) $ + outcome + === Right + InviteScenarioObservation + { invitationResult = Just TeamInvitationEmailTaken, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [scimUser.id] + } + + prop "allows a manual invitation after a matching SCIM invitation expired" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = False, + status = Just PendingInvitation, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [scimUser.id], + observedUser = Nothing, + observedPendingScimUsers = [] + } + + prop "rejects a manual invitation for an active SCIM account" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Just TeamInvitationEmailTaken, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [scimUser.id] + } + + prop "allows a manual invitation when the SCIM index entry is stale" $ + \(tid :: TeamId) + (inviter :: StoredUser) + (staleUid :: UserId) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter.id /= staleUid ==> + let preparedInviter = + inviter + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter = preparedInviter, + otherUsers = [], + pendingScimUsers = [(tid, inviteeEmail, staleUid)], + liveInvitations = [], + inviteeEmail, + observedUid = staleUid + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [], + observedUser = Nothing, + observedPendingScimUsers = [] + } + + prop "allows a manual invitation in another team despite a pending SCIM invitation" $ + \(scimTeam :: TeamId) + (manualTeam :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + scimTeam /= manualTeam && inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just manualTeam, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = False, + status = Just PendingInvitation, + teamId = Just scimTeam, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = manualTeam, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(scimTeam, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [] + } + + prop "honors domain config from `brig.domain_registration`" $ \(tid :: TeamId) (preDomRegUpd :: DomainRegistrationUpdate) (preInviter :: StoredUser) From 37c2b1b6e14cc0fd46c1825e06ff13ca31686f09 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 18:46:28 +0200 Subject: [PATCH 19/23] revove diagnostic internal endpoint and updated tests --- integration/test/API/BrigInternal.hs | 13 ++++--- integration/test/Test/Spar.hs | 19 ++++------ .../src/Wire/API/Routes/Internal/Brig.hs | 7 ---- libs/wire-api/src/Wire/API/User.hs | 35 ------------------- services/brig/src/Brig/API/Internal.hs | 25 ------------- 5 files changed, 15 insertions(+), 84 deletions(-) diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index bd9a7abf58..4407e1a043 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -76,10 +76,15 @@ getUsersId domain ids = do req <- baseRequest domain Brig Unversioned "/i/users" submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)] -getUsersIdRaw :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response -getUsersIdRaw domain ids = do - req <- baseRequest domain Brig Unversioned "/i/users/raw" - submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)] +getUsersIdIncludingPending :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response +getUsersIdIncludingPending domain ids = do + req <- baseRequest domain Brig Unversioned "/i/users" + submit "GET" $ + req + & addQueryParams + [ ("ids", intercalate "," ids), + ("includePendingInvitations", "true") + ] getUsersByEmail :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response getUsersByEmail domain emails = do diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index a71a3d9c47..0f941d5717 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -89,19 +89,16 @@ testTeamInvitationWhenScimInvitationExpired = do -- The handle previously held by the SCIM account is available again. putHandle user handle >>= assertSuccess - -- The raw endpoint also returns the tombstone for the deleted SCIM account. - manualUser <- getUsersIdRaw domain [manualUserId] >>= getJSON 200 >>= asList >>= assertOne + manualUser <- getUsersId domain [manualUserId] >>= getJSON 200 >>= asList >>= assertOne manualUser %. "id" `shouldMatch` manualUserId manualUser %. "email" `shouldMatch` email manualUser %. "handle" `shouldMatch` handle manualUser %. "managed_by" `shouldMatch` "wire" manualUser %. "status" `shouldMatch` "active" - activated <- manualUser %. "activated" >>= asBool - activated `shouldMatch` True - deletedScimUser <- getUsersIdRaw domain [scid] >>= getJSON 200 >>= asList >>= assertOne - deletedScimUser %. "id" `shouldMatch` scid - deletedScimUser %. "status" `shouldMatch` "deleted" + -- The regular internal users API filters deleted records, so it cannot + -- distinguish a deleted SCIM account from an account that is not found. + shouldBeEmpty $ getUsersId domain [scid] >>= getJSON 200 >>= asList testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App () testTeamInvitationWhenScimInvitationPending = do @@ -124,14 +121,12 @@ testTeamInvitationWhenScimInvitationPending = do -- pending SCIM invitation could be used for an email-registration DoS. postInvitation otherOwner (def {email = Just email}) >>= assertStatus 201 - users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList + users <- getUsersIdIncludingPending OwnDomain [scid] >>= getJSON 200 >>= asList user <- assertOne users user %. "email" `shouldMatch` email user %. "handle" `shouldMatch` handle user %. "managed_by" `shouldMatch` "scim" user %. "status" `shouldMatch` "pending-invitation" - activated <- user %. "activated" >>= asBool - activated `shouldMatch` True testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App () testTeamInvitationWhenScimAccountExists = do @@ -152,14 +147,12 @@ testTeamInvitationWhenScimAccountExists = do -- for the same email must therefore be rejected with a conflict. postInvitation owner (def {email = Just email}) >>= assertStatus 409 - users <- getUsersIdRaw OwnDomain [scid] >>= getJSON 200 >>= asList + users <- getUsersId OwnDomain [scid] >>= getJSON 200 >>= asList user <- assertOne users user %. "email" `shouldMatch` email user %. "handle" `shouldMatch` handle user %. "managed_by" `shouldMatch` "scim" user %. "status" `shouldMatch` "active" - activated <- user %. "activated" >>= asBool - activated `shouldMatch` True testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 6ce8efee2a..7ef9eead11 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -444,13 +444,6 @@ type AccountAPI = Bool :> Get '[Servant.JSON] [User] ) - :<|> Named - "iGetUsersRaw" - ( "users" - :> "raw" - :> QueryParam' [Required, Strict] "ids" (CommaSeparatedList UserId) - :> Get '[Servant.JSON] [RawUser] - ) :<|> Named "iGetUserContacts" ( "users" diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 43e3a99722..161040a456 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -36,7 +36,6 @@ module Wire.API.User SelfProfile (..), -- User (should not be here) User (..), - RawUser (..), UserType (..), isSamlUser, userId, @@ -728,40 +727,6 @@ userObjectSchema = <* (fromMaybe False <$> (\u -> if userDeleted u then Just True else Nothing) .= maybe_ (optField "deleted" schema)) <*> userSearchable .= (fromMaybe True <$> optField "searchable" schema) --- | Stored account data exposed by the diagnostic users endpoint. --- --- This intentionally keeps the storage-level activation flag separate from --- 'AccountStatus' and from the derived 'UserIdentity'. -data RawUser = RawUser - { rawUserId :: UserId, - rawUserName :: Name, - rawUserEmail :: Maybe EmailAddress, - rawUserEmailUnvalidated :: Maybe EmailAddress, - rawUserSSOId :: Maybe A.Value, - rawUserActivated :: Bool, - rawUserStatus :: Maybe AccountStatus, - rawUserHandle :: Maybe Handle, - rawUserTeamId :: Maybe TeamId, - rawUserManagedBy :: Maybe ManagedBy - } - deriving stock (Eq, Ord, Show, Generic) - deriving (ToJSON, FromJSON, S.ToSchema) via (Schema RawUser) - -instance ToSchema RawUser where - schema = - object $ - RawUser - <$> rawUserId .= field "id" schema - <*> rawUserName .= field "name" schema - <*> rawUserEmail .= maybe_ (optField "email" schema) - <*> rawUserEmailUnvalidated .= maybe_ (optField "email_unvalidated" schema) - <*> rawUserSSOId .= maybe_ (optField "sso_id" schema) - <*> rawUserActivated .= field "activated" schema - <*> rawUserStatus .= maybe_ (optField "status" schema) - <*> rawUserHandle .= maybe_ (optField "handle" schema) - <*> rawUserTeamId .= maybe_ (optField "team" schema) - <*> rawUserManagedBy .= maybe_ (optField "managed_by" schema) - userEmail :: User -> Maybe EmailAddress userEmail = emailIdentity <=< userIdentity diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index be8d728187..86f9f2e9b2 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -42,7 +42,6 @@ import Brig.User.Search.Index qualified as Search import Control.Error hiding (bool) import Control.Lens (preview, to, _Just) import Control.Lens.Extras (is) -import Data.Aeson qualified as A import Data.ByteString.Conversion (toByteString) import Data.Code qualified as Code import Data.CommaSeparatedList @@ -277,7 +276,6 @@ accountAPI = :<|> Named @"iPutUserStatus" changeAccountStatusH :<|> Named @"iGetUserStatus" getAccountStatusH :<|> Named @"iGetUsersByVariousKeys" listActivatedAccountsH - :<|> Named @"iGetUsersRaw" listUsersRawH :<|> Named @"iGetUserContacts" getContactListH :<|> Named @"iGetUserActivationCode" getActivationCode :<|> Named @"iGetUserPasswordResetCode" getPasswordResetCodeH @@ -765,29 +763,6 @@ listActivatedAccountsH } pure $ filter (\u -> u.userStatus /= Deleted) $ others <> byEmails --- | Diagnostic lookup of user records without the normal status, identity, or expired-invitation filtering. -listUsersRawH :: - (Member UserStore r) => - CommaSeparatedList UserId -> - Handler r [RawUser] -listUsersRawH (fromCommaSeparatedList -> uids) = - lift . liftSem $ catMaybes <$> traverse (fmap (fmap rawUserFromStored) . UserStore.getUser) uids - -rawUserFromStored :: StoredUser -> RawUser -rawUserFromStored user = - RawUser - { rawUserId = user.id, - rawUserName = user.name, - rawUserEmail = user.email, - rawUserEmailUnvalidated = user.emailUnvalidated, - rawUserSSOId = A.toJSON <$> user.ssoId, - rawUserActivated = user.activated, - rawUserStatus = user.status, - rawUserHandle = user.handle, - rawUserTeamId = user.teamId, - rawUserManagedBy = user.managedBy - } - getActivationCode :: ( Member ActivationCodeStore r, Member (Embed IO) r From eca3093771e7176ead53a858286b9c3e02e214f2 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 18:50:56 +0200 Subject: [PATCH 20/23] changelogs --- changelog.d/2-features/WPB-23177 | 1 + changelog.d/3-bug-fixes/WPB-23177 | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/2-features/WPB-23177 create mode 100644 changelog.d/3-bug-fixes/WPB-23177 diff --git a/changelog.d/2-features/WPB-23177 b/changelog.d/2-features/WPB-23177 new file mode 100644 index 0000000000..492ac9a01d --- /dev/null +++ b/changelog.d/2-features/WPB-23177 @@ -0,0 +1 @@ +Manual team invitations now conflict when a matching pending SCIM invitation already exists for the same team and email address. diff --git a/changelog.d/3-bug-fixes/WPB-23177 b/changelog.d/3-bug-fixes/WPB-23177 new file mode 100644 index 0000000000..b52e4bd670 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-23177 @@ -0,0 +1 @@ +Release a handle claimed after a SCIM invitation expired, before cleanup, preventing a subsequent team invitation from using that handle. From 0c07fa2dc2db0491f588419520c8dcc1ae98e67f Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 19:02:11 +0200 Subject: [PATCH 21/23] clean up --- .../src/Wire/TeamInvitationSubsystem/Interpreter.hs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index ceb89cb2e4..61d861146f 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -161,13 +161,13 @@ inviteUserImpl luid tid request = do classifyScimUser requestedEmail invitations uid = do mStoredUser <- UserStore.getUser uid - case mStoredUser of - Nothing -> pure $ ScimInvitationStale uid + pure $ case mStoredUser of + Nothing -> ScimInvitationStale uid Just storedUser | storedUser.teamId /= Just tid || storedUser.email /= Just requestedEmail || storedUser.managedBy /= Just ManagedByScim -> do - pure $ ScimInvitationStale uid + ScimInvitationStale uid | otherwise -> case storedUser.status of Just PendingInvitation -> @@ -175,15 +175,15 @@ inviteUserImpl luid tid request = do then -- Only a matching pending SCIM account can be cleaned -- up when its invitation has expired. - pure $ ScimInvitationExpired uid + ScimInvitationExpired uid else -- The SCIM invitation is still usable, so the existing -- pending account must not be deleted or replaced. - pure ScimInvitationConflict + ScimInvitationConflict _ -> -- An active SCIM account must continue to block a manual -- invitation, even if the index was not removed on activation. - pure ScimInvitationConflict + ScimInvitationConflict invitationIsLive uid inv = inv.teamId == tid From d794d4d33175a129300cceaeef65c86c0056963c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 4 Aug 2026 19:05:27 +0200 Subject: [PATCH 22/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs index 7de81bc367..ef2f85d765 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs @@ -218,7 +218,7 @@ runLocalErrors = fmap toLocalErrors . runError . runError . runError spec :: Spec spec = do - focus $ describe "InviteUser" $ do + describe "InviteUser" $ do prop "rejects a manual invitation when a matching SCIM invitation is pending" $ \(tid :: TeamId) (inviter0 :: StoredUser) From 6e227d682c71990df94d353c62992392b71bf391 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 5 Aug 2026 12:38:18 +0200 Subject: [PATCH 23/23] small clean up and added assertion in test --- integration/test/Test/Spar.hs | 7 +++++-- .../src/Wire/TeamInvitationSubsystem/Interpreter.hs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 0f941d5717..cd70ea77b2 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -26,7 +26,6 @@ import API.GalleyInternal (setTeamFeatureStatus) import qualified API.Nginz as Nginz import API.Spar import API.SparInternal -import Control.Concurrent (threadDelay) import Control.Lens (to, (^.)) import qualified Data.Aeson as A import qualified Data.Aeson.KeyMap as KeyMap @@ -44,6 +43,7 @@ import qualified SAML2.WebSSO.Test.MockResponse as SAML import qualified SAML2.WebSSO.Test.Util as SAML import qualified SAML2.WebSSO.XML as SAMLXML import SetupHelpers +import Testlib.Assertions import Testlib.JSON import Testlib.PTest import Testlib.Prelude @@ -75,8 +75,11 @@ testTeamInvitationWhenScimInvitationExpired = do scid <- createScimUser domain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString handle <- scimUser %. "userName" >>= asString + -- assert that the SCIM handle is claimed + putHandle owner handle >>= assertStatus 409 + -- Wait until the SCIM invitation has expired. - liftIO $ threadDelay 2_100_000 + eventually $ getInvitationByEmail domain email >>= assertStatus 404 -- Create and accept a manual team invitation for the same email. This is -- expected to succeed after the expired SCIM account has been cleaned up. diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index 61d861146f..82002248f6 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -166,7 +166,7 @@ inviteUserImpl luid tid request = do Just storedUser | storedUser.teamId /= Just tid || storedUser.email /= Just requestedEmail - || storedUser.managedBy /= Just ManagedByScim -> do + || storedUser.managedBy /= Just ManagedByScim -> ScimInvitationStale uid | otherwise -> case storedUser.status of