From b11f8a90cf6267cda4cc1ff4ae9e46c6e9fac787 Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Thu, 20 Aug 2026 18:36:54 +0200 Subject: [PATCH] Add allowManualMigration flag to mlsMigration.config (#5456) The `mlsMigration` team feature config now includes an `allowManualMigration` boolean field (default `false`) that controls whether clients are permitted to perform single-group (manual) MLS migrations. The field only steers client behaviour (e.g. if a migration button is shown or not). It does not enforce checks in the backend. --- ...28028-mlsmigration-allowManualMigration.md | 5 + charts/wire-server/values.yaml | 2 + .../test/Test/FeatureFlags/MlsMigration.hs | 104 +++++++++++++++++- integration/test/Test/FeatureFlags/Util.hs | 6 +- libs/wire-api/src/Wire/API/Team/Feature.hs | 20 +++- .../golden/Test/Wire/API/Golden/Manual.hs | 10 ++ .../API/Golden/Manual/MlsMigrationConfig.hs | 54 +++++++++ .../testObject_MlsMigrationConfig_1.json | 9 ++ .../testObject_MlsMigrationConfig_2.json | 8 ++ .../testObject_MlsMigrationConfig_3.json | 8 ++ libs/wire-api/wire-api.cabal | 1 + tools/db/migrate-features/src/Work.hs | 1 + 12 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json diff --git a/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md b/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md new file mode 100644 index 00000000000..7a745fdf82d --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md @@ -0,0 +1,5 @@ +The `mlsMigration` team feature config now includes an `allowManualMigration` +boolean field (default `false`) that controls whether clients are permitted to +perform single-group (manual) MLS migrations. The field only steers client +behaviour (e.g. if a migration button is shown or not). It does not enforce +checks in the backend. diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 96793d35794..2e91d8ece12 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -222,6 +222,8 @@ galley: finaliseRegardlessAfter: null # "2029-10-17T00:00:00.000Z" usersThreshold: 100 clientsThreshold: 100 + # Allow group-wise migration by clients + allowManualMigration: false lockStatus: locked limitedEventFanout: defaults: diff --git a/integration/test/Test/FeatureFlags/MlsMigration.hs b/integration/test/Test/FeatureFlags/MlsMigration.hs index fefd5b70068..a60cd339675 100644 --- a/integration/test/Test/FeatureFlags/MlsMigration.hs +++ b/integration/test/Test/FeatureFlags/MlsMigration.hs @@ -49,6 +49,101 @@ testMlsMigrationDefaults = do feat <- Internal.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 feat %. "config" `shouldMatch` mlsMigrationDefaultConfig +testMlsMigrationAllowManualMigration :: (HasCallStack) => App () +testMlsMigrationAllowManualMigration = do + (owner, tid, _) <- createTeam OwnDomain 0 + void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200 + + getResp0 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + (getResp0 %. "config" %. "allowManualMigration") `shouldMatch` False + + patchResp0 <- + Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"]) + >>= getJSON 200 + (patchResp0 %. "config" %. "allowManualMigration") `shouldMatch` False + + setResp1 <- + Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig1 + >>= getJSON 200 + getResp1 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + (getResp1 %. "config" %. "allowManualMigration") `shouldMatch` False + (getResp1 %. "config") `shouldMatch` (setResp1 %. "config") + + setResp2 <- + Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2 + >>= getJSON 200 + getResp2 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + (getResp2 %. "config" %. "allowManualMigration") `shouldMatch` True + (getResp2 %. "config") `shouldMatch` (setResp2 %. "config") + + let patchWithoutField = + object + [ "status" .= "enabled", + "config" + .= object + [ "startTime" .= "2030-01-01T00:00:00Z" + ] + ] + setResp3 <- + Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithoutField + >>= getJSON 200 + getResp3 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + (getResp3 %. "config" %. "allowManualMigration") `shouldMatch` False + (getResp3 %. "config") `shouldMatch` (setResp3 %. "config") + + let patchWithField = + object + [ "status" .= "enabled", + "config" + .= object + [ "startTime" .= "2030-01-01T00:00:00Z", + "allowManualMigration" .= True + ] + ] + setResp4 <- + Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithField + >>= getJSON 200 + getResp4 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + (getResp4 %. "config" %. "allowManualMigration") `shouldMatch` True + (getResp4 %. "config") `shouldMatch` (setResp4 %. "config") + +-- | PUT replaces the whole config; it does not merge omitted fields with the +-- previously stored value. This pins down that assumption so that future +-- schema changes to individual fields (e.g. allowManualMigration) don't +-- accidentally start relying on merge behaviour that doesn't exist. +testMlsMigrationPutDoesNotMergeOmittedFields :: (HasCallStack) => App () +testMlsMigrationPutDoesNotMergeOmittedFields = do + (owner, tid, _) <- createTeam OwnDomain 0 + void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200 + void + $ Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"]) + >>= getJSON 200 + + beforePatch <- + Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2 + >>= getJSON 200 + (beforePatch %. "config" %. "finaliseRegardlessAfter") `shouldMatch` "2031-10-17T00:00:00Z" + + let partialConfig = + object + [ "status" .= "enabled", + "config" + .= object + [ "startTime" .= "2030-01-01T00:00:00Z" + ] + ] + void + $ Public.setTeamFeatureConfig owner tid "mlsMigration" partialConfig + >>= getJSON 200 + afterPatch <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200 + + -- omitted fields are dropped, not carried over from mlsMigrationConfig2 + assertFieldMissing afterPatch "config.finaliseRegardlessAfter" + + -- allowManualMigration is always rendered (never omitted); reset to its + -- default of False, not merged from mlsMigrationConfig2's True + (afterPatch %. "config" %. "allowManualMigration") `shouldMatch` False + mlsEnableConfig :: Value mlsEnableConfig = object @@ -70,7 +165,8 @@ mlsMigrationDefaultConfig :: Value mlsMigrationDefaultConfig = object [ "startTime" .= "2029-05-16T10:11:12.123Z", - "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z" + "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z", + "allowManualMigration" .= False ] mlsMigrationConfig1 :: Value @@ -80,7 +176,8 @@ mlsMigrationConfig1 = "config" .= object [ "startTime" .= "2029-05-16T10:11:12.123Z", - "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z" + "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z", + "allowManualMigration" .= False ] ] @@ -91,6 +188,7 @@ mlsMigrationConfig2 = "config" .= object [ "startTime" .= "2030-05-16T10:11:12.123Z", - "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z" + "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z", + "allowManualMigration" .= True ] ] diff --git a/integration/test/Test/FeatureFlags/Util.hs b/integration/test/Test/FeatureFlags/Util.hs index 64381efc644..6615e17d669 100644 --- a/integration/test/Test/FeatureFlags/Util.hs +++ b/integration/test/Test/FeatureFlags/Util.hs @@ -180,7 +180,8 @@ defAllFeatures = "config" .= object [ "startTime" .= "2029-05-16T10:11:12.123Z", - "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z" + "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z", + "allowManualMigration" .= False ] ], "enforceFileDownloadLocation" @@ -346,7 +347,8 @@ defAllConfiguredFeatures = "config" .= object [ "startTime" .= "2029-05-16T10:11:12.123Z", - "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z" + "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z", + "allowManualMigration" .= False ] ] ), diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 12185dbcc89..44860bd7adc 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -1370,7 +1370,16 @@ instance IsFeatureConfig MlsE2EIdConfig where data MlsMigrationConfigB t f = MlsMigrationConfig { startTime :: Wear t f (Maybe UTCTime), - finaliseRegardlessAfter :: Wear t f (Maybe UTCTime) + finaliseRegardlessAfter :: Wear t f (Maybe UTCTime), + -- | Allow users to manually trigger migrations from Proteus to MLS for + -- group conversations. + -- + -- There is no logic behind this flag in the backend. It is solely meant for + -- clients to decide if they should show a button (to facilitate MLS + -- migration for a group conversation) or not. + -- + -- The default is `False`. + allowManualMigration :: Wear t f Bool } deriving (BareB, Generic) @@ -1393,24 +1402,27 @@ deriving via (BarbieFeature MlsMigrationConfigB) instance (ToSchema MlsMigration deriving via (RenderableTypeName MlsMigrationConfig) instance (RenderableSymbol MlsMigrationConfig) instance Default MlsMigrationConfig where - def = MlsMigrationConfig Nothing Nothing + def = MlsMigrationConfig Nothing Nothing False instance Arbitrary MlsMigrationConfig where arbitrary = do startTime <- fmap fromUTCTimeMillis <$> arbitrary finaliseRegardlessAfter <- fmap fromUTCTimeMillis <$> arbitrary + allowManualMigration <- arbitrary pure MlsMigrationConfig { startTime = startTime, - finaliseRegardlessAfter = finaliseRegardlessAfter + finaliseRegardlessAfter = finaliseRegardlessAfter, + allowManualMigration = allowManualMigration } -instance (Typeable f, NestedMaybe f) => ToSchema (MlsMigrationConfigB Covered f) where +instance (Typeable f, NestedMaybe f, OptWithDefault f) => ToSchema (MlsMigrationConfigB Covered f) where schema = object $ MlsMigrationConfig <$> startTime .= nestedMaybeField "startTime" (unnamed utcTimeSchema) <*> finaliseRegardlessAfter .= nestedMaybeField "finaliseRegardlessAfter" (unnamed utcTimeSchema) + <*> allowManualMigration .= fromOpt (optField "allowManualMigration" (schema @Bool)) instance Default (LockableFeature MlsMigrationConfig) where def = defLockedFeature diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index de8aaafc9fb..33e75fadaaf 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -49,6 +49,7 @@ import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.LoginId_user import Test.Wire.API.Golden.Manual.Login_user import Test.Wire.API.Golden.Manual.MLSKeys +import Test.Wire.API.Golden.Manual.MlsMigrationConfig import Test.Wire.API.Golden.Manual.Pagination import Test.Wire.API.Golden.Manual.Presence import Test.Wire.API.Golden.Manual.Push @@ -304,6 +305,15 @@ tests = testObjects [ (testObject_MLSKeysByPurpose1, "testObject_MLSKeysByPurpose_1.json") ], + testGroup "Feature MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_1, "testObject_MlsMigrationConfig_1.json")], + testGroup "LockableFeature MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_2, "testObject_MlsMigrationConfig_2.json")], + testGroup "LockableFeaturePatch MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_3, "testObject_MlsMigrationConfig_3.json")], testGroup "SendActivationCode" $ testObjects [ (testObject_SendActivationCode_1, "testObject_SendActivationCode_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs new file mode 100644 index 00000000000..793baa173c9 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs @@ -0,0 +1,54 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 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 . + +module Test.Wire.API.Golden.Manual.MlsMigrationConfig where + +import Data.Time +import Imports +import Wire.API.Team.Feature + +testObject_MlsMigrationConfig_1 :: Feature MlsMigrationConfig +testObject_MlsMigrationConfig_1 = + Feature + FeatureStatusEnabled + ( MlsMigrationConfig + (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0})) + (Just (UTCTime {utctDay = ModifiedJulianDay 58200, utctDayTime = 0})) + True + ) + +testObject_MlsMigrationConfig_2 :: LockableFeature MlsMigrationConfig +testObject_MlsMigrationConfig_2 = + LockableFeature + { status = FeatureStatusEnabled, + lockStatus = LockStatusUnlocked, + config = MlsMigrationConfig Nothing Nothing False + } + +testObject_MlsMigrationConfig_3 :: LockableFeaturePatch MlsMigrationConfig +testObject_MlsMigrationConfig_3 = + LockableFeaturePatch + { status = Just FeatureStatusEnabled, + lockStatus = Nothing, + config = + Just + ( MlsMigrationConfig + Nothing + (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0})) + False + ) + } diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json new file mode 100644 index 00000000000..a83c41d54a5 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json @@ -0,0 +1,9 @@ +{ + "config": { + "allowManualMigration": true, + "finaliseRegardlessAfter": "2018-03-23T00:00:00Z", + "startTime": "2018-01-01T00:00:00Z" + }, + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json new file mode 100644 index 00000000000..6014232db55 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json @@ -0,0 +1,8 @@ +{ + "config": { + "allowManualMigration": false + }, + "lockStatus": "unlocked", + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json new file mode 100644 index 00000000000..eba2ce56578 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json @@ -0,0 +1,8 @@ +{ + "config": { + "allowManualMigration": false, + "finaliseRegardlessAfter": "2018-01-01T00:00:00Z" + }, + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 841fc2114b6..ce51050e037 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -644,6 +644,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.Login_user Test.Wire.API.Golden.Manual.LoginId_user Test.Wire.API.Golden.Manual.MLSKeys + Test.Wire.API.Golden.Manual.MlsMigrationConfig Test.Wire.API.Golden.Manual.Pagination Test.Wire.API.Golden.Manual.Presence Test.Wire.API.Golden.Manual.Push diff --git a/tools/db/migrate-features/src/Work.hs b/tools/db/migrate-features/src/Work.hs index 8aef63d20dd..60a1f8061d4 100644 --- a/tools/db/migrate-features/src/Work.hs +++ b/tools/db/migrate-features/src/Work.hs @@ -337,6 +337,7 @@ writeFeatures ( MlsMigrationConfig @Covered (fmap unOptionalUTCTime mls_migration_start_time) (fmap unOptionalUTCTime mls_migration_finalise_regardless_after) + def -- allowManualMigration was added recently, so there's nothing to migrate ) }