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
1 change: 1 addition & 0 deletions changelog.d/0-release-notes/WPB-22970
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The background worker now cleans up expired activation keys from Postgres via a nightly cron job; new required config flag 'background-worker.config.activationKeysCleanup.schedule' (default '0 3 * * *').
1 change: 1 addition & 0 deletions changelog.d/5-internal/WPB-22970
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Migration of activation keys 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 }}
migrateActivationKeys: {{ .migrateActivationKeys }}
migrationOptions:
{{ toYaml .migrationOptions | indent 6 }}

Expand Down Expand Up @@ -111,6 +112,10 @@ data:
workerStaleThreshold: {{ .jobs.workerStaleThreshold }}
{{- with .meetingsCleanup }}
meetingsCleanup:
{{ toYaml . | indent 6 }}
{{- end }}
{{- with .activationKeysCleanup }}
activationKeysCleanup:
{{ toYaml . | indent 6 }}
{{- end }}
{{- if $.Values.galley.config.postgresMigration }}
Expand Down
6 changes: 6 additions & 0 deletions charts/wire-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ galley:
conversationCodes: cassandra
teamFeatures: cassandra
domainRegistration: cassandra
activationKeys: cassandra
user: cassandra
settings:
httpPoolSize: 128
Expand Down Expand Up @@ -1026,6 +1027,7 @@ background-worker:
# It's important to set `settings.postgresMigration.domainRegistration` to `migration-to-postgresql`
# before starting the migration.
migrateDomainRegistration: false
migrateActivationKeys: false

backendNotificationPusher:
pushBackoffMinWait: 10000 # in microseconds, so 10ms
Expand Down Expand Up @@ -1075,6 +1077,10 @@ background-worker:
# Cron schedule for the cleanup job (0 * * * * = every hour)
schedule: "0 * * * *"

# Cleanup of expired activation keys in Postgres (nightly)
activationKeysCleanup:
schedule: "0 3 * * *"

secrets: {}

podSecurityContext:
Expand Down
27 changes: 19 additions & 8 deletions docs/src/developer/reference/config-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -2150,13 +2150,18 @@ galley:
conversationCodes: postgresql
teamFeatures: postgresql
domainRegistration: postgresql
activationKeys: postgresql
user: postgresql
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateActivationKeys: false
# Cleanup of expired activation keys in Postgres (nightly)
activationKeysCleanup:
schedule: "0 3 * * *"
```

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

**Migration pattern per migration setting**

Expand All @@ -2205,13 +2211,15 @@ The current settings and their background-worker flags are:
conversation: migration-to-postgresql
conversationCodes: migration-to-postgresql
teamFeatures: migration-to-postgresql
domainRegistration: cassandra
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
domainRegistration: cassandra
activationKeys: migration-to-postgresql
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateActivationKeys: false
```

