From b5f66326a2cd602de61c7f97ace09b8761fad9c5 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Mon, 9 Mar 2026 11:19:28 +0100 Subject: [PATCH 01/22] UserStore.Postgres: Implement the interpreter for UserStore --- libs/wire-api/src/Wire/API/Asset.hs | 7 + libs/wire-api/src/Wire/API/Locale.hs | 17 + libs/wire-api/src/Wire/API/Password.hs | 14 +- .../wire-api/src/Wire/API/PostgresMarshall.hs | 147 ++++ libs/wire-api/src/Wire/API/Team/Feature.hs | 20 +- libs/wire-api/src/Wire/API/User.hs | 60 +- .../src/Wire/API/User/EmailAddress.hs | 9 + libs/wire-api/src/Wire/API/User/Identity.hs | 2 + libs/wire-api/src/Wire/API/User/Profile.hs | 31 +- libs/wire-api/src/Wire/API/User/RichInfo.hs | 2 + .../20260113140936-create-user-tables.sql | 63 ++ libs/wire-subsystems/src/Wire/StoredUser.hs | 1 + libs/wire-subsystems/src/Wire/UserStore.hs | 6 +- .../src/Wire/UserStore/Cassandra.hs | 4 +- .../src/Wire/UserStore/Postgres.hs | 734 ++++++++++++++++++ libs/wire-subsystems/wire-subsystems.cabal | 1 + 16 files changed, 1071 insertions(+), 47 deletions(-) create mode 100644 libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql create mode 100644 libs/wire-subsystems/src/Wire/UserStore/Postgres.hs diff --git a/libs/wire-api/src/Wire/API/Asset.hs b/libs/wire-api/src/Wire/API/Asset.hs index d54acd2a80d..53e676edb77 100644 --- a/libs/wire-api/src/Wire/API/Asset.hs +++ b/libs/wire-api/src/Wire/API/Asset.hs @@ -94,6 +94,7 @@ import Imports import Servant import URI.ByteString import Wire.API.Error +import Wire.API.PostgresMarshall import Wire.API.Routes.MultiVerb import Wire.Arbitrary (Arbitrary (..), GenericUniform (..)) @@ -200,6 +201,12 @@ instance C.Cql AssetKey where fromCql (C.CqlText txt) = runParser parser . T.encodeUtf8 $ txt fromCql _ = Left "AssetKey: Text expected" +instance PostgresMarshall Text AssetKey where + postgresMarshall = assetKeyToText + +instance PostgresUnmarshall Text AssetKey where + postgresUnmarshall = mapLeft (\e -> "failed to parse AssetKey: " <> T.pack e) . runParser parser . T.encodeUtf8 + -------------------------------------------------------------------------------- -- AssetToken diff --git a/libs/wire-api/src/Wire/API/Locale.hs b/libs/wire-api/src/Wire/API/Locale.hs index 076f8b29d55..72395544d9c 100644 --- a/libs/wire-api/src/Wire/API/Locale.hs +++ b/libs/wire-api/src/Wire/API/Locale.hs @@ -48,6 +48,7 @@ import Data.Time.Format import Data.Time.LocalTime (TimeZone (..), utc) import Imports import Test.QuickCheck +import Wire.API.PostgresMarshall import Wire.API.User.Orphans () import Wire.Arbitrary @@ -185,6 +186,14 @@ instance C.Cql Language where Nothing -> Left "Language: ISO 639-1 expected." fromCql _ = Left "Language: ASCII expected" +instance PostgresMarshall Text Language where + postgresMarshall = lan2Text + +instance PostgresUnmarshall Text Language where + postgresUnmarshall = + mapLeft (\e -> "failed to parse Language: " <> Text.pack e) + . parseOnly languageParser + languageParser :: Parser Language languageParser = codeParser "language" $ fmap Language . checkAndConvert isLower @@ -210,6 +219,14 @@ instance C.Cql Country where Nothing -> Left "Country: ISO 3166-1-alpha2 expected." fromCql _ = Left "Country: ASCII expected" +instance PostgresMarshall Text Country where + postgresMarshall = con2Text + +instance PostgresUnmarshall Text Country where + postgresUnmarshall = + mapLeft (\e -> "failed to parse Country: " <> Text.pack e) + . parseOnly countryParser + countryParser :: Parser Country countryParser = codeParser "country" $ fmap Country . checkAndConvert isUpper diff --git a/libs/wire-api/src/Wire/API/Password.hs b/libs/wire-api/src/Wire/API/Password.hs index 8289725834b..a966f352bc4 100644 --- a/libs/wire-api/src/Wire/API/Password.hs +++ b/libs/wire-api/src/Wire/API/Password.hs @@ -74,11 +74,15 @@ instance Cql Password where fromCql (CqlBlob lbs) = parsePassword . Text.decodeUtf8 . toStrict $ lbs fromCql _ = Left "password: expected blob" - toCql pw = CqlBlob . fromStrict $ Text.encodeUtf8 encoded - where - encoded = case pw of - Argon2Password argon2pw -> encodeArgon2HashedPassword argon2pw - ScryptPassword scryptpw -> encodeScryptPassword scryptpw + toCql = CqlBlob . fromStrict . Text.encodeUtf8 . postgresMarshall + +instance PostgresMarshall Text Password where + postgresMarshall = \case + Argon2Password argon2pw -> encodeArgon2HashedPassword argon2pw + ScryptPassword scryptpw -> encodeScryptPassword scryptpw + +instance PostgresUnmarshall Text Password where + postgresUnmarshall = mapLeft Text.pack . parsePassword ------------------------------------------------------------------------------- diff --git a/libs/wire-api/src/Wire/API/PostgresMarshall.hs b/libs/wire-api/src/Wire/API/PostgresMarshall.hs index 46a806c7fc7..e1a6f55f18d 100644 --- a/libs/wire-api/src/Wire/API/PostgresMarshall.hs +++ b/libs/wire-api/src/Wire/API/PostgresMarshall.hs @@ -18,6 +18,7 @@ module Wire.API.PostgresMarshall ( PostgresMarshall (..), PostgresUnmarshall (..), + StoreAsJSON (..), lmapPG, rmapPG, dimapPG, @@ -31,13 +32,16 @@ import Data.ByteString.Conversion (toByteString') import Data.ByteString.Conversion qualified as BSC import Data.Code qualified as Code import Data.Domain +import Data.Handle import Data.Id +import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis), toUTCTimeMillis) import Data.Misc import Data.Profunctor import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.Ascii qualified as Ascii import Data.Text.Encoding qualified as Text +import Data.Time (UTCTime) import Data.UUID import Data.Vector (Vector) import Data.Vector qualified as V @@ -505,6 +509,18 @@ instance (PostgresMarshall a1 b1, PostgresMarshall a2 b2, PostgresMarshall a3 b3 postgresMarshall a20 ) +instance (PostgresMarshall a1 b1, PostgresMarshall a2 b2, PostgresMarshall a3 b3, PostgresMarshall a4 b4, PostgresMarshall a5 b5, PostgresMarshall a6 b6, PostgresMarshall a7 b7, PostgresMarshall a8 b8, PostgresMarshall a9 b9, PostgresMarshall a10 b10, PostgresMarshall a11 b11, PostgresMarshall a12 b12, PostgresMarshall a13 b13, PostgresMarshall a14 b14, PostgresMarshall a15 b15, PostgresMarshall a16 b16, PostgresMarshall a17 b17, PostgresMarshall a18 b18, PostgresMarshall a19 b19, PostgresMarshall a20 b20, PostgresMarshall a21 b21) => PostgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21) where + postgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21) = (postgresMarshall a1, postgresMarshall a2, postgresMarshall a3, postgresMarshall a4, postgresMarshall a5, postgresMarshall a6, postgresMarshall a7, postgresMarshall a8, postgresMarshall a9, postgresMarshall a10, postgresMarshall a11, postgresMarshall a12, postgresMarshall a13, postgresMarshall a14, postgresMarshall a15, postgresMarshall a16, postgresMarshall a17, postgresMarshall a18, postgresMarshall a19, postgresMarshall a20, postgresMarshall a21) + +instance (PostgresMarshall a1 b1, PostgresMarshall a2 b2, PostgresMarshall a3 b3, PostgresMarshall a4 b4, PostgresMarshall a5 b5, PostgresMarshall a6 b6, PostgresMarshall a7 b7, PostgresMarshall a8 b8, PostgresMarshall a9 b9, PostgresMarshall a10 b10, PostgresMarshall a11 b11, PostgresMarshall a12 b12, PostgresMarshall a13 b13, PostgresMarshall a14 b14, PostgresMarshall a15 b15, PostgresMarshall a16 b16, PostgresMarshall a17 b17, PostgresMarshall a18 b18, PostgresMarshall a19 b19, PostgresMarshall a20 b20, PostgresMarshall a21 b21, PostgresMarshall a22 b22) => PostgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22) where + postgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22) = (postgresMarshall a1, postgresMarshall a2, postgresMarshall a3, postgresMarshall a4, postgresMarshall a5, postgresMarshall a6, postgresMarshall a7, postgresMarshall a8, postgresMarshall a9, postgresMarshall a10, postgresMarshall a11, postgresMarshall a12, postgresMarshall a13, postgresMarshall a14, postgresMarshall a15, postgresMarshall a16, postgresMarshall a17, postgresMarshall a18, postgresMarshall a19, postgresMarshall a20, postgresMarshall a21, postgresMarshall a22) + +instance (PostgresMarshall a1 b1, PostgresMarshall a2 b2, PostgresMarshall a3 b3, PostgresMarshall a4 b4, PostgresMarshall a5 b5, PostgresMarshall a6 b6, PostgresMarshall a7 b7, PostgresMarshall a8 b8, PostgresMarshall a9 b9, PostgresMarshall a10 b10, PostgresMarshall a11 b11, PostgresMarshall a12 b12, PostgresMarshall a13 b13, PostgresMarshall a14 b14, PostgresMarshall a15 b15, PostgresMarshall a16 b16, PostgresMarshall a17 b17, PostgresMarshall a18 b18, PostgresMarshall a19 b19, PostgresMarshall a20 b20, PostgresMarshall a21 b21, PostgresMarshall a22 b22, PostgresMarshall a23 b23) => PostgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22, b23) where + postgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23) = (postgresMarshall a1, postgresMarshall a2, postgresMarshall a3, postgresMarshall a4, postgresMarshall a5, postgresMarshall a6, postgresMarshall a7, postgresMarshall a8, postgresMarshall a9, postgresMarshall a10, postgresMarshall a11, postgresMarshall a12, postgresMarshall a13, postgresMarshall a14, postgresMarshall a15, postgresMarshall a16, postgresMarshall a17, postgresMarshall a18, postgresMarshall a19, postgresMarshall a20, postgresMarshall a21, postgresMarshall a22, postgresMarshall a23) + +instance (PostgresMarshall a1 b1, PostgresMarshall a2 b2, PostgresMarshall a3 b3, PostgresMarshall a4 b4, PostgresMarshall a5 b5, PostgresMarshall a6 b6, PostgresMarshall a7 b7, PostgresMarshall a8 b8, PostgresMarshall a9 b9, PostgresMarshall a10 b10, PostgresMarshall a11 b11, PostgresMarshall a12 b12, PostgresMarshall a13 b13, PostgresMarshall a14 b14, PostgresMarshall a15 b15, PostgresMarshall a16 b16, PostgresMarshall a17 b17, PostgresMarshall a18 b18, PostgresMarshall a19 b19, PostgresMarshall a20 b20, PostgresMarshall a21 b21, PostgresMarshall a22 b22, PostgresMarshall a23 b23, PostgresMarshall a24 b24) => PostgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22, b23, b24) where + postgresMarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24) = (postgresMarshall a1, postgresMarshall a2, postgresMarshall a3, postgresMarshall a4, postgresMarshall a5, postgresMarshall a6, postgresMarshall a7, postgresMarshall a8, postgresMarshall a9, postgresMarshall a10, postgresMarshall a11, postgresMarshall a12, postgresMarshall a13, postgresMarshall a14, postgresMarshall a15, postgresMarshall a16, postgresMarshall a17, postgresMarshall a18, postgresMarshall a19, postgresMarshall a20, postgresMarshall a21, postgresMarshall a22, postgresMarshall a23, postgresMarshall a24) + instance PostgresMarshall UUID (Id a) where postgresMarshall = toUUID @@ -523,6 +539,12 @@ instance PostgresMarshall Int64 Milliseconds where instance PostgresMarshall Text Domain where postgresMarshall = domainText +instance PostgresMarshall Text Handle where + postgresMarshall = fromHandle + +instance PostgresMarshall UTCTime UTCTimeMillis where + postgresMarshall = fromUTCTimeMillis + instance (PostgresMarshall a b) => PostgresMarshall (Maybe a) (Maybe b) where postgresMarshall = fmap postgresMarshall @@ -861,6 +883,112 @@ instance (PostgresUnmarshall a1 b1, PostgresUnmarshall a2 b2, PostgresUnmarshall <*> postgresUnmarshall a19 <*> postgresUnmarshall a20 +instance (PostgresUnmarshall a1 b1, PostgresUnmarshall a2 b2, PostgresUnmarshall a3 b3, PostgresUnmarshall a4 b4, PostgresUnmarshall a5 b5, PostgresUnmarshall a6 b6, PostgresUnmarshall a7 b7, PostgresUnmarshall a8 b8, PostgresUnmarshall a9 b9, PostgresUnmarshall a10 b10, PostgresUnmarshall a11 b11, PostgresUnmarshall a12 b12, PostgresUnmarshall a13 b13, PostgresUnmarshall a14 b14, PostgresUnmarshall a15 b15, PostgresUnmarshall a16 b16, PostgresUnmarshall a17 b17, PostgresUnmarshall a18 b18, PostgresUnmarshall a19 b19, PostgresUnmarshall a20 b20, PostgresUnmarshall a21 b21) => PostgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21) where + postgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21) = + (,,,,,,,,,,,,,,,,,,,,) + <$> postgresUnmarshall a1 + <*> postgresUnmarshall a2 + <*> postgresUnmarshall a3 + <*> postgresUnmarshall a4 + <*> postgresUnmarshall a5 + <*> postgresUnmarshall a6 + <*> postgresUnmarshall a7 + <*> postgresUnmarshall a8 + <*> postgresUnmarshall a9 + <*> postgresUnmarshall a10 + <*> postgresUnmarshall a11 + <*> postgresUnmarshall a12 + <*> postgresUnmarshall a13 + <*> postgresUnmarshall a14 + <*> postgresUnmarshall a15 + <*> postgresUnmarshall a16 + <*> postgresUnmarshall a17 + <*> postgresUnmarshall a18 + <*> postgresUnmarshall a19 + <*> postgresUnmarshall a20 + <*> postgresUnmarshall a21 + +instance (PostgresUnmarshall a1 b1, PostgresUnmarshall a2 b2, PostgresUnmarshall a3 b3, PostgresUnmarshall a4 b4, PostgresUnmarshall a5 b5, PostgresUnmarshall a6 b6, PostgresUnmarshall a7 b7, PostgresUnmarshall a8 b8, PostgresUnmarshall a9 b9, PostgresUnmarshall a10 b10, PostgresUnmarshall a11 b11, PostgresUnmarshall a12 b12, PostgresUnmarshall a13 b13, PostgresUnmarshall a14 b14, PostgresUnmarshall a15 b15, PostgresUnmarshall a16 b16, PostgresUnmarshall a17 b17, PostgresUnmarshall a18 b18, PostgresUnmarshall a19 b19, PostgresUnmarshall a20 b20, PostgresUnmarshall a21 b21, PostgresUnmarshall a22 b22) => PostgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22) where + postgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22) = + (,,,,,,,,,,,,,,,,,,,,,) + <$> postgresUnmarshall a1 + <*> postgresUnmarshall a2 + <*> postgresUnmarshall a3 + <*> postgresUnmarshall a4 + <*> postgresUnmarshall a5 + <*> postgresUnmarshall a6 + <*> postgresUnmarshall a7 + <*> postgresUnmarshall a8 + <*> postgresUnmarshall a9 + <*> postgresUnmarshall a10 + <*> postgresUnmarshall a11 + <*> postgresUnmarshall a12 + <*> postgresUnmarshall a13 + <*> postgresUnmarshall a14 + <*> postgresUnmarshall a15 + <*> postgresUnmarshall a16 + <*> postgresUnmarshall a17 + <*> postgresUnmarshall a18 + <*> postgresUnmarshall a19 + <*> postgresUnmarshall a20 + <*> postgresUnmarshall a21 + <*> postgresUnmarshall a22 + +instance (PostgresUnmarshall a1 b1, PostgresUnmarshall a2 b2, PostgresUnmarshall a3 b3, PostgresUnmarshall a4 b4, PostgresUnmarshall a5 b5, PostgresUnmarshall a6 b6, PostgresUnmarshall a7 b7, PostgresUnmarshall a8 b8, PostgresUnmarshall a9 b9, PostgresUnmarshall a10 b10, PostgresUnmarshall a11 b11, PostgresUnmarshall a12 b12, PostgresUnmarshall a13 b13, PostgresUnmarshall a14 b14, PostgresUnmarshall a15 b15, PostgresUnmarshall a16 b16, PostgresUnmarshall a17 b17, PostgresUnmarshall a18 b18, PostgresUnmarshall a19 b19, PostgresUnmarshall a20 b20, PostgresUnmarshall a21 b21, PostgresUnmarshall a22 b22, PostgresUnmarshall a23 b23) => PostgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22, b23) where + postgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23) = + (,,,,,,,,,,,,,,,,,,,,,,) + <$> postgresUnmarshall a1 + <*> postgresUnmarshall a2 + <*> postgresUnmarshall a3 + <*> postgresUnmarshall a4 + <*> postgresUnmarshall a5 + <*> postgresUnmarshall a6 + <*> postgresUnmarshall a7 + <*> postgresUnmarshall a8 + <*> postgresUnmarshall a9 + <*> postgresUnmarshall a10 + <*> postgresUnmarshall a11 + <*> postgresUnmarshall a12 + <*> postgresUnmarshall a13 + <*> postgresUnmarshall a14 + <*> postgresUnmarshall a15 + <*> postgresUnmarshall a16 + <*> postgresUnmarshall a17 + <*> postgresUnmarshall a18 + <*> postgresUnmarshall a19 + <*> postgresUnmarshall a20 + <*> postgresUnmarshall a21 + <*> postgresUnmarshall a22 + <*> postgresUnmarshall a23 + +instance (PostgresUnmarshall a1 b1, PostgresUnmarshall a2 b2, PostgresUnmarshall a3 b3, PostgresUnmarshall a4 b4, PostgresUnmarshall a5 b5, PostgresUnmarshall a6 b6, PostgresUnmarshall a7 b7, PostgresUnmarshall a8 b8, PostgresUnmarshall a9 b9, PostgresUnmarshall a10 b10, PostgresUnmarshall a11 b11, PostgresUnmarshall a12 b12, PostgresUnmarshall a13 b13, PostgresUnmarshall a14 b14, PostgresUnmarshall a15 b15, PostgresUnmarshall a16 b16, PostgresUnmarshall a17 b17, PostgresUnmarshall a18 b18, PostgresUnmarshall a19 b19, PostgresUnmarshall a20 b20, PostgresUnmarshall a21 b21, PostgresUnmarshall a22 b22, PostgresUnmarshall a23 b23, PostgresUnmarshall a24 b24) => PostgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24) (b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15, b16, b17, b18, b19, b20, b21, b22, b23, b24) where + postgresUnmarshall (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24) = + (,,,,,,,,,,,,,,,,,,,,,,,) + <$> postgresUnmarshall a1 + <*> postgresUnmarshall a2 + <*> postgresUnmarshall a3 + <*> postgresUnmarshall a4 + <*> postgresUnmarshall a5 + <*> postgresUnmarshall a6 + <*> postgresUnmarshall a7 + <*> postgresUnmarshall a8 + <*> postgresUnmarshall a9 + <*> postgresUnmarshall a10 + <*> postgresUnmarshall a11 + <*> postgresUnmarshall a12 + <*> postgresUnmarshall a13 + <*> postgresUnmarshall a14 + <*> postgresUnmarshall a15 + <*> postgresUnmarshall a16 + <*> postgresUnmarshall a17 + <*> postgresUnmarshall a18 + <*> postgresUnmarshall a19 + <*> postgresUnmarshall a20 + <*> postgresUnmarshall a21 + <*> postgresUnmarshall a22 + <*> postgresUnmarshall a23 + <*> postgresUnmarshall a24 + instance PostgresUnmarshall UUID (Id a) where postgresUnmarshall = Right . Id @@ -928,6 +1056,12 @@ instance PostgresUnmarshall Int32 TeamInviteTag where instance PostgresUnmarshall UUID SAML.IdPId where postgresUnmarshall = Right . SAML.IdPId +instance PostgresUnmarshall Text Handle where + postgresUnmarshall = mapLeft Text.pack . parseHandleEither + +instance PostgresUnmarshall UTCTime UTCTimeMillis where + postgresUnmarshall = Right . toUTCTimeMillis + --- lmapPG :: (PostgresMarshall db domain, Profunctor p) => p db x -> p domain x @@ -941,3 +1075,16 @@ dimapPG :: Statement dbIn dbOut -> Statement domainIn domainOut dimapPG = refineResult postgresUnmarshall . lmapPG + +--- + +newtype StoreAsJSON a = StoreAsJSON a + +instance (ToJSON a) => PostgresMarshall Value (StoreAsJSON a) where + postgresMarshall (StoreAsJSON a) = toJSON a + +instance (FromJSON a) => PostgresUnmarshall Value (StoreAsJSON a) where + postgresUnmarshall v = + case fromJSON v of + Error e -> Left $ Text.pack e + Success a -> Right $ StoreAsJSON a diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 12185dbcc89..23e3eac336d 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -2179,23 +2179,21 @@ instance FromByteString FeatureStatus where instance Cass.Cql FeatureStatus where ctype = Cass.Tagged Cass.IntColumn - fromCql (Cass.CqlInt n) = case n of - 0 -> pure FeatureStatusDisabled - 1 -> pure FeatureStatusEnabled - _ -> Left "fromCql: Invalid FeatureStatus" + fromCql (Cass.CqlInt n) = mapLeft T.unpack $ postgresUnmarshall n fromCql _ = Left "fromCql: FeatureStatus: CqlInt expected" - toCql FeatureStatusDisabled = Cass.CqlInt 0 - toCql FeatureStatusEnabled = Cass.CqlInt 1 + toCql = Cass.CqlInt . postgresMarshall instance PostgresMarshall Int32 FeatureStatus where - postgresMarshall FeatureStatusEnabled = 1 - postgresMarshall FeatureStatusDisabled = 0 + postgresMarshall = \case + FeatureStatusDisabled -> 0 + FeatureStatusEnabled -> 1 instance PostgresUnmarshall Int32 FeatureStatus where - postgresUnmarshall 1 = Right FeatureStatusEnabled - postgresUnmarshall 0 = Right FeatureStatusDisabled - postgresUnmarshall _ = Left "invalid feature status" + postgresUnmarshall = \case + 0 -> Right FeatureStatusDisabled + 1 -> Right FeatureStatusEnabled + n -> Left $ "Invalid FeatureStatus: " <> T.pack (show n) -- | list of available features config types type Features :: [Type] diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 889e22a6191..1154b6cb86c 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -195,6 +195,7 @@ import Data.Schema hiding (description) import Data.Schema qualified as Schema import Data.Set qualified as Set import Data.Text qualified as T +import Data.Text qualified as Text import Data.Text.Ascii import Data.Text.Encoding qualified as T import Data.Text.Encoding.Error @@ -215,6 +216,7 @@ import Wire.API.Error.Brig import Wire.API.Error.Brig qualified as E import Wire.API.Locale import Wire.API.Password +import Wire.API.PostgresMarshall import Wire.API.Provider.Service (ServiceRef) import Wire.API.Routes.MultiVerb import Wire.API.Team @@ -499,16 +501,23 @@ instance ToSchema UserType where instance C.Cql UserType where ctype = C.Tagged C.IntColumn - toCql UserTypeRegular = C.CqlInt 0 - toCql UserTypeBot = C.CqlInt 1 - toCql UserTypeApp = C.CqlInt 2 + toCql = C.CqlInt . postgresMarshall - fromCql (C.CqlInt i) = case i of + fromCql (C.CqlInt i) = mapLeft Text.unpack $ postgresUnmarshall i + fromCql _ = Left "user type: int expected" + +instance PostgresMarshall Int32 UserType where + postgresMarshall = \case + UserTypeRegular -> 0 + UserTypeBot -> 1 + UserTypeApp -> 2 + +instance PostgresUnmarshall Int32 UserType where + postgresUnmarshall = \case 0 -> pure UserTypeRegular 1 -> pure UserTypeBot 2 -> pure UserTypeApp - n -> Left $ "unexpected user type: " ++ show n - fromCql _ = Left "user type: int expected" + n -> Left $ "unexpected user type: " <> Text.pack (show n) -------------------------------------------------------------------------------- -- UserProfile @@ -1870,21 +1879,28 @@ instance Schema.ToSchema AccountStatus where instance C.Cql AccountStatus where ctype = C.Tagged C.IntColumn - toCql Active = C.CqlInt 0 - toCql Suspended = C.CqlInt 1 - toCql Deleted = C.CqlInt 2 - toCql Ephemeral = C.CqlInt 3 - toCql PendingInvitation = C.CqlInt 4 - - fromCql (C.CqlInt i) = case i of - 0 -> pure Active - 1 -> pure Suspended - 2 -> pure Deleted - 3 -> pure Ephemeral - 4 -> pure PendingInvitation - n -> Left $ "unexpected account status: " ++ show n + toCql = C.CqlInt . postgresMarshall + + fromCql (C.CqlInt i) = mapLeft Text.unpack $ postgresUnmarshall i fromCql _ = Left "account status: int expected" +instance PostgresMarshall Int32 AccountStatus where + postgresMarshall = \case + Active -> 0 + Suspended -> 1 + Deleted -> 2 + Ephemeral -> 3 + PendingInvitation -> 4 + +instance PostgresUnmarshall Int32 AccountStatus where + postgresUnmarshall = \case + 0 -> Right Active + 1 -> Right Suspended + 2 -> Right Deleted + 3 -> Right Ephemeral + 4 -> Right PendingInvitation + n -> Left $ "unexpected account status: " <> Text.show n + data AccountStatusResp = AccountStatusResp {fromAccountStatusResp :: AccountStatus} deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform AccountStatusResp) @@ -2024,6 +2040,12 @@ instance C.Cql (Imports.Set BaseProtocolTag) where fromCql (C.CqlInt bits) = pure $ protocolSetFromBits (fromIntegral bits) fromCql _ = Left "Protocol set: Int expected" +instance PostgresMarshall Int32 (Imports.Set BaseProtocolTag) where + postgresMarshall = fromIntegral . protocolSetBits + +instance PostgresUnmarshall Int32 (Imports.Set BaseProtocolTag) where + postgresUnmarshall = Right . protocolSetFromBits . fromIntegral + baseProtocolMask :: BaseProtocolTag -> Word32 baseProtocolMask BaseProtocolProteusTag = 1 baseProtocolMask BaseProtocolMLSTag = 2 diff --git a/libs/wire-api/src/Wire/API/User/EmailAddress.hs b/libs/wire-api/src/Wire/API/User/EmailAddress.hs index 1b3a58554e1..9bde18007ec 100644 --- a/libs/wire-api/src/Wire/API/User/EmailAddress.hs +++ b/libs/wire-api/src/Wire/API/User/EmailAddress.hs @@ -50,6 +50,7 @@ import Servant.API qualified as S import Test.QuickCheck import Text.Email.Parser import Text.Email.Validate +import Wire.API.PostgresMarshall -------------------------------------------------------------------------------- -- Email @@ -103,6 +104,14 @@ instance C.Cql EmailAddress where toCql = C.toCql . fromEmail +instance PostgresMarshall Text EmailAddress where + postgresMarshall = fromEmail + +instance PostgresUnmarshall Text EmailAddress where + postgresUnmarshall t = case emailAddressText t of + Just e -> Right e + Nothing -> Left "postgresUnmarshall: Invalid email" + fromEmail :: EmailAddress -> Text fromEmail = decodeUtf8 . toByteString diff --git a/libs/wire-api/src/Wire/API/User/Identity.hs b/libs/wire-api/src/Wire/API/User/Identity.hs index 97a3c503e59..edcc3c3d842 100644 --- a/libs/wire-api/src/Wire/API/User/Identity.hs +++ b/libs/wire-api/src/Wire/API/User/Identity.hs @@ -71,6 +71,7 @@ import Text.Email.Parser import URI.ByteString qualified as URI import URI.ByteString.QQ (uri) import Web.Scim.Schema.User.Email () +import Wire.API.PostgresMarshall import Wire.API.User.EmailAddress import Wire.API.User.Phone import Wire.API.User.Profile (fromName, mkName) @@ -150,6 +151,7 @@ data UserSSOId | UserScimExternalId Text deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserSSOId) + deriving (PostgresMarshall A.Value, PostgresUnmarshall A.Value) via (StoreAsJSON UserSSOId) isUserSSOId :: UserSSOId -> Bool isUserSSOId (UserSSOId _) = True diff --git a/libs/wire-api/src/Wire/API/User/Profile.hs b/libs/wire-api/src/Wire/API/User/Profile.hs index 3fba25d81c9..9681275d828 100644 --- a/libs/wire-api/src/Wire/API/User/Profile.hs +++ b/libs/wire-api/src/Wire/API/User/Profile.hs @@ -58,6 +58,7 @@ import Data.Text.Encoding qualified as TE import Imports import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..)) import Wire.API.Asset (AssetKey (..)) +import Wire.API.PostgresMarshall import Wire.API.User.Orphans () import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) @@ -69,7 +70,7 @@ import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) newtype Name = Name {fromName :: Text} deriving stock (Eq, Ord, Show, Generic) - deriving newtype (FromByteString, ToByteString) + deriving newtype (FromByteString, ToByteString, PostgresMarshall Text, PostgresUnmarshall Text) deriving (Arbitrary) via (Ranged 1 128 Text) deriving (FromJSON, ToJSON, S.ToSchema) via Schema Name @@ -88,7 +89,7 @@ deriving instance C.Cql Name newtype TextStatus = TextStatus {fromTextStatus :: Text} deriving stock (Eq, Ord, Show, Generic) - deriving newtype (FromByteString, ToByteString) + deriving newtype (FromByteString, ToByteString, PostgresMarshall Text, PostgresUnmarshall Text) deriving (Arbitrary) via (Ranged 1 256 Text) deriving (FromJSON, ToJSON, S.ToSchema) via Schema TextStatus @@ -105,7 +106,7 @@ deriving instance C.Cql TextStatus newtype ColourId = ColourId {fromColourId :: Int32} deriving stock (Eq, Ord, Show, Generic) - deriving newtype (Num, ToSchema, Arbitrary) + deriving newtype (Num, ToSchema, Arbitrary, PostgresMarshall Int32, PostgresUnmarshall Int32) deriving (FromJSON, ToJSON, S.ToSchema) via Schema ColourId defaultAccentId :: ColourId @@ -191,12 +192,21 @@ instance ToSchema AssetSize where instance C.Cql AssetSize where ctype = C.Tagged C.IntColumn - fromCql (C.CqlInt 0) = pure AssetPreview - fromCql (C.CqlInt 1) = pure AssetComplete + fromCql (C.CqlInt n) = mapLeft Text.unpack $ postgresUnmarshall n fromCql n = Left $ "Unexpected asset size: " ++ show n - toCql AssetPreview = C.CqlInt 0 - toCql AssetComplete = C.CqlInt 1 + toCql = C.CqlInt . postgresMarshall + +instance PostgresMarshall Int32 AssetSize where + postgresMarshall = \case + AssetPreview -> 0 + AssetComplete -> 1 + +instance PostgresUnmarshall Int32 AssetSize where + postgresUnmarshall = \case + 0 -> Right AssetPreview + 1 -> Right AssetComplete + n -> Left $ "Unexpected asset size: " <> Text.show n -------------------------------------------------------------------------------- -- ManagedBy @@ -258,6 +268,12 @@ instance C.Cql ManagedBy where toCql = C.CqlInt . managedByToInt32 +instance PostgresMarshall Int32 ManagedBy where + postgresMarshall = managedByToInt32 + +instance PostgresUnmarshall Int32 ManagedBy where + postgresUnmarshall = managedByFromInt32 + defaultManagedBy :: ManagedBy defaultManagedBy = ManagedByWire @@ -279,6 +295,7 @@ managedByFromInt32 = \case newtype Pict = Pict {fromPict :: [A.Object]} deriving stock (Eq, Ord, Show, Generic) deriving (FromJSON, ToJSON, S.ToSchema) via Schema Pict + deriving (PostgresMarshall A.Value, PostgresUnmarshall A.Value) via StoreAsJSON Pict instance ToSchema Pict where schema = diff --git a/libs/wire-api/src/Wire/API/User/RichInfo.hs b/libs/wire-api/src/Wire/API/User/RichInfo.hs index c53a1e611ea..db66a59bfd1 100644 --- a/libs/wire-api/src/Wire/API/User/RichInfo.hs +++ b/libs/wire-api/src/Wire/API/User/RichInfo.hs @@ -58,6 +58,7 @@ import Data.Schema import Data.Text qualified as Text import Imports import Test.QuickCheck qualified as QC +import Wire.API.PostgresMarshall import Wire.Arbitrary (Arbitrary (arbitrary)) -------------------------------------------------------------------------------- @@ -271,6 +272,7 @@ richInfoAssocListURN = "urn:wire:scim:schemas:profile:1.0" newtype RichInfoAssocList = RichInfoAssocList {unRichInfoAssocList :: [RichField]} deriving stock (Eq, Show, Generic) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema RichInfoAssocList) + deriving (PostgresMarshall A.Value, PostgresUnmarshall A.Value) via (StoreAsJSON RichInfoAssocList) -- | Uses 'normalizeRichInfoAssocList'. mkRichInfoAssocList :: [RichField] -> RichInfoAssocList diff --git a/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql b/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql new file mode 100644 index 00000000000..8d5d14a241a --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql @@ -0,0 +1,63 @@ +CREATE TABLE wire_user ( + id uuid PRIMARY KEY, + user_type integer NOT NULL, + accent_id integer NOT NULL, + activated boolean NOT NULL, + country text, + email text, + email_unvalidated text, + expires timestamptz, + feature_conference_calling integer, + handle text UNIQUE, + language text, + managed_by integer, + name text NOT NULL, + password text, + picture jsonb, + provider uuid, + service uuid, + searchable boolean, + sso_id jsonb, + account_status integer, + supported_protocols integer, + team uuid, + text_status text, + rich_info jsonb, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp +); + +CREATE INDEX wire_user_service_idx ON wire_user(provider, service); + +CREATE OR REPLACE FUNCTION update_updated_at() + RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON wire_user FOR EACH ROW EXECUTE PROCEDURE update_updated_at(); + +CREATE TABLE asset ( + user_id uuid NOT NULL, + typ integer NOT NULL, + key text NOT NULL, + size integer +); + +CREATE INDEX asset_user_id_idx ON asset (user_id); + +CREATE TABLE bot_conv ( + id uuid PRIMARY KEY, + conv uuid NOT NULL, + conv_team uuid, + FOREIGN KEY (id) REFERENCES wire_user(id) ON DELETE CASCADE +); + +CREATE INDEX bot_conv_conv_idx ON bot_conv (conv); +CREATE INDEX bot_conv_team_idx ON bot_conv (conv_team); + +CREATE TABLE deleted_user ( + id uuid PRIMARY KEY +); diff --git a/libs/wire-subsystems/src/Wire/StoredUser.hs b/libs/wire-subsystems/src/Wire/StoredUser.hs index 7aec8368fbe..414598883f3 100644 --- a/libs/wire-subsystems/src/Wire/StoredUser.hs +++ b/libs/wire-subsystems/src/Wire/StoredUser.hs @@ -39,6 +39,7 @@ import Wire.Arbitrary data StoredUser = StoredUser { id :: UserId, + -- | Remove 'Maybe' from this when Cassandra support is removed userType :: Maybe UserType, name :: Name, textStatus :: Maybe TextStatus, diff --git a/libs/wire-subsystems/src/Wire/UserStore.hs b/libs/wire-subsystems/src/Wire/UserStore.hs index 5c35eeae407..d254b697fbf 100644 --- a/libs/wire-subsystems/src/Wire/UserStore.hs +++ b/libs/wire-subsystems/src/Wire/UserStore.hs @@ -71,7 +71,7 @@ data UserStore m a where CreateUser :: NewStoredUser -> Maybe (ConvId, Maybe TeamId) -> UserStore m () GetIndexUser :: UserId -> UserStore m (Maybe IndexUser) DoesUserExist :: UserId -> UserStore m Bool - GetIndexUsersPaginated :: Int32 -> Maybe (GeneralPaginationState Void) -> UserStore m (PageWithState Void IndexUser) + GetIndexUsersPaginated :: Int32 -> Maybe (GeneralPaginationState UserId) -> UserStore m (PageWithState UserId IndexUser) GetUsers :: [UserId] -> UserStore m [StoredUser] UpdateUser :: UserId -> StoredUserUpdate -> UserStore m () UpdateEmail :: UserId -> EmailAddress -> UserStore m () @@ -112,8 +112,8 @@ data UserStore m a where UpdateFeatureConferenceCalling :: UserId -> Maybe FeatureStatus -> UserStore m () LookupFeatureConferenceCalling :: UserId -> UserStore m (Maybe FeatureStatus) DeleteServiceUser :: ProviderId -> ServiceId -> BotId -> UserStore m () - LookupServiceUsers :: ProviderId -> ServiceId -> Maybe (GeneralPaginationState Void) -> UserStore m (PageWithState Void (BotId, ConvId, Maybe TeamId)) - LookupServiceUsersForTeam :: ProviderId -> ServiceId -> TeamId -> Maybe (GeneralPaginationState Void) -> UserStore m (PageWithState Void (BotId, ConvId)) + LookupServiceUsers :: ProviderId -> ServiceId -> Maybe (GeneralPaginationState BotId) -> UserStore m (PageWithState BotId (BotId, ConvId, Maybe TeamId)) + LookupServiceUsersForTeam :: ProviderId -> ServiceId -> TeamId -> Maybe (GeneralPaginationState BotId) -> UserStore m (PageWithState BotId (BotId, ConvId)) makeSem ''UserStore diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index c9d4f54784b..bc919cd854d 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -423,7 +423,7 @@ lookupServiceUsersImpl :: ProviderId -> ServiceId -> Maybe PagingState -> - Client (PageWithState Void (BotId, ConvId, Maybe TeamId)) + Client (PageWithState x (BotId, ConvId, Maybe TeamId)) lookupServiceUsersImpl pid sid mPagingState = paginateWithState cql (paramsPagingState LocalQuorum (pid, sid) 100 mPagingState) x1 where @@ -437,7 +437,7 @@ lookupServiceUsersForTeamImpl :: ServiceId -> TeamId -> Maybe PagingState -> - Client (PageWithState Void (BotId, ConvId)) + Client (PageWithState x (BotId, ConvId)) lookupServiceUsersForTeamImpl pid sid tid mPagingState = paginateWithState cql (paramsPagingState LocalQuorum (pid, sid, tid) 100 mPagingState) x1 where diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs new file mode 100644 index 00000000000..8267b460ad2 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -0,0 +1,734 @@ +{-# LANGUAGE RecordWildCards #-} +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + +module Wire.UserStore.Postgres where + +import Cassandra (GeneralPaginationState (PaginationStatePostgres), PageWithState (..), paginationStatePostgres) +import Control.Error (lastMay) +import Data.Handle +import Data.Id +import Data.Json.Util +import Data.Map qualified as Map +import Data.Qualified (Qualified (qUnqualified)) +import Data.Time +import Data.Tuple.Extra (fst3) +import Data.Vector (Vector) +import Data.Vector qualified as V +import Hasql.Pipeline qualified as Pipeline +import Hasql.Statement qualified as Hasql +import Hasql.TH +import Hasql.Transaction qualified as Transaction +import Hasql.Transaction.Sessions +import Imports +import Polysemy +import Wire.API.Asset hiding (Asset) +import Wire.API.Password +import Wire.API.PostgresMarshall +import Wire.API.Team.Feature (FeatureStatus) +import Wire.API.User hiding (DeleteUser) +import Wire.API.User.RichInfo +import Wire.API.User.Search +import Wire.Postgres +import Wire.StoredUser +import Wire.UserStore +import Wire.UserStore.IndexUser + +interpretUserStoreCassandra :: (PGConstraints r) => InterpreterFor UserStore r +interpretUserStoreCassandra = + interpret $ \case + CreateUser new mbConv -> createUserImpl new mbConv + ActivateUser uid identity -> activateUserImpl uid identity + DeactivateUser uid -> deactivateUserImpl uid + GetUsers uids -> getUsersImpl uids + DoesUserExist uid -> doesUserExistImpl uid + GetIndexUser uid -> getIndexUserImpl uid + GetIndexUsersPaginated pageSize mPagingState -> getIndexUsersPaginatedImpl pageSize (paginationStatePostgres =<< mPagingState) + UpdateUser uid update -> updateUserImpl uid update + UpdateEmail uid email -> updateEmailImpl uid (Just email) + DeleteEmail uid -> updateEmailImpl uid Nothing + UpdateEmailUnvalidated uid email -> updateEmailUnvalidatedImpl uid (Just email) + DeleteEmailUnvalidated uid -> updateEmailUnvalidatedImpl uid Nothing + LookupName uid -> lookupNameImpl uid + LookupHandle hdl -> lookupHandleImpl hdl + GlimpseHandle hdl -> lookupHandleImpl hdl + UpdateUserHandleEither uid update -> updateUserHandleEitherImpl uid update + UpdateSSOId uid ssoId -> updateSSOIdImpl uid ssoId + UpdateManagedBy uid managedBy -> updateManagedByImpl uid managedBy + UpdateAccountStatus uid accountStatus -> updateAccountStatusImpl uid accountStatus + UpdateRichInfo uid richInfo -> updateRichInfoImpl uid richInfo + UpdateFeatureConferenceCalling uid feat -> updateFeatureConferenceCallingImpl uid feat + LookupFeatureConferenceCalling uid -> lookupFeatureConferenceCallingImpl uid + DeleteUser user -> deleteUserImpl user + LookupStatus uid -> lookupStatusImpl uid + IsActivated uid -> isActivatedImpl uid + LookupLocale uid -> lookupLocaleImpl uid + GetUserTeam uid -> getUserTeamImpl uid + UpdateUserTeam uid tid -> updateUserTeamImpl uid tid + GetRichInfo uid -> getRichInfoImpl uid + LookupRichInfos uids -> lookupRichInfosImpl uids + UpsertHashedPassword uid pw -> upsertHashedPasswordImpl uid pw + LookupHashedPassword uid -> lookupHashedPasswordImpl uid + GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid + SetUserSearchable uid searchable -> setUserSearchableImpl uid searchable + DeleteServiceUser pid sid bid -> deleteServiceUserImpl pid sid bid + LookupServiceUsers pid sid mPagingState -> lookupServiceUsersImpl pid sid (paginationStatePostgres =<< mPagingState) + LookupServiceUsersForTeam pid sid tid mPagingState -> lookupServiceUsersForTeamImpl pid sid tid (paginationStatePostgres =<< mPagingState) + +{- ORMOLU_DISABLE -} +type InsertUserRow = + ( UserId, Name, Maybe TextStatus, Pict, Maybe EmailAddress, + Maybe UserSSOId, ColourId, Maybe Password, Bool, AccountStatus, + Maybe UTCTimeMillis, Language, Maybe Country, Maybe ProviderId, Maybe ServiceId, + Maybe Handle, Maybe TeamId, ManagedBy, Set BaseProtocolTag, Bool, + UserType + ) +type + SelectUserRow = + ( UserId, Name, Maybe TextStatus, Maybe Pict, Maybe EmailAddress, Maybe EmailAddress, + Maybe UserSSOId, ColourId, Bool, Maybe AccountStatus, + Maybe UTCTimeMillis, Maybe Language, Maybe Country, Maybe ProviderId, Maybe ServiceId, + Maybe Handle, Maybe TeamId, Maybe ManagedBy, Maybe (Set BaseProtocolTag), Maybe Bool, + UserType + ) + +storedUserFromRow :: SelectUserRow -> StoredUser +storedUserFromRow (id_, name, textStatus, pict, email, emailUnvalidated, + ssoId, accentId, activated, status, + expires, language, country, providerId, serviceId, + handle, teamId, managedBy, supportedProtocols, searchable, + userTypeInDB) + = StoredUser{ id = id_, + assets = Nothing, + userType = Just userTypeInDB, + .. + } + +type SelectIndexUserRow = + (UserId, Maybe TeamId, Name, Maybe AccountStatus, Maybe Handle, + Maybe EmailAddress, Maybe EmailAddress, ColourId, Bool, Maybe ServiceId, + Maybe ManagedBy, Maybe UserSSOId, Maybe Bool, UTCTime, UTCTime, + UserType) + +indexUserFromRow :: SelectIndexUserRow -> IndexUser +indexUserFromRow ( uid, teamId, name, accountStatus, handle, + email, unverifiedEmail, colourId, activated, serviceId, + managedBy, ssoId, searchable, createdAt, updatedAt, + userType + ) = IndexUser{userId = uid, ..} +{- ORMOLU_ENABLE -} + +createUserImpl :: (PGConstraints r) => NewStoredUser -> Maybe (ConvId, Maybe TeamId) -> Sem r () +createUserImpl new mbConv = + runTransaction Serializable Write $ do + Transaction.statement userRow insertUser + Transaction.statement new.id deleteAssetsStatement + Transaction.statement (mkAssetRows new.id new.assets) insertAssetsStatement + for_ mbConv $ \(convId, mTeamId) -> do + Transaction.statement (new.id, convId, mTeamId) insertBotConv + where + userRow = + ( new.id, + new.name, + new.textStatus, + new.pict, + new.email, + new.ssoId, + new.accentId, + new.password, + new.activated, + new.status, + new.expires, + new.language, + new.country, + new.providerId, + new.serviceId, + new.handle, + new.teamId, + new.managedBy, + new.supportedProtocols, + new.searchable, + new.userType + ) + + insertUser :: Hasql.Statement InsertUserRow () + insertUser = + lmapPG + [resultlessStatement| + INSERT INTO wire_user + (id, name, text_status, picture, email, + sso_id, accent_id, password, activated, account_status, + expires, language, country, provider, service, + handle, team, managed_by, supported_protocols, searchable, + user_type) + VALUES + ($1 :: uuid, $2 :: text, $3 :: text?, $4 :: jsonb, $5 :: text?, + $6 :: jsonb?, $7 :: integer, $8 :: text?, $9 :: boolean, $10 :: integer, + $11 :: timestamptz?, $12 :: text, $13 :: text?, $14 :: uuid?, $15 :: uuid?, + $16 :: text?, $17 :: uuid?, $18 :: integer, $19 :: integer, $20 :: boolean, + $21 :: integer) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + text_status = EXCLUDED.text_status, + picture = EXCLUDED.picture, + email = EXCLUDED.email, + sso_id = EXCLUDED.sso_id, + accent_id = EXCLUDED.accent_id, + password = EXCLUDED.password, + activated = EXCLUDED.activated, + account_status = EXCLUDED.account_status, + expires = EXCLUDED.expires, + language = EXCLUDED.language, + country = EXCLUDED.country, + provider = EXCLUDED.provider, + service = EXCLUDED.service, + handle = EXCLUDED.handle, + team = EXCLUDED.team, + managed_by = EXCLUDED.managed_by, + supported_protocols = EXCLUDED.supported_protocols, + searchable = EXCLUDED.searchable, + user_type = EXCLUDED.user_type + |] + + insertBotConv :: Hasql.Statement (UserId, ConvId, Maybe TeamId) () + insertBotConv = + lmapPG + [resultlessStatement| + INSERT INTO bot_conv + (id, conv, conv_team) + VALUES + ($1 :: uuid, $2 :: uuid, $3 :: uuid?) + ON CONFLICT (id) DO UPDATE + SET conv = EXCLUDED.conv, + conv_team = EXCLUDED.conv_team + |] + +mkAssetRows :: UserId -> [Asset] -> ([UserId], [Int32], [AssetKey], [Maybe AssetSize]) +mkAssetRows uid assets = + unzip4 $ + map (\asset -> (uid, 0, asset.assetKey, asset.assetSize)) assets + +insertAssetsStatement :: Hasql.Statement ([UserId], [Int32], [AssetKey], [Maybe AssetSize]) () +insertAssetsStatement = + lmapPG @(Vector _, Vector _, Vector _, Vector _) + [resultlessStatement| + INSERT INTO asset + (user_id, typ, key, size) + SELECT * FROM UNNEST ($1 :: uuid[], $2 :: integer[], $3 :: text[], $4 :: integer?[]) + |] + +deleteAssetsStatement :: Hasql.Statement UserId () +deleteAssetsStatement = + lmapPG + [resultlessStatement|DELETE FROM asset where user_id = $1 :: uuid|] + +getUsersImpl :: (PGConstraints r) => [UserId] -> Sem r [StoredUser] +getUsersImpl uids = do + (userRows, assetRows) <- + runPipeline $ + (,) + <$> Pipeline.statement uids selectUsers + <*> Pipeline.statement uids selectAssets + let assetMap = + foldr + (\(uid, _, key, size) -> Map.insertWith (<>) uid [ImageAsset key size]) + Map.empty + assetRows + pure $ + map + ( \row -> + let storedUser = storedUserFromRow row + in storedUser {assets = Map.lookup storedUser.id assetMap} :: StoredUser + ) + userRows + where + selectUsers :: Hasql.Statement [UserId] [SelectUserRow] + selectUsers = + dimapPG @(Vector _) + [vectorStatement| + SELECT + id :: uuid, name :: text, text_status :: text?, picture :: jsonb?, email :: text?, email_unvalidated :: text?, + sso_id :: jsonb?, accent_id :: integer, activated :: boolean, account_status :: integer?, + expires :: timestamptz?, language :: text?, country :: text?, provider :: uuid?, service :: uuid?, + handle :: text?, team :: uuid?, managed_by :: integer?, supported_protocols :: integer?, searchable :: boolean?, + user_type :: integer + FROM wire_user + WHERE id = ANY($1 :: uuid[]) + |] + + -- TODO: Implement this, but make some test fail before implementing + -- selectDeletedUsers :: Hasql.Statement [UserId] [UserId] + -- selectDeletedUsers = pure [] + + selectAssets :: Hasql.Statement [UserId] [(UserId, Int32, AssetKey, Maybe AssetSize)] + selectAssets = + dimapPG @(Vector _) + [vectorStatement| + SELECT user_id :: uuid, typ :: integer, key :: text, size :: integer? + FROM asset + WHERE user_id = ANY($1 :: uuid[]) + |] + +doesUserExistImpl :: (PGConstraints r) => UserId -> Sem r Bool +doesUserExistImpl uid = + runStatement uid check + where + check :: Hasql.Statement UserId Bool + check = + lmapPG + [singletonStatement| + SELECT EXISTS ( + SELECT 1 FROM wire_user WHERE id = $1 :: uuid + UNION ALL + SELECT 1 FROM deleted_user WHERE id = $1 :: uuid + ) :: bool + |] + +activateUserImpl :: (PGConstraints r) => UserId -> UserIdentity -> Sem r () +activateUserImpl uid (emailIdentity -> email) = + runStatement (uid, email) update + where + update :: Hasql.Statement (UserId, Maybe EmailAddress) () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET activated = true, + email = $2 :: text? + WHERE id = $1 :: uuid + |] + +deactivateUserImpl :: (PGConstraints r) => UserId -> Sem r () +deactivateUserImpl uid = + runStatement uid update + where + update :: Hasql.Statement UserId () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET activated = false + WHERE id = $1 :: uuid + |] + +getIndexUserImpl :: (PGConstraints r) => UserId -> Sem r (Maybe IndexUser) +getIndexUserImpl uid = do + indexUserFromRow <$$> runStatement uid selectUser + where + selectUser :: Hasql.Statement UserId (Maybe SelectIndexUserRow) + selectUser = + dimapPG + [maybeStatement| + SELECT + id :: uuid, team :: uuid?, name :: text, account_status :: integer?, handle :: text?, + email :: text?, email_unvalidated :: text?, accent_id :: integer, activated :: Bool, service :: uuid?, + managed_by :: integer?, sso_id :: jsonb?, searchable :: boolean?, created_at :: timestamptz, updated_at :: timestamptz, + user_type :: integer + FROM wire_user + WHERE id = $1 :: uuid + |] + +getIndexUsersPaginatedImpl :: (PGConstraints r) => Int32 -> Maybe UserId -> Sem r (PageWithState UserId IndexUser) +getIndexUsersPaginatedImpl lim mState = do + rows <- case mState of + Nothing -> runStatement lim selectStart + Just startId -> runStatement (startId, lim) selectFrom + let results = indexUserFromRow <$> rows + pure + PageWithState + { pwsResults = results, + pwsState = PaginationStatePostgres . (.userId) <$> lastMay results + } + where + selectStart :: Hasql.Statement Int32 [SelectIndexUserRow] + selectStart = + dimapPG + [vectorStatement| + SELECT + id :: uuid, team :: uuid?, name :: text, account_status :: integer?, handle :: text?, + email :: text?, email_unvalidated :: text?, accent_id :: integer, activated :: Bool, service :: uuid?, + managed_by :: integer?, sso_id :: jsonb?, searchable :: boolean?, created_at :: timestamptz, updated_at :: timestamptz, + user_type :: integer + FROM wire_user + ORDER BY id ASC + LIMIT ($1 :: integer) + |] + + selectFrom :: Hasql.Statement (UserId, Int32) [SelectIndexUserRow] + selectFrom = + dimapPG + [vectorStatement| + SELECT + id :: uuid, team :: uuid?, name :: text, account_status :: integer?, handle :: text?, + email :: text?, email_unvalidated :: text?, accent_id :: integer, activated :: Bool, service :: uuid?, + managed_by :: integer?, sso_id :: jsonb?, searchable :: boolean?, created_at :: timestamptz, updated_at :: timestamptz, + user_type :: integer + FROM wire_user + WHERE id > ($1 :: uuid) + ORDER BY id ASC + LIMIT ($2 :: integer) + |] + +updateUserImpl :: (PGConstraints r) => UserId -> StoredUserUpdate -> Sem r () +updateUserImpl uid MkStoredUserUpdate {..} = + runTransaction ReadCommitted Write $ do + Transaction.statement + (uid, name, textStatus, pict, accentId, lLanguage <$> locale, lCountry =<< locale, supportedProtocols) + updateUserFields + for_ assets $ \newAssets -> do + Transaction.statement uid deleteAssetsStatement + Transaction.statement (mkAssetRows uid newAssets) insertAssetsStatement + where + updateUserFields :: Hasql.Statement (UserId, Maybe Name, Maybe TextStatus, Maybe Pict, Maybe ColourId, Maybe Language, Maybe Country, Maybe (Set BaseProtocolTag)) () + updateUserFields = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET name = COALESCE($2 :: text?, name), + text_status = COALESCE($3 :: text?, text_status), + picture = COALESCE($4 :: jsonb?, picture), + accent_id = COALESCE($5 :: integer?, accent_id), + language = COALESCE($6 :: text?, language), + country = COALESCE($7 :: text?, country), + supported_protocols = COALESCE($8 :: integer?, supported_protocols) + WHERE id = ($1 :: uuid) + |] + +updateEmailUnvalidatedImpl :: (PGConstraints r) => UserId -> Maybe EmailAddress -> Sem r () +updateEmailUnvalidatedImpl uid email = + runStatement (uid, email) update + where + update :: Hasql.Statement (UserId, Maybe EmailAddress) () + update = + lmapPG + [resultlessStatement|UPDATE wire_user SET email_unvalidated = ($2 :: text?) WHERE id = ($1 :: uuid)|] + +updateEmailImpl :: (PGConstraints r) => UserId -> Maybe EmailAddress -> Sem r () +updateEmailImpl uid email = + runStatement (uid, email) update + where + update :: Hasql.Statement (UserId, Maybe EmailAddress) () + update = + lmapPG + [resultlessStatement|UPDATE wire_user SET email = ($2 :: text?) WHERE id = ($1 :: uuid)|] + +lookupNameImpl :: (PGConstraints r) => UserId -> Sem r (Maybe Name) +lookupNameImpl uid = runStatement uid select + where + select :: Hasql.Statement UserId (Maybe Name) + select = + dimapPG + [maybeStatement| + SELECT name :: text + FROM wire_user + WHERE id = $1 :: uuid + |] + +lookupHandleImpl :: (PGConstraints r) => Handle -> Sem r (Maybe UserId) +lookupHandleImpl h = runStatement h selectUserIdByHandleStatement + +selectUserIdByHandleStatement :: Hasql.Statement Handle (Maybe UserId) +selectUserIdByHandleStatement = + dimapPG + [maybeStatement| + SELECT id :: uuid + FROM wire_user + WHERE handle = $1 :: text + |] + +updateUserHandleEitherImpl :: (PGConstraints r) => UserId -> StoredUserHandleUpdate -> Sem r (Either StoredUserUpdateError ()) +updateUserHandleEitherImpl uid upd = + runTransaction ReadCommitted Write $ do + mOwner <- Transaction.statement upd.new selectUserIdByHandleStatement + case mOwner of + Just uid' | uid' /= uid -> pure $ Left StoredUserUpdateHandleExists + Just _ -> pure $ Right () + Nothing -> Right <$> Transaction.statement (uid, upd.new) update + where + update :: Hasql.Statement (UserId, Handle) () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET handle = $2 :: text + WHERE id = $1 :: uuid + |] + +deleteUserImpl :: (PGConstraints r) => User -> Sem r () +deleteUserImpl user = + runTransaction ReadCommitted Write $ do + let uid = user.userQualifiedId.qUnqualified + Transaction.statement uid delete + Transaction.statement uid noteDeleted + where + delete :: Hasql.Statement UserId () + delete = + lmapPG + [resultlessStatement| + DELETE FROM wire_user + WHERE id = $1 :: uuid + |] + + noteDeleted :: Hasql.Statement (UserId) () + noteDeleted = + lmapPG + [resultlessStatement| + INSERT INTO deleted_user + (id) + VALUES ($1 :: uuid) + ON CONFLICT (id) DO NOTHING + |] + +-- TODO: This probably needs to work for deleted users +lookupStatusImpl :: (PGConstraints r) => UserId -> Sem r (Maybe AccountStatus) +lookupStatusImpl uid = + join <$> runStatement uid select + where + select :: Hasql.Statement UserId (Maybe (Maybe AccountStatus)) + select = + dimapPG + [maybeStatement|SELECT account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] + +-- TODO: This probably needs to work for deleted users +isActivatedImpl :: (PGConstraints r) => UserId -> Sem r Bool +isActivatedImpl uid = + fromMaybe False <$> runStatement uid select + where + select :: Hasql.Statement UserId (Maybe Bool) + select = + lmapPG + [maybeStatement|SELECT activated :: bool FROM wire_user WHERE id = $1 :: uuid|] + +lookupLocaleImpl :: (PGConstraints r) => UserId -> Sem r (Maybe (Maybe Language, Maybe Country)) +lookupLocaleImpl uid = + runStatement uid select + where + select :: Hasql.Statement UserId (Maybe (Maybe Language, Maybe Country)) + select = + dimapPG + [maybeStatement|SELECT language :: text?, country :: text? FROM wire_user WHERE id = $1 :: uuid|] + +-- TODO: This probably needs to work for deleted users +getUserTeamImpl :: (PGConstraints r) => UserId -> Sem r (Maybe TeamId) +getUserTeamImpl uid = + join <$> runStatement uid select + where + select :: Hasql.Statement UserId (Maybe (Maybe TeamId)) + select = + dimapPG + [maybeStatement|SELECT team :: uuid? FROM wire_user WHERE id = $1 :: uuid|] + +updateUserTeamImpl :: (PGConstraints r) => UserId -> TeamId -> Sem r () +updateUserTeamImpl uid tid = + runStatement (uid, tid) update + where + update :: Hasql.Statement (UserId, TeamId) () + update = + dimapPG + [resultlessStatement|UPDATE wire_user SET team = $2 :: uuid WHERE id = $1 :: uuid|] + +-- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. +getRichInfoImpl :: (PGConstraints r) => UserId -> Sem r (Maybe RichInfoAssocList) +getRichInfoImpl uid = + join <$> runStatement (uid) select + where + select :: Hasql.Statement (UserId) (Maybe (Maybe RichInfoAssocList)) + select = + dimapPG + [maybeStatement|SELECT rich_info :: json? FROM wire_user WHERE id = $1 :: uuid|] + +updateRichInfoImpl :: (PGConstraints r) => UserId -> RichInfoAssocList -> Sem r () +updateRichInfoImpl uid richInfo = + runStatement (uid, richInfo) update + where + update :: Hasql.Statement (UserId, RichInfoAssocList) () + update = + dimapPG + [resultlessStatement|UPDATE wire_user SET rich_info = $2 :: jsonb WHERE id = $1 :: uuid|] + +lookupRichInfosImpl :: (PGConstraints r) => [UserId] -> Sem r [(UserId, RichInfo)] +lookupRichInfosImpl uids = + mapMaybe (\(uid, mbRi) -> (uid,) . RichInfo <$> mbRi) <$> runStatement uids select + where + select :: Hasql.Statement [UserId] [(UserId, Maybe RichInfoAssocList)] + select = + dimapPG @(Vector _) + [vectorStatement|SELECT id :: uuid, rich_info :: json? FROM wire_user WHERE id = ANY($1 :: uuid[])|] + +upsertHashedPasswordImpl :: (PGConstraints r) => UserId -> Password -> Sem r () +upsertHashedPasswordImpl uid pw = runStatement (uid, pw) upsert + where + upsert :: Hasql.Statement (UserId, Password) () + upsert = + lmapPG + [resultlessStatement|UPDATE wire_user + SET password = $2 :: text + WHERE id = $1 :: uuid + |] + +lookupHashedPasswordImpl :: (PGConstraints r) => UserId -> Sem r (Maybe Password) +lookupHashedPasswordImpl uid = join <$> runStatement uid select + where + select :: Hasql.Statement UserId (Maybe (Maybe Password)) + select = + dimapPG + [maybeStatement|SELECT password :: text? from wire_user where id = $1 :: uuid|] + +-- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. +getUserAuthenticationInfoImpl :: (PGConstraints r) => UserId -> Sem r (Maybe (Maybe Password, AccountStatus)) +getUserAuthenticationInfoImpl uid = + withDefaultAccountStatus <$$> runStatement (uid) select + where + withDefaultAccountStatus :: (a, Maybe AccountStatus) -> (a, AccountStatus) + withDefaultAccountStatus (a, mStatus) = (a, fromMaybe Active mStatus) + + select :: Hasql.Statement (UserId) (Maybe (Maybe Password, Maybe AccountStatus)) + select = + dimapPG + [maybeStatement|SELECT password :: bytea?, account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] + +-- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. +setUserSearchableImpl :: (PGConstraints r) => UserId -> SetSearchable -> Sem r () +setUserSearchableImpl uid (SetSearchable searchable) = + runStatement (uid, searchable) update + where + update :: Hasql.Statement (UserId, Bool) () + update = + dimapPG + [resultlessStatement|UPDATE wire_user SET searchable = $2 :: boolean WHERE id = $1 :: uuid|] + +deleteServiceUserImpl :: (PGConstraints r) => ProviderId -> ServiceId -> BotId -> Sem r () +deleteServiceUserImpl _ _ bid = + runStatement (botUserId bid) delete + where + delete :: Hasql.Statement (UserId) () + delete = + lmapPG + [resultlessStatement|DELETE FROM bot_conv where id = $1 :: uuid|] + +lookupServiceUsersImpl :: (PGConstraints r) => ProviderId -> ServiceId -> Maybe BotId -> Sem r (PageWithState BotId (BotId, ConvId, Maybe TeamId)) +lookupServiceUsersImpl _ _ mBotId = do + bots <- case mBotId of + Nothing -> runStatement () selectStart + Just bid -> runStatement bid selectFrom + pure + PageWithState + { pwsState = PaginationStatePostgres . fst3 <$> (bots V.!? (V.length bots - 1)), + pwsResults = V.toList bots + } + where + selectStart :: Hasql.Statement () (Vector (BotId, ConvId, Maybe TeamId)) + selectStart = + dimapPG + [vectorStatement| + SELECT id :: uuid, conv :: uuid, conv_team :: uuid? + FROM bot_conv + ORDER BY id + LIMIT 100 + |] + + selectFrom :: Hasql.Statement (BotId) (Vector (BotId, ConvId, Maybe TeamId)) + selectFrom = + dimapPG + [vectorStatement| + SELECT id :: uuid, conv :: uuid, conv_team :: uuid? + FROM bot_conv + WHERE id > $1 :: uuid + ORDER BY id + LIMIT 100 + |] + +lookupServiceUsersForTeamImpl :: (PGConstraints r) => ProviderId -> ServiceId -> TeamId -> Maybe BotId -> Sem r (PageWithState BotId (BotId, ConvId)) +lookupServiceUsersForTeamImpl _ _ tid mBotId = do + bots <- case mBotId of + Nothing -> runStatement (tid) selectStart + Just bid -> runStatement (tid, bid) selectFrom + pure + PageWithState + { pwsState = PaginationStatePostgres . fst <$> (bots V.!? (V.length bots - 1)), + pwsResults = V.toList bots + } + where + selectStart :: Hasql.Statement (TeamId) (Vector (BotId, ConvId)) + selectStart = + dimapPG + [vectorStatement| + SELECT id :: uuid, conv :: uuid + FROM bot_conv + WHERE conv_team = $1 :: uuid + ORDER BY id + LIMIT 100 + |] + + selectFrom :: Hasql.Statement (TeamId, BotId) (Vector (BotId, ConvId)) + selectFrom = + dimapPG + [vectorStatement| + SELECT id :: uuid, conv :: uuid + FROM bot_conv + WHERE conv_team = $1 :: uuid + AND id > $2 :: uuid + ORDER BY id + LIMIT 100 + |] + +updateSSOIdImpl :: (PGConstraints r) => UserId -> Maybe UserSSOId -> Sem r Bool +updateSSOIdImpl uid ssoid = + isJust . join <$> runStatement (uid, ssoid) update + where + update :: Hasql.Statement (UserId, Maybe UserSSOId) (Maybe (Maybe TeamId)) + update = + dimapPG + [maybeStatement| + UPDATE wire_user + SET sso_id = $2 :: jsonb? + WHERE id = $1 :: uuid + AND team IS NOT NULL + RETURNING team :: uuid? + |] + +updateManagedByImpl :: (PGConstraints r) => UserId -> ManagedBy -> Sem r () +updateManagedByImpl uid managedBy = + runStatement (uid, managedBy) update + where + update :: Hasql.Statement (UserId, ManagedBy) () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET managed_by = $2 :: integer + WHERE id = $1 :: uuid + |] + +updateAccountStatusImpl :: (PGConstraints r) => UserId -> AccountStatus -> Sem r () +updateAccountStatusImpl uid status = + runStatement (uid, status) update + where + update :: Hasql.Statement (UserId, AccountStatus) () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET account_status = $2 :: integer + WHERE id = $1 :: uuid + |] + +updateFeatureConferenceCallingImpl :: (PGConstraints r) => UserId -> Maybe FeatureStatus -> Sem r () +updateFeatureConferenceCallingImpl uid featureStatus = + runStatement (uid, featureStatus) update + where + update :: Hasql.Statement (UserId, Maybe FeatureStatus) () + update = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET feature_conference_calling = $2 :: integer? + WHERE id = $1 :: uuid + |] + +lookupFeatureConferenceCallingImpl :: (PGConstraints r) => UserId -> Sem r (Maybe FeatureStatus) +lookupFeatureConferenceCallingImpl uid = join <$> runStatement uid select + where + select :: Hasql.Statement UserId (Maybe (Maybe FeatureStatus)) + select = + dimapPG + [maybeStatement|SELECT feature_conference_calling :: integer? FROM wire_user WHERE id = $1 :: uuid|] diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 0f4c739c004..95277e5a10b 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -454,6 +454,7 @@ library Wire.UserStore Wire.UserStore.Cassandra Wire.UserStore.IndexUser + Wire.UserStore.Postgres Wire.UserStore.Unique Wire.UserSubsystem Wire.UserSubsystem.Error From 9bfd510e44da3b2cccb5898eda9b750898f1b35a Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 25 Feb 2026 14:54:12 +0100 Subject: [PATCH 02/22] brig: Allow selecting postgres storage for user --- .../templates/migrate-data.yaml | 2 + charts/elasticsearch-index/values.yaml | 3 ++ charts/wire-server/values.yaml | 27 +++++------ hack/helm_vars/common.yaml.gotmpl | 1 + hack/helm_vars/wire-server/values.yaml.gotmpl | 1 + .../src/Wire/PostgresMigrationOpts.hs | 25 +++++++--- .../src/Wire/UserStore/Postgres.hs | 6 +-- .../background-worker.integration.yaml | 6 --- .../Wire/BackendNotificationPusherSpec.hs | 6 ++- .../background-worker/test/Test/Wire/Util.hs | 3 +- services/brig/brig.integration.yaml | 1 + .../brig/src/Brig/CanonicalInterpreter.hs | 15 ++++-- services/brig/src/Brig/Index/Eval.hs | 46 ++++++++++++------- services/brig/src/Brig/Index/Options.hs | 28 ++++++++--- services/brig/test/integration/API/Search.hs | 5 +- services/galley/galley.integration.yaml | 1 + 16 files changed, 117 insertions(+), 59 deletions(-) diff --git a/charts/elasticsearch-index/templates/migrate-data.yaml b/charts/elasticsearch-index/templates/migrate-data.yaml index 0b6ded659ab..ec23f91f8ca 100644 --- a/charts/elasticsearch-index/templates/migrate-data.yaml +++ b/charts/elasticsearch-index/templates/migrate-data.yaml @@ -71,6 +71,8 @@ spec: {{- end }} - --pg-settings - {{ toJson .Values.postgresql | quote }} + - --user-storage-location + - {{ .Values.postgresMigration.user }} volumeMounts: {{- if hasKey .Values.secrets "elasticsearch" }} - name: "elasticsearch-index-secrets" diff --git a/charts/elasticsearch-index/values.yaml b/charts/elasticsearch-index/values.yaml index b653042eeac..9aa145388fe 100644 --- a/charts/elasticsearch-index/values.yaml +++ b/charts/elasticsearch-index/values.yaml @@ -49,6 +49,9 @@ postgresqlPool: agingTimeout: 1d idlenessTimeout: 10m +postgresMigration: + user: cassandra + galley: host: galley port: 8080 diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index fc50c17dfad..405f4e85c31 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -7,7 +7,7 @@ tags: legalhold: false - federation: false + federation: false backoffice: false mlsstats: false integration: false @@ -90,6 +90,7 @@ galley: conversationCodes: cassandra teamFeatures: cassandra domainRegistration: cassandra + user: cassandra settings: httpPoolSize: 128 maxTeamSize: 10000 @@ -1031,7 +1032,7 @@ brig: # tlsCaSecretRef: # name: # key: - + elasticsearch: scheme: http host: elasticsearch-client @@ -1077,7 +1078,7 @@ brig: # tlsCaSecretRef: # name: # key: - + # Postgres connection settings # # Values are described in https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-PARAMKEYWORDS @@ -1100,7 +1101,7 @@ brig: acquisitionTimeout: 10s agingTimeout: 1d idlenessTimeout: 10m - + emailSMS: general: templateBranding: @@ -1195,25 +1196,25 @@ brig: maxRateLimitedKeys: 100000 # Estimated memory usage: 4 MB # setAuditLogEmailRecipient: security@wire.com setEphemeralUserCreationEnabled: true - + smtp: passwordFile: /etc/wire/brig/secrets/smtp-password.txt proxy: {} wireServerEnterprise: enabled: false - + turnStatic: v1: - turn:localhost:3478 v2: - turn:localhost:3478 - turn:localhost:3478?transport=tcp - + turn: serversSource: files # files | dns # baseDomain: turn.wire.example # Must be configured if serversSource is dns discoveryIntervalSeconds: 10 # Used only if serversSource is dns - + serviceAccount: # When setting this to 'false', either make sure that a service account named # 'brig' exists or change the 'name' field to 'default' @@ -1221,9 +1222,9 @@ brig: name: brig annotations: {} automountServiceAccountToken: true - + secrets: {} - + podSecurityContext: allowPrivilegeEscalation: false capabilities: @@ -1237,11 +1238,11 @@ brig: {} # uploadXml: # baseUrl: s3://bucket/path/ - + secrets: # uploadXmlAwsAccessKeyId: # uploadXmlAwsSecretAccessKey: - + # These "secrets" are only used in tests and are therefore safe to be stored unencrypted providerPrivateKey: | -----BEGIN RSA PRIVATE KEY----- @@ -1303,7 +1304,7 @@ brig: hZMuK3BWD3fzkQVfW0yMwz6fWRXB483ZmekGkgndOTDoJQMdJXZxHpI3t2FcxQYj T45GXxRd18neXtuYa/OoAw9UQFDN5XfXN0g= -----END CERTIFICATE----- - + # pgPassword: test: elasticsearch: diff --git a/hack/helm_vars/common.yaml.gotmpl b/hack/helm_vars/common.yaml.gotmpl index 17b0dbd6005..2276355e2a9 100644 --- a/hack/helm_vars/common.yaml.gotmpl +++ b/hack/helm_vars/common.yaml.gotmpl @@ -18,6 +18,7 @@ conversationStore: {{ $preferredStore }} conversationCodesStore: {{ $preferredStore }} teamFeaturesStore: {{ $preferredStore }} domainRegistration: {{ $preferredStore }} +userStore: {{ $preferredStore }} {{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }} uploadXml: {} diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index 116b9315c68..c45c056c7c9 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -305,6 +305,7 @@ galley: conversationCodes: {{ .Values.conversationCodesStore }} teamFeatures: {{ .Values.teamFeaturesStore }} domainRegistration: {{ .Values.domainRegistration }} + user: {{ .Values.userStore }} settings: maxConvAndTeamSize: 16 maxTeamSize: 32 diff --git a/libs/wire-subsystems/src/Wire/PostgresMigrationOpts.hs b/libs/wire-subsystems/src/Wire/PostgresMigrationOpts.hs index f02ade14b9b..327862f7cd5 100644 --- a/libs/wire-subsystems/src/Wire/PostgresMigrationOpts.hs +++ b/libs/wire-subsystems/src/Wire/PostgresMigrationOpts.hs @@ -34,17 +34,29 @@ data StorageLocation deriving (Show) instance FromJSON StorageLocation where - parseJSON = withText "StorageLocation" $ \case - "cassandra" -> pure CassandraStorage - "migration-to-postgresql" -> pure MigrationToPostgresql - "postgresql" -> pure PostgresqlStorage - x -> fail $ "Invalid storage location: " <> Text.unpack x <> ". Valid options: cassandra, postgresql, migration-to-postgresql" + parseJSON = + withText "StorageLocation" $ + either fail pure . parseStorageLocation . Text.unpack + +parseStorageLocation :: String -> Either String StorageLocation +parseStorageLocation = \case + "cassandra" -> Right CassandraStorage + "migration-to-postgresql" -> Right MigrationToPostgresql + "postgresql" -> Right PostgresqlStorage + x -> Left $ "Invalid storage location: " <> x <> ". Valid options: cassandra, postgresql, migration-to-postgresql" + +storageLocationString :: StorageLocation -> String +storageLocationString = \case + CassandraStorage -> "cassandra" + MigrationToPostgresql -> "migration-to-postgresql" + PostgresqlStorage -> "postgresql" data PostgresMigrationOpts = PostgresMigrationOpts { conversation :: StorageLocation, conversationCodes :: StorageLocation, teamFeatures :: StorageLocation, - domainRegistration :: StorageLocation + domainRegistration :: StorageLocation, + user :: StorageLocation } deriving (Show) @@ -55,3 +67,4 @@ instance FromJSON PostgresMigrationOpts where <*> o .: "conversationCodes" <*> o .: "teamFeatures" <*> o .: "domainRegistration" + <*> o .: "user" diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 8267b460ad2..e2400320be0 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -1,7 +1,7 @@ {-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} -module Wire.UserStore.Postgres where +module Wire.UserStore.Postgres (interpretUserStorePostgres) where import Cassandra (GeneralPaginationState (PaginationStatePostgres), PageWithState (..), paginationStatePostgres) import Control.Error (lastMay) @@ -33,8 +33,8 @@ import Wire.StoredUser import Wire.UserStore import Wire.UserStore.IndexUser -interpretUserStoreCassandra :: (PGConstraints r) => InterpreterFor UserStore r -interpretUserStoreCassandra = +interpretUserStorePostgres :: (PGConstraints r) => InterpreterFor UserStore r +interpretUserStorePostgres = interpret $ \case CreateUser new mbConv -> createUserImpl new mbConv ActivateUser uid identity -> activateUserImpl uid identity diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index fa398766188..184bb87f3ea 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -64,9 +64,3 @@ backgroundJobs: concurrency: 4 jobTimeout: 5s maxAttempts: 3 - -postgresMigration: - conversation: postgresql - conversationCodes: postgresql - teamFeatures: postgresql - domainRegistration: postgresql diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 1b4715d07a1..a39f2d5e6e8 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -367,7 +367,8 @@ spec = do { conversation = CassandraStorage, conversationCodes = CassandraStorage, teamFeatures = CassandraStorage, - domainRegistration = CassandraStorage + domainRegistration = CassandraStorage, + user = CassandraStorage } gundeckEndpoint = undefined brigEndpoint = undefined @@ -419,7 +420,8 @@ spec = do { conversation = CassandraStorage, conversationCodes = CassandraStorage, teamFeatures = CassandraStorage, - domainRegistration = CassandraStorage + domainRegistration = CassandraStorage, + user = CassandraStorage } gundeckEndpoint = undefined brigEndpoint = undefined diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index a3e23d4ea56..cb5d30bc790 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -45,7 +45,8 @@ testEnv = do { conversation = CassandraStorage, conversationCodes = CassandraStorage, teamFeatures = CassandraStorage, - domainRegistration = CassandraStorage + domainRegistration = CassandraStorage, + user = CassandraStorage } statuses <- newIORef mempty backendNotificationMetrics <- mkBackendNotificationMetrics diff --git a/services/brig/brig.integration.yaml b/services/brig/brig.integration.yaml index e59957d04a6..05ec68a0213 100644 --- a/services/brig/brig.integration.yaml +++ b/services/brig/brig.integration.yaml @@ -176,6 +176,7 @@ postgresMigration: conversationCodes: postgresql teamFeatures: postgresql domainRegistration: postgresql + user: postgresql optSettings: setActivationTimeout: 4 diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 06eb36f10ff..d56152bdb37 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -162,6 +162,7 @@ import Wire.UserKeyStore import Wire.UserKeyStore.Cassandra import Wire.UserStore import Wire.UserStore.Cassandra +import Wire.UserStore.Postgres (interpretUserStorePostgres) import Wire.UserSubsystem import Wire.UserSubsystem.Error import Wire.UserSubsystem.Interpreter @@ -205,6 +206,8 @@ type BrigLowerLevelEffects = BackendNotificationQueueAccess, BackgroundJobsPublisher, RateLimit, + UserKeyStore, + UserStore, UserGroupStore, DomainRegistrationStore, DomainVerificationChallengeStore, @@ -228,8 +231,6 @@ type BrigLowerLevelEffects = CryptoSign, HashPassword, ClientStore, - UserKeyStore, - UserStore, IndexedUserStore, SessionStore, PasswordStore, @@ -405,6 +406,12 @@ runBrigToIO e (AppT ma) = do PostgresqlStorage -> interpretDomainVerificationChallengeStoreToPostgres e.settings.challengeTTL MigrationToPostgresql -> interpretDomainVerificationChallengeStoreToCassandraAndPostgres e.settings.challengeTTL + userStoreInterpreter = + case e.postgresMigration.user of + CassandraStorage -> interpretUserStoreCassandra e.casClient + PostgresqlStorage -> interpretUserStorePostgres + MigrationToPostgresql -> error "Migration not implemented for user" + ( either throwM pure <=< ( runFinal . unsafelyPerformConcurrency @@ -453,8 +460,6 @@ runBrigToIO e (AppT ma) = do . interpretPasswordStore e.casClient . interpretSessionStoreCassandra e.casClient . interpretIndexedUserStoreES indexedUserStoreConfig - . interpretUserStoreCassandra e.casClient - . interpretUserKeyStoreCassandra e.casClient . interpretClientStoreCassandra clientStoreCassandraEnv . runHashPassword e.settings.passwordHashingOptions . runCryptoSign @@ -478,6 +483,8 @@ runBrigToIO e (AppT ma) = do . domainVerificationChallengeStore . domainRegistrationStore . interpretUserGroupStoreToPostgres + . userStoreInterpreter + . interpretUserKeyStoreCassandra e.casClient . interpretRateLimit e.rateLimitEnv . interpretBackgroundJobsPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel . interpretBackendNotificationQueueAccess (Just backendNotificationQueueEnv) diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index f2b1baa3d1f..4590db22449 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -40,10 +40,13 @@ import Data.Id import Database.Bloodhound qualified as ES import Database.Bloodhound.Internal.Client (BHEnv (..)) import Hasql.Pool +import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended import Imports import Network.HTTP.Client (Manager) import Polysemy import Polysemy.Error +import Polysemy.Input import Polysemy.TinyLog (TinyLog) import System.Logger qualified as Log import System.Logger.Class (Logger) @@ -57,6 +60,7 @@ import Wire.IndexedUserStore.ElasticSearch import Wire.IndexedUserStore.MigrationStore (IndexedUserMigrationStore) import Wire.IndexedUserStore.MigrationStore.ElasticSearch import Wire.ParseException +import Wire.PostgresMigrationOpts import Wire.Rpc import Wire.Sem.Logger.TinyLog import Wire.Sem.Metrics (Metrics) @@ -66,6 +70,7 @@ import Wire.UserKeyStore.Cassandra import Wire.UserSearch.Migration (MigrationException) import Wire.UserStore (UserStore) import Wire.UserStore.Cassandra +import Wire.UserStore.Postgres (interpretUserStorePostgres) type BrigIndexEffectStack = [ UserKeyStore, @@ -79,17 +84,21 @@ type BrigIndexEffectStack = Rpc, Metrics, TinyLog, + Input Hasql.Pool, Error UsageError, Error ClientError, Embed IO, Final IO ] -mkSemDeps :: ESConnectionSettings -> CassandraSettings -> Logger -> IO (Manager, ClientState, BHEnv, IndexedUserStoreConfig, RequestId, IndexName) -mkSemDeps esConn cas logger = do +type SemDeps = (Manager, ClientState, Hasql.Pool, BHEnv, IndexedUserStoreConfig, RequestId, IndexName) + +mkSemDeps :: ESConnectionSettings -> CassandraSettings -> PostgresSettings -> Logger -> IO SemDeps +mkSemDeps esConn cas pg logger = do mgr <- initHttpManagerWithTLSConfig esConn.esInsecureSkipVerifyTls esConn.esCaCert mEsCreds :: Maybe Credentials <- for esConn.esCredentials initCredentials casClient <- defInitCassandra (toCassandraOpts cas) logger + pgPool <- initPostgresPool pg.pool pg.settings pg.passwordFile let bhEnv = BHEnv { bhServer = toESServer esConn.esServer, @@ -107,14 +116,19 @@ mkSemDeps esConn cas logger = do } reqId = (RequestId "brig-index") migrationIndexName = fromMaybe defaultMigrationIndexName (esMigrationIndexName esConn) - pure (mgr, casClient, bhEnv, indexedUserStoreConfig, reqId, migrationIndexName) - -runSem :: (Manager, ClientState, BHEnv, IndexedUserStoreConfig, RequestId, IndexName) -> Endpoint -> Logger -> Sem BrigIndexEffectStack a -> IO a -runSem (mgr, casClient, bhEnv, indexedUserStoreConfig, reqId, migrationIndexName) galleyEndpoint logger action = do + pure (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationIndexName) + +runSem :: SemDeps -> UserStorageLocation -> Endpoint -> Logger -> Sem BrigIndexEffectStack a -> IO a +runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationIndexName) userStorage galleyEndpoint logger action = do + let userStoreInterpreter = case userStorage.userStorageLocation of + CassandraStorage -> interpretUserStoreCassandra casClient + MigrationToPostgresql -> error "Migration not implemented for user" + PostgresqlStorage -> interpretUserStorePostgres runFinal . embedToFinal . throwErrorToIOFinal @ClientError . throwErrorToIOFinal @UsageError + . runInputConst pgPool . loggerToTinyLogReqId reqId logger . ignoreMetrics . runRpcWithHttp mgr reqId @@ -124,7 +138,7 @@ runSem (mgr, casClient, bhEnv, indexedUserStoreConfig, reqId, migrationIndexName . interpretIndexedUserMigrationStoreES bhEnv migrationIndexName . throwErrorToIOFinal @IndexedUserStoreError . interpretIndexedUserStoreES indexedUserStoreConfig - . interpretUserStoreCassandra casClient + . userStoreInterpreter . interpretUserKeyStoreCassandra casClient $ action @@ -142,18 +156,18 @@ runCommand l = \case Reset es galley -> do e <- initIndex l (es ^. esConnection) galley runIndexIO e $ resetIndex (mkCreateIndexSettings es) - Reindex es cas _pg galley -> do - semDeps <- mkSemDeps (es ^. esConnection) cas l - IndexedUserStoreBulk.syncAllUsers (runSem semDeps galley l) - ReindexSameOrNewer es cas _pg galley -> do - semDeps <- mkSemDeps (es ^. esConnection) cas l - IndexedUserStoreBulk.forceSyncAllUsers (runSem semDeps galley l) + Reindex es cas pg userStorageLocation galley -> do + semDeps <- mkSemDeps (es ^. esConnection) cas pg l + IndexedUserStoreBulk.syncAllUsers (runSem semDeps userStorageLocation galley l) + ReindexSameOrNewer es cas pg userStorageLocation galley -> do + semDeps <- mkSemDeps (es ^. esConnection) cas pg l + IndexedUserStoreBulk.forceSyncAllUsers (runSem semDeps userStorageLocation galley l) UpdateMapping esConn galley -> do e <- initIndex l esConn galley runIndexIO e updateMapping - Migrate es cas _pg galley -> do - semDeps <- mkSemDeps (es ^. esConnection) cas l - IndexedUserStoreBulk.migrateData (runSem semDeps galley l) + Migrate es cas pg userStorageLocation galley -> do + semDeps <- mkSemDeps (es ^. esConnection) cas pg l + IndexedUserStoreBulk.migrateData (runSem semDeps userStorageLocation galley l) ReindexFromAnotherIndex reindexSettings -> do mgr <- initHttpManagerWithTLSConfig diff --git a/services/brig/src/Brig/Index/Options.hs b/services/brig/src/Brig/Index/Options.hs index 8b0966d25f5..8c7a0a1c590 100644 --- a/services/brig/src/Brig/Index/Options.hs +++ b/services/brig/src/Brig/Index/Options.hs @@ -35,6 +35,7 @@ module Brig.Index.Options cTlsCa, cKeyspace, PostgresSettings (..), + UserStorageLocation (..), localElasticSettings, brigOptsToPostgresSettings, localCassandraSettings, @@ -71,15 +72,16 @@ import Options.Applicative import URI.ByteString import URI.ByteString.QQ import Util.Options (CassandraOpts (..), Endpoint (..), FilePathSecrets) +import Wire.PostgresMigrationOpts data Command = Create ElasticSettings Endpoint | Reset ElasticSettings Endpoint - | Reindex ElasticSettings CassandraSettings PostgresSettings Endpoint - | ReindexSameOrNewer ElasticSettings CassandraSettings PostgresSettings Endpoint + | Reindex ElasticSettings CassandraSettings PostgresSettings UserStorageLocation Endpoint + | ReindexSameOrNewer ElasticSettings CassandraSettings PostgresSettings UserStorageLocation Endpoint | -- | 'ElasticSettings' has shards and other settings that are not needed here. UpdateMapping ESConnectionSettings Endpoint - | Migrate ElasticSettings CassandraSettings PostgresSettings Endpoint + | Migrate ElasticSettings CassandraSettings PostgresSettings UserStorageLocation Endpoint | ReindexFromAnotherIndex ReindexFromAnotherIndexSettings deriving (Show) @@ -126,6 +128,9 @@ data ReindexFromAnotherIndexSettings = ReindexFromAnotherIndexSettings } deriving (Show) +newtype UserStorageLocation = UserStorageLocation {userStorageLocation :: StorageLocation} + deriving (Show) + makeLenses ''ElasticSettings makeLenses ''CassandraSettings @@ -444,6 +449,17 @@ reindexToAnotherIndexSettingsParser = <> showDefault ) +userStorageLocationParser :: Parser UserStorageLocation +userStorageLocationParser = + UserStorageLocation + <$> option + (eitherReader parseStorageLocation) + ( long "user-storage-location" + <> help "Storage location of user, valid options: cassandra, postgersql, migration-to-postgresql" + <> value CassandraStorage + <> showDefaultWith storageLocationString + ) + galleyEndpointParser :: Parser Endpoint galleyEndpointParser = Endpoint @@ -487,19 +503,19 @@ commandParser = <> command "reindex" ( info - (Reindex <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> galleyEndpointParser) + (Reindex <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> userStorageLocationParser <*> galleyEndpointParser) (progDesc "Reindex all users from Cassandra if there is a new version.") ) <> command "reindex-if-same-or-newer" ( info - (ReindexSameOrNewer <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> galleyEndpointParser) + (ReindexSameOrNewer <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> userStorageLocationParser <*> galleyEndpointParser) (progDesc "Reindex all users from Cassandra, even if the version has not changed.") ) <> command "migrate-data" ( info - (Migrate <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> galleyEndpointParser) + (Migrate <$> elasticSettingsParser <*> cassandraSettingsParser <*> postgresSettingsParser <*> userStorageLocationParser <*> galleyEndpointParser) (progDesc "Migrate data in elastic search") ) <> command diff --git a/services/brig/test/integration/API/Search.hs b/services/brig/test/integration/API/Search.hs index 966bec0f148..b46cb07fbc6 100644 --- a/services/brig/test/integration/API/Search.hs +++ b/services/brig/test/integration/API/Search.hs @@ -89,6 +89,7 @@ import Wire.API.User.Search import Wire.API.User.Search qualified as Search import Wire.IndexedUserStore.ElasticSearch (mappingName) import Wire.IndexedUserStore.MigrationStore.ElasticSearch (defaultMigrationIndexName) +import Wire.PostgresMigrationOpts tests :: Opt.Opts -> ES.Server -> Manager -> Galley -> Brig -> IO TestTree tests opts additionalElasticSearch mgr galley brig = do @@ -802,7 +803,7 @@ runReindexFromAnotherIndex logger opts newIndexName migrationIndexName = in runCommand logger $ ReindexFromAnotherIndex reindexSettings runReindexFromDatabase :: - (ElasticSettings -> CassandraSettings -> PostgresSettings -> Endpoint -> Command) -> + (ElasticSettings -> CassandraSettings -> PostgresSettings -> UserStorageLocation -> Endpoint -> Command) -> Log.Logger -> Opt.Opts -> ES.IndexName -> @@ -828,7 +829,7 @@ runReindexFromDatabase syncCommand logger opts newIndexName migrationIndexName = postgresSettings :: PostgresSettings = brigOptsToPostgresSettings opts endpoint :: Endpoint = opts.galley - in runCommand logger $ syncCommand elasticSettings cassandraSettings postgresSettings endpoint + in runCommand logger $ syncCommand elasticSettings cassandraSettings postgresSettings (UserStorageLocation opts.postgresMigration.user) endpoint toESConnectionSettings :: ElasticSearchOpts -> ES.IndexName -> ESConnectionSettings toESConnectionSettings opts migrationIndexName = ESConnectionSettings {..} diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index 9d1c23291cb..df0c7fc3ca8 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -253,3 +253,4 @@ postgresMigration: conversationCodes: postgresql teamFeatures: postgresql domainRegistration: postgresql + user: postgresql From 4518e4c2c6f4575bce8741e40a50c387ee9ebb8a Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Mar 2026 11:22:16 +0100 Subject: [PATCH 03/22] UserStore.Postgres: Allow getting deleted users --- .../src/Wire/UserStore/Postgres.hs | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index e2400320be0..c1a2bbbdbb2 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -223,23 +223,58 @@ deleteAssetsStatement = getUsersImpl :: (PGConstraints r) => [UserId] -> Sem r [StoredUser] getUsersImpl uids = do - (userRows, assetRows) <- + (userRows, deletedUserIds, assetRows) <- runPipeline $ - (,) + (,,) <$> Pipeline.statement uids selectUsers + <*> Pipeline.statement uids selectDeletedUsers <*> Pipeline.statement uids selectAssets let assetMap = foldr (\(uid, _, key, size) -> Map.insertWith (<>) uid [ImageAsset key size]) Map.empty assetRows - pure $ - map - ( \row -> - let storedUser = storedUserFromRow row - in storedUser {assets = Map.lookup storedUser.id assetMap} :: StoredUser - ) - userRows + mkDeletedUser deletedUserId = + StoredUser + { id = deletedUserId, + name = Name "default", + status = Just Deleted, + userType = Just UserTypeRegular, + textStatus = Nothing, + pict = Nothing, + email = Nothing, + emailUnvalidated = Nothing, + ssoId = Nothing, + accentId = defaultAccentId, + assets = Nothing, + activated = False, + expires = Nothing, + language = Nothing, + country = Nothing, + providerId = Nothing, + serviceId = Nothing, + handle = Nothing, + teamId = Nothing, + managedBy = Nothing, + supportedProtocols = Nothing, + searchable = Nothing + } + mkUser row = + let storedUser = storedUserFromRow row + in storedUser {assets = Map.lookup storedUser.id assetMap} :: StoredUser + deletedUsersMap = + foldr + (\uid -> Map.insert uid (mkDeletedUser uid)) + mempty + deletedUserIds + foundUsersMap = + foldr + (\userRow -> let user = mkUser userRow in Map.insert user.id user) + mempty + userRows + -- If a user is found in deletedUsers and normal users, prefer the deleted + -- user. + pure $ Map.elems $ Map.union deletedUsersMap foundUsersMap where selectUsers :: Hasql.Statement [UserId] [SelectUserRow] selectUsers = @@ -255,9 +290,14 @@ getUsersImpl uids = do WHERE id = ANY($1 :: uuid[]) |] - -- TODO: Implement this, but make some test fail before implementing - -- selectDeletedUsers :: Hasql.Statement [UserId] [UserId] - -- selectDeletedUsers = pure [] + selectDeletedUsers :: Hasql.Statement [UserId] [UserId] + selectDeletedUsers = + dimapPG @(Vector _) + [vectorStatement| + SELECT id :: uuid + FROM deleted_user + WHERE id = ANY ($1 :: uuid[]) + |] selectAssets :: Hasql.Statement [UserId] [(UserId, Int32, AssetKey, Maybe AssetSize)] selectAssets = From fdad35e54731f607ee0c5b7456c76d4c41991014 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Mar 2026 11:37:06 +0100 Subject: [PATCH 04/22] UserStore.Postgres: Make sure lookupStatus returns Deleted for deleted users --- .../src/Wire/UserStore/Postgres.hs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index c1a2bbbdbb2..8d8fc4f55de 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -518,15 +518,26 @@ deleteUserImpl user = ON CONFLICT (id) DO NOTHING |] --- TODO: This probably needs to work for deleted users lookupStatusImpl :: (PGConstraints r) => UserId -> Sem r (Maybe AccountStatus) -lookupStatusImpl uid = - join <$> runStatement uid select +lookupStatusImpl uid = do + (status, isDeleted) <- + runPipeline $ + (,) + <$> Pipeline.statement uid select + <*> Pipeline.statement uid selectDeleted + pure $ + if isDeleted + then Just Deleted + else join status where select :: Hasql.Statement UserId (Maybe (Maybe AccountStatus)) select = dimapPG [maybeStatement|SELECT account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] + selectDeleted :: Hasql.Statement UserId Bool + selectDeleted = + dimapPG + [singletonStatement|SELECT EXISTS (SELECT 1 FROM deleted_user where id = $1 :: uuid) :: bool|] -- TODO: This probably needs to work for deleted users isActivatedImpl :: (PGConstraints r) => UserId -> Sem r Bool From e5a1a12b993a9c6ad7be54dd2cfc3437008152f7 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Mar 2026 11:42:07 +0100 Subject: [PATCH 05/22] UserStore.Postgres: Makes no sense to say a deleted user is activated This makes postgres behave differently from Cassandra, but this is more correct. --- libs/wire-subsystems/src/Wire/UserStore/Postgres.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 8d8fc4f55de..a359599e937 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -539,7 +539,6 @@ lookupStatusImpl uid = do dimapPG [singletonStatement|SELECT EXISTS (SELECT 1 FROM deleted_user where id = $1 :: uuid) :: bool|] --- TODO: This probably needs to work for deleted users isActivatedImpl :: (PGConstraints r) => UserId -> Sem r Bool isActivatedImpl uid = fromMaybe False <$> runStatement uid select From a58c8c992a8ad14d90069713efe428f40a36cb36 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Mar 2026 11:49:18 +0100 Subject: [PATCH 06/22] UserStore.Postgres: Keep track of team id of a deleted user --- .../20260113140936-create-user-tables.sql | 3 ++- .../src/Wire/UserStore/Postgres.hs | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql b/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql index 8d5d14a241a..f47d176054d 100644 --- a/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql +++ b/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql @@ -59,5 +59,6 @@ CREATE INDEX bot_conv_conv_idx ON bot_conv (conv); CREATE INDEX bot_conv_team_idx ON bot_conv (conv_team); CREATE TABLE deleted_user ( - id uuid PRIMARY KEY + id uuid PRIMARY KEY, + team uuid ); diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index a359599e937..44770afecc6 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -234,18 +234,19 @@ getUsersImpl uids = do (\(uid, _, key, size) -> Map.insertWith (<>) uid [ImageAsset key size]) Map.empty assetRows - mkDeletedUser deletedUserId = + mkDeletedUser deletedUserId mTid = StoredUser { id = deletedUserId, name = Name "default", status = Just Deleted, userType = Just UserTypeRegular, + teamId = mTid, + accentId = defaultAccentId, textStatus = Nothing, pict = Nothing, email = Nothing, emailUnvalidated = Nothing, ssoId = Nothing, - accentId = defaultAccentId, assets = Nothing, activated = False, expires = Nothing, @@ -254,7 +255,6 @@ getUsersImpl uids = do providerId = Nothing, serviceId = Nothing, handle = Nothing, - teamId = Nothing, managedBy = Nothing, supportedProtocols = Nothing, searchable = Nothing @@ -264,7 +264,7 @@ getUsersImpl uids = do in storedUser {assets = Map.lookup storedUser.id assetMap} :: StoredUser deletedUsersMap = foldr - (\uid -> Map.insert uid (mkDeletedUser uid)) + (\(uid, mTid) -> Map.insert uid (mkDeletedUser uid mTid)) mempty deletedUserIds foundUsersMap = @@ -290,11 +290,11 @@ getUsersImpl uids = do WHERE id = ANY($1 :: uuid[]) |] - selectDeletedUsers :: Hasql.Statement [UserId] [UserId] + selectDeletedUsers :: Hasql.Statement [UserId] [(UserId, Maybe TeamId)] selectDeletedUsers = dimapPG @(Vector _) [vectorStatement| - SELECT id :: uuid + SELECT id :: uuid, team :: uuid? FROM deleted_user WHERE id = ANY ($1 :: uuid[]) |] @@ -498,7 +498,7 @@ deleteUserImpl user = runTransaction ReadCommitted Write $ do let uid = user.userQualifiedId.qUnqualified Transaction.statement uid delete - Transaction.statement uid noteDeleted + Transaction.statement (uid, user.userTeam) noteDeleted where delete :: Hasql.Statement UserId () delete = @@ -508,13 +508,13 @@ deleteUserImpl user = WHERE id = $1 :: uuid |] - noteDeleted :: Hasql.Statement (UserId) () + noteDeleted :: Hasql.Statement (UserId, Maybe TeamId) () noteDeleted = lmapPG [resultlessStatement| INSERT INTO deleted_user - (id) - VALUES ($1 :: uuid) + (id, team) + VALUES ($1 :: uuid, $2 :: uuid?) ON CONFLICT (id) DO NOTHING |] @@ -557,7 +557,6 @@ lookupLocaleImpl uid = dimapPG [maybeStatement|SELECT language :: text?, country :: text? FROM wire_user WHERE id = $1 :: uuid|] --- TODO: This probably needs to work for deleted users getUserTeamImpl :: (PGConstraints r) => UserId -> Sem r (Maybe TeamId) getUserTeamImpl uid = join <$> runStatement uid select @@ -565,7 +564,11 @@ getUserTeamImpl uid = select :: Hasql.Statement UserId (Maybe (Maybe TeamId)) select = dimapPG - [maybeStatement|SELECT team :: uuid? FROM wire_user WHERE id = $1 :: uuid|] + [maybeStatement| + SELECT team :: uuid? FROM wire_user WHERE id = $1 :: uuid + UNION ALL + SELECT team :: uuid? FROM deleted_user WHERE id = $1 :: uuid + |] updateUserTeamImpl :: (PGConstraints r) => UserId -> TeamId -> Sem r () updateUserTeamImpl uid tid = From 857f17501e6bbec3b7fe1d7d951271cb7770b76d Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Mar 2026 11:50:00 +0100 Subject: [PATCH 07/22] UserStore.Postgres: Remove TODOs about making things work for deleted users These actions shouldn't work for deleted users. --- libs/wire-subsystems/src/Wire/UserStore/Postgres.hs | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 44770afecc6..79f760100f5 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -579,7 +579,6 @@ updateUserTeamImpl uid tid = dimapPG [resultlessStatement|UPDATE wire_user SET team = $2 :: uuid WHERE id = $1 :: uuid|] --- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. getRichInfoImpl :: (PGConstraints r) => UserId -> Sem r (Maybe RichInfoAssocList) getRichInfoImpl uid = join <$> runStatement (uid) select @@ -626,7 +625,6 @@ lookupHashedPasswordImpl uid = join <$> runStatement uid select dimapPG [maybeStatement|SELECT password :: text? from wire_user where id = $1 :: uuid|] --- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. getUserAuthenticationInfoImpl :: (PGConstraints r) => UserId -> Sem r (Maybe (Maybe Password, AccountStatus)) getUserAuthenticationInfoImpl uid = withDefaultAccountStatus <$$> runStatement (uid) select @@ -639,7 +637,6 @@ getUserAuthenticationInfoImpl uid = dimapPG [maybeStatement|SELECT password :: bytea?, account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] --- TODO: This used to work for deleted users, see what breaks if it doesn't, because it really shouldn't. setUserSearchableImpl :: (PGConstraints r) => UserId -> SetSearchable -> Sem r () setUserSearchableImpl uid (SetSearchable searchable) = runStatement (uid, searchable) update From 2370c53417ab2bb2b194abac68a18a6bf2021db0 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 11 Mar 2026 11:16:43 +0100 Subject: [PATCH 08/22] Test 6 letter passwords in unit tests --- .../InterpreterSpec.hs | 31 ++++++++++++ services/brig/test/integration/API/User.hs | 2 +- .../brig/test/integration/API/User/Auth.hs | 47 +------------------ 3 files changed, 34 insertions(+), 46 deletions(-) diff --git a/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs index ac5139937c9..079324eece0 100644 --- a/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/AuthenticationSubsystem/InterpreterSpec.hs @@ -1,3 +1,4 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} -- This file is part of the Wire Server implementation. @@ -161,6 +162,36 @@ toInputPassword pw8 = spec :: Spec spec = describe "AuthenticationSubsystem.Interpreter" do + describe "authenticateEither" do + prop "should allow authenticating for active users" $ + \user0 password -> + let user = user0 {status = Just Active} :: StoredUser + passwords = Map.singleton user.id (hashPassword password) + res = runAllEffects testDomain [user] passwords Nothing $ do + authenticateEither user.id password + in res === Right (Right ()) + prop "should fail authentication when wrong password is provided" $ + \user0 mActualPassword inputPassword -> + let user = user0 {status = Just Active} :: StoredUser + passwords = foldMap @Maybe (Map.singleton user.id . hashPassword @6) mActualPassword + res = runAllEffects testDomain [user] passwords Nothing $ do + authenticateEither user.id inputPassword + in mActualPassword /= Just inputPassword ==> res === Right (Left AuthInvalidCredentials) + + prop "should fail authentication when user is not active" $ + \user password -> + let passwords = Map.singleton user.id (hashPassword password) + res = runAllEffects testDomain [user] passwords Nothing $ do + authenticateEither user.id password + in res + === Right + if + | user.status == Just Active -> Right () + | user.status `elem` [Just Deleted, Nothing] -> Left AuthInvalidUser + | user.status == Just Suspended -> Left AuthSuspended + | user.status == Just Ephemeral -> Left AuthEphemeral + | otherwise -> Left AuthPendingInvitation + describe "password reset" do prop "password reset should work with the email being used as password reset key" $ \email userNoEmail (cookiesWithTTL :: [(Cookie (), Maybe TTL)]) mPreviousPassword newPassword -> diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 7c88c057abf..5fd105f333c 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -73,7 +73,7 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do "user" [ API.User.Client.tests cl at conf p db n b c g, API.User.Account.tests cl at conf p b c ch g aws userJournalWatcher, - API.User.Auth.tests conf p authenticationSubsystemConfig db b g n, + API.User.Auth.tests conf p authenticationSubsystemConfig b g n, API.User.Connection.tests cl at p b c g fbc db, API.User.Handles.tests cl at conf p b c g, API.User.RichInfo.tests cl at conf p b c g diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index 9cd8047a67e..21fd84a00e9 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -29,9 +29,6 @@ import Bilge hiding (body) import Bilge qualified as Http import Bilge.Assert hiding (assert) import Brig.Options qualified as Opts -import Cassandra hiding (Client, Value) -import Cassandra qualified as DB -import Control.Arrow ((&&&)) import Control.Retry import Data.Aeson as Aeson import Data.ByteString qualified as BS @@ -61,7 +58,6 @@ import UnliftIO.Async hiding (wait) import Util import Util.Timeout import Wire.API.Conversation hiding (Member) -import Wire.API.Password as Password import Wire.API.User as Public import Wire.API.User.Auth as Auth import Wire.API.User.Auth.LegalHold @@ -70,7 +66,6 @@ import Wire.API.User.Auth.Sso import Wire.API.User.Client import Wire.AuthenticationSubsystem.Config import Wire.AuthenticationSubsystem.ZAuth qualified as ZAuth -import Wire.HashPassword.Interpreter import Wire.Sem.Now (Now) import Wire.Sem.Now.IO import Wire.Sem.Random (Random) @@ -95,8 +90,8 @@ onlyIfLhWhitelisted action = do \(the 'withLHWhitelist' trick does not work because it does not allow \ \brig to talk to the dynamically spawned galley)." -tests :: Opts.Opts -> Manager -> AuthenticationSubsystemConfig -> DB.ClientState -> Brig -> Galley -> Nginz -> TestTree -tests conf m authCfg db b g n = +tests :: Opts.Opts -> Manager -> AuthenticationSubsystemConfig -> Brig -> Galley -> Nginz -> TestTree +tests conf m authCfg b g n = testGroup "auth" [ testGroup @@ -106,7 +101,6 @@ tests conf m authCfg db b g n = test m "email-untrusted-domain" (testLoginUntrustedDomain b), test m "testLoginFailure - failure" (testLoginFailure b), test m "throttle" (testThrottleLogins conf b), - test m "login with 6 character password" (testLoginWith6CharPassword conf b db), testGroup "sso-login" [ test m "email" (testEmailSsoLogin b), @@ -171,43 +165,6 @@ tests conf m authCfg db b g n = ] ] -testLoginWith6CharPassword :: Opts.Opts -> Brig -> DB.ClientState -> Http () -testLoginWith6CharPassword opts brig db = do - (uid, Just email) <- (userId &&& userEmail) <$> randomUser brig - checkLogin email defPassword 200 - let pw6 = plainTextPassword6Unsafe "123456" - writeDirectlyToDB uid pw6 - checkLogin email defPassword 403 - checkLogin email pw6 200 - where - checkLogin :: EmailAddress -> PlainTextPassword6 -> Int -> Http () - checkLogin email pw expectedStatusCode = - login - brig - (MkLogin (LoginByEmail email) pw Nothing Nothing) - PersistentCookie - !!! const expectedStatusCode === statusCode - - -- Since 8 char passwords are required, when setting a password via the API, - -- we need to write this directly to the db, to be able to test this - writeDirectlyToDB :: UserId -> PlainTextPassword6 -> Http () - writeDirectlyToDB uid pw = - liftIO (runClient db (updatePassword uid pw >> deleteAllCookies uid)) - - updatePassword :: (MonadClient m) => UserId -> PlainTextPassword6 -> m () - updatePassword u t = do - p <- liftIO $ runM . randomToIO $ hashPasswordImpl opts.settings.passwordHashingOptions t - retry x5 $ write userPasswordUpdate (params LocalQuorum (p, u)) - - userPasswordUpdate :: PrepQuery W (Password, UserId) () - userPasswordUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET password = ? WHERE id = ?" - - deleteAllCookies :: (MonadClient m) => UserId -> m () - deleteAllCookies u = retry x5 (write cql (params LocalQuorum (Identity u))) - where - cql :: PrepQuery W (Identity UserId) () - cql = "DELETE FROM user_cookies WHERE user = ?" - -------------------------------------------------------------------------------- -- ZAuth test environment for generating arbitrary tokens. From 06b7f648459ac76b4d6b63701cc3008d431fd86b Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Mon, 4 May 2026 10:44:37 +0200 Subject: [PATCH 09/22] Fix queries for service user lookup --- .../src/Wire/UserStore/Postgres.hs | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 79f760100f5..44dfafb4093 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -656,67 +656,77 @@ deleteServiceUserImpl _ _ bid = [resultlessStatement|DELETE FROM bot_conv where id = $1 :: uuid|] lookupServiceUsersImpl :: (PGConstraints r) => ProviderId -> ServiceId -> Maybe BotId -> Sem r (PageWithState BotId (BotId, ConvId, Maybe TeamId)) -lookupServiceUsersImpl _ _ mBotId = do +lookupServiceUsersImpl pid sid mBotId = do bots <- case mBotId of - Nothing -> runStatement () selectStart - Just bid -> runStatement bid selectFrom + Nothing -> runStatement (pid, sid) selectStart + Just bid -> runStatement (pid, sid, bid) selectFrom pure PageWithState { pwsState = PaginationStatePostgres . fst3 <$> (bots V.!? (V.length bots - 1)), pwsResults = V.toList bots } where - selectStart :: Hasql.Statement () (Vector (BotId, ConvId, Maybe TeamId)) + selectStart :: Hasql.Statement (ProviderId, ServiceId) (Vector (BotId, ConvId, Maybe TeamId)) selectStart = dimapPG [vectorStatement| SELECT id :: uuid, conv :: uuid, conv_team :: uuid? FROM bot_conv + WHERE provider = $1 :: uuid + AND service = $2 :: uuid ORDER BY id LIMIT 100 |] - selectFrom :: Hasql.Statement (BotId) (Vector (BotId, ConvId, Maybe TeamId)) + selectFrom :: Hasql.Statement (ProviderId, ServiceId, BotId) (Vector (BotId, ConvId, Maybe TeamId)) selectFrom = dimapPG [vectorStatement| SELECT id :: uuid, conv :: uuid, conv_team :: uuid? FROM bot_conv - WHERE id > $1 :: uuid + WHERE provider = $1 :: uuid + AND service = $2 :: uuid + AND id > $3 :: uuid ORDER BY id LIMIT 100 |] lookupServiceUsersForTeamImpl :: (PGConstraints r) => ProviderId -> ServiceId -> TeamId -> Maybe BotId -> Sem r (PageWithState BotId (BotId, ConvId)) -lookupServiceUsersForTeamImpl _ _ tid mBotId = do +lookupServiceUsersForTeamImpl pid sid tid mBotId = do bots <- case mBotId of - Nothing -> runStatement (tid) selectStart - Just bid -> runStatement (tid, bid) selectFrom + Nothing -> runStatement (pid, sid, tid) selectStart + Just bid -> runStatement (pid, sid, tid, bid) selectFrom pure PageWithState { pwsState = PaginationStatePostgres . fst <$> (bots V.!? (V.length bots - 1)), pwsResults = V.toList bots } where - selectStart :: Hasql.Statement (TeamId) (Vector (BotId, ConvId)) + selectStart :: Hasql.Statement (ProviderId, ServiceId, TeamId) (Vector (BotId, ConvId)) selectStart = dimapPG [vectorStatement| - SELECT id :: uuid, conv :: uuid + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid FROM bot_conv - WHERE conv_team = $1 :: uuid + JOIN wire_user ON bot_conv.id = wire_user.id + WHERE wire_user.provider = $1 :: uuid + AND wire_user.service = $2 :: uuid + AND bot_conv.conv_team = $3 :: uuid ORDER BY id LIMIT 100 |] - selectFrom :: Hasql.Statement (TeamId, BotId) (Vector (BotId, ConvId)) + selectFrom :: Hasql.Statement (ProviderId, ServiceId, TeamId, BotId) (Vector (BotId, ConvId)) selectFrom = dimapPG [vectorStatement| - SELECT id :: uuid, conv :: uuid + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid FROM bot_conv - WHERE conv_team = $1 :: uuid - AND id > $2 :: uuid + JOIN wire_user ON bot_conv.id = wire_user.id + WHERE wire_user.provider = $1 :: uuid + AND wire_user.service = $2 :: uuid + AND bot_conv.conv_team = $3 :: uuid + AND id > $4 :: uuid ORDER BY id LIMIT 100 |] From 234a52d3ac7a22b016c0333d478e671ab1d3ca5b Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Mon, 4 May 2026 11:09:21 +0200 Subject: [PATCH 10/22] tab -> spaces --- libs/wire-subsystems/src/Wire/UserStore/Postgres.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 44dfafb4093..a0524cdaadd 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -163,7 +163,7 @@ createUserImpl new mbConv = VALUES ($1 :: uuid, $2 :: text, $3 :: text?, $4 :: jsonb, $5 :: text?, $6 :: jsonb?, $7 :: integer, $8 :: text?, $9 :: boolean, $10 :: integer, - $11 :: timestamptz?, $12 :: text, $13 :: text?, $14 :: uuid?, $15 :: uuid?, + $11 :: timestamptz?, $12 :: text, $13 :: text?, $14 :: uuid?, $15 :: uuid?, $16 :: text?, $17 :: uuid?, $18 :: integer, $19 :: integer, $20 :: boolean, $21 :: integer) ON CONFLICT (id) DO UPDATE From de9cad6c5c939ec99151bff75aa48607d3b0b4da Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Mon, 4 May 2026 11:35:11 +0200 Subject: [PATCH 11/22] docs and changelog --- changelog.d/2-features/user-pg | 14 ++++++++++++++ docs/src/developer/reference/config-options.md | 1 + 2 files changed, 15 insertions(+) create mode 100644 changelog.d/2-features/user-pg diff --git a/changelog.d/2-features/user-pg b/changelog.d/2-features/user-pg new file mode 100644 index 00000000000..c0d8ba0f972 --- /dev/null +++ b/changelog.d/2-features/user-pg @@ -0,0 +1,14 @@ +Allow storing user data in PostgreaSQL. + +This is currently not the default and is experimental. The migration path from Cassandra is yet to be programmed. + +However, new installations can use this by configuring the wire-server helm chart like this: + +```yaml +galley: + config: + postgresqlMigration: + user: postgresql +``` + +(##) diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 4a269a7fe45..b7d856dcd08 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -1893,6 +1893,7 @@ galley: conversationCodes: postgresql teamFeatures: postgresql domainRegistration: postgresql + user: postgresql background-worker: config: migrateConversations: false From 9d153e979e4a1187e540425900cfebd9b395787e Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 5 May 2026 14:06:55 +0200 Subject: [PATCH 12/22] More fixes for queries --- .../src/Wire/UserStore/Postgres.hs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index a0524cdaadd..831d57f053a 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -670,11 +670,12 @@ lookupServiceUsersImpl pid sid mBotId = do selectStart = dimapPG [vectorStatement| - SELECT id :: uuid, conv :: uuid, conv_team :: uuid? + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid, bot_conv.conv_team :: uuid? FROM bot_conv - WHERE provider = $1 :: uuid - AND service = $2 :: uuid - ORDER BY id + JOIN wire_user ON bot_conv.id = wire_user.id + WHERE wire_user.provider = $1 :: uuid + AND wire_user.service = $2 :: uuid + ORDER BY bot_conv.id LIMIT 100 |] @@ -682,12 +683,13 @@ lookupServiceUsersImpl pid sid mBotId = do selectFrom = dimapPG [vectorStatement| - SELECT id :: uuid, conv :: uuid, conv_team :: uuid? + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid, bot_conv.conv_team :: uuid? FROM bot_conv - WHERE provider = $1 :: uuid - AND service = $2 :: uuid - AND id > $3 :: uuid - ORDER BY id + JOIN wire_user ON bot_conv.id = wire_user.id + WHERE wire_user.provider = $1 :: uuid + AND wire_user.service = $2 :: uuid + AND bot_conv.id > $3 :: uuid + ORDER BY bot_conv.id LIMIT 100 |] @@ -712,7 +714,7 @@ lookupServiceUsersForTeamImpl pid sid tid mBotId = do WHERE wire_user.provider = $1 :: uuid AND wire_user.service = $2 :: uuid AND bot_conv.conv_team = $3 :: uuid - ORDER BY id + ORDER BY bot_conv.id LIMIT 100 |] @@ -726,8 +728,8 @@ lookupServiceUsersForTeamImpl pid sid tid mBotId = do WHERE wire_user.provider = $1 :: uuid AND wire_user.service = $2 :: uuid AND bot_conv.conv_team = $3 :: uuid - AND id > $4 :: uuid - ORDER BY id + AND bot_conv.id > $4 :: uuid + ORDER BY bot_conv.id LIMIT 100 |] From b459d45fea01b616d0414f59c5b9e0429c607eea Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Thu, 7 May 2026 12:43:50 +0200 Subject: [PATCH 13/22] Use serializable transaction to update handle --- .../src/Wire/UserStore/Postgres.hs | 26 ++++++++++++------- .../brig/test/integration/API/User/Handles.hs | 5 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 831d57f053a..074aab9310f 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -476,21 +476,27 @@ selectUserIdByHandleStatement = |] updateUserHandleEitherImpl :: (PGConstraints r) => UserId -> StoredUserHandleUpdate -> Sem r (Either StoredUserUpdateError ()) -updateUserHandleEitherImpl uid upd = - runTransaction ReadCommitted Write $ do - mOwner <- Transaction.statement upd.new selectUserIdByHandleStatement - case mOwner of - Just uid' | uid' /= uid -> pure $ Left StoredUserUpdateHandleExists - Just _ -> pure $ Right () - Nothing -> Right <$> Transaction.statement (uid, upd.new) update - where - update :: Hasql.Statement (UserId, Handle) () +updateUserHandleEitherImpl uid upd = do + updates <- + runTransaction Serializable Write $ + Transaction.statement (uid, upd.new) update + case updates of + 0 -> pure $ Left StoredUserUpdateHandleExists + _ -> pure $ Right () + where + update :: Hasql.Statement (UserId, Handle) Int64 update = lmapPG - [resultlessStatement| + [rowsAffectedStatement| UPDATE wire_user SET handle = $2 :: text WHERE id = $1 :: uuid + AND NOT EXISTS ( + SELECT 1 + FROM wire_user + WHERE handle = $2 :: text + AND id != $1 :: uuid + ) |] deleteUserImpl :: (PGConstraints r) => User -> Sem r () diff --git a/services/brig/test/integration/API/User/Handles.hs b/services/brig/test/integration/API/User/Handles.hs index 3ade2b74f90..e9d088fd749 100644 --- a/services/brig/test/integration/API/User/Handles.hs +++ b/services/brig/test/integration/API/User/Handles.hs @@ -169,8 +169,11 @@ testHandleRace brig = do void . replicateM 10 $ do hdl <- randomHandle let update = RequestBodyLBS . encode $ HandleUpdate hdl - void . flip mapConcurrently us $ \u -> + responses <- flip mapConcurrently us $ \u -> put (brig . path "/self/handle" . contentJson . zUser u . zConn "c" . body update) + let statusCodes = map statusCode responses + liftIO $ assertBool "At most one update should succeed" (length (filter (== 200) statusCodes) <= 1) + liftIO $ assertBool "Failed updates should return 409" ((Set.fromList $ filter (/= 200) statusCodes) == Set.singleton 409) ps <- forM us $ \u -> responseJsonMaybe <$> get (brig . path "/self" . zUser u) let owners = catMaybes $ filter (maybe False ((== Just (fromJust (parseHandle hdl))) . userHandle)) ps liftIO $ assertBool "More than one owner of a handle" (length owners <= 1) From 5d5afe6a481c1a598ee60153264879422436c9b0 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Thu, 7 May 2026 13:15:48 +0200 Subject: [PATCH 14/22] Typos and better words Co-authored-by: Sven Tennie --- changelog.d/2-features/user-pg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.d/2-features/user-pg b/changelog.d/2-features/user-pg index c0d8ba0f972..52228d7fc52 100644 --- a/changelog.d/2-features/user-pg +++ b/changelog.d/2-features/user-pg @@ -1,8 +1,8 @@ -Allow storing user data in PostgreaSQL. +Allow storing user data in PostgreSQL. -This is currently not the default and is experimental. The migration path from Cassandra is yet to be programmed. +This is currently not the default and is experimental. The migration path from Cassandra is yet to be implemented. -However, new installations can use this by configuring the wire-server helm chart like this: +However, new installations can use this by configuring the wire-server Helm chart like this: ```yaml galley: From 389ae24f73f82307de5b680489e0443f1781b189 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 13 May 2026 12:04:38 +0200 Subject: [PATCH 15/22] hlint and fix import --- .../brig/test/integration/API/User/Handles.hs | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/services/brig/test/integration/API/User/Handles.hs b/services/brig/test/integration/API/User/Handles.hs index e9d088fd749..289cb8113bd 100644 --- a/services/brig/test/integration/API/User/Handles.hs +++ b/services/brig/test/integration/API/User/Handles.hs @@ -1,21 +1,3 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 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 . -{-# OPTIONS_GHC -Wno-deferred-out-of-scope-variables #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2025 Wire Swiss GmbH @@ -53,6 +35,7 @@ import Data.Handle (parseHandle) import Data.Id import Data.List.NonEmpty qualified as NonEmpty import Data.Qualified (Qualified (..)) +import Data.Set qualified as Set import Data.UUID qualified as UUID import Imports import Network.Wai.Utilities.Error qualified as Error @@ -173,7 +156,7 @@ testHandleRace brig = do put (brig . path "/self/handle" . contentJson . zUser u . zConn "c" . body update) let statusCodes = map statusCode responses liftIO $ assertBool "At most one update should succeed" (length (filter (== 200) statusCodes) <= 1) - liftIO $ assertBool "Failed updates should return 409" ((Set.fromList $ filter (/= 200) statusCodes) == Set.singleton 409) + liftIO $ assertBool "Failed updates should return 409" (Set.fromList (filter (/= 200) statusCodes) == Set.singleton 409) ps <- forM us $ \u -> responseJsonMaybe <$> get (brig . path "/self" . zUser u) let owners = catMaybes $ filter (maybe False ((== Just (fromJust (parseHandle hdl))) . userHandle)) ps liftIO $ assertBool "More than one owner of a handle" (length owners <= 1) From 846428f5ed3f8eb86ed083a92945989e05ff9557 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 11:08:54 +0200 Subject: [PATCH 16/22] background-worker.integration.yaml: Add postgresMigration settings back --- .../background-worker/background-worker.integration.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index 184bb87f3ea..86e5b40e4ee 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -64,3 +64,10 @@ backgroundJobs: concurrency: 4 jobTimeout: 5s maxAttempts: 3 + +postgresMigration: + conversation: postgresql + conversationCodes: postgresql + teamFeatures: postgresql + domainRegistration: postgresql + user: postgresql From e28cbfd2a4824967bf782b645885d83ddfa945ec Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 11:09:14 +0200 Subject: [PATCH 17/22] UserStore.Postgres: Log when inconsistence is found between wire_user and deleted_user tables --- .../src/Wire/UserStore/Postgres.hs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 074aab9310f..8f6d08d2cc1 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -21,6 +21,8 @@ import Hasql.Transaction qualified as Transaction import Hasql.Transaction.Sessions import Imports import Polysemy +import Polysemy.TinyLog (TinyLog) +import System.Logger.Message qualified as Log import Wire.API.Asset hiding (Asset) import Wire.API.Password import Wire.API.PostgresMarshall @@ -29,11 +31,12 @@ import Wire.API.User hiding (DeleteUser) import Wire.API.User.RichInfo import Wire.API.User.Search import Wire.Postgres +import Wire.Sem.Logger import Wire.StoredUser import Wire.UserStore import Wire.UserStore.IndexUser -interpretUserStorePostgres :: (PGConstraints r) => InterpreterFor UserStore r +interpretUserStorePostgres :: (PGConstraints r, Member TinyLog r) => InterpreterFor UserStore r interpretUserStorePostgres = interpret $ \case CreateUser new mbConv -> createUserImpl new mbConv @@ -221,7 +224,7 @@ deleteAssetsStatement = lmapPG [resultlessStatement|DELETE FROM asset where user_id = $1 :: uuid|] -getUsersImpl :: (PGConstraints r) => [UserId] -> Sem r [StoredUser] +getUsersImpl :: (PGConstraints r, Member TinyLog r) => [UserId] -> Sem r [StoredUser] getUsersImpl uids = do (userRows, deletedUserIds, assetRows) <- runPipeline $ @@ -272,8 +275,14 @@ getUsersImpl uids = do (\userRow -> let user = mkUser userRow in Map.insert user.id user) mempty userRows + inconsistentUsers = Map.intersection foundUsersMap deletedUsersMap + when (not (Map.null inconsistentUsers)) $ + warn $ + (Log.msg (Log.val "Found data about users which have been marked as deleted. This is likely a database inconsistence and must be addressed.")) + . Log.field "userIds" (show (Map.keys inconsistentUsers)) + -- If a user is found in deletedUsers and normal users, prefer the deleted - -- user. + -- user. 'Map.union' is left biased. pure $ Map.elems $ Map.union deletedUsersMap foundUsersMap where selectUsers :: Hasql.Statement [UserId] [SelectUserRow] From 0c6f33a2b32333db5a46d6942237cdaa82170094 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 11:11:10 +0200 Subject: [PATCH 18/22] UserStore.Postgres: Reduce scope of a statement only used in one impl --- .../src/Wire/UserStore/Postgres.hs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 8f6d08d2cc1..9a1a50118a5 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -473,16 +473,16 @@ lookupNameImpl uid = runStatement uid select |] lookupHandleImpl :: (PGConstraints r) => Handle -> Sem r (Maybe UserId) -lookupHandleImpl h = runStatement h selectUserIdByHandleStatement - -selectUserIdByHandleStatement :: Hasql.Statement Handle (Maybe UserId) -selectUserIdByHandleStatement = - dimapPG - [maybeStatement| - SELECT id :: uuid - FROM wire_user - WHERE handle = $1 :: text - |] +lookupHandleImpl h = runStatement h select + where + select :: Hasql.Statement Handle (Maybe UserId) + select = + dimapPG + [maybeStatement| + SELECT id :: uuid + FROM wire_user + WHERE handle = $1 :: text + |] updateUserHandleEitherImpl :: (PGConstraints r) => UserId -> StoredUserHandleUpdate -> Sem r (Either StoredUserUpdateError ()) updateUserHandleEitherImpl uid upd = do From 589f6221c80fd0b04dd7465307247b12fd44e652 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 11:12:19 +0200 Subject: [PATCH 19/22] UserStore.Postgres: select password as the correct type --- libs/wire-subsystems/src/Wire/UserStore/Postgres.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 9a1a50118a5..8b39d0a5820 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -650,7 +650,7 @@ getUserAuthenticationInfoImpl uid = select :: Hasql.Statement (UserId) (Maybe (Maybe Password, Maybe AccountStatus)) select = dimapPG - [maybeStatement|SELECT password :: bytea?, account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] + [maybeStatement|SELECT password :: text?, account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] setUserSearchableImpl :: (PGConstraints r) => UserId -> SetSearchable -> Sem r () setUserSearchableImpl uid (SetSearchable searchable) = From 198b9dd6fe001e0bb0daa72fba4a91ba534c7530 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 12:21:01 +0200 Subject: [PATCH 20/22] integration: Add assertions for updates to actually happen, add some cases for locale update The test are failing for Postgres due to a bug. --- integration/test/Test/User.hs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/integration/test/Test/User.hs b/integration/test/Test/User.hs index 4124cd129fb..99a0169427f 100644 --- a/integration/test/Test/User.hs +++ b/integration/test/Test/User.hs @@ -185,16 +185,22 @@ testUpdateSelf (MkTagged mode) = do TestUpdateEmailAddress -> do -- allowed unconditionally *for owner* (this is a bit off-topic: team members can't -- change their email addresses themselves under any conditions) - someEmail <- (<> "@example.com") . UUID.toString <$> liftIO UUID.nextRandom - bindResponse (putUserEmail owner owner someEmail) $ \resp -> do + newEmail <- (<> "@example.com") . UUID.toString <$> liftIO UUID.nextRandom + bindResponse (putUserEmail owner owner newEmail) $ \resp -> do resp.status `shouldMatchInt` 200 + getSelf owner `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "email_unvalidated" `shouldMatch` newEmail TestUpdateLocale -> do -- scim maps "User.preferredLanguage" to brig's locale field. allowed unconditionally. -- we try two languages to make sure it doesn't work because it's already the active -- locale. - forM_ ["uk", "he"] $ \someLocale -> - bindResponse (putSelfLocale mem1 someLocale) $ \resp -> do + forM_ ["en-GB", "hi", "de-DE", "de", "he"] $ \newLocale -> do + bindResponse (putSelfLocale mem1 newLocale) $ \resp -> do + resp.status `shouldMatchInt` 200 + getSelf mem1 `bindResponse` \resp -> do resp.status `shouldMatchInt` 200 + resp.json %. "locale" `shouldMatch` newLocale data TestUpdateSelfMode = TestUpdateDisplayName From d172ef5aaab340f2a9d6e72cad5e4a4fdab17dba Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 19 May 2026 12:28:01 +0200 Subject: [PATCH 21/22] UserStore.Postgres: Correctly update locale Override country with `NULL` when it is not provided --- .../src/Wire/UserStore/Postgres.hs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 8b39d0a5820..edf4e8ef63b 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -417,17 +417,20 @@ getIndexUsersPaginatedImpl lim mState = do LIMIT ($2 :: integer) |] -updateUserImpl :: (PGConstraints r) => UserId -> StoredUserUpdate -> Sem r () -updateUserImpl uid MkStoredUserUpdate {..} = - runTransaction ReadCommitted Write $ do +updateUserImpl :: (PGConstraints r, Member TinyLog r) => UserId -> StoredUserUpdate -> Sem r () +updateUserImpl uid MkStoredUserUpdate {..} = do + warn $ Log.msg (Log.val "Updating user") . Log.field "locale" (show locale) + runTransaction Serializable Write $ do Transaction.statement - (uid, name, textStatus, pict, accentId, lLanguage <$> locale, lCountry =<< locale, supportedProtocols) + (uid, name, textStatus, pict, accentId, supportedProtocols) updateUserFields + for_ locale $ \newLocale -> + Transaction.statement (uid, newLocale.lLanguage, newLocale.lCountry) updateLocale for_ assets $ \newAssets -> do Transaction.statement uid deleteAssetsStatement Transaction.statement (mkAssetRows uid newAssets) insertAssetsStatement where - updateUserFields :: Hasql.Statement (UserId, Maybe Name, Maybe TextStatus, Maybe Pict, Maybe ColourId, Maybe Language, Maybe Country, Maybe (Set BaseProtocolTag)) () + updateUserFields :: Hasql.Statement (UserId, Maybe Name, Maybe TextStatus, Maybe Pict, Maybe ColourId, Maybe (Set BaseProtocolTag)) () updateUserFields = lmapPG [resultlessStatement| @@ -436,9 +439,16 @@ updateUserImpl uid MkStoredUserUpdate {..} = text_status = COALESCE($3 :: text?, text_status), picture = COALESCE($4 :: jsonb?, picture), accent_id = COALESCE($5 :: integer?, accent_id), - language = COALESCE($6 :: text?, language), - country = COALESCE($7 :: text?, country), - supported_protocols = COALESCE($8 :: integer?, supported_protocols) + supported_protocols = COALESCE($6 :: integer?, supported_protocols) + WHERE id = ($1 :: uuid) + |] + updateLocale :: Hasql.Statement (UserId, Language, Maybe Country) () + updateLocale = + lmapPG + [resultlessStatement| + UPDATE wire_user + SET language = $2 :: text, + country = $3 :: text? WHERE id = ($1 :: uuid) |] From 50fc241baf845c703926ab692969d6f71d84cfca Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 20 May 2026 20:01:27 +0200 Subject: [PATCH 22/22] WIP --- integration/test/Test/Search.hs | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/integration/test/Test/Search.hs b/integration/test/Test/Search.hs index fc822aedef7..d92b81d699d 100644 --- a/integration/test/Test/Search.hs +++ b/integration/test/Test/Search.hs @@ -26,11 +26,14 @@ import qualified API.Common as API import API.Galley import qualified API.Galley as Galley import qualified API.GalleyInternal as GalleyI +import Control.Monad.Codensity (Codensity (runCodensity)) +import Control.Monad.Reader import qualified Data.Set as Set import GHC.Stack import SetupHelpers import Testlib.Assertions import Testlib.Prelude +import Testlib.ResourcePool (acquireResources) -- * Local Search @@ -563,6 +566,40 @@ testSuspendedUserSearch = do BrigI.refreshIndex OwnDomain assertCanFind searcher searcheeQid (searchee %. "name") OwnDomain +testReindexAllUsers :: (HasCallStack) => App () +testReindexAllUsers = do + resourcePool <- asks (.resourcePool) + runCodensity (acquireResources 1 resourcePool) $ \[testBackend] -> do + let domain = testBackend.berDomain + + (alice, bob, charlie) <- runCodensity (startDynamicBackend testBackend def) $ \_ -> do + alice <- randomUser domain def + bob <- randomUser domain def + charlie <- randomUser domain def + + BrigI.refreshIndex domain + assertCanFind alice bob (bob %. "name") domain + assertCanFind alice charlie (charlie %. "name") domain + pure (alice, bob, charlie) + + -- TODO: Connect with some bogus ES + (dan, bobNewName) <- runCodensity (startDynamicBackend testBackend def) $ \_ -> do + dan <- randomUser domain def + + BrigI.refreshIndex domain + assertCannotFind alice bob (bob %. "name") domain + assertCannotFind alice charlie (charlie %. "name") domain + assertCanFind alice dan (dan %. "name") domain + + bobNewName <- API.randomName + BrigP.putSelf bob (def {BrigP.name = Just bobNewName}) >>= assertSuccess + BrigI.refreshIndex domain + assertCannotFind alice bob bobNewName domain + + pure (dan, bobNewName) + + undefined + -- * Assertion Helpers assertCanFind ::