Skip to content

Commit 0aff1b4

Browse files
committed
WPB-22969: migrate password-reset to PostGreSQL
Add Postgres + DualWrite interpreters and a background-worker copy loop for PasswordResetCodeStore, selected at startup by the shared passwordReset StorageLocation. Mirrors the brig DomainRegistrationStore migration and the canonical CodeStore worked example; preserves dual expiry (TTL->expires_at and the timeout column). Bead: ws-migrate-postgresql-7fp
1 parent 06a57e1 commit 0aff1b4

25 files changed

Lines changed: 569 additions & 8 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Migration of password reset from cassandra to postgres

charts/wire-server/templates/background-worker/configmap.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ data:
8484
migrateConversationCodes: {{ .migrateConversationCodes }}
8585
migrateTeamFeatures: {{ .migrateTeamFeatures }}
8686
migrateDomainRegistration: {{ .migrateDomainRegistration }}
87+
migratePasswordReset: {{ .migratePasswordReset }}
8788
migrationOptions:
8889
{{ toYaml .migrationOptions | indent 6 }}
8990

charts/wire-server/values.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ galley:
9090
teamFeatures: cassandra
9191
domainRegistration: cassandra
9292
user: cassandra
93+
passwordReset: cassandra
9394
settings:
9495
httpPoolSize: 128
9596
maxTeamSize: 10000
@@ -1017,6 +1018,10 @@ background-worker:
10171018
# It's important to set `settings.postgresMigration.domainRegistration` to `migration-to-postgresql`
10181019
# before starting the migration.
10191020
migrateDomainRegistration: false
1021+
# This will start the migration of password reset data.
1022+
# It's important to set `settings.postgresMigration.passwordReset` to `migration-to-postgresql`
1023+
# before starting the migration.
1024+
migratePasswordReset: false
10201025

10211026
backendNotificationPusher:
10221027
pushBackoffMinWait: 10000 # in microseconds, so 10ms

docs/src/developer/reference/config-options.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,12 +2106,14 @@ galley:
21062106
teamFeatures: postgresql
21072107
domainRegistration: postgresql
21082108
user: postgresql
2109+
passwordReset: postgresql
21092110
background-worker:
21102111
config:
21112112
migrateConversations: false
21122113
migrateConversationCodes: false
21132114
migrateTeamFeatures: false
21142115
migrateDomainRegistration: false
2116+
migratePasswordReset: false
21152117
```
21162118

21172119
#### Migration for existing installations
@@ -2142,6 +2144,7 @@ The current settings and their background-worker flags are:
21422144
- `conversationCodes` -> `migrateConversationCodes`
21432145
- `teamFeatures` -> `migrateTeamFeatures`
21442146
- `domainRegistration` -> `migrateDomainRegistration`
2147+
- `passwordReset` -> `migratePasswordReset`
21452148

21462149
**Migration pattern per migration setting**
21472150

@@ -2161,12 +2164,14 @@ The current settings and their background-worker flags are:
21612164
conversationCodes: migration-to-postgresql
21622165
teamFeatures: migration-to-postgresql
21632166
domainRegistration: cassandra
2167+
passwordReset: cassandra
21642168
background-worker:
21652169
config:
21662170
migrateConversations: false
21672171
migrateConversationCodes: false
21682172
migrateTeamFeatures: false
21692173
migrateDomainRegistration: false
2174+
migratePasswordReset: false
21702175
```
21712176

21722177
This change should restart the affected pods, and new writes will follow the
@@ -2181,6 +2186,7 @@ The current settings and their background-worker flags are:
21812186
migrateConversationCodes: true
21822187
migrateTeamFeatures: true
21832188
migrateDomainRegistration: true
2189+
migratePasswordReset: true
21842190
```
21852191

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

22002207
3. Cut over reads and writes to PostgreSQL for the selected migration
22012208
setting(s). This configuration must be used from now on for every new
@@ -2209,12 +2216,14 @@ The current settings and their background-worker flags are:
22092216
conversationCodes: postgresql
22102217
teamFeatures: postgresql
22112218
domainRegistration: cassandra
2219+
passwordReset: cassandra
22122220
background-worker:
22132221
config:
22142222
migrateConversations: false
22152223
migrateConversationCodes: false
22162224
migrateTeamFeatures: false
22172225
migrateDomainRegistration: false
2226+
migratePasswordReset: false
22182227
```
22192228

