diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 7d2e47e16e0..c79723f599c 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/changelog.d/2-features/WPB-23177 b/changelog.d/2-features/WPB-23177 new file mode 100644 index 00000000000..492ac9a01d9 --- /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 00000000000..b52e4bd670b --- /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. diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index 4a0051e8907..4407e1a043b 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -76,6 +76,16 @@ getUsersId domain ids = do req <- baseRequest domain Brig Unversioned "/i/users" 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 req <- baseRequest domain Brig Unversioned "/i/users" diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index fff111393d5..cd70ea77b27 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -43,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 @@ -52,6 +53,110 @@ import qualified Time.System as Hourglass ---------------------------------------------------------------------- -- scim stuff +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 $ \domain -> do + (owner, _tid, _) <- createTeam domain 1 + token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString + + -- 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 + + -- assert that the SCIM handle is claimed + putHandle owner handle >>= assertStatus 409 + + -- Wait until the SCIM invitation has expired. + 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. + 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 + + 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" + + -- 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 + (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. + 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. A second team invitation for the + -- 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 <- 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" + +testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App () +testTeamInvitationWhenScimAccountExists = do + (owner, tid, _) <- createTeam OwnDomain 1 + token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString + + -- Create a SCIM user and accept the resulting SCIM invitation below. + 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. A second team invitation + -- for the same email must therefore be rejected with a conflict. + postInvitation owner (def {email = Just email}) >>= assertStatus 409 + + 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" + testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do (owner, tid, _) <- createTeam OwnDomain 1 diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index a1c0aedf911..d9d7156640e 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/InvitationStore.hs b/libs/wire-subsystems/src/Wire/InvitationStore.hs index 54873c400f0..e875677cce5 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 e3ecf2d63bb..fbafe1e291a 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/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index d7e7b9e4682..82002248f68 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -54,11 +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 (email, managedBy, status, teamId)) import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Error import Wire.TeamSubsystem import Wire.UserKeyStore -import Wire.UserSubsystem (UserSubsystem, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) +import Wire.UserStore (UserStore) +import Wire.UserStore qualified as UserStore +import Wire.UserSubsystem (UserSubsystem, getAccountNoFilter, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) data TeamInvitationSubsystemConfig = TeamInvitationSubsystemConfig { maxTeamSize :: Word32, @@ -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, @@ -88,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, @@ -100,7 +110,8 @@ inviteUserImpl :: Member EmailSubsystem r, Member EnterpriseLoginSubsystem r, Member TeamSubsystem r, - Member UserKeyStore r + Member UserKeyStore r, + Member UserStore r ) => Local UserId -> TeamId -> @@ -111,6 +122,7 @@ inviteUserImpl luid tid request = do let inviteePerms = Teams.rolePermissions inviteeRole ensurePermissionToAddUser (tUnqualified luid) tid inviteePerms + reconcileScimInvitation request.inviteeEmail inviterEmail <- note TeamInvitationNoEmail =<< runMaybeT do @@ -131,6 +143,63 @@ inviteUserImpl luid tid request = do loc inv = InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId + 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 + pure $ case mStoredUser of + Nothing -> ScimInvitationStale uid + Just storedUser + | storedUser.teamId /= Just tid + || storedUser.email /= Just requestedEmail + || storedUser.managedBy /= Just ManagedByScim -> + 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. + ScimInvitationExpired uid + else + -- The SCIM invitation is still usable, so the existing + -- pending account must not be deleted or replaced. + ScimInvitationConflict + _ -> + -- An active SCIM account must continue to block a manual + -- invitation, even if the index was not removed on activation. + 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, Member UserSubsystem r, diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 7873ab84b3e..d5cb2dfee62 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/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 1aa9f8bdf01..1324d919db3 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 0f7b6f7aec7..7bc540116f4 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/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 8cedb9a6584..d30c8546589 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 ce135460d02..ef2f85d7657 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 @@ -75,6 +78,7 @@ type AllEffects = UserKeyStore, State (Map (TeamId, InvitationId) StoredInvitation), State (Map (InvitationCode) StoredInvitation), + State (Map (TeamId, EmailAddress) [UserId]), Now, State UTCTime, Error TeamInvitationSubsystemError, @@ -84,6 +88,7 @@ type AllEffects = State (Map EmailAddress [SentMail]), UserSubsystem, UserStore, + State [UserId], UserKeyStore ] @@ -94,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 @@ -107,6 +143,7 @@ runAllEffects args = . interpretNowAsState . evalState mempty . evalState mempty + . evalState mempty . (evalState mempty . inMemoryUserKeyStoreInterpreter . raiseUnder) . inMemoryInvitationStoreInterpreter . evalState (mkStdGen 3) @@ -116,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 @@ -139,7 +219,250 @@ runLocalErrors = fmap toLocalErrors . runError . runError . runError spec :: Spec spec = do describe "InviteUser" $ do - prop "honors dommain config from `brig.domain_registration`" $ + 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) diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index ecb51c1d058..e741240792f 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/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 9967a35dfb7..86f9f2e9b2d 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -129,7 +129,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 @@ -629,6 +629,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 8b8d887043c..86985430749 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 f785d6513d0..bbf1d2d86d1 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/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 3a36c4c3054..fe176284211 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/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index af018889184..9d148ac70ff 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/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs index bef0e82ce37..560cf64f2e7 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 00000000000..bca1d4ea904 --- /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) + ) + |] diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index 323b260960b..48430fbebab 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 @@ -147,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,