Skip to content

Commit 7a5e718

Browse files
supersvenfisx
andauthored
Make deletions via SCIM more stable (#2637)
Cassandra doesn't support transactions. Thus, in rare circumstances, a user could be only partially deleted in brig (e.g. due to the pod shutting down). To be able to clean up a partially deleted user/account, the SCIM user deletion handler now executes the internal deletion function in brig again even if the user is not found in brig as it's only a "tombstone". This internal deletion function then figures out if the user ever existed and if there are any left overs. In case, deletion is executed for the user/account again. To gather the result of a user deletion, the brig endpoint is now synchronous (was asynchronous before). Co-authored-by: Matthias Fischmann <[email protected]>
1 parent ca31215 commit 7a5e718

File tree

26 files changed

+481
-93
lines changed

26 files changed

+481
-93
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SCIM user deletion suffered from a couple of race conditions. The user in now first deleted in spar, because this process depends on data from brig. Then, the user is deleted in brig. If any error occurs, the SCIM deletion request can be made again. This change depends on brig being completely deployed before using the SCIM deletion endpoint in brig. In the unlikely event of using SCIM deletion during the deployment, these requests can be retried (in case of error).

libs/wire-api/src/Wire/API/Connection.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ data Relation
168168
| Cancelled
169169
| -- | behaves like blocked, the extra constructor is just to inform why.
170170
MissingLegalholdConsent
171-
deriving stock (Eq, Ord, Show, Generic)
171+
deriving stock (Bounded, Enum, Eq, Ord, Show, Generic)
172172
deriving (Arbitrary) via (GenericUniform Relation)
173173
deriving (FromJSON, ToJSON, S.ToSchema) via (Schema Relation)
174174

libs/wire-api/src/Wire/API/User.hs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ module Wire.API.User
9595
VerifyDeleteUser (..),
9696
mkVerifyDeleteUser,
9797
DeletionCodeTimeout (..),
98+
DeleteUserResult (..),
9899

99100
-- * List Users
100101
ListUsersQuery (..),
@@ -1356,6 +1357,16 @@ instance FromJSON DeletionCodeTimeout where
13561357
parseJSON = A.withObject "DeletionCodeTimeout" $ \o ->
13571358
DeletionCodeTimeout <$> o A..: "expires_in"
13581359

1360+
-- | Result of an internal user/account deletion
1361+
data DeleteUserResult
1362+
= -- | User never existed
1363+
NoUser
1364+
| -- | User/account was deleted before
1365+
AccountAlreadyDeleted
1366+
| -- | User/account was deleted in this call
1367+
AccountDeleted
1368+
deriving (Eq, Show)
1369+
13591370
data ListUsersQuery
13601371
= ListUsersByIds [Qualified UserId]
13611372
| ListUsersByHandles (Range 1 4 [Qualified Handle])

services/brig/brig.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ library
204204
, errors >=1.4
205205
, exceptions >=0.5
206206
, extended
207+
, extra
207208
, file-embed
208209
, file-embed-lzma
209210
, filepath >=1.3

services/brig/src/Brig/API/Internal.hs

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ import qualified Brig.Data.MLS.KeyPackage as Data
4242
import qualified Brig.Data.User as Data
4343
import Brig.Effects.BlacklistPhonePrefixStore (BlacklistPhonePrefixStore)
4444
import Brig.Effects.BlacklistStore (BlacklistStore)
45-
import qualified Brig.IO.Intra as Intra
46-
import Brig.Options hiding (internalEvents, sesQueue)
47-
import qualified Brig.Provider.API as Provider
4845
import Brig.Effects.CodeStore (CodeStore)
4946
import Brig.Effects.PasswordResetStore (PasswordResetStore)
5047
import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
48+
import qualified Brig.IO.Intra as Intra
49+
import Brig.Options hiding (internalEvents, sesQueue)
50+
import qualified Brig.Provider.API as Provider
5151
import qualified Brig.Team.API as Team
5252
import Brig.Team.DB (lookupInvitationByEmail)
5353
import Brig.Types.Connection
@@ -286,7 +286,7 @@ sitemap = do
286286
-- This endpoint will lead to the following events being sent:
287287
-- - UserDeleted event to all of its contacts
288288
-- - MemberLeave event to members for all conversations the user was in (via galley)
289-
delete "/i/users/:uid" (continue deleteUserNoVerifyH) $
289+
delete "/i/users/:uid" (continue deleteUserNoAuthH) $
290290
capture "uid"
291291

292292
put "/i/connections/connection-update" (continue updateConnectionInternalH) $
@@ -508,16 +508,13 @@ createUserNoVerifySpar uData =
508508
in API.activate key code (Just uid) !>> CreateUserSparRegistrationError . activationErrorToRegisterError
509509
pure . SelfProfile $ usr
510510

511-
deleteUserNoVerifyH :: UserId -> (Handler r) Response
512-
deleteUserNoVerifyH uid = do
513-
setStatus status202 empty <$ deleteUserNoVerify uid
514-
515-
deleteUserNoVerify :: UserId -> (Handler r) ()
516-
deleteUserNoVerify uid = do
517-
void $
518-
lift (wrapClient $ API.lookupAccount uid)
519-
>>= ifNothing (errorToWai @'E.UserNotFound)
520-
lift $ API.deleteUserNoVerify uid
511+
deleteUserNoAuthH :: UserId -> (Handler r) Response
512+
deleteUserNoAuthH uid = do
513+
r <- lift $ wrapHttp $ API.ensureAccountDeleted uid
514+
case r of
515+
NoUser -> throwStd (errorToWai @'E.UserNotFound)
516+
AccountAlreadyDeleted -> pure $ setStatus ok200 empty
517+
AccountDeleted -> pure $ setStatus accepted202 empty
521518

522519
changeSelfEmailMaybeSendH :: Member BlacklistStore r => UserId ::: Bool ::: JsonRequest EmailUpdate -> (Handler r) Response
523520
changeSelfEmailMaybeSendH (u ::: validate ::: req) = do
@@ -796,8 +793,3 @@ getContactListH :: JSON ::: UserId -> (Handler r) Response
796793
getContactListH (_ ::: uid) = do
797794
contacts <- lift . wrapClient $ API.lookupContactList uid
798795
pure $ json $ UserIds contacts
799-
800-
-- Utilities
801-
802-
ifNothing :: Utilities.Error -> Maybe a -> (Handler r) a
803-
ifNothing e = maybe (throwStd e) pure

services/brig/src/Brig/API/Public.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ import qualified Brig.Data.User as Data
4646
import qualified Brig.Data.UserKey as UserKey
4747
import Brig.Effects.BlacklistPhonePrefixStore (BlacklistPhonePrefixStore)
4848
import Brig.Effects.BlacklistStore (BlacklistStore)
49-
import qualified Brig.IO.Intra as Intra
50-
import Brig.Options hiding (internalEvents, sesQueue)
51-
import qualified Brig.Provider.API as Provider
5249
import Brig.Effects.CodeStore (CodeStore)
5350
import Brig.Effects.PasswordResetStore (PasswordResetStore)
5451
import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
52+
import qualified Brig.IO.Intra as Intra
53+
import Brig.Options hiding (internalEvents, sesQueue)
54+
import qualified Brig.Provider.API as Provider
5555
import qualified Brig.Team.API as Team
5656
import qualified Brig.Team.Email as Team
5757
import Brig.Types.Activation (ActivationPair)

services/brig/src/Brig/API/User.hs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ module Brig.API.User
5454
deleteUsersNoVerify,
5555
deleteSelfUser,
5656
verifyDeleteUser,
57+
ensureAccountDeleted,
5758
deleteAccount,
5859
checkHandles,
5960
isBlacklistedHandle,
@@ -100,6 +101,7 @@ import qualified Brig.Code as Code
100101
import Brig.Data.Activation (ActivationEvent (..), activationErrorToRegisterError)
101102
import qualified Brig.Data.Activation as Data
102103
import qualified Brig.Data.Client as Data
104+
import Brig.Data.Connection (countConnections)
103105
import qualified Brig.Data.Connection as Data
104106
import qualified Brig.Data.Properties as Data
105107
import Brig.Data.User
@@ -110,25 +112,25 @@ import Brig.Effects.BlacklistPhonePrefixStore (BlacklistPhonePrefixStore)
110112
import qualified Brig.Effects.BlacklistPhonePrefixStore as BlacklistPhonePrefixStore
111113
import Brig.Effects.BlacklistStore (BlacklistStore)
112114
import qualified Brig.Effects.BlacklistStore as BlacklistStore
113-
import qualified Brig.Federation.Client as Federation
114-
import qualified Brig.IO.Intra as Intra
115-
import qualified Brig.InternalEvent.Types as Internal
116-
import Brig.Options hiding (Timeout, internalEvents)
117-
import Brig.Password
118-
import qualified Brig.Queue as Queue
119115
import Brig.Effects.CodeStore (CodeStore)
120116
import qualified Brig.Effects.CodeStore as E
121117
import Brig.Effects.PasswordResetStore (PasswordResetStore)
122118
import qualified Brig.Effects.PasswordResetStore as E
123119
import Brig.Effects.UserPendingActivationStore (UserPendingActivation (..), UserPendingActivationStore)
124120
import qualified Brig.Effects.UserPendingActivationStore as UserPendingActivationStore
121+
import qualified Brig.Federation.Client as Federation
122+
import qualified Brig.IO.Intra as Intra
123+
import qualified Brig.InternalEvent.Types as Internal
124+
import Brig.Options hiding (Timeout, internalEvents)
125+
import Brig.Password
126+
import qualified Brig.Queue as Queue
125127
import qualified Brig.Team.DB as Team
126128
import Brig.Types.Activation (ActivationPair)
127129
import Brig.Types.Connection
128130
import Brig.Types.Intra
129131
import Brig.Types.User (HavePendingInvitations (..), ManagedByUpdate (..), PasswordResetPair)
130132
import Brig.Types.User.Event
131-
import Brig.User.Auth.Cookie (revokeAllCookies)
133+
import Brig.User.Auth.Cookie (listCookies, revokeAllCookies)
132134
import Brig.User.Email
133135
import Brig.User.Handle
134136
import Brig.User.Handle.Blacklist
@@ -147,6 +149,7 @@ import Data.Handle (Handle (fromHandle), parseHandle)
147149
import Data.Id as Id
148150
import Data.Json.Util
149151
import Data.LegalHold (UserLegalHoldStatus (..), defUserLegalHoldStatus)
152+
import Data.List.Extra
150153
import Data.List1 as List1 (List1, singleton)
151154
import qualified Data.Map.Strict as Map
152155
import qualified Data.Metrics as Metrics
@@ -1230,10 +1233,57 @@ verifyDeleteUser d = do
12301233
for_ account $ lift . wrapHttpClient . deleteAccount
12311234
lift . wrapClient $ Code.delete key Code.AccountDeletion
12321235

1233-
-- | Internal deletion without validation. Called via @delete /i/user/:uid@, or indirectly
1234-
-- via deleting self.
1235-
-- Team owners can be deleted if the team is not orphaned, i.e. there is at least one
1236-
-- other owner left.
1236+
-- | Check if `deleteAccount` succeeded and run it again if needed.
1237+
-- Called via @delete /i/user/:uid@.
1238+
ensureAccountDeleted ::
1239+
( MonadLogger m,
1240+
MonadCatch m,
1241+
MonadThrow m,
1242+
MonadIndexIO m,
1243+
MonadReader Env m,
1244+
MonadIO m,
1245+
MonadMask m,
1246+
MonadHttp m,
1247+
HasRequestId m,
1248+
MonadUnliftIO m,
1249+
MonadClient m,
1250+
MonadReader Env m
1251+
) =>
1252+
UserId ->
1253+
m DeleteUserResult
1254+
ensureAccountDeleted uid = do
1255+
mbAcc <- lookupAccount uid
1256+
case mbAcc of
1257+
Nothing -> pure NoUser
1258+
Just acc -> do
1259+
probs <- Data.lookupPropertyKeysAndValues uid
1260+
1261+
let accIsDeleted = accountStatus acc == Deleted
1262+
clients <- Data.lookupClients uid
1263+
1264+
localUid <- qualifyLocal uid
1265+
conCount <- countConnections localUid [(minBound @Relation) .. maxBound]
1266+
cookies <- listCookies uid []
1267+
1268+
if notNull probs
1269+
|| not accIsDeleted
1270+
|| notNull clients
1271+
|| conCount > 0
1272+
|| notNull cookies
1273+
then do
1274+
deleteAccount acc
1275+
pure AccountDeleted
1276+
else pure AccountAlreadyDeleted
1277+
1278+
-- | Internal deletion without validation.
1279+
--
1280+
-- Called via @delete /i/user/:uid@ (through `ensureAccountDeleted`), or
1281+
-- indirectly via deleting self. Team owners can be deleted if the team is not
1282+
-- orphaned, i.e. there is at least one other owner left.
1283+
--
1284+
-- N.B.: As Cassandra doesn't support transactions, the order of database
1285+
-- statements matters! Other functions reason upon some states to imply other
1286+
-- states. Please change this order only with care!
12371287
deleteAccount ::
12381288
( MonadLogger m,
12391289
MonadIndexIO m,
@@ -1250,8 +1300,8 @@ deleteAccount account@(accountUser -> user) = do
12501300
let uid = userId user
12511301
Log.info $ field "user" (toByteString uid) . msg (val "Deleting account")
12521302
-- Free unique keys
1253-
for_ (userEmail user) $ deleteKey . userEmailKey
1254-
for_ (userPhone user) $ deleteKey . userPhoneKey
1303+
for_ (userEmail user) $ deleteKeyForUser uid . userEmailKey
1304+
for_ (userPhone user) $ deleteKeyForUser uid . userPhoneKey
12551305
for_ (userHandle user) $ freeHandle (userId user)
12561306
-- Wipe data
12571307
Data.clearProperties uid

services/brig/src/Brig/Data/Activation.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ where
3434
import Brig.App (Env)
3535
import Brig.Data.User
3636
import Brig.Data.UserKey
37-
import Brig.Options
3837
import qualified Brig.Effects.CodeStore as E
3938
import Brig.Effects.CodeStore.Cassandra
39+
import Brig.Options
4040
import Brig.Types.Intra
4141
import Cassandra
4242
import Control.Error

services/brig/src/Brig/Data/UserKey.hs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ module Brig.Data.UserKey
2929
keyAvailable,
3030
lookupKey,
3131
deleteKey,
32+
deleteKeyForUser,
3233
lookupPhoneHashes,
3334
)
3435
where
@@ -164,6 +165,21 @@ deleteKey k = do
164165
retry x5 $ write deleteHashed (params LocalQuorum (Identity hk))
165166
retry x5 $ write keyDelete (params LocalQuorum (Identity $ keyText k))
166167

168+
-- | Delete `UserKey` for `UserId`
169+
--
170+
-- This function ensures that keys of other users aren't accidentally deleted.
171+
-- E.g. the email address or phone number of a partially deleted user could
172+
-- already belong to a new user. To not interrupt deletion flows (that may be
173+
-- executed several times due to cassandra not supporting transactions)
174+
-- `deleteKeyForUser` does not fail for missing keys or keys that belong to
175+
-- another user: It always returns `()` as result.
176+
deleteKeyForUser :: (MonadClient m, MonadReader Env m) => UserId -> UserKey -> m ()
177+
deleteKeyForUser uid k = do
178+
mbKeyUid <- lookupKey k
179+
case mbKeyUid of
180+
Just keyUid | keyUid == uid -> deleteKey k
181+
_ -> pure ()
182+
167183
hashKey :: MonadReader Env m => UserKey -> m UserKeyHash
168184
hashKey uk = do
169185
d <- view digestSHA256

services/brig/src/Brig/Run.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ import qualified Brig.AWS.SesNotification as SesNotification
3636
import Brig.App
3737
import qualified Brig.Calling as Calling
3838
import Brig.CanonicalInterpreter
39+
import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore)
40+
import qualified Brig.Effects.UserPendingActivationStore as UsersPendingActivationStore
3941
import qualified Brig.InternalEvent.Process as Internal
4042
import Brig.Options hiding (internalEvents, sesQueue)
4143
import qualified Brig.Queue as Queue
42-
import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore)
43-
import qualified Brig.Effects.UserPendingActivationStore as UsersPendingActivationStore
4444
import Brig.Types.Intra (AccountStatus (PendingInvitation))
4545
import Brig.Version
4646
import qualified Control.Concurrent.Async as Async

0 commit comments

Comments
 (0)