22202229
**How to run migrations independently or in batches**
@@ -2303,6 +2312,7 @@ migrateConversations: false
23032312
migrateConversationCodes: false
23042313
migrateTeamFeatures: false
23052314
migrateDomainRegistration: false
2315+
migratePasswordReset: false
23062316

23072317
# migration settings
23082318
migrationOptions:

hack/helm_vars/common.yaml.gotmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ conversationStore: {{ $preferredStore }}
1818
conversationCodesStore: {{ $preferredStore }}
1919
teamFeaturesStore: {{ $preferredStore }}
2020
domainRegistration: {{ $preferredStore }}
21+
passwordReset: {{ $preferredStore }}
2122
userStore: {{ $preferredStore }}
2223

2324
{{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }}

hack/helm_vars/wire-server/values.yaml.gotmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ galley:
305305
conversationCodes: {{ .Values.conversationCodesStore }}
306306
teamFeatures: {{ .Values.teamFeaturesStore }}
307307
domainRegistration: {{ .Values.domainRegistration }}
308+
passwordReset: {{ .Values.passwordReset }}
308309
user: {{ .Values.userStore }}
309310
settings:
310311
maxConvAndTeamSize: 16

integration/integration.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ library
180180
Test.Migration.Conversation
181181
Test.Migration.ConversationCodes
182182
Test.Migration.DomainRegistration
183+
Test.Migration.PasswordReset
183184
Test.Migration.TeamFeatures
184185
Test.Migration.Util
185186
Test.MLS
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
-- This file is part of the Wire Server implementation.
2+
--
3+
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
4+
--
5+
-- This program is free software: you can redistribute it and/or modify it under
6+
-- the terms of the GNU Affero General Public License as published by the Free
7+
-- Software Foundation, either version 3 of the License, or (at your option) any
8+
-- later version.
9+
--
10+
-- This program is distributed in the hope that it will be useful, but WITHOUT
11+
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
12+
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
13+
-- details.
14+
--
15+
-- You should have received a copy of the GNU Affero General Public License along
16+
-- with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
module Test.Migration.PasswordReset (testPasswordResetMigration) where
19+
20+
import API.Brig
21+
import API.BrigInternal (getPasswordResetCode)
22+
import Control.Monad.Codensity
23+
import Control.Monad.Reader
24+
import SetupHelpers
25+
import Test.Migration.Util (waitForMigration)
26+
import Testlib.Prelude
27+
import Testlib.ResourcePool
28+
29+
-- | Drives the password-reset store through the full cutover lifecycle
30+
-- (cassandra -> migration-to-postgresql -> postgresql). A reset code written to
31+
-- Cassandra before migration must be served from Postgres after the cutover and
32+
-- still complete the reset, proving the row was backfilled.
33+
testPasswordResetMigration :: (HasCallStack) => App ()
34+
testPasswordResetMigration = do
35+
resourcePool <- asks (.resourcePool)
36+
runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do
37+
let domain = backend.berDomain
38+
39+
-- Cassandra: create a user and initiate a password reset (writes the code to Cassandra)
40+
(email, key, code) <-
41+
runCodensity (startDynamicBackend backend (conf "cassandra" False)) $ \_ -> do
42+
user <- randomUser domain def
43+
email <- user %. "email" & asString
44+
passwordReset domain email >>= assertSuccess
45+
getResetData domain email
46+
47+
-- migration-to-postgresql (worker off): the code is still served from Cassandra
48+
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" False)) $ \_ ->
49+
checkCode domain email key code
50+
51+
-- migration-to-postgresql (worker on): backfill the code to Postgres and wait for completion
52+
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) $ \_ ->
53+
waitForMigration domain counterName
54+
55+
-- postgresql: the migrated code is served from Postgres and completes the reset
56+
runCodensity (startDynamicBackend backend (conf "postgresql" False)) $ \_ -> do
57+
checkCode domain email key code
58+
let newPassword = "shiny-new-password"
59+
completePasswordReset domain key code newPassword >>= assertSuccess
60+
login domain email newPassword >>= assertSuccess
61+
where
62+
conf db runMigration =
63+
def
64+
{ galleyCfg = setField "postgresMigration.passwordReset" db,
65+
backgroundWorkerCfg = setField "migratePasswordReset" runMigration
66+
}
67+
counterName = "^wire_password_reset_migration_finished"
68+
getResetData dom email =
69+
bindResponse (getPasswordResetCode dom email) $ \resp -> do
70+
resp.status `shouldMatchInt` 200
71+
(,) <$> (resp.json %. "key" & asString) <*> (resp.json %. "code" & asString)
72+
checkCode dom email key code = do
73+
(key', code') <- getResetData dom email
74+
key' `shouldMatch` key
75+
code' `shouldMatch` code

