diff --git a/cabal.project b/cabal.project index ed3bbc74931..61843de1a3a 100644 --- a/cabal.project +++ b/cabal.project @@ -50,6 +50,7 @@ packages: , tools/db/move-team/ , tools/db/phone-users/ , tools/db/repair-handles/ + , tools/db/team-info/ , tools/db/repair-brig-clients-table/ , tools/db/service-backfill/ , tools/fedcalls/ diff --git a/changelog.d/5-internal/WPB-11301-db-tool-team-info b/changelog.d/5-internal/WPB-11301-db-tool-team-info new file mode 100644 index 00000000000..e1cda09aa88 --- /dev/null +++ b/changelog.d/5-internal/WPB-11301-db-tool-team-info @@ -0,0 +1 @@ +tools/db/team-info: collects last login times of all team members \ No newline at end of file diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix index 38c381258a4..5a7d488c79a 100644 --- a/nix/local-haskell-packages.nix +++ b/nix/local-haskell-packages.nix @@ -53,6 +53,7 @@ repair-brig-clients-table = hself.callPackage ../tools/db/repair-brig-clients-table/default.nix { inherit gitignoreSource; }; repair-handles = hself.callPackage ../tools/db/repair-handles/default.nix { inherit gitignoreSource; }; service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { inherit gitignoreSource; }; + team-info = hself.callPackage ../tools/db/team-info/default.nix { inherit gitignoreSource; }; fedcalls = hself.callPackage ../tools/fedcalls/default.nix { inherit gitignoreSource; }; mlsstats = hself.callPackage ../tools/mlsstats/default.nix { inherit gitignoreSource; }; rabbitmq-consumer = hself.callPackage ../tools/rabbitmq-consumer/default.nix { inherit gitignoreSource; }; diff --git a/nix/wire-server.nix b/nix/wire-server.nix index 9f3eed3d4e4..bf1593940f4 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -86,6 +86,7 @@ let integration = [ "integration" ]; rabbitmq-consumer = [ "rabbitmq-consumer" ]; test-stats = [ "test-stats" ]; + team-info = [ "team-info" ]; }; inherit (lib) attrsets; diff --git a/tools/db/team-info/.ormolu b/tools/db/team-info/.ormolu new file mode 120000 index 00000000000..ffc2ca9745e --- /dev/null +++ b/tools/db/team-info/.ormolu @@ -0,0 +1 @@ +../../../.ormolu \ No newline at end of file diff --git a/tools/db/team-info/README.md b/tools/db/team-info/README.md new file mode 100644 index 00000000000..81961bae4de --- /dev/null +++ b/tools/db/team-info/README.md @@ -0,0 +1,48 @@ +# Team info + +This program scans brig's and galley's cassandra for members of a team, their clients, and those clients' last access times. + +Useful for finding out which accounts you don't want to pay license fees any more. + +Example usage: + +```shell +team-info \ + --brig-cassandra-port 9048 --brig-cassandra-keyspace brig \ + --galley-cassandra-port 9049 --galley-cassandra-keyspace galley \ + --team-id=904912aa-7c10-11ef-9c85-8bfd758593f6 +``` + +Display usage: + +```shell +team-info -h +``` + +```text +team-info + +Usage: team-info [--brig-cassandra-host HOST] [--brig-cassandra-port PORT] + [--brig-cassandra-keyspace STRING] + [--galley-cassandra-host HOST] [--galley-cassandra-port PORT] + [--galley-cassandra-keyspace STRING] (-t|--team-id ID) + + get team info + +Available options: + -h,--help Show this help text + --brig-cassandra-host HOST + Cassandra Host for brig (default: "localhost") + --brig-cassandra-port PORT + Cassandra Port for brig (default: 9042) + --brig-cassandra-keyspace STRING + Cassandra Keyspace for brig (default: "brig_test") + --galley-cassandra-host HOST + Cassandra Host for galley (default: "localhost") + --galley-cassandra-port PORT + Cassandra Port for galley (default: 9043) + --galley-cassandra-keyspace STRING + Cassandra Keyspace for galley + (default: "galley_test") + -t,--team-id ID Team ID +``` diff --git a/tools/db/team-info/app/Main.hs b/tools/db/team-info/app/Main.hs new file mode 100644 index 00000000000..46b640ea4b6 --- /dev/null +++ b/tools/db/team-info/app/Main.hs @@ -0,0 +1,23 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2024 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 Main where + +import qualified TeamInfo.Lib as Lib + +main :: IO () +main = Lib.main diff --git a/tools/db/team-info/default.nix b/tools/db/team-info/default.nix new file mode 100644 index 00000000000..d939d1c1fe4 --- /dev/null +++ b/tools/db/team-info/default.nix @@ -0,0 +1,40 @@ +# WARNING: GENERATED FILE, DO NOT EDIT. +# This file is generated by running hack/bin/generate-local-nix-packages.sh and +# must be regenerated whenever local packages are added or removed, or +# dependencies are added or removed. +{ mkDerivation +, base +, cassandra-util +, conduit +, cql +, gitignoreSource +, imports +, lens +, lib +, optparse-applicative +, time +, tinylog +, types-common +}: +mkDerivation { + pname = "team-info"; + version = "1.0.0"; + src = gitignoreSource ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + cassandra-util + conduit + cql + imports + lens + optparse-applicative + time + tinylog + types-common + ]; + executableHaskellDepends = [ base ]; + description = "get team info from cassandra"; + license = lib.licenses.agpl3Only; + mainProgram = "team-info"; +} diff --git a/tools/db/team-info/src/TeamInfo/Lib.hs b/tools/db/team-info/src/TeamInfo/Lib.hs new file mode 100644 index 00000000000..f68fa9fa153 --- /dev/null +++ b/tools/db/team-info/src/TeamInfo/Lib.hs @@ -0,0 +1,93 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2024 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 TeamInfo.Lib where + +import Cassandra as C +import Cassandra.Settings as C +import Data.Conduit +import qualified Data.Conduit.Combinators as Conduit +import qualified Data.Conduit.List as CL +import Data.Id (TeamId, UserId) +import Data.Time +import qualified Database.CQL.Protocol as CQL +import Imports +import Options.Applicative +import qualified System.Logger as Log +import TeamInfo.Types + +lookupClientsLastActiveTimestamps :: ClientState -> UserId -> IO [Maybe UTCTime] +lookupClientsLastActiveTimestamps client u = do + runClient client $ runIdentity <$$> retry x1 (query selectClients (params One (Identity u))) + where + selectClients :: PrepQuery R (Identity UserId) (Identity (Maybe UTCTime)) + selectClients = "SELECT last_active from clients where user = ?" + +selectTeamMembers :: ClientState -> TeamId -> ConduitM () [TeamMemberRow] IO () +selectTeamMembers client teamId = + transPipe (runClient client) (paginateC cql (paramsP One (Identity teamId) 1000) x5) + .| Conduit.map (fmap CQL.asRecord) + where + cql :: C.PrepQuery C.R (Identity TeamId) (CQL.TupleType TeamMemberRow) + cql = + "SELECT user, legalhold_status FROM team_member WHERE team = ?" + +lookUpActivity :: ClientState -> TeamMemberRow -> IO TeamMember +lookUpActivity brigClient tmr = do + lastActiveTimestamps <- catMaybes <$> lookupClientsLastActiveTimestamps brigClient tmr.id + if null lastActiveTimestamps + then do + pure $ TeamMember tmr.id tmr.legalhold Nothing + else do + let lastActive = maximum lastActiveTimestamps + pure $ TeamMember tmr.id tmr.legalhold (Just lastActive) + +process :: TeamId -> ClientState -> ClientState -> IO [TeamMember] +process teamId brigClient galleyClient = + runConduit + $ selectTeamMembers galleyClient teamId + .| Conduit.concat + .| Conduit.mapM (lookUpActivity brigClient) + .| CL.consume + +main :: IO () +main = do + opts <- execParser (info (helper <*> optsParser) desc) + logger <- initLogger + brigClient <- initCas opts.brigDb logger + galleyClient <- initCas opts.galleyDb logger + teamMembers <- process opts.teamId brigClient galleyClient + for_ teamMembers $ \tm -> Log.info logger $ Log.msg (show tm) + where + initLogger = + Log.new + . Log.setLogLevel Log.Info + . Log.setOutput Log.StdOut + . Log.setFormat Nothing + . Log.setBufSize 0 + $ Log.defSettings + initCas settings l = + C.init + . C.setLogger (C.mkLogger l) + . C.setContacts settings.host [] + . C.setPortNumber (fromIntegral settings.port) + . C.setKeyspace settings.keyspace + . C.setProtocolVersion C.V4 + $ C.defSettings + desc = header "team-info" <> progDesc "get team info" <> fullDesc diff --git a/tools/db/team-info/src/TeamInfo/Types.hs b/tools/db/team-info/src/TeamInfo/Types.hs new file mode 100644 index 00000000000..e9112a7b1bc --- /dev/null +++ b/tools/db/team-info/src/TeamInfo/Types.hs @@ -0,0 +1,134 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2024 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 TeamInfo.Types where + +import Cassandra as C +import Control.Lens +import Data.Id +import Data.LegalHold (UserLegalHoldStatus) +import Data.Text.Strict.Lens +import Data.Time +import Database.CQL.Protocol hiding (Result) +import Imports +import Options.Applicative + +data CassandraSettings = CassandraSettings + { host :: String, + port :: Int, + keyspace :: C.Keyspace + } + +data Opts = Opts + { brigDb :: CassandraSettings, + galleyDb :: CassandraSettings, + teamId :: TeamId + } + +optsParser :: Parser Opts +optsParser = + Opts + <$> brigCassandraParser + <*> galleyCassandraParser + <*> ( option + auto + ( long "team-id" + <> short 't' + <> metavar "ID" + <> help "Team ID" + ) + ) + +galleyCassandraParser :: Parser CassandraSettings +galleyCassandraParser = + CassandraSettings + <$> strOption + ( long "galley-cassandra-host" + <> metavar "HOST" + <> help "Cassandra Host for galley" + <> value "localhost" + <> showDefault + ) + <*> option + auto + ( long "galley-cassandra-port" + <> metavar "PORT" + <> help "Cassandra Port for galley" + <> value 9043 + <> showDefault + ) + <*> ( C.Keyspace + . view packed + <$> strOption + ( long "galley-cassandra-keyspace" + <> metavar "STRING" + <> help "Cassandra Keyspace for galley" + <> value "galley_test" + <> showDefault + ) + ) + +brigCassandraParser :: Parser CassandraSettings +brigCassandraParser = + CassandraSettings + <$> strOption + ( long "brig-cassandra-host" + <> metavar "HOST" + <> help "Cassandra Host for brig" + <> value "localhost" + <> showDefault + ) + <*> option + auto + ( long "brig-cassandra-port" + <> metavar "PORT" + <> help "Cassandra Port for brig" + <> value 9042 + <> showDefault + ) + <*> ( C.Keyspace + . view packed + <$> strOption + ( long "brig-cassandra-keyspace" + <> metavar "STRING" + <> help "Cassandra Keyspace for brig" + <> value "brig_test" + <> showDefault + ) + ) + +data TeamMemberRow = TeamMemberRow + { id :: UserId, + legalhold :: Maybe UserLegalHoldStatus + } + deriving (Show, Generic) + +recordInstance ''TeamMemberRow + +data TeamMember = TeamMember + { id :: UserId, + legalhold :: Maybe UserLegalHoldStatus, + lastActive :: Maybe UTCTime + } + deriving (Generic) + +-- output as csv +instance Show TeamMember where + show tm = show tm.id <> "," <> maybe " " show tm.legalhold <> "," <> maybe " " show tm.lastActive diff --git a/tools/db/team-info/team-info.cabal b/tools/db/team-info/team-info.cabal new file mode 100644 index 00000000000..c96cb3485c1 --- /dev/null +++ b/tools/db/team-info/team-info.cabal @@ -0,0 +1,92 @@ +cabal-version: 3.0 +name: team-info +version: 1.0.0 +synopsis: get team info from cassandra +category: Network +author: Wire Swiss GmbH +maintainer: Wire Swiss GmbH +copyright: (c) 2024 Wire Swiss GmbH +license: AGPL-3.0-only +build-type: Simple + +library + hs-source-dirs: src + exposed-modules: + TeamInfo.Lib + TeamInfo.Types + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -funbox-strict-fields -threaded -with-rtsopts=-N + -Wredundant-constraints -Wunused-packages + + build-depends: + , cassandra-util + , conduit + , cql + , imports + , lens + , optparse-applicative + , time + , tinylog + , types-common + + default-extensions: + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + GeneralizedNewtypeDeriving + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NoImplicitPrelude + OverloadedLabels + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + RecordWildCards + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + +executable team-info + main-is: Main.hs + build-depends: + , base + , team-info + + hs-source-dirs: app + default-language: Haskell2010 + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -funbox-strict-fields -threaded -with-rtsopts=-N + -Wredundant-constraints -Wunused-packages