This change should restart the affected pods, and new writes will follow the
Expand All @@ -2225,7 +2233,8 @@ The current settings and their background-worker flags are:
migrateConversations: true
migrateConversationCodes: true
migrateTeamFeatures: true
migrateDomainRegistration: true
migrateDomainRegistration: true
migrateActivationKeys: true
```

During migration, Cassandra rows are not deleted. Writes and migration share
Expand All @@ -2241,6 +2250,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`
- `activationKeys`: `wire_activation_keys_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 Down Expand Up @@ -2273,6 +2283,7 @@ The current settings and their background-worker flags are:
- Some settings cover multiple Cassandra tables. For example,
`postgresMigration.domainRegistration` covers `domain_registration`,
`domain_registration_by_team`, and `domain_registration_challenge`.
`postgresMigration.activationKeys` covers the `activation_keys` table.

## Configure Cells

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 }}
activationKeysStore: {{ $preferredStore }}
userStore: {{ $preferredStore }}

{{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }}
Expand Down
3 changes: 3 additions & 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 }}
activationKeys: {{ .Values.activationKeysStore }}
user: {{ .Values.userStore }}
settings:
maxConvAndTeamSize: 16
Expand Down Expand Up @@ -685,6 +686,8 @@ background-worker:
cleanOlderThanHours: 0.0014
batchSize: 100
schedule: "* * * * *"
activationKeysCleanup:
schedule: "* * * * *"
# Cassandra clusters used by background-worker
cassandra:
host: {{ .Values.cassandraHost }}
Expand Down
1 change: 1 addition & 0 deletions integration/integration.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ library
Test.Login
Test.Meetings
Test.MessageTimer
Test.Migration.ActivationKeys
Test.Migration.Conversation
Test.Migration.ConversationCodes
Test.Migration.DomainRegistration
Expand Down
104 changes: 104 additions & 0 deletions integration/test/Test/Migration/ActivationKeys.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
-- 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.ActivationKeys where

import qualified API.Brig as Brig
import qualified API.BrigInternal as BrigI
import API.Common (randomEmail)
import Control.Monad.Codensity
import Control.Monad.Reader (asks)
import Test.Migration.Util (waitForMigration)
import Testlib.Prelude
import Testlib.ResourcePool
import Text.Printf (printf)

testActivationKeysMigration :: (HasCallStack) => App ()
testActivationKeysMigration = do
resourcePool <- asks (.resourcePool)
runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do
let domain = backend.berDomain

-- cassandra: two pending registrations
(k1, c1) <-
runCodensity (startDynamicBackend backend (conf "cassandra" False))
. const
$ newPendingActivation domain
(k2, c2) <-
runCodensity (startDynamicBackend backend (conf "cassandra" False))
. const
$ newPendingActivation domain

-- dual-write, worker off: one wrong attempt on the second registration
-- (retries 3 -> 2, mirrored to Postgres)
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" False)) . const $ do
wrongActivationCodeFails domain (k2, c2)

-- dual-write, worker on: copy Cassandra rows; create a fresh pending
-- registration
(k4, c4) <-
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) . const $ do
waitForMigration domain counterName
newPendingActivation domain

-- postgresql: pre-migration codes activate from Postgres; retry state
-- converged; exhaustion deletes the row
runCodensity (startDynamicBackend backend (conf "postgresql" False)) . const $ do
-- code created in cassandra mode activates (copied by the worker)
activateCode domain (k1, c1)
-- code that was wrong-attempted once in dual-write activates
-- (mirrored writes kept Postgres in sync)
activateCode domain (k2, c2)
-- brute-force exhaustion: 3 wrong attempts decrement to 0, correct code
-- still works, the 4th wrong attempt deletes the row, correct then fails
forM_ [1 :: Int .. 3] $ \_ -> wrongActivationCodeFails domain (k4, c4)
activateCode domain (k4, c4)
wrongActivationCodeFails domain (k4, c4)
bindResponse (Brig.activate domain k4 c4) $ \resp -> do
resp.status `shouldMatchInt` 404
resp.json %. "label" `shouldMatch` "invalid-code"
where
-- create a pending registration for a random email, then fetch its
-- (key, code) via the internal API
newPendingActivation domain = do
email <- randomEmail
Brig.activateSend domain email Nothing >>= assertSuccess
bindResponse (BrigI.getActivationCode domain email) $ \resp -> do
resp.status `shouldMatchInt` 200
(,)
<$> (resp.json %. "key" >>= asString)
<*> (resp.json %. "code" >>= asString)

activateCode domain (k, c) = Brig.activate domain k c >>= assertSuccess

-- wrong code must 404 with 'invalid-code'
wrongActivationCodeFails domain (k, c) = do
let wrong = printf "%06d" $ (read @Int c + 1) `mod` 1000000
bindResponse (Brig.activate domain k wrong) $ \resp -> do
resp.status `shouldMatchInt` 404
resp.json %. "label" `shouldMatch` "invalid-code"
conf :: String -> Bool -> ServiceOverrides
conf db runMigration =
def
{ brigCfg = setField "postgresMigration.activationKeys" db,
backgroundWorkerCfg =
setField "postgresMigration.activationKeys" db
>=> setField "migrateActivationKeys" runMigration
}

counterName :: String
counterName = "^wire_activation_keys_migration_finished"
61 changes: 60 additions & 1 deletion libs/wire-api/src/Wire/API/Jobs.hs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ type ConversationsQueueName = "conversations"
conversationsQueueName :: Text
conversationsQueueName = Text.pack $ symbolVal (Proxy @ConversationsQueueName)

-- | The queue/table for jobs that operate on activation keys.
type ActivationKeysQueueName = "activation-keys"

activationKeysQueueName :: Text
activationKeysQueueName = Text.pack $ symbolVal (Proxy @ActivationKeysQueueName)

-- | Empty payload because the schedule itself carries all execution context.
data MeetingsCleanupJob = MeetingsCleanupJob
deriving stock (Eq, Generic, Show)
Expand All @@ -61,6 +67,17 @@ instance ToSchema MeetingsCleanupJob where
instance Arbitrary MeetingsCleanupJob where
arbitrary = pure MeetingsCleanupJob

-- | Empty payload because the schedule itself carries all execution context.
data ActivationKeysCleanupJob = ActivationKeysCleanupJob
deriving stock (Eq, Generic, Show)
deriving (ToJSON, FromJSON, S.ToSchema) via (Schema ActivationKeysCleanupJob)

instance ToSchema ActivationKeysCleanupJob where
schema = object $ pure ActivationKeysCleanupJob

instance Arbitrary ActivationKeysCleanupJob where
arbitrary = pure ActivationKeysCleanupJob

-- | Payload for adminless deletions.
-- Arbiter persists these payloads and workers decode them later, so changes to
-- field names or shapes require a coordinated rollout. The origin user is
Expand Down Expand Up @@ -193,6 +210,47 @@ deriving via (Schema MeetingsJobPayload) instance S.ToSchema MeetingsJobPayload
instance Arbitrary MeetingsJobPayload where
arbitrary = MeetingsCleanup <$> arbitrary

-- | Payload for the activation-keys queue.
data ActivationKeysJobPayload
= ActivationKeysCleanup ActivationKeysCleanupJob
deriving stock (Eq, Generic, Show)

data ActivationKeysJobPayloadTag
= ActivationKeysCleanupTag
deriving stock (Eq, Ord, Bounded, Enum, Show, Generic)
deriving (Arbitrary) via GenericUniform ActivationKeysJobPayloadTag

instance ToSchema ActivationKeysJobPayloadTag where
schema =
enum @Text $
element "activation_keys_cleanup" ActivationKeysCleanupTag

makePrisms ''ActivationKeysJobPayload

activationKeysJobPayloadObjectSchema :: ObjectSchema SwaggerDoc ActivationKeysJobPayload
activationKeysJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchema
where
toTag :: ActivationKeysJobPayload -> ActivationKeysJobPayloadTag
toTag =
\case
ActivationKeysCleanup {} -> ActivationKeysCleanupTag

toSchema :: ActivationKeysJobPayloadTag -> ObjectSchema SwaggerDoc ActivationKeysJobPayload
toSchema = \case
ActivationKeysCleanupTag -> tag _ActivationKeysCleanup (field "data" schema)

instance ToSchema ActivationKeysJobPayload where
schema = object activationKeysJobPayloadObjectSchema

deriving via (Schema ActivationKeysJobPayload) instance FromJSON ActivationKeysJobPayload

deriving via (Schema ActivationKeysJobPayload) instance ToJSON ActivationKeysJobPayload

deriving via (Schema ActivationKeysJobPayload) instance S.ToSchema ActivationKeysJobPayload

instance Arbitrary ActivationKeysJobPayload where
arbitrary = ActivationKeysCleanup <$> arbitrary

-- | Payload persisted in the conversations queue. Keep the type tags and
-- nested data shapes stable when changing job payloads.
data ConversationsJobPayload
Expand Down Expand Up @@ -250,5 +308,6 @@ instance Arbitrary ConversationsJobPayload where
-- | Registry for the jobs we expose via Arbiter.
type JobRegistry =
'[ Queue MeetingsQueueName MeetingsJobPayload,
Queue ConversationsQueueName ConversationsJobPayload
Queue ConversationsQueueName ConversationsJobPayload,
Queue ActivationKeysQueueName ActivationKeysJobPayload
]
14 changes: 14 additions & 0 deletions libs/wire-api/src/Wire/API/User/Activation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ import Data.Data (Proxy (Proxy))
import Data.OpenApi (ToParamSchema)
import Data.OpenApi qualified as S
import Data.Schema
import Data.Text (pack)
import Data.Text.Ascii
import Imports
import Servant (FromHttpApiData (..))
import Wire.API.Locale
import Wire.API.PostgresMarshall
import Wire.API.User.Identity
import Wire.Arbitrary (Arbitrary, GenericUniform (..))

Expand Down Expand Up @@ -76,6 +78,12 @@ instance FromHttpApiData ActivationKey where

deriving instance C.Cql ActivationKey

instance PostgresMarshall Text ActivationKey where
postgresMarshall = toText . fromActivationKey

instance PostgresUnmarshall Text ActivationKey where
postgresUnmarshall = bimap pack ActivationKey . validateBase64Url

--------------------------------------------------------------------------------
-- ActivationCode

Expand All @@ -101,6 +109,12 @@ deriving instance C.Cql ActivationCode
-- | A pair of 'ActivationKey' and 'ActivationCode' as required for activation.
type ActivationPair = (ActivationKey, ActivationCode)

instance PostgresMarshall Text ActivationCode where
postgresMarshall = toText . fromActivationCode

instance PostgresUnmarshall Text ActivationCode where
postgresUnmarshall = bimap pack ActivationCode . validateBase64Url

--------------------------------------------------------------------------------
-- Activate

Expand Down
Loading
Loading