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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Migration of password reset from cassandra to postgres
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ data:
migrateConversationCodes: {{ .migrateConversationCodes }}
migrateTeamFeatures: {{ .migrateTeamFeatures }}
migrateDomainRegistration: {{ .migrateDomainRegistration }}
migratePasswordReset: {{ .migratePasswordReset }}
migrationOptions:
{{ toYaml .migrationOptions | indent 6 }}

Expand Down
5 changes: 5 additions & 0 deletions charts/wire-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ galley:
teamFeatures: cassandra
domainRegistration: cassandra
user: cassandra
passwordReset: cassandra
settings:
httpPoolSize: 128
maxTeamSize: 10000
Expand Down Expand Up @@ -1017,6 +1018,10 @@ background-worker:
# It's important to set `settings.postgresMigration.domainRegistration` to `migration-to-postgresql`
# before starting the migration.
migrateDomainRegistration: false
# This will start the migration of password reset data.
# It's important to set `settings.postgresMigration.passwordReset` to `migration-to-postgresql`
# before starting the migration.
migratePasswordReset: false

backendNotificationPusher:
pushBackoffMinWait: 10000 # in microseconds, so 10ms
Expand Down
10 changes: 10 additions & 0 deletions docs/src/developer/reference/config-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -2106,12 +2106,14 @@ galley:
teamFeatures: postgresql
domainRegistration: postgresql
user: postgresql
passwordReset: postgresql
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migratePasswordReset: false
```

#### Migration for existing installations
Expand Down Expand Up @@ -2142,6 +2144,7 @@ The current settings and their background-worker flags are:
- `conversationCodes` -> `migrateConversationCodes`
- `teamFeatures` -> `migrateTeamFeatures`
- `domainRegistration` -> `migrateDomainRegistration`
- `passwordReset` -> `migratePasswordReset`

**Migration pattern per migration setting**

Expand All @@ -2161,12 +2164,14 @@ The current settings and their background-worker flags are:
conversationCodes: migration-to-postgresql
teamFeatures: migration-to-postgresql
domainRegistration: cassandra
passwordReset: cassandra
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migratePasswordReset: false
```

This change should restart the affected pods, and new writes will follow the
Expand All @@ -2181,6 +2186,7 @@ The current settings and their background-worker flags are:
migrateConversationCodes: true
migrateTeamFeatures: true
migrateDomainRegistration: true
migratePasswordReset: true
```

During migration, Cassandra rows are not deleted. Writes and migration share
Expand All @@ -2196,6 +2202,7 @@ The current settings and their background-worker flags are:
- `conversationCodes`: `wire_conv_codes_migration_finished`
- `teamFeatures`: `wire_team_features_migration_finished`
- `domainRegistration`: `wire_domain_registration_migration_finished`
- `passwordReset`: `wire_password_reset_migration_finished`

3. Cut over reads and writes to PostgreSQL for the selected migration
setting(s). This configuration must be used from now on for every new
Expand All @@ -2209,12 +2216,14 @@ The current settings and their background-worker flags are:
conversationCodes: postgresql
teamFeatures: postgresql
domainRegistration: cassandra
passwordReset: cassandra
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migratePasswordReset: false
```

**How to run migrations independently or in batches**
Expand Down Expand Up @@ -2303,6 +2312,7 @@ migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migratePasswordReset: false

# migration settings
migrationOptions:
Expand Down
1 change: 1 addition & 0 deletions hack/helm_vars/common.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ conversationStore: {{ $preferredStore }}
conversationCodesStore: {{ $preferredStore }}
teamFeaturesStore: {{ $preferredStore }}
domainRegistration: {{ $preferredStore }}
passwordReset: {{ $preferredStore }}
userStore: {{ $preferredStore }}

{{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }}
Expand Down
1 change: 1 addition & 0 deletions hack/helm_vars/wire-server/values.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ galley:
conversationCodes: {{ .Values.conversationCodesStore }}
teamFeatures: {{ .Values.teamFeaturesStore }}
domainRegistration: {{ .Values.domainRegistration }}
passwordReset: {{ .Values.passwordReset }}
user: {{ .Values.userStore }}
settings:
maxConvAndTeamSize: 16
Expand Down
1 change: 1 addition & 0 deletions integration/integration.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ library
Test.Migration.Conversation
Test.Migration.ConversationCodes
Test.Migration.DomainRegistration
Test.Migration.PasswordReset
Test.Migration.TeamFeatures
Test.Migration.Util
Test.MLS
Expand Down
75 changes: 75 additions & 0 deletions integration/test/Test/Migration/PasswordReset.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- 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 <https://www.gnu.org/licenses/>.

module Test.Migration.PasswordReset (testPasswordResetMigration) where

import API.Brig
import API.BrigInternal (getPasswordResetCode)
import Control.Monad.Codensity
import Control.Monad.Reader
import SetupHelpers
import Test.Migration.Util (waitForMigration)
import Testlib.Prelude
import Testlib.ResourcePool

-- | Drives the password-reset store through the full cutover lifecycle
-- (cassandra -> migration-to-postgresql -> postgresql). A reset code written to
-- Cassandra before migration must be served from Postgres after the cutover and
-- still complete the reset, proving the row was backfilled.
testPasswordResetMigration :: (HasCallStack) => App ()
testPasswordResetMigration = do
resourcePool <- asks (.resourcePool)
runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do
let domain = backend.berDomain

