diff --git a/CHANGELOG.md b/CHANGELOG.md index 05169b89e83..064d3a036b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,9 @@ This release requires a manual change in your galley configuration: `settings.co ## API Changes +* A new team feature for classified domains is available (#1626): + - a public endpoint is at `GET /teams/:tid/features/classifiedDomains` + - an internal endpoint is at `GET /i/teams/:tid/features/classifiedDomains` * Several public team feature endpoints are removed (their internal and Stern-based counterparts remain available): - `PUT /teams/:tid/features/sso` diff --git a/charts/galley/templates/configmap.yaml b/charts/galley/templates/configmap.yaml index c648db2b986..3200588cd52 100644 --- a/charts/galley/templates/configmap.yaml +++ b/charts/galley/templates/configmap.yaml @@ -62,5 +62,7 @@ data: sso: {{ .settings.featureFlags.sso }} legalhold: {{ .settings.featureFlags.legalhold }} teamSearchVisibility: {{ .settings.featureFlags.teamSearchVisibility }} + classifiedDomains: + {{- toYaml .settings.featureFlags.classifiedDomains | nindent 10 }} {{- end }} {{- end }} diff --git a/charts/galley/values.yaml b/charts/galley/values.yaml index 6a1689ef0d5..f50aa5dc17c 100644 --- a/charts/galley/values.yaml +++ b/charts/galley/values.yaml @@ -30,6 +30,10 @@ config: sso: disabled-by-default legalhold: disabled-by-default teamSearchVisibility: disabled-by-default + classifiedDomains: + status: disabled + config: + domains: [] aws: region: "eu-west-1" proxy: {} diff --git a/deploy/services-demo/conf/galley.demo.yaml b/deploy/services-demo/conf/galley.demo.yaml index 0b9f282a14e..628e0d22b22 100644 --- a/deploy/services-demo/conf/galley.demo.yaml +++ b/deploy/services-demo/conf/galley.demo.yaml @@ -39,6 +39,10 @@ settings: config: enforceAppLock: false inactivityTimeoutSecs: 60 + classifiedDomains: + status: enabled + config: + domains: ["example.com"] federationDomain: example.com diff --git a/docs/reference/config-options.md b/docs/reference/config-options.md index 6dcf4555367..93ddd3d36bc 100644 --- a/docs/reference/config-options.md +++ b/docs/reference/config-options.md @@ -29,7 +29,7 @@ production. ## Feature flags Feature flags can be used to turn features on or off, or determine the -behavior of the features. Example: +behavior of the features. Example: ``` # [galley.yaml] @@ -101,6 +101,25 @@ pull-down-menu "body": [Allowd values](https://github.com/wireapp/wire-server/blob/0126651a25aabc0c5589edc2b1988bb06550a03a/services/brig/src/Brig/Options.hs#L304-L306) and their [description](https://github.com/wireapp/wire-server/blob/0126651a25aabc0c5589edc2b1988bb06550a03a/services/brig/src/Brig/Options.hs#L290-L299). +### Classified domains + +To enable classified domains, the following needs to be in galley.yaml or wire-server/values.yaml under `settings` / `featureFlags`: + +```yaml +classifiedDomains: + status: enabled + config: + domains: ["example.com", "example2.com"] +``` + +To disable, either omit the entry entirely (it is disabled by default), or provide the following: + +```yaml +classifiedDomains: + status: disabled + config: + domains: [] +``` ### Federation Domain diff --git a/hack/helm_vars/wire-server/values.yaml b/hack/helm_vars/wire-server/values.yaml index 189496f2473..984900be33e 100644 --- a/hack/helm_vars/wire-server/values.yaml +++ b/hack/helm_vars/wire-server/values.yaml @@ -148,6 +148,10 @@ galley: sso: disabled-by-default # this needs to be the default; tests can enable it when needed. legalhold: whitelist-teams-and-implicit-consent teamSearchVisibility: disabled-by-default + classifiedDomains: + status: enabled + config: + domains: ["example.com"] journal: endpoint: http://fake-aws-sqs:4568 queue: integration-team-events.fifo diff --git a/libs/galley-types/src/Galley/Types/Teams.hs b/libs/galley-types/src/Galley/Types/Teams.hs index 45e165e5319..98602c49010 100644 --- a/libs/galley-types/src/Galley/Types/Teams.hs +++ b/libs/galley-types/src/Galley/Types/Teams.hs @@ -28,6 +28,7 @@ module Galley.Types.Teams flagLegalHold, flagTeamSearchVisibility, flagAppLockDefaults, + flagClassifiedDomains, Defaults (..), FeatureSSO (..), FeatureLegalHold (..), @@ -194,7 +195,8 @@ data FeatureFlags = FeatureFlags { _flagSSO :: !FeatureSSO, _flagLegalHold :: !FeatureLegalHold, _flagTeamSearchVisibility :: !FeatureTeamSearchVisibility, - _flagAppLockDefaults :: !(Defaults (TeamFeatureStatus 'TeamFeatureAppLock)) + _flagAppLockDefaults :: !(Defaults (TeamFeatureStatus 'TeamFeatureAppLock)), + _flagClassifiedDomains :: !(TeamFeatureStatus 'TeamFeatureClassifiedDomains) } deriving (Eq, Show, Generic) @@ -237,14 +239,16 @@ instance FromJSON FeatureFlags where <*> obj .: "legalhold" <*> obj .: "teamSearchVisibility" <*> (fromMaybe (Defaults defaultAppLockStatus) <$> (obj .:? "appLock")) + <*> (fromMaybe defaultClassifiedDomains <$> (obj .:? "classifiedDomains")) instance ToJSON FeatureFlags where - toJSON (FeatureFlags sso legalhold searchVisibility appLock) = + toJSON (FeatureFlags sso legalhold searchVisibility appLock classifiedDomains) = object $ [ "sso" .= sso, "legalhold" .= legalhold, "teamSearchVisibility" .= searchVisibility, - "appLock" .= appLock + "appLock" .= appLock, + "classifiedDomains" .= classifiedDomains ] instance FromJSON FeatureSSO where @@ -285,6 +289,9 @@ makeLenses ''FeatureFlags -- client apps treat permission bit matrices as opaque role identifiers, so if we add new -- permission flags, things will break there. -- +-- "Hidden" in "HiddenPerm", therefore, refers to a permission hidden from +-- clients, thereby making it internal to the backend. +-- -- The solution: add new permission bits to 'HiddenPerm', 'HiddenPermissions', and make -- 'hasPermission', 'mayGrantPermission' polymorphic. Now you can check both for the hidden -- permission bits and the old ones that we share with the client apps. @@ -329,8 +336,9 @@ roleHiddenPermissions role = HiddenPermissions p p [ ChangeLegalHoldTeamSettings, ChangeLegalHoldUserSettings, ChangeTeamSearchVisibility, - ChangeTeamFeature TeamFeatureAppLock {- the features not listed here can only be changed in stern -}, + ChangeTeamFeature TeamFeatureAppLock, ChangeTeamFeature TeamFeatureFileSharing, + ChangeTeamFeature TeamFeatureClassifiedDomains {- the features not listed here can only be changed in stern -}, ReadIdp, CreateUpdateDeleteIdp, CreateReadDeleteScimToken, @@ -348,6 +356,7 @@ roleHiddenPermissions role = HiddenPermissions p p ViewTeamFeature TeamFeatureDigitalSignatures, ViewTeamFeature TeamFeatureAppLock, ViewTeamFeature TeamFeatureFileSharing, + ViewTeamFeature TeamFeatureClassifiedDomains, ViewLegalHoldUserSettings, ViewTeamSearchVisibility ] diff --git a/libs/galley-types/test/unit/Test/Galley/Types.hs b/libs/galley-types/test/unit/Test/Galley/Types.hs index dba17a7cdf3..978d1bc40db 100644 --- a/libs/galley-types/test/unit/Test/Galley/Types.hs +++ b/libs/galley-types/test/unit/Test/Galley/Types.hs @@ -64,3 +64,4 @@ instance Arbitrary FeatureFlags where <*> QC.elements [minBound ..] <*> QC.elements [minBound ..] <*> arbitrary + <*> arbitrary 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 4072fd154db..e83b9207b69 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -323,7 +323,10 @@ data Api routes = Api :- FeatureStatusGet 'TeamFeatureFileSharing, teamFeatureStatusFileSharingPut :: routes - :- FeatureStatusPut 'TeamFeatureFileSharing + :- FeatureStatusPut 'TeamFeatureFileSharing, + teamFeatureStatusClassifiedDomainsGet :: + routes + :- FeatureStatusGet 'TeamFeatureClassifiedDomains } deriving (Generic) diff --git a/libs/wire-api/src/Wire/API/Swagger.hs b/libs/wire-api/src/Wire/API/Swagger.hs index d4b9f001c72..2d3989b03a3 100644 --- a/libs/wire-api/src/Wire/API/Swagger.hs +++ b/libs/wire-api/src/Wire/API/Swagger.hs @@ -126,7 +126,9 @@ models = Team.Feature.modelForTeamFeature Team.Feature.TeamFeatureValidateSAMLEmails, Team.Feature.modelForTeamFeature Team.Feature.TeamFeatureDigitalSignatures, Team.Feature.modelForTeamFeature Team.Feature.TeamFeatureAppLock, + Team.Feature.modelForTeamFeature Team.Feature.TeamFeatureClassifiedDomains, Team.Feature.modelTeamFeatureAppLockConfig, + Team.Feature.modelTeamFeatureClassifiedDomainsConfig, Team.Invitation.modelTeamInvitation, Team.Invitation.modelTeamInvitationList, Team.Invitation.modelTeamInvitationRequest, diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index d5d8f13fff7..23d9baf291e 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -22,6 +22,7 @@ module Wire.API.Team.Feature ( TeamFeatureName (..), TeamFeatureStatus, TeamFeatureAppLockConfig (..), + TeamFeatureClassifiedDomainsConfig (..), TeamFeatureStatusValue (..), FeatureHasNoConfig, EnforceAppLock (..), @@ -30,6 +31,7 @@ module Wire.API.Team.Feature TeamFeatureStatusWithConfig (..), HasDeprecatedFeatureName (..), defaultAppLockStatus, + defaultClassifiedDomains, -- * Swagger typeTeamFeatureName, @@ -37,12 +39,14 @@ module Wire.API.Team.Feature modelTeamFeatureStatusNoConfig, modelTeamFeatureStatusWithConfig, modelTeamFeatureAppLockConfig, + modelTeamFeatureClassifiedDomainsConfig, modelForTeamFeature, ) where import qualified Data.Attoparsec.ByteString as Parser import Data.ByteString.Conversion (FromByteString (..), ToByteString (..), toByteString') +import Data.Domain (Domain) import Data.Kind (Constraint) import Data.Schema import Data.String.Conversions (cs) @@ -94,6 +98,7 @@ data TeamFeatureName | TeamFeatureDigitalSignatures | TeamFeatureAppLock | TeamFeatureFileSharing + | TeamFeatureClassifiedDomains deriving stock (Eq, Show, Ord, Generic, Enum, Bounded, Typeable) deriving (Arbitrary) via (GenericUniform TeamFeatureName) @@ -129,6 +134,10 @@ instance KnownTeamFeatureName 'TeamFeatureFileSharing where type KnownTeamFeatureNameSymbol 'TeamFeatureFileSharing = "fileSharing" knownTeamFeatureName = TeamFeatureFileSharing +instance KnownTeamFeatureName 'TeamFeatureClassifiedDomains where + type KnownTeamFeatureNameSymbol 'TeamFeatureClassifiedDomains = "classifiedDomains" + knownTeamFeatureName = TeamFeatureClassifiedDomains + instance FromByteString TeamFeatureName where parser = Parser.takeByteString >>= \b -> @@ -144,6 +153,7 @@ instance FromByteString TeamFeatureName where Right "digital-signatures" -> pure TeamFeatureDigitalSignatures Right "appLock" -> pure TeamFeatureAppLock Right "fileSharing" -> pure TeamFeatureFileSharing + Right "classifiedDomains" -> pure TeamFeatureClassifiedDomains Right t -> fail $ "Invalid TeamFeatureName: " <> T.unpack t instance ToByteString TeamFeatureName where @@ -154,6 +164,7 @@ instance ToByteString TeamFeatureName where builder TeamFeatureDigitalSignatures = "digitalSignatures" builder TeamFeatureAppLock = "appLock" builder TeamFeatureFileSharing = "fileSharing" + builder TeamFeatureClassifiedDomains = "classifiedDomains" class HasDeprecatedFeatureName (a :: TeamFeatureName) where type DeprecatedFeatureName a :: Symbol @@ -220,6 +231,7 @@ type family TeamFeatureStatus (a :: TeamFeatureName) :: * where TeamFeatureStatus 'TeamFeatureDigitalSignatures = TeamFeatureStatusNoConfig TeamFeatureStatus 'TeamFeatureAppLock = TeamFeatureStatusWithConfig TeamFeatureAppLockConfig TeamFeatureStatus 'TeamFeatureFileSharing = TeamFeatureStatusNoConfig + TeamFeatureStatus 'TeamFeatureClassifiedDomains = TeamFeatureStatusWithConfig TeamFeatureClassifiedDomainsConfig type FeatureHasNoConfig (a :: TeamFeatureName) = (TeamFeatureStatus a ~ TeamFeatureStatusNoConfig) :: Constraint @@ -232,6 +244,7 @@ modelForTeamFeature TeamFeatureValidateSAMLEmails = modelTeamFeatureStatusNoConf modelForTeamFeature TeamFeatureDigitalSignatures = modelTeamFeatureStatusNoConfig modelForTeamFeature name@TeamFeatureAppLock = modelTeamFeatureStatusWithConfig name modelTeamFeatureAppLockConfig modelForTeamFeature TeamFeatureFileSharing = modelTeamFeatureStatusNoConfig +modelForTeamFeature name@TeamFeatureClassifiedDomains = modelTeamFeatureStatusWithConfig name modelTeamFeatureClassifiedDomainsConfig ---------------------------------------------------------------------- -- TeamFeatureStatusNoConfig @@ -283,6 +296,31 @@ instance ToSchema cfg => ToSchema (TeamFeatureStatusWithConfig cfg) where <$> tfwcStatus .= field "status" schema <*> tfwcConfig .= field "config" schema +---------------------------------------------------------------------- +-- TeamFeatureClassifiedDomainsConfig + +newtype TeamFeatureClassifiedDomainsConfig = TeamFeatureClassifiedDomainsConfig + { classifiedDomainsDomains :: [Domain] + } + deriving stock (Show, Eq, Generic) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema TeamFeatureClassifiedDomainsConfig) + +deriving via (GenericUniform TeamFeatureClassifiedDomainsConfig) instance Arbitrary TeamFeatureClassifiedDomainsConfig + +instance ToSchema TeamFeatureClassifiedDomainsConfig where + schema = + object "TeamFeatureClassifiedDomainsConfig" $ + TeamFeatureClassifiedDomainsConfig + <$> classifiedDomainsDomains .= field "domains" (array schema) + +modelTeamFeatureClassifiedDomainsConfig :: Doc.Model +modelTeamFeatureClassifiedDomainsConfig = + Doc.defineModel "TeamFeatureClassifiedDomainsConfig" $ do + Doc.property "domains" (Doc.array Doc.string') $ Doc.description "domains" + +defaultClassifiedDomains :: TeamFeatureStatusWithConfig TeamFeatureClassifiedDomainsConfig +defaultClassifiedDomains = TeamFeatureStatusWithConfig TeamFeatureDisabled (TeamFeatureClassifiedDomainsConfig []) + ---------------------------------------------------------------------- -- TeamFeatureAppLockConfig diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index 856f9f768f6..d4fba08d660 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -203,6 +203,7 @@ tests = testRoundTrip @(Team.Feature.TeamFeatureStatus 'Team.Feature.TeamFeatureDigitalSignatures), testRoundTrip @(Team.Feature.TeamFeatureStatus 'Team.Feature.TeamFeatureAppLock), testRoundTrip @(Team.Feature.TeamFeatureStatus 'Team.Feature.TeamFeatureFileSharing), + testRoundTrip @(Team.Feature.TeamFeatureStatus 'Team.Feature.TeamFeatureClassifiedDomains), testRoundTrip @Team.Feature.TeamFeatureStatusValue, testRoundTrip @Team.Invitation.InvitationRequest, testRoundTrip @Team.Invitation.Invitation, diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 8798831723b..a7e2a38880a 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -4,7 +4,7 @@ cabal-version: 1.12 -- -- see: https://github.com/sol/hpack -- --- hash: 0f109e5428272ef5ea11b8d726a52fbf24adbd53f628a7d2fe27b4996e18bb22 +-- hash: bb7dc649f5fc67c5e449f5b5984e8d7ae01caa5833ba0fa6a5db4f45cd5b1b42 name: galley version: 0.83.0 @@ -251,6 +251,7 @@ executable galley-integration , raw-strings-qq >=1.0 , retry , safe >=0.3 + , schema-profunctor , servant , servant-client , servant-client-core diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index 3a9a70428aa..26ff6687b3e 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -52,6 +52,10 @@ settings: config: enforceAppLock: false inactivityTimeoutSecs: 60 + classifiedDomains: + status: enabled + config: + domains: ["example.com"] logLevel: Info logNetStrings: false diff --git a/services/galley/package.yaml b/services/galley/package.yaml index 9951c3eb397..4b3430c0465 100644 --- a/services/galley/package.yaml +++ b/services/galley/package.yaml @@ -186,6 +186,7 @@ executables: - quickcheck-instances - random - retry + - schema-profunctor - servant - servant-server - servant-swagger diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index 16eeb98999c..2eb0d7e9cc4 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -147,7 +147,10 @@ data InternalApi routes = InternalApi :- IFeatureStatusGet 'Public.TeamFeatureFileSharing, iTeamFeatureStatusFileSharingPut :: routes - :- IFeatureStatusPut 'Public.TeamFeatureFileSharing + :- IFeatureStatusPut 'Public.TeamFeatureFileSharing, + iTeamFeatureStatusClassifiedDomainsGet :: + routes + :- IFeatureStatusGet 'Public.TeamFeatureClassifiedDomains } deriving (Generic) @@ -218,7 +221,8 @@ servantSitemap = iTeamFeatureStatusAppLockGet = iGetTeamFeature @'Public.TeamFeatureAppLock Features.getAppLockInternal, iTeamFeatureStatusAppLockPut = iPutTeamFeature @'Public.TeamFeatureAppLock Features.setAppLockInternal, iTeamFeatureStatusFileSharingGet = iGetTeamFeature @'Public.TeamFeatureFileSharing Features.getFileSharingInternal, - iTeamFeatureStatusFileSharingPut = iPutTeamFeature @'Public.TeamFeatureFileSharing Features.setFileSharingInternal + iTeamFeatureStatusFileSharingPut = iPutTeamFeature @'Public.TeamFeatureFileSharing Features.setFileSharingInternal, + iTeamFeatureStatusClassifiedDomainsGet = iGetTeamFeature @'Public.TeamFeatureClassifiedDomains Features.getClassifiedDomainsInternal } iGetTeamFeature :: diff --git a/services/galley/src/Galley/API/Public.hs b/services/galley/src/Galley/API/Public.hs index 7b474e76116..f3143326802 100644 --- a/services/galley/src/Galley/API/Public.hs +++ b/services/galley/src/Galley/API/Public.hs @@ -129,11 +129,15 @@ servantSitemap = getFeatureStatus @'Public.TeamFeatureAppLock Features.getAppLockInternal . DoAuth, GalleyAPI.teamFeatureStatusAppLockPut = - setFeatureStatus @'Public.TeamFeatureAppLock Features.setAppLockInternal . DoAuth, + setFeatureStatus @'Public.TeamFeatureAppLock Features.setAppLockInternal + . DoAuth, GalleyAPI.teamFeatureStatusFileSharingGet = getFeatureStatus @'Public.TeamFeatureFileSharing Features.getFileSharingInternal . DoAuth, GalleyAPI.teamFeatureStatusFileSharingPut = - setFeatureStatus @'Public.TeamFeatureFileSharing Features.setFileSharingInternal . DoAuth + setFeatureStatus @'Public.TeamFeatureFileSharing Features.setFileSharingInternal . DoAuth, + GalleyAPI.teamFeatureStatusClassifiedDomainsGet = + getFeatureStatus @'Public.TeamFeatureClassifiedDomains Features.getClassifiedDomainsInternal + . DoAuth } sitemap :: Routes ApiBuilder Galley () diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index d35c74d881b..d70e7719d55 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -29,6 +29,7 @@ module Galley.API.Teams.Features setValidateSAMLEmailsInternal, getDigitalSignaturesInternal, setDigitalSignaturesInternal, + getClassifiedDomainsInternal, getAppLockInternal, setAppLockInternal, getFileSharingInternal, @@ -108,7 +109,8 @@ getAllFeatures uid tid = do getStatus @'Public.TeamFeatureValidateSAMLEmails getValidateSAMLEmailsInternal, getStatus @'Public.TeamFeatureDigitalSignatures getDigitalSignaturesInternal, getStatus @'Public.TeamFeatureAppLock getAppLockInternal, - getStatus @'Public.TeamFeatureFileSharing getFileSharingInternal + getStatus @'Public.TeamFeatureFileSharing getFileSharingInternal, + getStatus @'Public.TeamFeatureClassifiedDomains getClassifiedDomainsInternal ] where getStatus :: @@ -125,7 +127,7 @@ getAllFeatures uid tid = do getFeatureStatusNoConfig :: forall (a :: Public.TeamFeatureName). - (Public.KnownTeamFeatureName a, Public.FeatureHasNoConfig a) => + (Public.KnownTeamFeatureName a, Public.FeatureHasNoConfig a, TeamFeatures.HasStatusCol a) => Galley Public.TeamFeatureStatusValue -> TeamId -> Galley (Public.TeamFeatureStatus a) @@ -135,7 +137,7 @@ getFeatureStatusNoConfig getDefault tid = do setFeatureStatusNoConfig :: forall (a :: Public.TeamFeatureName). - (Public.KnownTeamFeatureName a, Public.FeatureHasNoConfig a) => + (Public.KnownTeamFeatureName a, Public.FeatureHasNoConfig a, TeamFeatures.HasStatusCol a) => (Public.TeamFeatureStatusValue -> TeamId -> Galley ()) -> TeamId -> Public.TeamFeatureStatus a -> @@ -231,3 +233,12 @@ setAppLockInternal tid status = do when (Public.applockInactivityTimeoutSecs (Public.tfwcConfig status) < 30) $ throwM inactivityTimeoutTooLow TeamFeatures.setApplockFeatureStatus tid status + +getClassifiedDomainsInternal :: TeamId -> Galley (Public.TeamFeatureStatus 'Public.TeamFeatureClassifiedDomains) +getClassifiedDomainsInternal _tid = do + globalConfig <- view (options . optSettings . setFeatureFlags . flagClassifiedDomains) + let config = globalConfig + pure $ case Public.tfwcStatus config of + Public.TeamFeatureDisabled -> + Public.TeamFeatureStatusWithConfig Public.TeamFeatureDisabled (Public.TeamFeatureClassifiedDomainsConfig []) + Public.TeamFeatureEnabled -> config diff --git a/services/galley/src/Galley/Data/TeamFeatures.hs b/services/galley/src/Galley/Data/TeamFeatures.hs index ff97c2e3827..91016ade90b 100644 --- a/services/galley/src/Galley/Data/TeamFeatures.hs +++ b/services/galley/src/Galley/Data/TeamFeatures.hs @@ -22,6 +22,7 @@ module Galley.Data.TeamFeatures setFeatureStatusNoConfig, getApplockFeatureStatus, setApplockFeatureStatus, + HasStatusCol (..), ) where @@ -38,54 +39,71 @@ import Wire.API.Team.Feature ) import qualified Wire.API.Team.Feature as Public -toCol :: TeamFeatureName -> String -toCol TeamFeatureLegalHold = "legalhold_status" -toCol TeamFeatureSSO = "sso_status" -toCol TeamFeatureSearchVisibility = "search_visibility_status" -toCol TeamFeatureValidateSAMLEmails = "validate_saml_emails" -toCol TeamFeatureDigitalSignatures = "digital_signatures" -toCol TeamFeatureAppLock = "app_lock_status" -toCol TeamFeatureFileSharing = "file_sharing" +-- | Because not all so called team features are actually team-level features, +-- not all of them have a corresponding column in the database. Therefore, +-- instead of having a function like: +-- +-- statusCol :: TeamFeatureName -> String +-- +-- there is a need for turning it into a class and then defining an instance for +-- team features that do have a database column. +class HasStatusCol (a :: TeamFeatureName) where + statusCol :: String + +instance HasStatusCol 'TeamFeatureLegalHold where statusCol = "legalhold_status" + +instance HasStatusCol 'TeamFeatureSSO where statusCol = "sso_status" + +instance HasStatusCol 'TeamFeatureSearchVisibility where statusCol = "search_visibility_status" + +instance HasStatusCol 'TeamFeatureValidateSAMLEmails where statusCol = "validate_saml_emails" + +instance HasStatusCol 'TeamFeatureDigitalSignatures where statusCol = "digital_signatures" + +instance HasStatusCol 'TeamFeatureAppLock where statusCol = "app_lock_status" + +instance HasStatusCol 'TeamFeatureFileSharing where statusCol = "file_sharing" getFeatureStatusNoConfig :: forall (a :: Public.TeamFeatureName) m. ( MonadClient m, - Public.KnownTeamFeatureName a, - Public.FeatureHasNoConfig a + Public.FeatureHasNoConfig a, + HasStatusCol a ) => TeamId -> m (Maybe (TeamFeatureStatus a)) getFeatureStatusNoConfig tid = do - let q = query1 (select (Public.knownTeamFeatureName @a)) (params Quorum (Identity tid)) + let q = query1 select (params Quorum (Identity tid)) mStatusValue <- (>>= runIdentity) <$> retry x1 q pure $ TeamFeatureStatusNoConfig <$> mStatusValue where - select :: TeamFeatureName -> PrepQuery R (Identity TeamId) (Identity (Maybe TeamFeatureStatusValue)) - select feature = fromString $ "select " <> toCol feature <> " from team_features where team_id = ?" + select :: PrepQuery R (Identity TeamId) (Identity (Maybe TeamFeatureStatusValue)) + select = fromString $ "select " <> statusCol @a <> " from team_features where team_id = ?" setFeatureStatusNoConfig :: forall (a :: Public.TeamFeatureName) m. ( MonadClient m, - Public.KnownTeamFeatureName a, - Public.FeatureHasNoConfig a + Public.FeatureHasNoConfig a, + HasStatusCol a ) => TeamId -> - (TeamFeatureStatus a) -> + TeamFeatureStatus a -> m (TeamFeatureStatus a) setFeatureStatusNoConfig tid status = do let flag = Public.tfwoStatus status - retry x5 $ write (update (Public.knownTeamFeatureName @a)) (params Quorum (flag, tid)) + retry x5 $ write update (params Quorum (flag, tid)) pure status where - update :: TeamFeatureName -> PrepQuery W (TeamFeatureStatusValue, TeamId) () - update feature = fromString $ "update team_features set " <> toCol feature <> " = ? where team_id = ?" + update :: PrepQuery W (TeamFeatureStatusValue, TeamId) () + update = fromString $ "update team_features set " <> statusCol @a <> " = ? where team_id = ?" getApplockFeatureStatus :: + forall m. (MonadClient m) => TeamId -> m (Maybe (TeamFeatureStatus 'Public.TeamFeatureAppLock)) getApplockFeatureStatus tid = do - let q = query1 (select) (params Quorum (Identity tid)) + let q = query1 select (params Quorum (Identity tid)) mTuple <- retry x1 q pure $ mTuple >>= \(mbStatusValue, mbEnforce, mbTimeout) -> @@ -94,13 +112,13 @@ getApplockFeatureStatus tid = do select :: PrepQuery R (Identity TeamId) (Maybe TeamFeatureStatusValue, Maybe Public.EnforceAppLock, Maybe Int32) select = fromString $ - "select " <> toCol Public.TeamFeatureAppLock <> ", app_lock_enforce, app_lock_inactivity_timeout_secs " + "select " <> statusCol @'Public.TeamFeatureAppLock <> ", app_lock_enforce, app_lock_inactivity_timeout_secs " <> "from team_features where team_id = ?" setApplockFeatureStatus :: (MonadClient m) => TeamId -> - (TeamFeatureStatus 'Public.TeamFeatureAppLock) -> + TeamFeatureStatus 'Public.TeamFeatureAppLock -> m (TeamFeatureStatus 'Public.TeamFeatureAppLock) setApplockFeatureStatus tid status = do let statusValue = Public.tfwcStatus status @@ -113,7 +131,7 @@ setApplockFeatureStatus tid status = do update = fromString $ "update team_features set " - <> toCol Public.TeamFeatureAppLock + <> statusCol @'Public.TeamFeatureAppLock <> " = ?, " <> "app_lock_enforce = ?, " <> "app_lock_inactivity_timeout_secs = ? " diff --git a/services/galley/test/integration/API/Teams/Feature.hs b/services/galley/test/integration/API/Teams/Feature.hs index f034f3b0798..f3453e12e0e 100644 --- a/services/galley/test/integration/API/Teams/Feature.hs +++ b/services/galley/test/integration/API/Teams/Feature.hs @@ -17,33 +17,44 @@ module API.Teams.Feature (tests) where +import API.Util (HasGalley, withSettingsOverrides) import qualified API.Util as Util import qualified API.Util.TeamFeature as Util import Bilge import Bilge.Assert -import Control.Lens (view) +import Control.Lens (over, view) import Control.Monad.Catch (MonadCatch) -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON, object, (.=)) +import Data.ByteString.Conversion (toByteString') +import Data.Domain (Domain (..)) import Data.Id import Data.List1 (list1) +import Data.Schema (ToSchema) +import qualified Data.Text.Encoding as TE import Galley.Options (optSettings, setFeatureFlags) import Galley.Types.Teams import Imports import Network.Wai.Utilities (label) import Test.Tasty +import Test.Tasty.HUnit ((@?=)) import TestHelpers (test) import TestSetup +import Wire.API.Team.Feature (TeamFeatureName (..), TeamFeatureStatusValue (..)) import qualified Wire.API.Team.Feature as Public tests :: IO TestSetup -> TestTree tests s = - testGroup "Team Features API" $ + testGroup + "Team Features API" [ test s "SSO" testSSO, test s "LegalHold" testLegalHold, test s "SearchVisibility" testSearchVisibility, test s "DigitalSignatures" $ testSimpleFlag @'Public.TeamFeatureDigitalSignatures Public.TeamFeatureDisabled, test s "ValidateSAMLEmails" $ testSimpleFlag @'Public.TeamFeatureValidateSAMLEmails Public.TeamFeatureDisabled, - test s "FileSharing" $ testSimpleFlag @'Public.TeamFeatureFileSharing Public.TeamFeatureEnabled + test s "FileSharing" $ testSimpleFlag @'Public.TeamFeatureFileSharing Public.TeamFeatureEnabled, + test s "Classified Domains (enabled)" testClassifiedDomainsEnabled, + test s "Classified Domains (disabled)" testClassifiedDomainsDisabled, + test s "All features" testAllFeatures ] testSSO :: TestM () @@ -183,6 +194,56 @@ testSearchVisibility = do getTeamSearchVisibility tid3 Public.TeamFeatureEnabled getTeamSearchVisibilityInternal tid3 Public.TeamFeatureEnabled +getClassifiedDomains :: + (HasCallStack, HasGalley m, MonadIO m, MonadHttp m, MonadCatch m) => + UserId -> + TeamId -> + Public.TeamFeatureStatus 'Public.TeamFeatureClassifiedDomains -> + m () +getClassifiedDomains member tid = + assertFlagWithConfig @Public.TeamFeatureClassifiedDomainsConfig $ + Util.getTeamFeatureFlag Public.TeamFeatureClassifiedDomains member tid + +getClassifiedDomainsInternal :: + (HasCallStack, HasGalley m, MonadIO m, MonadHttp m, MonadCatch m) => + TeamId -> + Public.TeamFeatureStatus 'Public.TeamFeatureClassifiedDomains -> + m () +getClassifiedDomainsInternal tid = + assertFlagWithConfig @Public.TeamFeatureClassifiedDomainsConfig $ + Util.getTeamFeatureFlagInternal Public.TeamFeatureClassifiedDomains tid + +testClassifiedDomainsEnabled :: TestM () +testClassifiedDomainsEnabled = do + (_owner, tid, member : _) <- Util.createBindingTeamWithNMembers 1 + let expected = + Public.TeamFeatureStatusWithConfig + { Public.tfwcStatus = Public.TeamFeatureEnabled, + Public.tfwcConfig = Public.TeamFeatureClassifiedDomainsConfig [Domain "example.com"] + } + + getClassifiedDomains member tid expected + getClassifiedDomainsInternal tid expected + +testClassifiedDomainsDisabled :: TestM () +testClassifiedDomainsDisabled = do + (_owner, tid, member : _) <- Util.createBindingTeamWithNMembers 1 + let expected = + Public.TeamFeatureStatusWithConfig + { Public.tfwcStatus = Public.TeamFeatureDisabled, + Public.tfwcConfig = Public.TeamFeatureClassifiedDomainsConfig [] + } + + opts <- view tsGConf + let classifiedDomainsDisabled = + opts + & over + (optSettings . setFeatureFlags . flagClassifiedDomains) + (\s -> s {Public.tfwcStatus = Public.TeamFeatureDisabled}) + withSettingsOverrides classifiedDomainsDisabled $ do + getClassifiedDomains member tid expected + getClassifiedDomainsInternal tid expected + testSimpleFlag :: forall (a :: Public.TeamFeatureName). ( HasCallStack, @@ -230,6 +291,35 @@ testSimpleFlag defaultValue = do getFlag otherValue getFlagInternal otherValue +-- | Call 'GET /teams/:tid/features' and check if all features are there +testAllFeatures :: TestM () +testAllFeatures = do + (_owner, tid, member : _) <- Util.createBindingTeamWithNMembers 1 + let res = Util.getAllTeamFeatures member tid + res !!! do + statusCode === const 200 + responseJsonMaybe === const (Just expected) + where + expected = + object + [ toS TeamFeatureLegalHold .= Public.TeamFeatureStatusNoConfig TeamFeatureDisabled, + toS TeamFeatureSSO .= Public.TeamFeatureStatusNoConfig TeamFeatureDisabled, + toS TeamFeatureSearchVisibility .= Public.TeamFeatureStatusNoConfig TeamFeatureDisabled, + toS TeamFeatureValidateSAMLEmails .= Public.TeamFeatureStatusNoConfig TeamFeatureDisabled, + toS TeamFeatureDigitalSignatures .= Public.TeamFeatureStatusNoConfig TeamFeatureDisabled, + toS TeamFeatureAppLock + .= Public.TeamFeatureStatusWithConfig + TeamFeatureEnabled + (Public.TeamFeatureAppLockConfig (Public.EnforceAppLock False) (60 :: Int32)), + toS TeamFeatureFileSharing .= Public.TeamFeatureStatusNoConfig TeamFeatureEnabled, + toS TeamFeatureClassifiedDomains + .= Public.TeamFeatureStatusWithConfig + TeamFeatureEnabled + (Public.TeamFeatureClassifiedDomainsConfig [Domain "example.com"]) + ] + toS :: TeamFeatureName -> Text + toS = TE.decodeUtf8 . toByteString' + assertFlagForbidden :: HasCallStack => TestM ResponseLBS -> TestM () assertFlagForbidden res = do res !!! do @@ -254,3 +344,24 @@ assertFlagNoConfig res expected = do . responseJsonEither @(Public.TeamFeatureStatus a) ) === const (Right expected) + +assertFlagWithConfig :: + forall cfg m. + ( HasCallStack, + Eq cfg, + ToSchema cfg, + Show cfg, + Typeable cfg, + MonadIO m, + MonadCatch m + ) => + m ResponseLBS -> + Public.TeamFeatureStatusWithConfig cfg -> + m () +assertFlagWithConfig response expected = do + r <- response + let rJson = responseJsonEither @(Public.TeamFeatureStatusWithConfig cfg) r + pure r !!! statusCode === const 200 + liftIO $ do + fmap Public.tfwcStatus rJson @?= (Right . Public.tfwcStatus $ expected) + fmap Public.tfwcConfig rJson @?= (Right . Public.tfwcConfig $ expected) diff --git a/services/galley/test/integration/API/Util/TeamFeature.hs b/services/galley/test/integration/API/Util/TeamFeature.hs index 78edbf72342..a6b5311c344 100644 --- a/services/galley/test/integration/API/Util/TeamFeature.hs +++ b/services/galley/test/integration/API/Util/TeamFeature.hs @@ -17,7 +17,7 @@ module API.Util.TeamFeature where -import API.Util (zUser) +import API.Util (HasGalley (viewGalley), zUser) import qualified API.Util as Util import Bilge import qualified Bilge.TestSession as BilgeTest @@ -70,25 +70,42 @@ putLegalHoldEnabledInternal' g tid statusValue = -------------------------------------------------------------------------------- getTeamFeatureFlagInternal :: - (HasCallStack) => + (HasGalley m, MonadIO m, MonadHttp m) => Public.TeamFeatureName -> TeamId -> - TestM ResponseLBS + m ResponseLBS getTeamFeatureFlagInternal feature tid = do - g <- view tsGalley + g <- viewGalley getTeamFeatureFlagInternalWithGalley feature g tid -getTeamFeatureFlagInternalWithGalley :: (MonadIO m, MonadHttp m, HasCallStack) => Public.TeamFeatureName -> (Request -> Request) -> HasCallStack => TeamId -> m ResponseLBS +getTeamFeatureFlagInternalWithGalley :: (MonadIO m, MonadHttp m, HasCallStack) => Public.TeamFeatureName -> (Request -> Request) -> TeamId -> m ResponseLBS getTeamFeatureFlagInternalWithGalley feature g tid = do get $ g . paths ["i", "teams", toByteString' tid, "features", toByteString' feature] -getTeamFeatureFlag :: HasCallStack => Public.TeamFeatureName -> UserId -> TeamId -> TestM ResponseLBS +getTeamFeatureFlag :: + (HasGalley m, MonadIO m, MonadHttp m, HasCallStack) => + Public.TeamFeatureName -> + UserId -> + TeamId -> + m ResponseLBS getTeamFeatureFlag feature uid tid = do - g <- view tsGalley + g <- viewGalley getTeamFeatureFlagWithGalley feature g uid tid +getAllTeamFeatures :: + (HasCallStack, HasGalley m, MonadIO m, MonadHttp m) => + UserId -> + TeamId -> + m ResponseLBS +getAllTeamFeatures uid tid = do + g <- viewGalley + get $ + g + . paths ["teams", toByteString' tid, "features"] + . zUser uid + getTeamFeatureFlagWithGalley :: (MonadIO m, MonadHttp m, HasCallStack) => Public.TeamFeatureName -> (Request -> Request) -> UserId -> TeamId -> m ResponseLBS getTeamFeatureFlagWithGalley feature galley uid tid = do get $