Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Send emails to team admins on app creation / update / deletion.
15 changes: 9 additions & 6 deletions libs/wire-subsystems/src/Wire/AppSubsystem.hs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ instance Default AppSubsystemConfig where
data AppSubsystemError
= AppSubsystemErrorNoPerm
| AppSubsystemErrorMissingAuth
| AppSubsystemErrorNoUser -- The user having created the app not found
| AppSubsystemErrorAppUserNotFound -- The user used to "enact" the app not found
| AppSubsystemErrorNoApp
| AppSubsystemErrorNoCreator
| AppSubsystemErrorNoAppData
| AppSubsystemErrorNoAppUser
| AppSubsystemErrorNoAppTeamMember
deriving (Eq, Show)

instance Exception AppSubsystemError
Expand All @@ -54,9 +55,11 @@ appSubsystemErrorToHttpError =
StdError . \case
AppSubsystemErrorNoPerm -> Wai.mkError status403 "app-no-permission" "User does not have permission to create or manage apps"
AppSubsystemErrorMissingAuth -> Wai.mkError status403 "missing-auth" "Re-authentication via password required"
AppSubsystemErrorNoUser -> Wai.mkError status403 "create-app-no-user" "App owner not found"
AppSubsystemErrorAppUserNotFound -> Wai.mkError status403 "app-user-not-found" "App user not found"
AppSubsystemErrorNoApp -> Wai.mkError status404 "app-not-found" "App not found"
-- TODO(fisx): announce these changes.
AppSubsystemErrorNoCreator -> Wai.mkError status403 "no-app-creator" "App owner not found"
AppSubsystemErrorNoAppData -> Wai.mkError status404 "app-not-found" "App not found (metadata record)"
AppSubsystemErrorNoAppUser -> Wai.mkError status404 "app-not-found" "App not found (user record)"
AppSubsystemErrorNoAppTeamMember -> Wai.mkError status404 "app-not-found" "App not found (team member record)"

data AppSubsystem m a where
CreateApp :: Local UserId -> TeamId -> NewApp -> AppSubsystem m CreatedApp
Expand Down
220 changes: 196 additions & 24 deletions libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

module Wire.AppSubsystem.Interpreter where

import Control.Lens
import Data.ByteString.Conversion
import Data.Default
import Data.Id
Expand Down Expand Up @@ -46,6 +47,8 @@ import Wire.AppSubsystem
import Wire.AuthenticationSubsystem
import Wire.AuthenticationSubsystem.Cookie (revokeAllCookies)
import Wire.AuthenticationSubsystem.ZAuth
import Wire.EmailSubsystem (AppEvent (..), EmailSubsystem)
import Wire.EmailSubsystem qualified as Email
import Wire.Events
import Wire.GalleyAPIAccess
import Wire.NotificationSubsystem
Expand All @@ -59,17 +62,18 @@ import Wire.UserStore qualified as Store
import Wire.UserSubsystem (UserSubsystem, internalUpdateSearchIndex)

