diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 07f6cd1b01..ce8798ca5d 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -143,6 +143,11 @@ CREATE TABLE galley_test.team_features ( guest_links_lock_status int, guest_links_status int, legalhold_status int, + mls_allowed_ciphersuites set, + mls_default_ciphersuite int, + mls_default_protocol int, + mls_protocol_toggle_users set, + mls_status int, search_visibility_inbound_status int, search_visibility_status int, self_deleting_messages_lock_status int, diff --git a/changelog.d/2-features/pr-2499 b/changelog.d/2-features/pr-2499 new file mode 100644 index 0000000000..5e10fd55f3 --- /dev/null +++ b/changelog.d/2-features/pr-2499 @@ -0,0 +1 @@ +Add MLS team feature configuration diff --git a/charts/galley/templates/configmap.yaml b/charts/galley/templates/configmap.yaml index 08ae916ab1..31070a8ab7 100644 --- a/charts/galley/templates/configmap.yaml +++ b/charts/galley/templates/configmap.yaml @@ -103,5 +103,9 @@ data: searchVisibilityInbound: {{- toYaml .settings.featureFlags.searchVisibilityInbound | nindent 10 }} {{- end }} + {{- if .settings.featureFlags.mls }} + mls: + {{- toYaml .settings.featureFlags.mls | nindent 10 }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/galley/values.yaml b/charts/galley/values.yaml index 78a8537d24..5332e3ea9d 100644 --- a/charts/galley/values.yaml +++ b/charts/galley/values.yaml @@ -52,6 +52,14 @@ config: lockStatus: unlocked status: enabled legalhold: disabled-by-default + mls: + defaults: + status: disabled + config: + protocolToggleUsers: [] + defaultProtocol: proteus + allowedCipherSuites: [1] + defaultCipherSuite: 1 searchVisibilityInbound: defaults: status: disabled diff --git a/docs/legacy/reference/config-options.md b/docs/legacy/reference/config-options.md index 87b17e9f3e..2ef6b24480 100644 --- a/docs/legacy/reference/config-options.md +++ b/docs/legacy/reference/config-options.md @@ -236,6 +236,32 @@ sndFactorPasswordChallenge: lockStatus: locked|unlocked ``` +### MLS + +This feature specifies how should behave. It has no effect on the server's behaviour. + +If this feature is enabled then clients that support this feature will allowing its user to switch between Proteus and the MLS protocol provided the user is listed ini `protocolToggleUsers`. The default protocol that clients will create new conversations with is specified in `defaultProtocol`. The `defaultCipherSuite` and `allowedCipherSuites` contain the default ciphersuite and the allowed ciphersuites that clients should be using. The numerical values should correspond to the indices (starting at 1) specified here https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5 + +If this feature is disabled then clients will use the Proteus protocol with this backend. + +The default configuration that applies to all teams that didn't explicitly change their feature configuration can be given in galley's `featureFlags` section in the config file: + +``` +# galley.yaml +mls: + defaults: + status: disabled + config: + protocolToggleUsers: [] + defaultProtocol: proteus + allowedCipherSuites: [1] + defaultCipherSuite: 1 + +``` + +This default configuration can be overriden on a per-team basis through the [feature config API](./features.md) + + ### Federation Domain Regardless of whether a backend wants to enable federation or not, the operator diff --git a/libs/galley-types/src/Galley/Types/Teams.hs b/libs/galley-types/src/Galley/Types/Teams.hs index 22aceff3e0..b978c5ef37 100644 --- a/libs/galley-types/src/Galley/Types/Teams.hs +++ b/libs/galley-types/src/Galley/Types/Teams.hs @@ -38,6 +38,7 @@ module Galley.Types.Teams flagsTeamFeatureValidateSAMLEmailsStatus, flagTeamFeatureSndFactorPasswordChallengeStatus, flagTeamFeatureSearchVisibilityInbound, + flagMLS, Defaults (..), ImplicitLockStatus (..), unImplicitLockStatus, @@ -235,7 +236,8 @@ data FeatureFlags = FeatureFlags _flagConversationGuestLinks :: !(Defaults (WithStatus GuestLinksConfig)), _flagsTeamFeatureValidateSAMLEmailsStatus :: !(Defaults (ImplicitLockStatus ValidateSAMLEmailsConfig)), _flagTeamFeatureSndFactorPasswordChallengeStatus :: !(Defaults (WithStatus SndFactorPasswordChallengeConfig)), - _flagTeamFeatureSearchVisibilityInbound :: !(Defaults (ImplicitLockStatus SearchVisibilityInboundConfig)) + _flagTeamFeatureSearchVisibilityInbound :: !(Defaults (ImplicitLockStatus SearchVisibilityInboundConfig)), + _flagMLS :: !(Defaults (ImplicitLockStatus MLSConfig)) } deriving (Eq, Show, Generic) @@ -286,6 +288,7 @@ instance FromJSON FeatureFlags where <*> withImplicitLockStatusOrDefault obj "validateSAMLEmails" <*> (fromMaybe (Defaults (defFeatureStatus @SndFactorPasswordChallengeConfig)) <$> (obj .:? "sndFactorPasswordChallenge")) <*> withImplicitLockStatusOrDefault obj "searchVisibilityInbound" + <*> withImplicitLockStatusOrDefault obj "mls" where withImplicitLockStatusOrDefault :: forall cfg. (IsFeatureConfig cfg, Schema.ToSchema cfg) => Object -> Key -> A.Parser (Defaults (ImplicitLockStatus cfg)) withImplicitLockStatusOrDefault obj fieldName = fromMaybe (Defaults (ImplicitLockStatus (defFeatureStatus @cfg))) <$> obj .:? fieldName @@ -305,6 +308,7 @@ instance ToJSON FeatureFlags where validateSAMLEmails sndFactorPasswordChallenge searchVisibilityInbound + mls ) = object [ "sso" .= sso, @@ -318,7 +322,8 @@ instance ToJSON FeatureFlags where "conversationGuestLinks" .= guestLinks, "validateSAMLEmails" .= validateSAMLEmails, "sndFactorPasswordChallenge" .= sndFactorPasswordChallenge, - "searchVisibilityInbound" .= searchVisibilityInbound + "searchVisibilityInbound" .= searchVisibilityInbound, + "mls" .= mls ] instance FromJSON FeatureSSO where diff --git a/libs/galley-types/test/unit/Test/Galley/Types.hs b/libs/galley-types/test/unit/Test/Galley/Types.hs index 6cd120d350..b59b28325a 100644 --- a/libs/galley-types/test/unit/Test/Galley/Types.hs +++ b/libs/galley-types/test/unit/Test/Galley/Types.hs @@ -95,6 +95,7 @@ instance Arbitrary FeatureFlags where <*> fmap (fmap unlocked) arbitrary <*> arbitrary <*> fmap (fmap unlocked) arbitrary + <*> fmap (fmap unlocked) arbitrary where unlocked :: ImplicitLockStatus a -> ImplicitLockStatus a unlocked = ImplicitLockStatus . setUnlocked . _unImplicitLockStatus diff --git a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs index 0524136838..60eae0e8fc 100644 --- a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs +++ b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs @@ -20,10 +20,16 @@ module Wire.API.MLS.CipherSuite where +import Control.Lens ((?~)) import Crypto.Error import Crypto.Hash.Algorithms import qualified Crypto.KDF.HKDF as HKDF import qualified Crypto.PubKey.Ed25519 as Ed25519 +import Data.Aeson (parseJSON, toJSON) +import Data.Proxy +import Data.Schema +import qualified Data.Swagger as S +import qualified Data.Swagger.Internal.Schema as S import Data.Word import Imports import Wire.API.Arbitrary @@ -35,7 +41,29 @@ newtype CipherSuite = CipherSuite {cipherSuiteNumber :: Word16} deriving newtype (ParseMLS, Arbitrary) data CipherSuiteTag = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 - deriving stock (Bounded, Enum, Eq, Show) + deriving stock (Bounded, Enum, Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform CipherSuiteTag) + +instance S.ToSchema CipherSuiteTag where + declareNamedSchema _ = + pure . S.named "CipherSuiteTag" $ + ( S.paramSchemaToSchema (Proxy @Word16) + & S.description ?~ "Index number of ciphersuite. See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5" + ) + +instance ToSchema CipherSuiteTag where + schema = + mkSchema + (swaggerDoc @CipherSuiteTag) + tagParser + (Just . toJSON . cipherSuiteNumber . tagCipherSuite) + where + tagParser v = do + index <- parseJSON v + maybe + (fail "Not a valid index number of a ciphersuite. See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5.") + pure + (cipherSuiteTag (CipherSuite index)) -- | See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5. cipherSuiteTag :: CipherSuite -> Maybe CipherSuiteTag @@ -43,6 +71,10 @@ cipherSuiteTag (CipherSuite n) = case n of 1 -> pure MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 _ -> Nothing +-- | Inverse of 'cipherSuiteTag' +tagCipherSuite :: CipherSuiteTag -> CipherSuite +tagCipherSuite MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = CipherSuite 1 + csHash :: CipherSuiteTag -> ByteString -> ByteString -> ByteString csHash MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 ctx value = HKDF.expand (HKDF.extract @SHA256 (mempty :: ByteString) value) ctx 16 diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs index 3d09e303ba..9abb4cde7f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -1118,6 +1118,8 @@ type FeatureAPI = :<|> FeatureStatusPut '() GuestLinksConfig :<|> FeatureStatusGet SndFactorPasswordChallengeConfig :<|> FeatureStatusPut '() SndFactorPasswordChallengeConfig + :<|> FeatureStatusGet MLSConfig + :<|> FeatureStatusPut '() MLSConfig :<|> AllFeatureConfigsUserGet :<|> AllFeatureConfigsTeamGet :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is not used by team management, or webapp, and is potentially used by the old Android client as of June 2022" LegalholdConfig @@ -1132,6 +1134,7 @@ type FeatureAPI = :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is not used by team management, or webapp, and is potentially used by the old Android client as of June 2022" SelfDeletingMessagesConfig :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is used by team management, webapp, and potentially the old Android client as of June 2022" GuestLinksConfig :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is used by team management, webapp, and potentially the old Android client as of June 2022" SndFactorPasswordChallengeConfig + :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is used by team management, webapp, and potentially the old Android client as of June 2022" MLSConfig type FeatureStatusGet f = Named diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index fa78f36355..590a2d42a8 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -54,6 +54,7 @@ module Wire.API.Team.Feature ClassifiedDomainsConfig (..), AppLockConfig (..), FileSharingConfig (..), + MLSConfig (..), AllFeatureConfigs (..), typeFeatureTTL, withStatusModel, @@ -66,13 +67,14 @@ module Wire.API.Team.Feature where import qualified Cassandra.CQL as Cass -import Control.Lens (makeLenses) +import Control.Lens (makeLenses, (?~)) import qualified Data.Aeson as A import qualified Data.Attoparsec.ByteString as Parser import Data.ByteString.Conversion import qualified Data.ByteString.UTF8 as UTF8 import Data.Domain (Domain) import Data.Either.Extra (maybeToEither) +import Data.Id import Data.Proxy import Data.Schema import Data.String.Conversions (cs) @@ -87,64 +89,62 @@ import Imports import Servant (FromHttpApiData (..), ToHttpApiData (..)) import Test.QuickCheck.Arbitrary (arbitrary) import Wire.API.Arbitrary (Arbitrary, GenericUniform (..)) +import Wire.API.Conversation.Protocol (ProtocolTag (ProtocolProteusTag)) +import Wire.API.MLS.CipherSuite (CipherSuiteTag (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519)) ---------------------------------------------------------------------- -- FeatureTag --- | If you add a constructor here, you need extend multiple definitions, which --- aren't checked by GHC. +-- | Checklist for adding a new feature -- --- Follow this Checklist: +-- 1. Add a data type for your feature's "config" part, naming convention: +-- **Config**. If your feature doesn't have a config besides +-- being enabled/disabled, locked/unlocked, then the config should be a unit +-- type, e.g. **data MyFeatureConfig = MyFeatureConfig**. Implement type clases +-- 'ToSchema', 'IsFeatureConfig' and 'Arbitrary'. If your feature doesn't have a +-- config implement 'FeatureTrivialConfig'. -- --- * FUTUREWORK: Update this checklist when a new feature is added +-- 2. Add the config to to 'AllFeatureConfigs'. Add your feature to 'allFeatureModels'. -- --- * libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs --- * add call to 'testRoundTrip' --- * libs/wire-api/src/Wire/API/Routes/Public/Galley.hs --- * add a FeatureStatusGet (and maybe FeatureStatusPut) route to the FeatureAPI --- * maybe add a FeatureConfigGet route to FeatureAPI --- * services/galley/src/Galley/API/Internal.hs --- * add IFeatureStatus to IFeatureAPI --- * libs/galley-types/src/Galley/Types/Teams.hs --- * FeatureFlags for server config file --- * Update the Arbitrary instance of FeatureFlags --- in libs/galley-types/test/unit/Test/Galley/Types.hs --- * roleHiddenPermissions ChangeTeamFeature and ViewTeamFeature --- * add the feature status to `AllFeatureConfigs` (see below) --- * follow the type errors and fix them (e.g. in services/galley/src/Galley/API/Teams/Features.hs) --- * services/galley/schema/src/ --- * add a migration like the one in "V43_TeamFeatureDigitalSignatures.hs" --- * services/galley/test/integration/API/Teams/Feature.hs --- * add an integration test for the feature --- * extend testAllFeatures --- * consider personal-account configurability (like for `conferenceCalling`, see --- eg. https://github.com/wireapp/wire-server/pull/1811, --- https://github.com/wireapp/wire-server/pull/1818) +-- 2. If your feature is configurable on a per-team basis, add a schema +-- migration in galley and add 'FeatureStatusCassandra' instance in +-- Galley.Cassandra.TreamFeatures together with a schema migration -- --- An example of all the places to change (including compiler errors and failing tests) can be found --- in eg. https://github.com/wireapp/wire-server/pull/1652. (applock and conference calling also --- add interesting aspects, though.) +-- 3. Add the feature to the config schema of galley in Galley.Types.Teams. +-- and extend the Arbitrary instance of FeatureConfigs in the unit tests Test.Galley.Types -- --- Using something like '[minBound..]' on those expressions would require dependent types. We --- could generate exhaustive lists of those calls using TH, along the lines of: +-- 4. Implement 'GetFeatureConfig' and 'SetFeatureConfig' in +-- Galley.API.Teams.Features which defines the main business logic for getting +-- and setting (with side-effects). -- --- @ --- forAllFeatureTags :: --- ExpQ {- [forall (a :: FeatureTag). b] -} -> --- ExpQ {- [b] -} --- forAllFeatureTags = --- error --- "... and then somehow turn the values from '[minBound..]' into \ --- \type applications in the syntax tree" --- @ +-- 5. Add public routes to Routes.Public.Galley: 'FeatureStatusGet', +-- 'FeatureStatusPut' (optional) and by by user: 'FeatureConfigGet'. Then +-- implement them in Galley.API.Public. -- --- But that seems excessive. Let's wait for dependent types to be ready in ghc! +-- 6. Add internal routes in Galley.API.Internal +-- +-- 7. If the feature should be configurable via Stern add routes to Stern.API. +-- Manually check that the swagger looks okay. +-- +-- 8. If the feature is configured on a per-user level, see the +-- 'ConferenceCallingConfig' as an example. +-- (https://github.com/wireapp/wire-server/pull/1811, +-- https://github.com/wireapp/wire-server/pull/1818) +-- +-- 9. Extend the integration tests with cases class IsFeatureConfig cfg where type FeatureSymbol cfg :: Symbol defFeatureStatus :: WithStatus cfg + + -- | Swagger 1.2 model for stern and wai routes configModel :: Maybe Doc.Model configModel = Nothing - objectSchema :: ObjectSchema SwaggerDoc cfg + + objectSchema :: + -- | Should be "pure MyFeatureConfig" if the feature doesn't have config, + -- which results in a trivial empty schema and the "config" field being + -- omitted/ignored in the JSON encoder / parser. + ObjectSchema SwaggerDoc cfg class FeatureTrivialConfig cfg where trivialConfig :: cfg @@ -404,6 +404,7 @@ allFeatureModels = withStatusNoLockModel @GuestLinksConfig, withStatusNoLockModel @SndFactorPasswordChallengeConfig, withStatusNoLockModel @SearchVisibilityInboundConfig, + withStatusNoLockModel @MLSConfig, withStatusModel @LegalholdConfig, withStatusModel @SSOConfig, withStatusModel @SearchVisibilityAvailableConfig, @@ -416,7 +417,8 @@ allFeatureModels = withStatusModel @SelfDeletingMessagesConfig, withStatusModel @GuestLinksConfig, withStatusModel @SndFactorPasswordChallengeConfig, - withStatusModel @SearchVisibilityInboundConfig + withStatusModel @SearchVisibilityInboundConfig, + withStatusModel @MLSConfig ] <> catMaybes [ configModel @LegalholdConfig, @@ -431,7 +433,8 @@ allFeatureModels = configModel @SelfDeletingMessagesConfig, configModel @GuestLinksConfig, configModel @SndFactorPasswordChallengeConfig, - configModel @SearchVisibilityInboundConfig + configModel @SearchVisibilityInboundConfig, + configModel @MLSConfig ] -------------------------------------------------------------------------------- @@ -721,6 +724,40 @@ instance IsFeatureConfig SelfDeletingMessagesConfig where Doc.property "enforcedTimeoutSeconds" Doc.int32' $ Doc.description "optional; default: `0` (no enforcement)" objectSchema = field "config" schema +---------------------------------------------------------------------- +-- MLSConfig + +data MLSConfig = MLSConfig + { mlsProtocolToggleUsers :: [UserId], + mlsDefaultProtocol :: ProtocolTag, + mlsAllowedCipherSuites :: [CipherSuiteTag], + mlsDefaultCipherSuite :: CipherSuiteTag + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform MLSConfig) + +instance ToSchema MLSConfig where + schema = + object "MLSConfig" $ + MLSConfig + <$> mlsProtocolToggleUsers .= fieldWithDocModifier "protocolToggleUsers" (S.description ?~ "allowlist of users that may change protocols") (array schema) + <*> mlsDefaultProtocol .= field "defaultProtocol" schema + <*> mlsAllowedCipherSuites .= field "allowedCipherSuites" (array schema) + <*> mlsDefaultCipherSuite .= field "defaultCipherSuite" schema + +instance IsFeatureConfig MLSConfig where + type FeatureSymbol MLSConfig = "mls" + defFeatureStatus = + let config = MLSConfig [] ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + in WithStatus FeatureStatusDisabled LockStatusUnlocked config + objectSchema = field "config" schema + configModel = Just $ + Doc.defineModel "MLSConfig" $ do + Doc.property "protocolToggleUsers" (Doc.array Doc.string') $ Doc.description "allowlist of users that may change protocols" + Doc.property "defaultProtocol" Doc.string' $ Doc.description "default protocol, either \"proteus\" or \"mls\"" + Doc.property "allowedCipherSuites" (Doc.array Doc.int32') $ Doc.description "cipher suite numbers, See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5" + Doc.property "defaultCipherSuite" Doc.int32' $ Doc.description "cipher suite number. See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5" + ---------------------------------------------------------------------- -- FeatureStatus @@ -787,7 +824,8 @@ data AllFeatureConfigs = AllFeatureConfigs afcConferenceCalling :: WithStatus ConferenceCallingConfig, afcSelfDeletingMessages :: WithStatus SelfDeletingMessagesConfig, afcGuestLink :: WithStatus GuestLinksConfig, - afcSndFactorPasswordChallenge :: WithStatus SndFactorPasswordChallengeConfig + afcSndFactorPasswordChallenge :: WithStatus SndFactorPasswordChallengeConfig, + afcMLS :: WithStatus MLSConfig } deriving stock (Eq, Show) deriving (FromJSON, ToJSON, S.ToSchema) via (Schema AllFeatureConfigs) @@ -808,6 +846,7 @@ instance ToSchema AllFeatureConfigs where <*> afcSelfDeletingMessages .= featureField <*> afcGuestLink .= featureField <*> afcSndFactorPasswordChallenge .= featureField + <*> afcMLS .= featureField where featureField :: forall cfg. @@ -830,5 +869,6 @@ instance Arbitrary AllFeatureConfigs where <*> arbitrary <*> arbitrary <*> arbitrary + <*> arbitrary makeLenses ''ImplicitLockStatus diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 48adcf5282..5e1abe32f2 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -624,6 +624,7 @@ executable galley-schema V64_Epoch V65_MLSRemoteClients V66_AddSplashScreen + V67_MLSFeature Paths_galley hs-source-dirs: schema/src diff --git a/services/galley/schema/src/Main.hs b/services/galley/schema/src/Main.hs index a67a74bbf3..1ae83a6ff7 100644 --- a/services/galley/schema/src/Main.hs +++ b/services/galley/schema/src/Main.hs @@ -69,6 +69,7 @@ import qualified V63_MLSConversationClients import qualified V64_Epoch import qualified V65_MLSRemoteClients import qualified V66_AddSplashScreen +import qualified V67_MLSFeature main :: IO () main = do @@ -123,7 +124,8 @@ main = do V63_MLSConversationClients.migration, V64_Epoch.migration, V65_MLSRemoteClients.migration, - V66_AddSplashScreen.migration + V66_AddSplashScreen.migration, + V67_MLSFeature.migration -- When adding migrations here, don't forget to update -- 'schemaVersion' in Galley.Cassandra -- (see also docs/developer/cassandra-interaction.md) diff --git a/services/galley/schema/src/V67_MLSFeature.hs b/services/galley/schema/src/V67_MLSFeature.hs new file mode 100644 index 0000000000..b3c5a3066a --- /dev/null +++ b/services/galley/schema/src/V67_MLSFeature.hs @@ -0,0 +1,35 @@ +-- 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 . + +module V67_MLSFeature where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 67 "Add team feature config for MLSConfig" $ + schema' + [r| ALTER TABLE team_features ADD ( + mls_status int, + mls_default_protocol int, + mls_protocol_toggle_users set, + mls_allowed_ciphersuites set, + mls_default_ciphersuite int + ) + |] diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index bcaac0873e..87e2490495 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -170,6 +170,9 @@ type IFeatureAPI = :<|> IFeatureNoConfigMultiGet SearchVisibilityInboundConfig -- ClassifiedDomainsConfig :<|> IFeatureStatusGet ClassifiedDomainsConfig + -- MLSConfig + :<|> IFeatureStatusGet MLSConfig + :<|> IFeatureStatusPut '() MLSConfig type InternalAPI = "i" :> InternalAPIBase @@ -483,6 +486,8 @@ featureAPI = <@> mkNamedAPI (\tid ws ttl -> setFeatureStatus @Cassandra ttl DontDoAuth tid ws) <@> mkNamedAPI (getFeatureStatusMulti @Cassandra @SearchVisibilityInboundConfig) <@> mkNamedAPI (getFeatureStatus @Cassandra DontDoAuth) + <@> mkNamedAPI (getFeatureStatus @Cassandra DontDoAuth) + <@> mkNamedAPI (\tid ws ttl -> setFeatureStatus @Cassandra ttl DontDoAuth tid ws) internalSitemap :: Routes a (Sem GalleyEffects) () internalSitemap = do diff --git a/services/galley/src/Galley/API/Public/Servant.hs b/services/galley/src/Galley/API/Public/Servant.hs index 770bbeebe0..9f821a80b2 100644 --- a/services/galley/src/Galley/API/Public/Servant.hs +++ b/services/galley/src/Galley/API/Public/Servant.hs @@ -136,6 +136,8 @@ servantSitemap = <@> mkNamedAPI @'("put", GuestLinksConfig) (setFeatureStatus @Cassandra Nothing . DoAuth) <@> mkNamedAPI @'("get", SndFactorPasswordChallengeConfig) (getFeatureStatus @Cassandra . DoAuth) <@> mkNamedAPI @'("put", SndFactorPasswordChallengeConfig) (setFeatureStatus @Cassandra Nothing . DoAuth) + <@> mkNamedAPI @'("get", MLSConfig) (getFeatureStatus @Cassandra . DoAuth) + <@> mkNamedAPI @'("put", MLSConfig) (setFeatureStatus @Cassandra Nothing . DoAuth) <@> mkNamedAPI @"get-all-feature-configs-for-user" (getAllFeatureConfigsForUser @Cassandra) <@> mkNamedAPI @"get-all-feature-configs-for-team" (getAllFeatureConfigsForTeam @Cassandra) <@> mkNamedAPI @'("get-config", LegalholdConfig) (getFeatureStatusForUser @Cassandra) @@ -150,6 +152,7 @@ servantSitemap = <@> mkNamedAPI @'("get-config", SelfDeletingMessagesConfig) (getFeatureStatusForUser @Cassandra) <@> mkNamedAPI @'("get-config", GuestLinksConfig) (getFeatureStatusForUser @Cassandra) <@> mkNamedAPI @'("get-config", SndFactorPasswordChallengeConfig) (getFeatureStatusForUser @Cassandra) + <@> mkNamedAPI @'("get-config", MLSConfig) (getFeatureStatusForUser @Cassandra) mls :: API MLSAPI GalleyEffects mls = diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index d8cf644d81..a3467ed10a 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -141,6 +141,9 @@ class GetFeatureConfig (db :: *) cfg => SetFeatureConfig (db :: *) cfg where type SetConfigForTeamConstraints db cfg (r :: EffectRow) :: Constraint type SetConfigForTeamConstraints db cfg (r :: EffectRow) = () + -- | This method should generate the side-effects of changing the feature and + -- also (depending on the feature) persist the new setting to the database and + -- push a event to clients (see 'persistAndPushEvent'). setConfigForTeam :: ( SetConfigForTeamConstraints db cfg r, GetConfigForTeamConstraints db cfg r, @@ -158,6 +161,22 @@ class GetFeatureConfig (db :: *) cfg => SetFeatureConfig (db :: *) cfg where Maybe FeatureTTL -> Sem r (WithStatus cfg) +type FeaturePersistentAllFeatures db = + ( FeaturePersistentConstraint db LegalholdConfig, + FeaturePersistentConstraint db SSOConfig, + FeaturePersistentConstraint db SearchVisibilityAvailableConfig, + FeaturePersistentConstraint db ValidateSAMLEmailsConfig, + FeaturePersistentConstraint db DigitalSignaturesConfig, + FeaturePersistentConstraint db AppLockConfig, + FeaturePersistentConstraint db FileSharingConfig, + FeaturePersistentConstraint db ClassifiedDomainsConfig, + FeaturePersistentConstraint db ConferenceCallingConfig, + FeaturePersistentConstraint db SelfDeletingMessagesConfig, + FeaturePersistentConstraint db GuestLinksConfig, + FeaturePersistentConstraint db SndFactorPasswordChallengeConfig, + FeaturePersistentConstraint db MLSConfig + ) + getFeatureStatusNoPermissionCheck :: forall db cfg r. ( GetFeatureConfig db cfg, @@ -319,19 +338,7 @@ getAllFeatureConfigsForUser :: TeamStore ] r => - ( FeaturePersistentConstraint db LegalholdConfig, - FeaturePersistentConstraint db SSOConfig, - FeaturePersistentConstraint db SearchVisibilityAvailableConfig, - FeaturePersistentConstraint db ValidateSAMLEmailsConfig, - FeaturePersistentConstraint db DigitalSignaturesConfig, - FeaturePersistentConstraint db AppLockConfig, - FeaturePersistentConstraint db FileSharingConfig, - FeaturePersistentConstraint db ClassifiedDomainsConfig, - FeaturePersistentConstraint db ConferenceCallingConfig, - FeaturePersistentConstraint db SelfDeletingMessagesConfig, - FeaturePersistentConstraint db GuestLinksConfig, - FeaturePersistentConstraint db SndFactorPasswordChallengeConfig - ) => + FeaturePersistentAllFeatures db => UserId -> Sem r AllFeatureConfigs getAllFeatureConfigsForUser zusr = do @@ -357,19 +364,7 @@ getAllFeatureConfigsForTeam :: TeamStore ] r => - ( FeaturePersistentConstraint db LegalholdConfig, - FeaturePersistentConstraint db SSOConfig, - FeaturePersistentConstraint db SearchVisibilityAvailableConfig, - FeaturePersistentConstraint db ValidateSAMLEmailsConfig, - FeaturePersistentConstraint db DigitalSignaturesConfig, - FeaturePersistentConstraint db AppLockConfig, - FeaturePersistentConstraint db FileSharingConfig, - FeaturePersistentConstraint db ClassifiedDomainsConfig, - FeaturePersistentConstraint db ConferenceCallingConfig, - FeaturePersistentConstraint db SelfDeletingMessagesConfig, - FeaturePersistentConstraint db GuestLinksConfig, - FeaturePersistentConstraint db SndFactorPasswordChallengeConfig - ) => + FeaturePersistentAllFeatures db => Local UserId -> TeamId -> Sem r AllFeatureConfigs @@ -391,19 +386,7 @@ getAllFeatureConfigsUser :: TeamStore ] r => - ( FeaturePersistentConstraint db LegalholdConfig, - FeaturePersistentConstraint db SSOConfig, - FeaturePersistentConstraint db SearchVisibilityAvailableConfig, - FeaturePersistentConstraint db ValidateSAMLEmailsConfig, - FeaturePersistentConstraint db DigitalSignaturesConfig, - FeaturePersistentConstraint db AppLockConfig, - FeaturePersistentConstraint db FileSharingConfig, - FeaturePersistentConstraint db ClassifiedDomainsConfig, - FeaturePersistentConstraint db ConferenceCallingConfig, - FeaturePersistentConstraint db SelfDeletingMessagesConfig, - FeaturePersistentConstraint db GuestLinksConfig, - FeaturePersistentConstraint db SndFactorPasswordChallengeConfig - ) => + FeaturePersistentAllFeatures db => UserId -> Sem r AllFeatureConfigs getAllFeatureConfigsUser uid = @@ -420,6 +403,7 @@ getAllFeatureConfigsUser uid = <*> getConfigForUser @db @SelfDeletingMessagesConfig uid <*> getConfigForUser @db @GuestLinksConfig uid <*> getConfigForUser @db @SndFactorPasswordChallengeConfig uid + <*> getConfigForUser @db @MLSConfig uid getAllFeatureConfigsTeam :: forall db r. @@ -433,19 +417,7 @@ getAllFeatureConfigsTeam :: TeamStore ] r => - ( FeaturePersistentConstraint db LegalholdConfig, - FeaturePersistentConstraint db SSOConfig, - FeaturePersistentConstraint db SearchVisibilityAvailableConfig, - FeaturePersistentConstraint db ValidateSAMLEmailsConfig, - FeaturePersistentConstraint db DigitalSignaturesConfig, - FeaturePersistentConstraint db AppLockConfig, - FeaturePersistentConstraint db FileSharingConfig, - FeaturePersistentConstraint db ClassifiedDomainsConfig, - FeaturePersistentConstraint db ConferenceCallingConfig, - FeaturePersistentConstraint db SelfDeletingMessagesConfig, - FeaturePersistentConstraint db GuestLinksConfig, - FeaturePersistentConstraint db SndFactorPasswordChallengeConfig - ) => + FeaturePersistentAllFeatures db => TeamId -> Sem r AllFeatureConfigs getAllFeatureConfigsTeam tid = @@ -462,6 +434,7 @@ getAllFeatureConfigsTeam tid = <*> getConfigForTeam @db @SelfDeletingMessagesConfig tid <*> getConfigForTeam @db @GuestLinksConfig tid <*> getConfigForTeam @db @SndFactorPasswordChallengeConfig tid + <*> getConfigForTeam @db @MLSConfig tid -- | Note: this is an internal function which doesn't cover all features, e.g. LegalholdConfig genericGetConfigForTeam :: @@ -808,6 +781,14 @@ instance GetFeatureConfig db SearchVisibilityInboundConfig where getConfigForServer = input <&> view (optSettings . setFeatureFlags . flagTeamFeatureSearchVisibilityInbound . unDefaults . unImplicitLockStatus) +instance GetFeatureConfig db MLSConfig where + getConfigForServer = + input <&> view (optSettings . setFeatureFlags . flagMLS . unDefaults . unImplicitLockStatus) + +instance SetFeatureConfig db MLSConfig where + setConfigForTeam tid wsnl _ = do + persistAndPushEvent @db tid wsnl Nothing + -- -- | If second factor auth is enabled, make sure that end-points that don't support it, but should, are blocked completely. (This is a workaround until we have 2FA for those end-points as well.) -- -- -- This function exists to resolve a cyclic dependency. diff --git a/services/galley/src/Galley/Cassandra.hs b/services/galley/src/Galley/Cassandra.hs index 90eee8800f..75a2a880c5 100644 --- a/services/galley/src/Galley/Cassandra.hs +++ b/services/galley/src/Galley/Cassandra.hs @@ -20,4 +20,4 @@ module Galley.Cassandra (schemaVersion) where import Imports schemaVersion :: Int32 -schemaVersion = 66 +schemaVersion = 67 diff --git a/services/galley/src/Galley/Cassandra/Instances.hs b/services/galley/src/Galley/Cassandra/Instances.hs index 84411d2042..aaeaac6daf 100644 --- a/services/galley/src/Galley/Cassandra/Instances.hs +++ b/services/galley/src/Galley/Cassandra/Instances.hs @@ -37,6 +37,7 @@ import Galley.Types.Teams.SearchVisibility import Imports import Wire.API.Asset (AssetKey, assetKeyToText) import Wire.API.Conversation.Protocol +import Wire.API.MLS.CipherSuite (CipherSuite (CipherSuite, cipherSuiteNumber), CipherSuiteTag, cipherSuiteTag, tagCipherSuite) import Wire.API.Team import qualified Wire.API.Team.Feature as Public @@ -212,3 +213,13 @@ instance Cql Epoch where toCql = CqlBigInt . fromIntegral . epochNumber fromCql (CqlBigInt n) = pure (Epoch (fromIntegral n)) fromCql _ = Left "epoch: bigint expected" + +instance Cql CipherSuiteTag where + ctype = Tagged IntColumn + toCql = CqlInt . fromIntegral . cipherSuiteNumber . tagCipherSuite + + fromCql (CqlInt index) = + case cipherSuiteTag (CipherSuite (fromIntegral index)) of + Just tag -> Right tag + Nothing -> Left "CipherSuiteTag: unexpected index" + fromCql _ = Left "CipherSuiteTag: int expected" diff --git a/services/galley/src/Galley/Cassandra/TeamFeatures.hs b/services/galley/src/Galley/Cassandra/TeamFeatures.hs index 7a7ce74f86..96697b9bda 100644 --- a/services/galley/src/Galley/Cassandra/TeamFeatures.hs +++ b/services/galley/src/Galley/Cassandra/TeamFeatures.hs @@ -24,6 +24,7 @@ module Galley.Cassandra.TeamFeatures where import Cassandra +import qualified Cassandra as C import Control.Monad.Trans.Maybe import Data.Id import Data.Proxy @@ -34,6 +35,8 @@ import Imports import Polysemy import Polysemy.Input import UnliftIO.Async (pooledMapConcurrentlyN) +import Wire.API.Conversation.Protocol (ProtocolTag) +import Wire.API.MLS.CipherSuite import Wire.API.Team.Feature data Cassandra @@ -246,3 +249,45 @@ instance FeatureStatusCassandra SndFactorPasswordChallengeConfig where instance FeatureStatusCassandra SearchVisibilityInboundConfig where getFeatureConfig _ = getTrivialConfigC "search_visibility_status" setFeatureConfig _ tid statusNoLock _mTtl = setFeatureStatusC "search_visibility_status" tid (wssStatus statusNoLock) Nothing + +instance FeatureStatusCassandra MLSConfig where + getFeatureConfig _ tid = do + m <- retry x1 $ query1 select (params LocalQuorum (Identity tid)) + pure $ case m of + Nothing -> Nothing + Just (status, defaultProtocol, protocolToggleUsers, allowedCipherSuites, defaultCipherSuite) -> + WithStatusNoLock + <$> status + <*> ( MLSConfig + <$> maybe (Just []) (Just . C.fromSet) protocolToggleUsers + <*> defaultProtocol + <*> maybe (Just []) (Just . C.fromSet) allowedCipherSuites + <*> defaultCipherSuite + ) + where + select :: PrepQuery R (Identity TeamId) (Maybe FeatureStatus, Maybe ProtocolTag, Maybe (C.Set UserId), Maybe (C.Set CipherSuiteTag), Maybe CipherSuiteTag) + select = + "select mls_status, mls_default_protocol, mls_protocol_toggle_users, mls_allowed_ciphersuites, \ + \mls_default_ciphersuite from team_features where team_id = ?" + + setFeatureConfig _ tid statusNoLock _mTtl = do + let status = wssStatus statusNoLock + let MLSConfig protocolToggleUsers defaultProtocol allowedCipherSuites defaultCipherSuite = wssConfig statusNoLock + retry x5 $ + write + insert + ( params + LocalQuorum + ( tid, + status, + defaultProtocol, + C.Set protocolToggleUsers, + C.Set allowedCipherSuites, + defaultCipherSuite + ) + ) + where + insert :: PrepQuery W (TeamId, FeatureStatus, ProtocolTag, C.Set UserId, C.Set CipherSuiteTag, CipherSuiteTag) () + insert = + "insert into team_features (team_id, mls_status, mls_default_protocol, \ + \mls_protocol_toggle_users, mls_allowed_ciphersuites, mls_default_ciphersuite) values (?, ?, ?, ?, ?, ?)" diff --git a/services/galley/test/integration/API/Teams/Feature.hs b/services/galley/test/integration/API/Teams/Feature.hs index 6b0d672bb3..abc29e259a 100644 --- a/services/galley/test/integration/API/Teams/Feature.hs +++ b/services/galley/test/integration/API/Teams/Feature.hs @@ -48,10 +48,12 @@ import qualified Test.Tasty.Cannon as WS import Test.Tasty.HUnit (assertFailure, (@?=)) import TestHelpers (test) import TestSetup +import Wire.API.Conversation.Protocol (ProtocolTag (ProtocolMLSTag, ProtocolProteusTag)) import qualified Wire.API.Event.FeatureConfig as FeatureConfig import Wire.API.Internal.Notification (Notification) +import Wire.API.MLS.CipherSuite import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti as Multi -import Wire.API.Team.Feature (FeatureStatus (..), FeatureTTL (..)) +import Wire.API.Team.Feature (FeatureStatus (..), FeatureTTL (..), LockStatus (LockStatusUnlocked), MLSConfig (MLSConfig)) import qualified Wire.API.Team.Feature as Public tests :: IO TestSetup -> TestTree @@ -89,7 +91,8 @@ tests s = test s "reduce from unlimited" $ testSimpleFlagTTLOverride @Public.ConferenceCallingConfig Public.FeatureStatusEnabled FeatureTTLUnlimited (FeatureTTLSeconds 1), test s "reduce" $ testSimpleFlagTTLOverride @Public.ConferenceCallingConfig Public.FeatureStatusEnabled (FeatureTTLSeconds 5) (FeatureTTLSeconds 1), test s "Unlimited to unlimited" $ testSimpleFlagTTLOverride @Public.ConferenceCallingConfig Public.FeatureStatusEnabled FeatureTTLUnlimited FeatureTTLUnlimited - ] + ], + test s "MLS feature config" testMLS ] testSSO :: TestM () @@ -445,7 +448,6 @@ testSimpleFlagTTLOverride defaultValue ttl ttlAfter = do setFlagInternal defaultValue FeatureTTLUnlimited getFlag defaultValue --- TODO: remove a, add cfg testSimpleFlagTTL :: forall cfg. ( HasCallStack, @@ -505,7 +507,7 @@ testSimpleFlagTTL defaultValue ttl = do setFlagInternal otherValue ttl void . liftIO $ WS.assertMatch (5 # Second) ws $ - wsAssertFeatureConfigUpdate @cfg otherValue + wsAssertFeatureTrivialConfigUpdate @cfg otherValue getFlag otherValue getFeatureConfig otherValue getFlagInternal otherValue @@ -829,7 +831,8 @@ testAllFeatures = do toS @Public.GuestLinksConfig .= Public.WithStatus FeatureStatusEnabled Public.LockStatusUnlocked Public.GuestLinksConfig, toS @Public.ValidateSAMLEmailsConfig .= Public.WithStatus FeatureStatusEnabled Public.LockStatusUnlocked Public.GuestLinksConfig, toS @Public.GuestLinksConfig .= Public.WithStatus FeatureStatusEnabled Public.LockStatusUnlocked Public.GuestLinksConfig, - toS @Public.SndFactorPasswordChallengeConfig .= Public.WithStatus FeatureStatusDisabled Public.LockStatusLocked Public.SndFactorPasswordChallengeConfig + toS @Public.SndFactorPasswordChallengeConfig .= Public.WithStatus FeatureStatusDisabled Public.LockStatusLocked Public.SndFactorPasswordChallengeConfig, + toS @Public.MLSConfig .= Public.WithStatus FeatureStatusDisabled Public.LockStatusUnlocked (Public.MLSConfig [] ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) ] toS :: forall cfg. (Public.IsFeatureConfig cfg, KnownSymbol (Public.FeatureSymbol cfg)) => Aeson.Key @@ -911,6 +914,65 @@ testFeatureNoConfigMultiSearchVisibilityInbound = do Multi.TeamStatus _ team2Status <- Util.assertOne (filter ((== team2) . Multi.team) teamsStatuses) team2Status @?= Public.FeatureStatusEnabled +testMLS :: TestM () +testMLS = do + owner <- Util.randomUser + member <- Util.randomUser + tid <- Util.createNonBindingTeam "foo" owner [] + Util.connectUsers owner (list1 member []) + Util.addTeamMember owner tid member (rolePermissions RoleMember) Nothing + + galley <- view tsGalley + cannon <- view tsCannon + + let getForTeam :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + getForTeam expected = + flip assertFlagWithConfig expected $ Util.getTeamFeatureFlag @MLSConfig member tid + + getForTeamInternal :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + getForTeamInternal expected = + flip assertFlagWithConfig expected $ Util.getTeamFeatureFlagInternal @Public.MLSConfig tid + + getForUser :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + getForUser expected = + flip assertFlagWithConfig expected $ Util.getFeatureConfig @MLSConfig member + + getViaEndpoints :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + getViaEndpoints expected = do + getForTeam expected + getForTeamInternal expected + getForUser expected + + setForTeam :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + setForTeam wsnl = + Util.putTeamFeatureFlagWithGalley @MLSConfig galley owner tid wsnl + !!! statusCode === const 200 + + setForTeamInternal :: HasCallStack => Public.WithStatusNoLock MLSConfig -> TestM () + setForTeamInternal wsnl = + void $ Util.putTeamFeatureFlagInternal @Public.MLSConfig expect2xx tid wsnl + + let cipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + let defaultConfig = Public.WithStatusNoLock FeatureStatusDisabled (MLSConfig [] ProtocolProteusTag [cipherSuite] cipherSuite) + let config2 = Public.WithStatusNoLock FeatureStatusEnabled (MLSConfig [member] ProtocolMLSTag [] cipherSuite) + let config3 = Public.WithStatusNoLock FeatureStatusDisabled (MLSConfig [] ProtocolMLSTag [cipherSuite] cipherSuite) + + getViaEndpoints defaultConfig + + WS.bracketR cannon member $ \ws -> do + setForTeam config2 + void . liftIO $ + WS.assertMatch (5 # Second) ws $ + wsAssertFeatureConfigUpdate @MLSConfig config2 LockStatusUnlocked + getViaEndpoints config2 + + WS.bracketR cannon member $ \ws -> do + setForTeamInternal config3 + void . liftIO $ + WS.assertMatch (5 # Second) ws $ + wsAssertFeatureConfigUpdate @MLSConfig config3 LockStatusUnlocked + getViaEndpoints config3 + assertFlagForbidden :: HasCallStack => TestM ResponseLBS -> TestM () assertFlagForbidden res = do res !!! do @@ -978,7 +1040,7 @@ assertFlagWithConfig response expected = do fmap Public.wssStatus rJson @?= (Right . Public.wssStatus $ expected) fmap Public.wssConfig rJson @?= (Right . Public.wssConfig $ expected) -wsAssertFeatureConfigUpdate :: +wsAssertFeatureTrivialConfigUpdate :: forall cfg. ( Public.IsFeatureConfig cfg, KnownSymbol (Public.FeatureSymbol cfg), @@ -989,7 +1051,7 @@ wsAssertFeatureConfigUpdate :: Public.FeatureStatus -> Notification -> IO () -wsAssertFeatureConfigUpdate status notification = do +wsAssertFeatureTrivialConfigUpdate status notification = do let e :: FeatureConfig.Event = List1.head (WS.unpackPayload notification) FeatureConfig._eventType e @?= FeatureConfig.Update FeatureConfig._eventFeatureName e @?= Public.featureName @cfg @@ -1012,3 +1074,20 @@ wsAssertFeatureConfigWithLockStatusUpdate status lockStatus notification = do FeatureConfig._eventType e @?= FeatureConfig.Update FeatureConfig._eventFeatureName e @?= (Public.featureName @cfg) FeatureConfig._eventData e @?= Aeson.toJSON (Public.WithStatus status lockStatus (Public.trivialConfig @cfg)) + +wsAssertFeatureConfigUpdate :: + forall cfg. + ( Public.IsFeatureConfig cfg, + KnownSymbol (Public.FeatureSymbol cfg), + ToJSON (Public.WithStatus cfg), + ToSchema cfg + ) => + Public.WithStatusNoLock cfg -> + Public.LockStatus -> + Notification -> + IO () +wsAssertFeatureConfigUpdate config lockStatus notification = do + let e :: FeatureConfig.Event = List1.head (WS.unpackPayload notification) + FeatureConfig._eventType e @?= FeatureConfig.Update + FeatureConfig._eventFeatureName e @?= Public.featureName @cfg + FeatureConfig._eventData e @?= Aeson.toJSON (Public.withLockStatus lockStatus config) diff --git a/tools/stern/src/Stern/API.hs b/tools/stern/src/Stern/API.hs index ef3dff5d1f..caf2171332 100644 --- a/tools/stern/src/Stern/API.hs +++ b/tools/stern/src/Stern/API.hs @@ -395,6 +395,9 @@ routes = do mkFeatureGetRoute @AppLockConfig mkFeaturePutRoute @AppLockConfig + mkFeatureGetRoute @MLSConfig + mkFeaturePutRoute @MLSConfig + -- These endpoints should be part of team settings. Until then, we access them from here -- for authorized personnel to enable/disable this on the team's behalf get "/teams/:tid/search-visibility" (continue (fmap json . Intra.getSearchVisibility)) $