-- Cassandra: create a user and initiate a password reset (writes the code to Cassandra)
(email, key, code) <-
runCodensity (startDynamicBackend backend (conf "cassandra" False)) $ \_ -> do
user <- randomUser domain def
email <- user %. "email" & asString
passwordReset domain email >>= assertSuccess
getResetData domain email

-- migration-to-postgresql (worker off): the code is still served from Cassandra
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" False)) $ \_ ->
checkCode domain email key code

-- migration-to-postgresql (worker on): backfill the code to Postgres and wait for completion
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) $ \_ ->
waitForMigration domain counterName

-- postgresql: the migrated code is served from Postgres and completes the reset
runCodensity (startDynamicBackend backend (conf "postgresql" False)) $ \_ -> do
checkCode domain email key code
let newPassword = "shiny-new-password"
completePasswordReset domain key code newPassword >>= assertSuccess
login domain email newPassword >>= assertSuccess
where
conf db runMigration =
def
{ galleyCfg = setField "postgresMigration.passwordReset" db,
backgroundWorkerCfg = setField "migratePasswordReset" runMigration
}
counterName = "^wire_password_reset_migration_finished"
getResetData dom email =
bindResponse (getPasswordResetCode dom email) $ \resp -> do
resp.status `shouldMatchInt` 200
(,) <$> (resp.json %. "key" & asString) <*> (resp.json %. "code" & asString)
checkCode dom email key code = do
(key', code') <- getResetData dom email
key' `shouldMatch` key
code' `shouldMatch` code
15 changes: 15 additions & 0 deletions libs/wire-api/src/Wire/API/User/Password.hs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,15 @@ import Data.Proxy (Proxy (Proxy))
import Data.Range (Ranged (..))
import Data.Schema as Schema
import Data.Text.Ascii
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Tuple.Extra
import Imports
import Servant (FromHttpApiData (..))
import Wire.API.User.EmailAddress
import Wire.API.User.Phone
import Wire.Arbitrary (Arbitrary, GenericUniform (..))
import Wire.API.PostgresMarshall (PostgresMarshall (postgresMarshall), PostgresUnmarshall (postgresUnmarshall))

--------------------------------------------------------------------------------
-- NewPasswordReset
Expand Down Expand Up @@ -206,6 +209,18 @@ newtype PasswordResetCode = PasswordResetCode

deriving instance C.Cql PasswordResetCode

instance PostgresMarshall Text PasswordResetKey where
postgresMarshall = Text.decodeUtf8 . toByteString'

instance PostgresMarshall Text PasswordResetCode where
postgresMarshall = Text.decodeUtf8 . toByteString'

instance PostgresUnmarshall Text PasswordResetKey where
postgresUnmarshall = mapLeft Text.pack . runParser parser . Text.encodeUtf8

instance PostgresUnmarshall Text PasswordResetCode where
postgresUnmarshall = mapLeft Text.pack . runParser parser . Text.encodeUtf8

--------------------------------------------------------------------------------
-- DEPRECATED

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ import Wire.API.Password.Scrypt (encodeScryptPassword)
import Wire.API.PostgresMarshall
import Wire.API.Team.Feature
import Wire.Arbitrary qualified as Arbitrary ()
import Wire.API.User.Password (PasswordResetCode, PasswordResetKey)

tests :: T.TestTree
tests =
T.localOption (T.Timeout (60 * 1000000) "60s") . T.testGroup "PostgresMarshall roundtrip tests" $
[ testRoundTrip @Text @Code.Key,
testRoundTrip @Text @Code.Value,
testRoundTrip @Text @PasswordResetKey,
testRoundTrip @Text @PasswordResetCode,
testRoundTrip @ByteString @Password.Password,
testRoundTrip @Int32 @FeatureStatus,
testRoundTrip @Int32 @LockStatus,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS password_reset (
key text PRIMARY KEY,
code text NOT NULL,
"user" uuid NOT NULL,
retries int4,
timeout timestamptz,
expires_at timestamptz NOT NULL
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- 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 <https://www.gnu.org/licenses/>.

module Wire.PasswordResetCodeStore.DualWrite
( interpretPasswordResetCodeStoreToCassandraAndPostgres,
)
where

import Cassandra (MonadClient)
import Imports
import Polysemy
import Wire.PasswordResetCodeStore
import Wire.PasswordResetCodeStore.Cassandra (passwordResetCodeStoreToCassandra)
import Wire.PasswordResetCodeStore.Postgres qualified as Postgres
import Wire.Postgres (PGConstraints)

-- | During migration, Cassandra remains the source of truth for reads while
-- writes are mirrored to Postgres. The generation constructors are pure (they
-- only draw randomness); they run exactly once — invoking both interpreters
-- would produce two independent random codes.
interpretPasswordResetCodeStoreToCassandraAndPostgres ::
forall m r.
( MonadClient m,
Member (Embed m) r,
PGConstraints r
) =>
InterpreterFor PasswordResetCodeStore r
interpretPasswordResetCodeStoreToCassandraAndPostgres = interpret $ \case
GenerateEmailCode -> Postgres.genEmailCode
GeneratePhoneCode -> Postgres.genPhoneCode
CodeSelect prk ->
passwordResetCodeStoreToCassandra @m $ codeSelect prk
CodeInsert prk prqd ttl -> do
passwordResetCodeStoreToCassandra @m $ codeInsert prk prqd ttl
Postgres.interpretPasswordResetCodeStoreToPostgres $ codeInsert prk prqd ttl
CodeDelete prk -> do
passwordResetCodeStoreToCassandra @m $ codeDelete prk
Postgres.interpretPasswordResetCodeStoreToPostgres $ codeDelete prk
Loading
Loading