diff --git a/changelog.d/2-features/user-pg b/changelog.d/2-features/user-pg new file mode 100644 index 00000000000..52228d7fc52 --- /dev/null +++ b/changelog.d/2-features/user-pg @@ -0,0 +1,14 @@ +Allow storing user data in PostgreSQL. + +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: + +```yaml +galley: + config: + postgresqlMigration: + user: postgresql +``` + +(##) 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/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 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/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 :: 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 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..f47d176054d --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260113140936-create-user-tables.sql @@ -0,0 +1,64 @@ +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, + team uuid +); 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/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..edf4e8ef63b --- /dev/null +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -0,0 +1,821 @@ +{-# LANGUAGE RecordWildCards #-} +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + +module Wire.UserStore.Postgres (interpretUserStorePostgres) 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 Polysemy.TinyLog (TinyLog) +import System.Logger.Message qualified as Log +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.Sem.Logger +import Wire.StoredUser +import Wire.UserStore +import Wire.UserStore.IndexUser + +interpretUserStorePostgres :: (PGConstraints r, Member TinyLog r) => InterpreterFor UserStore r +interpretUserStorePostgres = + 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, Member TinyLog r) => [UserId] -> Sem r [StoredUser] +getUsersImpl uids = do + (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 + 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, + assets = Nothing, + activated = False, + expires = Nothing, + language = Nothing, + country = Nothing, + providerId = Nothing, + serviceId = Nothing, + handle = 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, mTid) -> Map.insert uid (mkDeletedUser uid mTid)) + mempty + deletedUserIds + foundUsersMap = + foldr + (\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. 'Map.union' is left biased. + pure $ Map.elems $ Map.union deletedUsersMap foundUsersMap + 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[]) + |] + + selectDeletedUsers :: Hasql.Statement [UserId] [(UserId, Maybe TeamId)] + selectDeletedUsers = + dimapPG @(Vector _) + [vectorStatement| + SELECT id :: uuid, team :: uuid? + FROM deleted_user + WHERE id = ANY ($1 :: uuid[]) + |] + + 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, 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, 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 (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), + 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) + |] + +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 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 + 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 + [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 () +deleteUserImpl user = + runTransaction ReadCommitted Write $ do + let uid = user.userQualifiedId.qUnqualified + Transaction.statement uid delete + Transaction.statement (uid, user.userTeam) noteDeleted + where + delete :: Hasql.Statement UserId () + delete = + lmapPG + [resultlessStatement| + DELETE FROM wire_user + WHERE id = $1 :: uuid + |] + + noteDeleted :: Hasql.Statement (UserId, Maybe TeamId) () + noteDeleted = + lmapPG + [resultlessStatement| + INSERT INTO deleted_user + (id, team) + VALUES ($1 :: uuid, $2 :: uuid?) + ON CONFLICT (id) DO NOTHING + |] + +lookupStatusImpl :: (PGConstraints r) => UserId -> Sem r (Maybe AccountStatus) +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|] + +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|] + +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 + UNION ALL + SELECT team :: uuid? FROM deleted_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|] + +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|] + +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 :: text?, account_status :: integer? FROM wire_user WHERE id = $1 :: uuid|] + +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 pid sid mBotId = do + bots <- case mBotId of + 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 (ProviderId, ServiceId) (Vector (BotId, ConvId, Maybe TeamId)) + selectStart = + dimapPG + [vectorStatement| + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid, bot_conv.conv_team :: uuid? + FROM bot_conv + 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 + |] + + selectFrom :: Hasql.Statement (ProviderId, ServiceId, BotId) (Vector (BotId, ConvId, Maybe TeamId)) + selectFrom = + dimapPG + [vectorStatement| + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid, bot_conv.conv_team :: uuid? + FROM bot_conv + 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 + |] + +lookupServiceUsersForTeamImpl :: (PGConstraints r) => ProviderId -> ServiceId -> TeamId -> Maybe BotId -> Sem r (PageWithState BotId (BotId, ConvId)) +lookupServiceUsersForTeamImpl pid sid tid mBotId = do + bots <- case mBotId of + 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 (ProviderId, ServiceId, TeamId) (Vector (BotId, ConvId)) + selectStart = + dimapPG + [vectorStatement| + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid + FROM bot_conv + 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 bot_conv.id + LIMIT 100 + |] + + selectFrom :: Hasql.Statement (ProviderId, ServiceId, TeamId, BotId) (Vector (BotId, ConvId)) + selectFrom = + dimapPG + [vectorStatement| + SELECT bot_conv.id :: uuid, bot_conv.conv :: uuid + FROM bot_conv + 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 bot_conv.id > $4 :: uuid + ORDER BY bot_conv.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/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/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 diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index fa398766188..86e5b40e4ee 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -70,3 +70,4 @@ postgresMigration: conversationCodes: postgresql teamFeatures: postgresql domainRegistration: postgresql + user: 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/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. diff --git a/services/brig/test/integration/API/User/Handles.hs b/services/brig/test/integration/API/User/Handles.hs index 3ade2b74f90..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 @@ -169,8 +152,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) 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