runAppSubsystem ::
( Member UserStore r,
Member TinyLog r,
( Member AppStore r,
Member EmailSubsystem r,
Member (Error AppSubsystemError) r,
Member (Input AppSubsystemConfig) r,
Member Events r,
Member GalleyAPIAccess r,
Member AppStore r,
Member Now r,
Member TeamSubsystem r,
Member (Input AppSubsystemConfig) r,
Member NotificationSubsystem r,
Member Now r,
Member Random r,
Member Events r
Member TeamSubsystem r,
Member TinyLog r,
Member UserStore r
) =>
InterpreterFor UserSubsystem (AuthenticationSubsystem ': r) ->
InterpreterFor AuthenticationSubsystem r ->
Expand All @@ -95,6 +99,7 @@ createAppImpl ::
Member Now r,
Member TeamSubsystem r,
Member NotificationSubsystem r,
Member EmailSubsystem r,
Member AuthenticationSubsystem r,
Member UserSubsystem r,
Member Random r
Expand Down Expand Up @@ -134,6 +139,8 @@ createAppImpl lusr tid newApp = do
-- generate a team event
generateTeamEvents creator.id tid [EdMemberJoin u.id]

createAppSendEmail creator tid u.id newApp

c :: Cookie (Token U) <- newCookie u.id Nothing PersistentCookie Nothing RevokeSameLabel
pure
CreatedApp
Expand All @@ -145,24 +152,52 @@ createAppImpl lusr tid newApp = do
cookie = mkSomeToken c.cookieValue
}

-- | Everything needed solely to notify team admins that an app was created.
-- Values retrieved or computed by 'createAppImpl' and reused here are passed
-- in: @creator@ (the actor, from the permission check) and @appId@ (the newly
-- created app user's id).
createAppSendEmail ::
( Member TeamSubsystem r,
Member UserStore r,
Member (Error AppSubsystemError) r,
Member EmailSubsystem r
) =>
StoredUser ->
TeamId ->
UserId ->
NewApp ->
Sem r ()
createAppSendEmail creator tid appId newApp = do
appTeamMember <- internalGetTeamMember appId tid >>= note AppSubsystemErrorNoAppTeamMember
notifyAdmins tid appId $
NewAppCreated
{ actorName = Just creator.name,
actorEmail = creator.email,
newAppName = Just newApp.name,
appCreatedAt = memberToAppCreatedAt appTeamMember
}

memberToAppCreatedAt :: T.TeamMember -> Maybe UTCTimeMillis
memberToAppCreatedAt = (^? T.newTeamMember . T.nInvitation . _Just . _2)

-- | Check that @lusr@ is member of team with @tid@.
ensureTeamMember ::
( Member UserStore r,
Member (Error AppSubsystemError) r,
Member GalleyAPIAccess r
( Member TeamSubsystem r,
Member UserStore r,
Member (Error AppSubsystemError) r
) =>
Local UserId ->
TeamId ->
Sem r (StoredUser, T.TeamMember)
ensureTeamMember lusr tid = do
storedUser <- Store.getUser (tUnqualified lusr) >>= note AppSubsystemErrorNoUser
teamMember <- getTeamMember storedUser.id tid >>= note AppSubsystemErrorNoPerm
storedUser <- Store.getUser (tUnqualified lusr) >>= note AppSubsystemErrorNoCreator
teamMember <- internalGetTeamMember storedUser.id tid >>= note AppSubsystemErrorNoPerm
pure (storedUser, teamMember)

getAppImpl ::
( Member AppStore r,
Member TeamSubsystem r,
Member (Error AppSubsystemError) r,
Member GalleyAPIAccess r,
Member UserStore r
) =>
Local UserId ->
Expand All @@ -171,7 +206,7 @@ getAppImpl ::
Sem r AppInfo
getAppImpl lusr tid uid = do
void $ ensureTeamMember lusr tid
storedApp <- Store.getApp uid tid >>= note AppSubsystemErrorNoApp
storedApp <- Store.getApp uid tid >>= note AppSubsystemErrorNoAppData
pure $ storedAppToAppInfo storedApp

storedAppToAppInfo :: StoredApp -> AppInfo
Expand All @@ -183,8 +218,8 @@ storedAppToAppInfo app =

getAppsImpl ::
( Member AppStore r,
Member TeamSubsystem r,
Member (Error AppSubsystemError) r,
Member GalleyAPIAccess r,
Member UserStore r
) =>
Local UserId ->
Expand All @@ -198,21 +233,23 @@ updateAppImpl ::
( Member AppStore r,
Member (Error AppSubsystemError) r,
Member Events r,
Member GalleyAPIAccess r,
Member TeamSubsystem r,
Member UserStore r,
Member UserSubsystem r,
Member UserStore r
Member EmailSubsystem r
) =>
Local UserId ->
TeamId ->
UserId ->
PutApp ->
Sem r ()
updateAppImpl lusr tid appid upd = do
(_updater, umem) <- ensureTeamMember lusr tid
(updater, umem) <- ensureTeamMember lusr tid
note AppSubsystemErrorNoPerm $ guard (T.hasPermission umem T.CreateApp)
oldApp <- Store.getUser appid >>= note AppSubsystemErrorNoAppUser
Store.updateApp tid appid (Store.MkStoredAppUpdate {category = upd.category, description = upd.description}) >>= \case
Right () -> pure ()
Left Store.NotFound -> throw AppSubsystemErrorNoApp
Left Store.NotFound -> throw AppSubsystemErrorNoAppData
Store.updateUser appid (def {Store.name = upd.name, Store.assets = upd.assets, Store.accentId = upd.accentId})
internalUpdateSearchIndex appid
generateUserEvent appid Nothing $
Expand All @@ -222,13 +259,41 @@ updateAppImpl lusr tid appid upd = do
eupAccentId = upd.accentId,
eupAssets = upd.assets
}
updateAppSendEmail updater oldApp tid appid upd

updateAppSendEmail ::
( Member TeamSubsystem r,
Member (Error AppSubsystemError) r,
Member UserStore r,
Member EmailSubsystem r
) =>
StoredUser ->
StoredUser ->
TeamId ->
UserId ->
PutApp ->
Sem r ()
updateAppSendEmail updater oldApp tid appid upd = do
appTeamMember <- internalGetTeamMember appid tid >>= note AppSubsystemErrorNoAppTeamMember
let oldAppName = oldApp.name
newAppName = fromMaybe oldAppName upd.name
notifyAdmins tid appid $
DetailsChangedForApp
{ actorName = Just updater.name,
actorEmail = updater.email,
newAppName = Just newAppName,
appCreatedAt = memberToAppCreatedAt appTeamMember,
oldAppName = Just oldAppName
}

refreshAppCookieImpl ::
( Member AuthenticationSubsystem r,
Member AppStore r,
Member (Error RetryAfter) r,
Member (Error AppSubsystemError) r,
Member GalleyAPIAccess r
Member TeamSubsystem r,
Member UserStore r,
Member EmailSubsystem r
) =>
Local UserId ->
TeamId ->
Expand All @@ -239,16 +304,42 @@ refreshAppCookieImpl (tUnqualified -> uid) tid appId mbPassword = do
reauthenticateEither uid mbPassword
>>= either (const $ throw AppSubsystemErrorMissingAuth) (const $ pure ())

mem <- getTeamMember uid tid >>= note AppSubsystemErrorNoPerm
mem <- internalGetTeamMember uid tid >>= note AppSubsystemErrorNoPerm
note AppSubsystemErrorNoPerm $ guard (T.hasPermission mem T.ManageApps)
void $ Store.getApp appId tid >>= note AppSubsystemErrorNoApp
void $ Store.getApp appId tid >>= note AppSubsystemErrorNoAppData

revokeAllCookies appId
c :: Cookie (Token U) <-
newCookieLimited appId Nothing PersistentCookie Nothing RevokeSameLabel
>>= either throw pure
refreshAppCookieSendEmail mem uid tid appId
pure $ mkSomeToken c.cookieValue

-- | Everything needed solely to notify team admins that an app's token was
-- refreshed. Only the identifiers known to 'refreshAppCookieImpl' are passed
-- in: @actorId@ (the caller), @tid@ and @appId@.
refreshAppCookieSendEmail ::
( Member TeamSubsystem r,
Member UserStore r,
Member (Error AppSubsystemError) r,
Member EmailSubsystem r
) =>
T.TeamMember ->
UserId ->
TeamId ->
UserId ->
Sem r ()
refreshAppCookieSendEmail mem actorId tid appId = do
appUser <- Store.getUser appId >>= note AppSubsystemErrorNoAppUser
(actorName, actorEmail) <- appEventActor actorId
notifyAdmins tid appId $
AppTokenUpdated
{ actorName = actorName,
actorEmail = actorEmail,
newAppName = Just appUser.name,
appCreatedAt = memberToAppCreatedAt mem
}

appNewStoredUser ::
(Member (Input AppSubsystemConfig) r, Member Random r) =>
StoredUser ->
Expand Down Expand Up @@ -287,10 +378,91 @@ appNewStoredUser creator new = do
defAppSupportedProtocols :: Set BaseProtocolTag
defAppSupportedProtocols = Set.singleton BaseProtocolMLSTag

-- | Send the notification email for an 'AppEvent' to every team admin/owner.
-- Admins without an email address are silently skipped.
notifyAdmins ::
( Member TeamSubsystem r,
Member UserStore r,
Member EmailSubsystem r
) =>
TeamId ->
UserId ->
AppEvent ->
Sem r ()
notifyAdmins tid appId event = do
admins <- internalGetTeamAdmins tid
let adminUids = admins ^.. T.teamMembers . traverse . T.userId
adminUsers <- Store.getUsers adminUids
forM_ adminUsers $ \u ->
for_ u.email $ \email ->
Email.sendAppEventEmail email u.name tid appId event u.locale

-- | Best-effort actor identity for an 'AppEvent'.
appEventActor :: (Member UserStore r) => UserId -> Sem r (Maybe Name, Maybe EmailAddress)
appEventActor actorId = do
mu <- Store.getUser actorId
pure ((.name) <$> mu, (.email) =<< mu)

-- | TODO: no operation toggles app availability yet. This helper wires the
-- 'AppAvailabilityChanged' event end-to-end; call it from the availability
-- toggle once that operation exists.
notifyAppAvailabilityChanged ::
( Member TeamSubsystem r,
Member UserStore r,
Member EmailSubsystem r
) =>
TeamId ->
UserId ->
UserId ->
Sem r ()
notifyAppAvailabilityChanged tid appId actorId = do
appName <- (.name) <$$> Store.getUser appId
appCreatedAt <- internalGetTeamMember appId tid <&> (>>= memberToAppCreatedAt)
(actorName, actorEmail) <- appEventActor actorId
notifyAdmins tid appId $
AppAvailabilityChanged
{ actorName = actorName,
actorEmail = actorEmail,
newAppName = appName,
appCreatedAt = appCreatedAt
}

deleteAppImpl ::
(Member AppStore r) =>
( Member AppStore r,
Member UserStore r,
Member TeamSubsystem r,
Member EmailSubsystem r,
Member (Error AppSubsystemError) r
) =>
TeamId ->
UserId ->
Sem r ()
deleteAppImpl teamId appId =
deleteAppImpl teamId appId = do
storedApp <- Store.getApp appId teamId >>= note AppSubsystemErrorNoAppData
Store.deleteApp appId teamId
deleteAppSendEmail storedApp.creator teamId appId

-- | Everything needed solely to notify team admins that an app was deleted.
-- @actorId@ is resolved from the app record (which must be read before the app
-- is deleted) and passed in; the app user itself is not deleted, so its name
-- and the actor's details can still be looked up here.
deleteAppSendEmail ::
( Member TeamSubsystem r,
Member UserStore r,
Member EmailSubsystem r
) =>
UserId ->
TeamId ->
UserId ->
Sem r ()
deleteAppSendEmail actorId tid appId = do
appName <- (.name) <$$> Store.getUser appId
appCreatedAt <- internalGetTeamMember appId tid <&> (>>= memberToAppCreatedAt)
(actorName, actorEmail) <- appEventActor actorId
notifyAdmins tid appId $
AppDeleted
{ actorName = actorName,
actorEmail = actorEmail,
newAppName = appName,
appCreatedAt = appCreatedAt
}
Loading