libs/wire-api/src/Wire/API/User/Password.hs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ import Data.Proxy (Proxy (Proxy))
4848
import Data.Range (Ranged (..))
4949
import Data.Schema as Schema
5050
import Data.Text.Ascii
51+
import Data.Text qualified as Text
52+
import Data.Text.Encoding qualified as Text
5153
import Data.Tuple.Extra
5254
import Imports
5355
import Servant (FromHttpApiData (..))
5456
import Wire.API.User.EmailAddress
5557
import Wire.API.User.Phone
5658
import Wire.Arbitrary (Arbitrary, GenericUniform (..))
59+
import Wire.API.PostgresMarshall (PostgresMarshall (postgresMarshall), PostgresUnmarshall (postgresUnmarshall))
5760

5861
--------------------------------------------------------------------------------
5962
-- NewPasswordReset
@@ -206,6 +209,18 @@ newtype PasswordResetCode = PasswordResetCode
206209

207210
deriving instance C.Cql PasswordResetCode
208211

212+
instance PostgresMarshall Text PasswordResetKey where
213+
postgresMarshall = Text.decodeUtf8 . toByteString'
214+
215+
instance PostgresMarshall Text PasswordResetCode where
216+
postgresMarshall = Text.decodeUtf8 . toByteString'
217+
218+
instance PostgresUnmarshall Text PasswordResetKey where
219+
postgresUnmarshall = mapLeft Text.pack . runParser parser . Text.encodeUtf8
220+
221+
instance PostgresUnmarshall Text PasswordResetCode where
222+
postgresUnmarshall = mapLeft Text.pack . runParser parser . Text.encodeUtf8
223+
209224
--------------------------------------------------------------------------------
210225
-- DEPRECATED
211226

libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ import Wire.API.Password.Scrypt (encodeScryptPassword)
3535
import Wire.API.PostgresMarshall
3636
import Wire.API.Team.Feature
3737
import Wire.Arbitrary qualified as Arbitrary ()
38+
import Wire.API.User.Password (PasswordResetCode, PasswordResetKey)
3839

3940
tests :: T.TestTree
4041
tests =
4142
T.localOption (T.Timeout (60 * 1000000) "60s") . T.testGroup "PostgresMarshall roundtrip tests" $
4243
[ testRoundTrip @Text @Code.Key,
4344
testRoundTrip @Text @Code.Value,
45+
testRoundTrip @Text @PasswordResetKey,
46+
testRoundTrip @Text @PasswordResetCode,
4447
testRoundTrip @ByteString @Password.Password,
4548
testRoundTrip @Int32 @FeatureStatus,
4649
testRoundTrip @Int32 @LockStatus,

0 commit comments

Comments
 (0)