diff --git a/changelog.d/3-bug-fixes/ascii-text-parsing b/changelog.d/3-bug-fixes/ascii-text-parsing new file mode 100644 index 00000000000..6472aa949f2 --- /dev/null +++ b/changelog.d/3-bug-fixes/ascii-text-parsing @@ -0,0 +1 @@ +Return HTTP 400 instead of 500 when property key is not printable ASCII \ No newline at end of file diff --git a/changelog.d/3-bug-fixes/max-properties b/changelog.d/3-bug-fixes/max-properties new file mode 100644 index 00000000000..4273020c7e2 --- /dev/null +++ b/changelog.d/3-bug-fixes/max-properties @@ -0,0 +1 @@ +Allow setting existing properties even if we have max properties \ No newline at end of file diff --git a/changelog.d/5-internal/property-subsystem b/changelog.d/5-internal/property-subsystem new file mode 100644 index 00000000000..6ef618ff81e --- /dev/null +++ b/changelog.d/5-internal/property-subsystem @@ -0,0 +1 @@ +Introduce proeprty subsytem \ No newline at end of file diff --git a/integration/integration.cabal b/integration/integration.cabal index 75e68530583..9a212d87b2a 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -139,6 +139,7 @@ library Test.MLS.Unreachable Test.Notifications Test.Presence + Test.Property Test.Provider Test.PushToken Test.Roles diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index b0d3bbbc4c4..36d6527ae9d 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -675,3 +675,33 @@ addBot user providerId serviceId convId = do req & zType "access" & addJSONObject ["provider" .= providerId, "service" .= serviceId] + +setProperty :: (MakesValue user, ToJSON val) => user -> String -> val -> App Response +setProperty user propName val = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties", propName] + submit "PUT" $ req & addJSON val + +getProperty :: (MakesValue user) => user -> String -> App Response +getProperty user propName = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties", propName] + submit "GET" req + +deleteProperty :: (MakesValue user) => user -> String -> App Response +deleteProperty user propName = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties", propName] + submit "DELETE" req + +getAllPropertyNames :: (MakesValue user) => user -> App Response +getAllPropertyNames user = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties"] + submit "GET" req + +getAllPropertyValues :: (MakesValue user) => user -> App Response +getAllPropertyValues user = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties-values"] + submit "GET" req + +clearProperties :: (MakesValue user) => user -> App Response +clearProperties user = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["properties"] + submit "DELETE" req diff --git a/integration/test/API/Common.hs b/integration/test/API/Common.hs index c07816cc5b4..12f4a3866ab 100644 --- a/integration/test/API/Common.hs +++ b/integration/test/API/Common.hs @@ -5,6 +5,8 @@ import Control.Monad.IO.Class import Data.Array ((!)) import qualified Data.Array as Array import qualified Data.ByteString as BS +import Data.Scientific (scientific) +import qualified Data.Vector as Vector import System.Random (randomIO, randomRIO) import Testlib.Prelude @@ -47,6 +49,29 @@ randomHandleWithRange min' max' = liftIO $ do randomBytes :: Int -> App ByteString randomBytes n = liftIO $ BS.pack <$> replicateM n randomIO +randomString :: Int -> App String +randomString n = liftIO $ replicateM n randomIO + +randomJSON :: App Value +randomJSON = do + let maxThings = 5 + liftIO (randomRIO (0 :: Int, 5)) >>= \case + 0 -> String . fromString <$> (randomString =<< randomRIO (0, maxThings)) + 1 -> Number <$> liftIO (scientific <$> randomIO <*> randomIO) + 2 -> Bool <$> liftIO randomIO + 3 -> pure Null + 4 -> do + n <- liftIO $ randomRIO (0, maxThings) + Array . Vector.fromList <$> replicateM n randomJSON + 5 -> do + n <- liftIO $ randomRIO (0, maxThings) + keys <- do + keyLength <- randomRIO (0, maxThings) + replicateM n (randomString keyLength) + vals <- replicateM n randomJSON + pure . object $ zipWith (.=) keys vals + _ -> error $ "impopssible: randomJSON" + randomHex :: Int -> App String randomHex n = liftIO $ replicateM n pick where diff --git a/integration/test/Test/Property.hs b/integration/test/Test/Property.hs new file mode 100644 index 00000000000..4440c6f8983 --- /dev/null +++ b/integration/test/Test/Property.hs @@ -0,0 +1,143 @@ +module Test.Property where + +import API.Brig +import API.Common +import qualified Data.Map as Map +import SetupHelpers +import Testlib.Prelude + +testSetGetDeleteProperty :: App () +testSetGetDeleteProperty = do + user <- randomUser OwnDomain def + setProperty user "foo" "bar" `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + getProperty user "foo" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` toJSON "bar" + + deleteProperty user "foo" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + + getProperty user "foo" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 404 + +testGetProperties :: App () +testGetProperties = do + user <- randomUser OwnDomain def + -- Property names can only be printable ascii, using the handle function here + -- as a little shortcut. + propertyNames <- replicateM 16 $ randomHandleWithRange 8 20 + propertyVals <- replicateM 16 $ randomJSON + let properties = zip propertyNames propertyVals + forM_ properties $ \(prop, val) -> + setProperty user prop val `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + getAllPropertyNames user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatchSet` propertyNames + + getAllPropertyValues user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` Map.fromList properties + +testClearProperties :: App () +testClearProperties = do + user <- randomUser OwnDomain def + + propertyNames <- replicateM 16 $ randomHandleWithRange 8 20 + propertyVals <- replicateM 16 $ randomJSON + let properties = zip propertyNames propertyVals + forM_ properties $ \(prop, val) -> + setProperty user prop val `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + clearProperties user `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + getAllPropertyNames user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatchSet` mempty @[String] + + getAllPropertyValues user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` Map.empty @String @Value + +testMaxProperties :: App () +testMaxProperties = do + user <- randomUser OwnDomain def + + -- This is hardcoded in the prod code. + let maxProperties = 16 + + propertyNames <- replicateM maxProperties $ randomHandleWithRange 8 20 + propertyVals <- replicateM maxProperties $ randomJSON + let properties = zip propertyNames propertyVals + forM_ properties $ \(prop, val) -> + setProperty user prop val `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + seventeenthPropName <- randomHandleWithRange 8 20 + seventeenthPropVal <- randomJSON + + -- cannot set seventeenth property + setProperty user seventeenthPropName seventeenthPropVal `bindResponse` \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "too-many-properties" + + -- Old properties are maintained + getAllPropertyValues user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` Map.fromList properties + + -- Can still update the old properties + newPropertyVals <- replicateM 16 $ randomJSON + let newProperties = zip propertyNames newPropertyVals + forM_ newProperties $ \(prop, val) -> + setProperty user prop val `bindResponse` \resp -> + resp.status `shouldMatchInt` 200 + + getAllPropertyValues user `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` Map.fromList newProperties + +testPropertyNameNotAscii :: App () +testPropertyNameNotAscii = do + user <- randomUser OwnDomain def + setProperty user "döner" "yes" `bindResponse` \resp -> + resp.status `shouldMatchInt` 400 + +testMaxLength :: App () +testMaxLength = do + user <- randomUser OwnDomain def + + maxKeyLength <- asInt $ readServiceConfig Brig %. "optSettings.setPropertyMaxKeyLen" + maxValLength <- asInt $ readServiceConfig Brig %. "optSettings.setPropertyMaxValueLen" + + tooLongProperty <- randomHandleWithRange (maxKeyLength + 1) (maxKeyLength + 1) + acceptableProperty <- randomHandleWithRange maxKeyLength maxKeyLength + + -- Two chars are taken by the quotes for string values. + -- + -- We use the `randomHandleWithRange` function because having non-ascii + -- characters or unprintable characters will increase the length of the JSON. + tooLongValue <- randomHandleWithRange (maxValLength - 1) (maxValLength - 1) + acceptableValue <- randomHandleWithRange (maxValLength - 2) (maxValLength - 2) + + putStrLn $ "acceptableValue= " <> acceptableValue + + setProperty user tooLongProperty acceptableValue `bindResponse` \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "property-key-too-large" + + setProperty user acceptableProperty tooLongValue `bindResponse` \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "property-value-too-large" + + setProperty user acceptableProperty acceptableValue `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + + getProperty user acceptableProperty `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json `shouldMatch` toJSON acceptableValue diff --git a/libs/types-common/src/Data/Text/Ascii.hs b/libs/types-common/src/Data/Text/Ascii.hs index aed072030c0..7f167d611cd 100644 --- a/libs/types-common/src/Data/Text/Ascii.hs +++ b/libs/types-common/src/Data/Text/Ascii.hs @@ -80,6 +80,7 @@ where import Cassandra hiding (Ascii) import Data.Aeson (FromJSON (..), FromJSONKey, ToJSON (..), ToJSONKey) import Data.Attoparsec.ByteString (Parser) +import Data.Bifunctor (first) import Data.ByteString.Base16 qualified as B16 import Data.ByteString.Base64 qualified as B64 import Data.ByteString.Base64.URL qualified as B64Url @@ -104,11 +105,9 @@ newtype AsciiText c = AsciiText {toText :: Text} Monoid, NFData, ToByteString, - FromJSONKey, ToJSONKey, Hashable, - ToHttpApiData, - FromHttpApiData + ToHttpApiData ) newtype AsciiChar c = AsciiChar {toChar :: Char} @@ -141,6 +140,9 @@ class AsciiChars c where instance (AsciiChars c) => FromByteString (AsciiText c) where parser = parseBytes validate +instance (AsciiChars c) => FromHttpApiData (AsciiText c) where + parseUrlPiece = first Text.pack . validate + -- | Note: 'fromString' is a partial function that will 'error' when given -- a string containing characters not in the set @c@. It is only intended to be used -- via the @OverloadedStrings@ extension, i.e. for known ASCII string literals. @@ -156,6 +158,8 @@ instance (AsciiChars c) => ToJSON (AsciiText c) where instance (AsciiChars c) => FromJSON (AsciiText c) where parseJSON = schemaParseJSON +instance (FromJSON (AsciiText c)) => FromJSONKey (AsciiText c) + instance (Typeable c, AsciiChars c) => S.ToSchema (AsciiText c) where declareNamedSchema = schemaToSwagger diff --git a/libs/wire-api/src/Wire/API/Error/Brig.hs b/libs/wire-api/src/Wire/API/Error/Brig.hs index e84846c1620..8399afaf1a5 100644 --- a/libs/wire-api/src/Wire/API/Error/Brig.hs +++ b/libs/wire-api/src/Wire/API/Error/Brig.hs @@ -94,6 +94,9 @@ data BrigError | ProviderNotFound | TeamsNotFederating | PasswordIsStale + | TooManyProperties + | PropertyKeyTooLarge + | PropertyValueTooLarge instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: BrigError) where addToOpenApi = addStaticErrorToSwagger @(MapError e) @@ -282,3 +285,9 @@ type instance MapError 'ConflictingInvitations = 'StaticError 409 "conflicting-i type instance MapError 'TeamsNotFederating = 'StaticError 403 "team-not-federating" "The target user is owned by a federated backend, but is not in an allow-listed team" type instance MapError 'PasswordIsStale = 'StaticError 403 "password-is-stale" "The password is too old, please update your password." + +type instance MapError 'TooManyProperties = 'StaticError 403 "too-many-properties" "Too many properties" + +type instance MapError 'PropertyKeyTooLarge = 'StaticError 403 "property-key-too-large" "The property key is too large." + +type instance MapError 'PropertyValueTooLarge = 'StaticError 403 "property-value-too-large" "The property value is too large" diff --git a/libs/wire-api/src/Wire/API/Properties.hs b/libs/wire-api/src/Wire/API/Properties.hs index 83c8ee1aa50..67a3b5b554c 100644 --- a/libs/wire-api/src/Wire/API/Properties.hs +++ b/libs/wire-api/src/Wire/API/Properties.hs @@ -21,7 +21,6 @@ module Wire.API.Properties ( PropertyKeysAndValues (..), PropertyKey (..), RawPropertyValue (..), - PropertyValue (..), ) where @@ -35,9 +34,10 @@ import Data.OpenApi qualified as S import Data.Text.Ascii import Imports import Servant -import Wire.Arbitrary (Arbitrary) +import Test.QuickCheck -newtype PropertyKeysAndValues = PropertyKeysAndValues (Map PropertyKey PropertyValue) +newtype PropertyKeysAndValues = PropertyKeysAndValues (Map PropertyKey Value) + deriving stock (Eq, Show) deriving newtype (ToJSON) instance S.ToSchema PropertyKeysAndValues where @@ -72,6 +72,7 @@ deriving instance C.Cql PropertyKey -- | A raw, unparsed property value. newtype RawPropertyValue = RawPropertyValue {rawPropertyBytes :: LByteString} + deriving (Eq, Show) instance C.Cql RawPropertyValue where ctype = C.Tagged C.BlobColumn @@ -89,15 +90,3 @@ instance S.ToSchema RawPropertyValue where declareNamedSchema _ = pure . S.NamedSchema (Just "PropertyValue") $ mempty & S.description ?~ "An arbitrary JSON value for a property" - --- | A property value together with its original serialisation. -data PropertyValue = PropertyValue - { propertyRaw :: RawPropertyValue, - propertyValue :: Value - } - -instance ToJSON PropertyValue where - toJSON = propertyValue - -instance Show PropertyValue where - show = show . propertyValue diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index ab5d6d19a48..890275b857d 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -56,6 +56,7 @@ , resource-pool , resourcet , retry +, scientific , servant , servant-client-core , stomp-queue @@ -174,6 +175,7 @@ mkDerivation { QuickCheck quickcheck-instances random + scientific servant-client-core streaming-commons string-conversions diff --git a/libs/wire-subsystems/src/Wire/Events.hs b/libs/wire-subsystems/src/Wire/Events.hs new file mode 100644 index 00000000000..57a48e4ac72 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/Events.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Wire.Events where + +import Data.Id +import Imports +import Polysemy +import Wire.API.UserEvent + +data Events m a where + GenerateUserEvent :: UserId -> Maybe ConnId -> UserEvent -> Events m () + GeneratePropertyEvent :: UserId -> ConnId -> PropertyEvent -> Events m () + +makeSem ''Events diff --git a/libs/wire-subsystems/src/Wire/PropertyStore.hs b/libs/wire-subsystems/src/Wire/PropertyStore.hs new file mode 100644 index 00000000000..77e255581ca --- /dev/null +++ b/libs/wire-subsystems/src/Wire/PropertyStore.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Wire.PropertyStore where + +import Data.Id +import Imports +import Polysemy +import Wire.API.Properties + +data PropertyStore m a where + InsertProperty :: UserId -> PropertyKey -> RawPropertyValue -> PropertyStore m () + LookupProperty :: UserId -> PropertyKey -> PropertyStore m (Maybe RawPropertyValue) + CountProperties :: UserId -> PropertyStore m Int + DeleteProperty :: UserId -> PropertyKey -> PropertyStore m () + ClearProperties :: UserId -> PropertyStore m () + GetPropertyKeys :: UserId -> PropertyStore m [PropertyKey] + GetAllProperties :: UserId -> PropertyStore m [(PropertyKey, RawPropertyValue)] + +makeSem ''PropertyStore diff --git a/libs/wire-subsystems/src/Wire/PropertyStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/PropertyStore/Cassandra.hs new file mode 100644 index 00000000000..f5a7189466e --- /dev/null +++ b/libs/wire-subsystems/src/Wire/PropertyStore/Cassandra.hs @@ -0,0 +1,78 @@ +module Wire.PropertyStore.Cassandra where + +import Cassandra +import Data.Id +import Imports +import Polysemy +import Polysemy.Embed +import Wire.API.Properties +import Wire.PropertyStore + +interpretPropertyStoreCassandra :: (Member (Embed IO) r) => ClientState -> InterpreterFor PropertyStore r +interpretPropertyStoreCassandra casClient = + interpret $ + runEmbedded (runClient @IO casClient) . embed . \case + InsertProperty u k v -> insertPropertyImpl u k v + LookupProperty u k -> lookupPropertyImpl u k + CountProperties u -> countPropertiesImpl u + DeleteProperty u k -> deletePropertyImpl u k + ClearProperties u -> clearPropertieImpl u + GetPropertyKeys u -> lookupPropertyKeyImpl u + GetAllProperties u -> getAllPropertiesImpl u + +insertPropertyImpl :: + (MonadClient m) => + UserId -> + PropertyKey -> + RawPropertyValue -> + m () +insertPropertyImpl u k v = + retry x5 $ write propertyInsert (params LocalQuorum (u, k, v)) + +deletePropertyImpl :: (MonadClient m) => UserId -> PropertyKey -> m () +deletePropertyImpl u k = retry x5 $ write propertyDelete (params LocalQuorum (u, k)) + +clearPropertieImpl :: (MonadClient m) => UserId -> m () +clearPropertieImpl u = retry x5 $ write propertyReset (params LocalQuorum (Identity u)) + +lookupPropertyImpl :: (MonadClient m) => UserId -> PropertyKey -> m (Maybe RawPropertyValue) +lookupPropertyImpl u k = + fmap runIdentity + <$> retry x1 (query1 propertySelect (params LocalQuorum (u, k))) + +lookupPropertyKeyImpl :: (MonadClient m) => UserId -> m [PropertyKey] +lookupPropertyKeyImpl u = + map runIdentity + <$> retry x1 (query propertyKeysSelect (params LocalQuorum (Identity u))) + +countPropertiesImpl :: (MonadClient m) => UserId -> m Int +countPropertiesImpl u = do + maybe 0 fromIntegral <$> retry x1 (query1 propertyCount (params LocalQuorum (Identity u))) + +getAllPropertiesImpl :: (MonadClient m) => UserId -> m [(PropertyKey, RawPropertyValue)] +getAllPropertiesImpl u = + retry x1 (query propertyKeysValuesSelect (params LocalQuorum (Identity u))) + +------------------------------------------------------------------------------- +-- Queries + +propertyInsert :: PrepQuery W (UserId, PropertyKey, RawPropertyValue) () +propertyInsert = "INSERT INTO properties (user, key, value) VALUES (?, ?, ?)" + +propertyDelete :: PrepQuery W (UserId, PropertyKey) () +propertyDelete = "DELETE FROM properties where user = ? and key = ?" + +propertyReset :: PrepQuery W (Identity UserId) () +propertyReset = "DELETE FROM properties where user = ?" + +propertySelect :: PrepQuery R (UserId, PropertyKey) (Identity RawPropertyValue) +propertySelect = "SELECT value FROM properties where user = ? and key = ?" + +propertyKeysSelect :: PrepQuery R (Identity UserId) (Identity PropertyKey) +propertyKeysSelect = "SELECT key FROM properties where user = ?" + +propertyKeysValuesSelect :: PrepQuery R (Identity UserId) (PropertyKey, RawPropertyValue) +propertyKeysValuesSelect = "SELECT key, value FROM properties where user = ?" + +propertyCount :: PrepQuery R (Identity UserId) (Identity Int64) +propertyCount = "SELECT COUNT(*) FROM properties where user = ?" diff --git a/libs/wire-subsystems/src/Wire/PropertySubsystem.hs b/libs/wire-subsystems/src/Wire/PropertySubsystem.hs new file mode 100644 index 00000000000..2a8bef98df2 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/PropertySubsystem.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Wire.PropertySubsystem where + +import Data.Id +import Data.Text.Lazy qualified as LText +import Imports +import Network.HTTP.Types +import Network.Wai.Utilities qualified as Wai +import Polysemy +import Wire.API.Error +import Wire.API.Error.Brig qualified as E +import Wire.API.Properties +import Wire.Error + +data PropertySubsystemError + = TooManyProperties + | PropertyKeyTooLarge + | PropertyValueTooLarge + | PropertyValueInvalid String + | StoredPropertyValueInvalid + deriving (Show, Eq) + +propertySubsystemErrorToHttpError :: PropertySubsystemError -> HttpError +propertySubsystemErrorToHttpError = + StdError . \case + TooManyProperties -> errorToWai @E.TooManyProperties + PropertyKeyTooLarge -> errorToWai @E.PropertyKeyTooLarge + PropertyValueTooLarge -> errorToWai @E.PropertyValueTooLarge + PropertyValueInvalid err -> Wai.mkError status400 "bad-request" (LText.pack err) + StoredPropertyValueInvalid -> Wai.mkError status500 "internal-server-error" "Internal Server Error" + +data PropertySubsystem m a where + SetProperty :: UserId -> ConnId -> PropertyKey -> RawPropertyValue -> PropertySubsystem m () + DeleteProperty :: UserId -> ConnId -> PropertyKey -> PropertySubsystem m () + ClearProperties :: UserId -> ConnId -> PropertySubsystem m () + OnUserDeleted :: UserId -> PropertySubsystem m () + LookupProperty :: UserId -> PropertyKey -> PropertySubsystem m (Maybe RawPropertyValue) + GetPropertyKeys :: UserId -> PropertySubsystem m [PropertyKey] + GetAllProperties :: UserId -> PropertySubsystem m PropertyKeysAndValues + +makeSem ''PropertySubsystem diff --git a/libs/wire-subsystems/src/Wire/PropertySubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/PropertySubsystem/Interpreter.hs new file mode 100644 index 00000000000..5c347928dfb --- /dev/null +++ b/libs/wire-subsystems/src/Wire/PropertySubsystem/Interpreter.hs @@ -0,0 +1,151 @@ +module Wire.PropertySubsystem.Interpreter where + +import Data.Aeson (Value) +import Data.Aeson qualified as Aeson +import Data.ByteString.Lazy qualified as LBS +import Data.Id +import Data.Map qualified as Map +import Data.Text qualified as Text +import Data.Text.Ascii qualified as Ascii +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.Input +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as Log +import System.Logger.Message qualified as Log +import Wire.API.Properties +import Wire.API.UserEvent +import Wire.Events +import Wire.PropertyStore (PropertyStore) +import Wire.PropertyStore qualified as PropertyStore +import Wire.PropertySubsystem + +data PropertySubsystemConfig = PropertySubsystemConfig + { maxKeyLength :: Int64, + maxValueLength :: Int64, + maxProperties :: Int + } + +interpretPropertySubsystem :: + ( Member PropertyStore r, + Member (Error PropertySubsystemError) r, + Member Events r, + Member TinyLog r + ) => + PropertySubsystemConfig -> + InterpreterFor PropertySubsystem r +interpretPropertySubsystem cfg = + interpret $ + runInputConst cfg . \case + SetProperty uid connId key val -> setPropertyImpl uid connId key val + DeleteProperty uid connId key -> deletePropertyImpl uid connId key + ClearProperties uid connId -> clearPropertiesImpl uid connId + OnUserDeleted uid -> onUserDeletdImpl uid + LookupProperty uid key -> lookupPropertyImpl uid key + GetPropertyKeys uid -> getPropertyKeysImpl uid + GetAllProperties uid -> getAllPropertiesImpl uid + +setPropertyImpl :: + ( Member PropertyStore r, + Member (Input PropertySubsystemConfig) r, + Member (Error PropertySubsystemError) r, + Member Events r + ) => + UserId -> + ConnId -> + PropertyKey -> + RawPropertyValue -> + Sem r () +setPropertyImpl uid connId key val = do + validatePropertyKey key + checkMaxProperties uid key + parsedVal <- validatePropertyValue val + PropertyStore.insertProperty uid key val + generatePropertyEvent uid connId $ PropertySet key parsedVal + +checkMaxProperties :: + ( Member PropertyStore r, + Member (Input PropertySubsystemConfig) r, + Member (Error PropertySubsystemError) r + ) => + UserId -> + PropertyKey -> + Sem r () +checkMaxProperties uid key = do + propExists <- isJust <$> PropertyStore.lookupProperty uid key + unless propExists $ do + cfg <- input + count <- PropertyStore.countProperties uid + when (count >= cfg.maxProperties) $ + throw TooManyProperties + +validatePropertyKey :: + ( Member (Input PropertySubsystemConfig) r, + Member (Error PropertySubsystemError) r + ) => + PropertyKey -> + Sem r () +validatePropertyKey key = do + cfg <- input + let keyText = Ascii.toText $ propertyKeyName key + when (Text.compareLength keyText (fromIntegral cfg.maxKeyLength) == GT) $ + throw PropertyKeyTooLarge + +validatePropertyValue :: + ( Member (Input PropertySubsystemConfig) r, + Member (Error PropertySubsystemError) r + ) => + RawPropertyValue -> + Sem r Value +validatePropertyValue (RawPropertyValue bs) = do + cfg <- input + when (LBS.compareLength bs cfg.maxValueLength == GT) $ + throw PropertyValueTooLarge + + case Aeson.eitherDecode @Value bs of + Left e -> throw $ PropertyValueInvalid e + Right val -> pure val + +deletePropertyImpl :: (Member PropertyStore r, Member Events r) => UserId -> ConnId -> PropertyKey -> Sem r () +deletePropertyImpl uid connId key = do + PropertyStore.deleteProperty uid key + generatePropertyEvent uid connId $ PropertyDeleted key + +onUserDeletdImpl :: (Member PropertyStore r) => UserId -> Sem r () +onUserDeletdImpl uid = do + PropertyStore.clearProperties uid + +clearPropertiesImpl :: (Member PropertyStore r, Member Events r) => UserId -> ConnId -> Sem r () +clearPropertiesImpl uid connId = do + PropertyStore.clearProperties uid + generatePropertyEvent uid connId PropertiesCleared + +lookupPropertyImpl :: (Member PropertyStore r) => UserId -> PropertyKey -> Sem r (Maybe RawPropertyValue) +lookupPropertyImpl uid key = + PropertyStore.lookupProperty uid key + +getPropertyKeysImpl :: (Member PropertyStore r) => UserId -> Sem r [PropertyKey] +getPropertyKeysImpl uid = + PropertyStore.getPropertyKeys uid + +getAllPropertiesImpl :: + ( Member PropertyStore r, + Member TinyLog r, + Member (Error PropertySubsystemError) r + ) => + UserId -> + Sem r PropertyKeysAndValues +getAllPropertiesImpl uid = do + rawProps <- Map.fromList <$> PropertyStore.getAllProperties uid + PropertyKeysAndValues <$> traverse parseStoredPropertyValue rawProps + +parseStoredPropertyValue :: (Member TinyLog r, Member (Error PropertySubsystemError) r) => RawPropertyValue -> Sem r Value +parseStoredPropertyValue raw = case Aeson.eitherDecode raw.rawPropertyBytes of + Right value -> pure value + Left e -> do + Log.err $ + Log.msg (Log.val "Failed to parse a stored property value") + . Log.field "raw_value" raw.rawPropertyBytes + . Log.field "parse_error" e + throw StoredPropertyValueInvalid diff --git a/libs/wire-subsystems/src/Wire/UserEvents.hs b/libs/wire-subsystems/src/Wire/UserEvents.hs deleted file mode 100644 index 0288dee8d92..00000000000 --- a/libs/wire-subsystems/src/Wire/UserEvents.hs +++ /dev/null @@ -1,13 +0,0 @@ -{-# LANGUAGE TemplateHaskell #-} - -module Wire.UserEvents where - -import Data.Id -import Imports -import Polysemy -import Wire.API.UserEvent - -data UserEvents m a where - GenerateUserEvent :: UserId -> Maybe ConnId -> UserEvent -> UserEvents m () - -makeSem ''UserEvents diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index ea11d3d16d0..505f763e7dc 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -29,13 +29,13 @@ import Wire.API.User import Wire.API.UserEvent import Wire.Arbitrary import Wire.DeleteQueue +import Wire.Events import Wire.FederationAPIAccess import Wire.GalleyAPIAccess import Wire.Sem.Concurrency import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser -import Wire.UserEvents import Wire.UserKeyStore import Wire.UserStore as UserStore import Wire.UserSubsystem @@ -60,7 +60,7 @@ runUserSubsystem :: Member (Error UserSubsystemError) r, Member (FederationAPIAccess fedM) r, Member DeleteQueue r, - Member UserEvents r, + Member Events r, Member Now r, RunClient (fedM 'Brig), FederationMonad fedM, @@ -80,7 +80,7 @@ interpretUserSubsystem :: Member (FederationAPIAccess fedM) r, Member (Input UserSubsystemConfig) r, Member DeleteQueue r, - Member UserEvents r, + Member Events r, Member Now r, RunClient (fedM 'Brig), FederationMonad fedM, @@ -360,7 +360,7 @@ guardLockedHandleField user updateOrigin handle = do updateUserProfileImpl :: ( Member UserStore r, Member (Error UserSubsystemError) r, - Member UserEvents r, + Member Events r, Member GalleyAPIAccess r ) => Local UserId -> @@ -423,7 +423,7 @@ getLocalUserAccountByUserKeyImpl target = runMaybeT $ do updateHandleImpl :: ( Member (Error UserSubsystemError) r, Member GalleyAPIAccess r, - Member UserEvents r, + Member Events r, Member UserStore r ) => Local UserId -> diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index d1fea2a4012..72bc9a465b9 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -53,6 +53,7 @@ import Wire.API.User as User hiding (DeleteUser) import Wire.API.User.Password import Wire.DeleteQueue import Wire.DeleteQueue.InMemory +import Wire.Events import Wire.FederationAPIAccess import Wire.FederationAPIAccess.Interpreter as FI import Wire.GalleyAPIAccess @@ -63,7 +64,6 @@ import Wire.Sem.Concurrency import Wire.Sem.Concurrency.Sequential import Wire.Sem.Now hiding (get) import Wire.StoredUser -import Wire.UserEvents import Wire.UserKeyStore import Wire.UserStore import Wire.UserSubsystem @@ -100,7 +100,7 @@ type MiniBackendEffects = UserKeyStore, State (Map EmailKey UserId), DeleteQueue, - UserEvents, + Events, State [InternalNotification], State MiniBackend, State [MiniEvent], diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index 9145369b703..5dc96a34f9a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -5,14 +5,15 @@ module Wire.MockInterpreters (module MockInterpreters) where import Wire.MockInterpreters.EmailSubsystem as MockInterpreters import Wire.MockInterpreters.Error as MockInterpreters +import Wire.MockInterpreters.Events as MockInterpreters import Wire.MockInterpreters.GalleyAPIAccess as MockInterpreters import Wire.MockInterpreters.HashPassword as MockInterpreters import Wire.MockInterpreters.Now as MockInterpreters import Wire.MockInterpreters.PasswordResetCodeStore as MockInterpreters import Wire.MockInterpreters.PasswordStore as MockInterpreters +import Wire.MockInterpreters.PropertyStore as MockInterpreters import Wire.MockInterpreters.Random as MockInterpreters import Wire.MockInterpreters.SessionStore as MockInterpreters -import Wire.MockInterpreters.UserEvents as MockInterpreters import Wire.MockInterpreters.UserKeyStore as MockInterpreters import Wire.MockInterpreters.UserStore as MockInterpreters import Wire.MockInterpreters.UserSubsystem as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/Events.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/Events.hs new file mode 100644 index 00000000000..a80ec590088 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/Events.hs @@ -0,0 +1,22 @@ +module Wire.MockInterpreters.Events where + +import Data.Id +import Imports +import Polysemy +import Polysemy.State +import Wire.API.UserEvent +import Wire.Events + +data MiniEvent = MkMiniEvent + { userId :: UserId, + mConnId :: Maybe ConnId, + event :: Event + } + deriving stock (Eq, Show) + +miniEventInterpreter :: + (Member (State [MiniEvent]) r) => + InterpreterFor Events r +miniEventInterpreter = interpret \case + GenerateUserEvent uid mconn e -> modify (MkMiniEvent uid mconn (UserEvent e) :) + GeneratePropertyEvent uid mconn e -> modify (MkMiniEvent uid (Just mconn) (PropertyEvent e) :) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/PropertyStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/PropertyStore.hs new file mode 100644 index 00000000000..6cbc980f1ff --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/PropertyStore.hs @@ -0,0 +1,19 @@ +module Wire.MockInterpreters.PropertyStore where + +import Data.Id +import Data.Map qualified as Map +import Imports +import Polysemy +import Polysemy.State +import Wire.API.Properties +import Wire.PropertyStore + +inMemoryPropertyStoreInterpreter :: (Member (State (Map UserId (Map PropertyKey RawPropertyValue))) r) => InterpreterFor PropertyStore r +inMemoryPropertyStoreInterpreter = interpret $ \case + InsertProperty u k v -> modify $ Map.insertWith (Map.union) u (Map.singleton k v) + LookupProperty u k -> gets $ Map.lookup k <=< Map.lookup u + CountProperties u -> gets $ Map.size . Map.findWithDefault mempty u + DeleteProperty u k -> modify $ Map.adjust (Map.delete k) u + ClearProperties u -> modify $ Map.delete u + GetPropertyKeys u -> gets $ Map.keys . Map.findWithDefault mempty u + GetAllProperties u -> gets $ Map.toAscList . Map.findWithDefault mempty u diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserEvents.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserEvents.hs deleted file mode 100644 index 4bcd7319418..00000000000 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserEvents.hs +++ /dev/null @@ -1,20 +0,0 @@ -module Wire.MockInterpreters.UserEvents where - -import Data.Id -import Imports -import Polysemy -import Polysemy.State -import Wire.API.UserEvent -import Wire.UserEvents - -data MiniEvent = MkMiniEvent - { userId :: UserId, - event :: UserEvent - } - deriving stock (Eq, Show) - -miniEventInterpreter :: - (Member (State [MiniEvent]) r) => - InterpreterFor UserEvents r -miniEventInterpreter = interpret \case - GenerateUserEvent uid _mconn e -> modify (MkMiniEvent uid e :) diff --git a/libs/wire-subsystems/test/unit/Wire/PropertySubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/PropertySubsystem/InterpreterSpec.hs new file mode 100644 index 00000000000..7065e5b8b1d --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/PropertySubsystem/InterpreterSpec.hs @@ -0,0 +1,265 @@ +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + +module Wire.PropertySubsystem.InterpreterSpec where + +import Data.Aeson (FromJSON, ToJSON, Value (..), (.=)) +import Data.Aeson qualified as Aeson +import Data.Aeson.Key qualified as Key +import Data.Aeson.Text qualified as Aeson +import Data.Bifunctor (second) +import Data.ByteString.Lazy qualified as LBS +import Data.Map qualified as Map +import Data.Range +import Data.Scientific (scientific) +import Data.Set qualified as Set +import Data.Text qualified as Text +import Data.Text.Ascii (AsciiPrintable, AsciiText (..), validatePrintable) +import Data.Text.Lazy qualified as LText +import Data.Text.Lazy.Encoding qualified as LText +import GHC.IsList (fromList) +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.State +import Polysemy.TinyLog +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.QuickCheck +import Wire.API.Properties +import Wire.API.UserEvent +import Wire.Events +import Wire.MockInterpreters +import Wire.PropertyStore (PropertyStore) +import Wire.PropertySubsystem +import Wire.PropertySubsystem.Interpreter +import Wire.Sem.Logger.TinyLog (discardTinyLogs) + +defaultConfig :: PropertySubsystemConfig +defaultConfig = + PropertySubsystemConfig + { maxKeyLength = 1024, + maxValueLength = 1024, + maxProperties = 16 + } + +interpretDependencies :: Sem '[PropertyStore, Events, State [MiniEvent], TinyLog, Error e] a -> Either e a +interpretDependencies = + run + . runError + . discardTinyLogs + . evalState mempty + . miniEventInterpreter + . evalState mempty + . inMemoryPropertyStoreInterpreter + . raiseUnder + +spec :: Spec +spec = do + describe "Wire.PropertySubsystem.Interpreter" $ do + prop "set/lookup property" $ + \uid connId key (SmallJSON val) -> + let valBS = Aeson.encode val + rawVal = RawPropertyValue valBS + retrievedVal = + interpretDependencies + . interpretPropertySubsystem defaultConfig + $ do + setProperty uid connId key rawVal + lookupProperty uid key + in retrievedVal === Right (Just rawVal) + + prop "events" $ do + \uid connId key (SmallJSON val) -> + let valBS = Aeson.encode val + rawVal = RawPropertyValue valBS + assertion = + interpretDependencies + . interpretPropertySubsystem defaultConfig + $ do + setProperty uid connId key rawVal + eventsAfterSet <- get + + -- clear events + put [] + deleteProperty uid connId key + eventsAfterDelete <- get + + put [] + clearProperties uid connId + eventsAfterClear <- get + + -- assertions + pure $ + eventsAfterSet === [MkMiniEvent uid (Just connId) $ PropertyEvent $ PropertySet key val] + .&&. eventsAfterDelete === [MkMiniEvent uid (Just connId) $ PropertyEvent $ PropertyDeleted key] + .&&. eventsAfterClear === [MkMiniEvent uid (Just connId) $ PropertyEvent PropertiesCleared] + in either + (\e -> counterexample ("UnexpectedError: " <> show e) False) + id + assertion + + prop "set/delete/lookup property" $ + \uid connId key (SmallJSON val) -> + let valBS = Aeson.encode val + rawVal = RawPropertyValue valBS + retrievedVal = interpretDependencies . interpretPropertySubsystem defaultConfig $ do + setProperty uid connId key rawVal + deleteProperty uid connId key + lookupProperty uid key + in retrievedVal === Right Nothing + + prop "getAllProperties" $ + -- 16 is the default maxProperties + \uid connId (fromRange @0 @16 @[(PropertyKey, SmallJSON)] -> keySmallVal) -> + let keyVal = unwrapSmallJSON <$> Map.fromList keySmallVal + keyValRaw = RawPropertyValue . Aeson.encode <$> keyVal + retrievedVal = + interpretDependencies + . interpretPropertySubsystem defaultConfig + $ do + forM_ (Map.toAscList keyValRaw) (uncurry (setProperty uid connId)) + getAllProperties uid + in retrievedVal === Right (PropertyKeysAndValues keyVal) + + prop "getPropertyKeys" $ + -- 16 is the default maxProperties + \uid connId (fromRange @0 @16 @[(PropertyKey, SmallJSON)] -> keyVals) -> + let keyValRaw = Map.fromList $ map (second (RawPropertyValue . Aeson.encode)) keyVals + retrievedVal = + interpretDependencies + . interpretPropertySubsystem defaultConfig + $ do + forM_ (Map.toAscList keyValRaw) (uncurry (setProperty uid connId)) + getPropertyKeys uid + in second Set.fromList retrievedVal === Right (Map.keysSet keyValRaw) + + prop "clearProperties" $ + -- 16 is the default maxProperties + \uid connId (fromRange @0 @16 @[(PropertyKey, SmallJSON)] -> keyVals) -> + let keyValRaw = Map.fromList $ map (second (RawPropertyValue . Aeson.encode)) keyVals + retrievedVal = + interpretDependencies + . interpretPropertySubsystem defaultConfig + $ do + forM_ (Map.toAscList keyValRaw) (uncurry (setProperty uid connId)) + clearProperties uid connId + getAllProperties uid + in retrievedVal === Right (PropertyKeysAndValues mempty) + + prop "setting non JSON values should result in an error" $ + -- 1024 is the default max value length + \uid connId key (fromRange @0 @1024 @[Word8] -> nonJSONBytes) -> + let nonJSONBS = LBS.pack nonJSONBytes + setPropertyResult = interpretDependencies . interpretPropertySubsystem defaultConfig $ do + setProperty uid connId key (RawPropertyValue nonJSONBS) + in isNothing (Aeson.decode @Value nonJSONBS) ==> + case setPropertyResult of + Left (PropertyValueInvalid _) -> property True + Left x -> counterexample ("Expected PropertyValueInvalid, got: " <> show x) False + Right () -> counterexample ("Expected PropertyValueInvalid, but there was no error") False + + prop "setting very big JSON values should result in an error" $ + -- Capping default max value length to 1024 to make tests faster, bigger + -- number => slower tests. + \uid connId key (val :: Value) (fromIntegral . fromRange @0 @1024 @Int32 -> maxValueLength) -> + let cfg = defaultConfig {maxValueLength = maxValueLength} + -- Adding spaces to the end shouldn't change the meaning of a JSON, + -- maybe there are better ways of generating a big JSON + valBS = + LText.encodeUtf8 + . LText.justifyLeft (fromIntegral $ maxValueLength + 1) ' ' + $ Aeson.encodeToLazyText val + rawVal = RawPropertyValue valBS + setPropertyResult = interpretDependencies . interpretPropertySubsystem cfg $ do + setProperty uid connId key rawVal + in setPropertyResult === Left PropertyValueTooLarge + + prop "setting very big key names should result in an error" $ + \uid connId (fromRange @1 @1024 @AsciiPrintable -> unwrappedKey) (val :: SmallJSON) -> + let cfg = defaultConfig {maxKeyLength = (fromIntegral . Text.length $ toText unwrappedKey) - 1} + valBS = Aeson.encode val + rawVal = RawPropertyValue valBS + setPropertyResult = interpretDependencies . interpretPropertySubsystem cfg $ do + setProperty uid connId (PropertyKey unwrappedKey) rawVal + in setPropertyResult === Left PropertyKeyTooLarge + + prop "setProperty should respect maxProperties config" $ + \uid connId keyPrefix (SmallJSON val) (fromIntegral . fromRange @1 @20 @Int32 -> maxProperties) -> + let cfg = defaultConfig {maxProperties = maxProperties} + mkKey n = + let Right suffix = validatePrintable $ Text.pack $ show n + in PropertyKey $ keyPrefix <> suffix + keys = map mkKey [1 .. maxProperties] + extraKey = mkKey (maxProperties + 1) + valBS = Aeson.encode val + rawVal = RawPropertyValue valBS + assertion = + interpretDependencies + . interpretPropertySubsystem cfg + $ do + forM_ keys $ \key -> setProperty uid connId key rawVal + setPropErr <- catchExpectedError $ setProperty uid connId extraKey rawVal + allProps <- getAllProperties uid + pure $ + LBS.length valBS <= defaultConfig.maxValueLength ==> + setPropErr === Just TooManyProperties + .&&. allProps === PropertyKeysAndValues (Map.fromList (map (,val) keys)) + in either + (\e -> counterexample ("UnexpectedError: " <> show e) False) + id + assertion + + prop "setProperty should work for pre-existing properties even when maxProperties is reached" $ + \uid connId keyPrefix (SmallJSON val) (SmallJSON newVal) (fromIntegral . fromRange @1 @20 @Int32 -> maxProperties) -> + let cfg = defaultConfig {maxProperties = maxProperties} + mkKey n = + let Right suffix = validatePrintable $ Text.pack $ show n + in PropertyKey $ keyPrefix <> suffix + keys = map mkKey [1 .. maxProperties] + rawVal = RawPropertyValue (Aeson.encode val) + newRawVal = RawPropertyValue (Aeson.encode newVal) + retrievedVal = + interpretDependencies + . interpretPropertySubsystem cfg + $ do + forM_ keys $ \key -> setProperty uid connId key rawVal + setProperty uid connId (head keys) newRawVal + lookupProperty uid (head keys) + in retrievedVal === Right (Just newRawVal) + + describe "arbitrary @SmallJSON" $ + -- Please run this at least a million times when something about it changes + prop "Always produces JSON <= 1024 bytes" $ + \(smallJSON :: SmallJSON) -> + let jsonStr = LText.unpack $ Aeson.encodeToLazyText smallJSON + jsonBS = Aeson.encode smallJSON + in counterexample ("length = " <> show (LBS.length jsonBS) <> "\n" <> jsonStr) $ LBS.length jsonBS <= 1024 + +newtype SmallJSON = SmallJSON {unwrapSmallJSON :: Value} + deriving stock (Show, Eq) + deriving newtype (FromJSON, ToJSON) + +-- | generates small-ish JSON values +instance Arbitrary SmallJSON where + arbitrary = SmallJSON <$> go 0 + where + maxThings = 5 + -- ASCII chars take less space in the JSON + genText = toText . fromRange <$> arbitrary @(Range 0 5 AsciiPrintable) + go depth + | depth >= maxThings = pure Null + | otherwise = do + chooseInt (0, 5) >>= \case + 0 -> String <$> genText + 1 -> Number <$> (scientific <$> chooseInteger (0, 1000) <*> chooseInt (-1, 2)) + 2 -> Bool <$> arbitrary + 3 -> pure $ Null + 4 -> do + n <- chooseInt (0, maxThings) + Array . fromList <$> replicateM n (go (depth + 1)) + _ -> do + n <- chooseInt (0, maxThings) + keys <- Key.fromText <$$> replicateM n genText + vals <- replicateM n $ go (depth + 1) + pure . Aeson.object $ zipWith (.=) keys vals diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 2aed0f71b41..03fb0a2cda5 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -251,16 +251,17 @@ spec = describe "UserSubsystem.Interpreter" do .&&. userAfterUpdate.userLocale === fromMaybe userBeforeUpdate.userLocale update.locale prop "Update user events" $ - \(NotPendingStoredUser alice) localDomain update config -> do + \(NotPendingStoredUser alice) connId localDomain update config -> do let lusr = toLocalUnsafe localDomain alice.id localBackend = def {users = [alice {managedBy = Just ManagedByWire}]} events = runNoFederationStack localBackend Nothing config do - updateUserProfile lusr Nothing UpdateOriginScim update + updateUserProfile lusr connId UpdateOriginScim update get @[MiniEvent] in events === [ MkMiniEvent alice.id - ( UserUpdated $ + connId + ( UserEvent . UserUpdated $ (emptyUserUpdatedData alice.id) { eupName = update.name, eupTextStatus = update.textStatus, diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index a603df5d43f..2169aa80aca 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -82,6 +82,7 @@ library Wire.EmailSubsystem.Interpreter Wire.EmailSubsystem.Template Wire.Error + Wire.Events Wire.FederationAPIAccess Wire.FederationAPIAccess.Interpreter Wire.GalleyAPIAccess @@ -96,11 +97,14 @@ library Wire.PasswordResetCodeStore.Cassandra Wire.PasswordStore Wire.PasswordStore.Cassandra + Wire.PropertyStore + Wire.PropertyStore.Cassandra + Wire.PropertySubsystem + Wire.PropertySubsystem.Interpreter Wire.Rpc Wire.SessionStore Wire.SessionStore.Cassandra Wire.StoredUser - Wire.UserEvents Wire.UserKeyStore Wire.UserKeyStore.Cassandra Wire.UserStore @@ -202,19 +206,21 @@ test-suite wire-subsystems-tests Wire.MockInterpreters Wire.MockInterpreters.EmailSubsystem Wire.MockInterpreters.Error + Wire.MockInterpreters.Events Wire.MockInterpreters.GalleyAPIAccess Wire.MockInterpreters.HashPassword Wire.MockInterpreters.Now Wire.MockInterpreters.PasswordResetCodeStore Wire.MockInterpreters.PasswordStore + Wire.MockInterpreters.PropertyStore Wire.MockInterpreters.Random Wire.MockInterpreters.SessionStore - Wire.MockInterpreters.UserEvents Wire.MockInterpreters.UserKeyStore Wire.MockInterpreters.UserStore Wire.MockInterpreters.UserSubsystem Wire.MockInterpreters.VerificationCodeStore Wire.NotificationSubsystem.InterpreterSpec + Wire.PropertySubsystem.InterpreterSpec Wire.UserStoreSpec Wire.UserSubsystem.InterpreterSpec Wire.VerificationCodeSubsystem.InterpreterSpec @@ -247,6 +253,7 @@ test-suite wire-subsystems-tests , QuickCheck , quickcheck-instances , random + , scientific , servant-client-core , streaming-commons , string-conversions diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 4ab0deeacf9..428264a318f 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -90,7 +90,6 @@ library Brig.API.MLS.KeyPackages.Validation Brig.API.MLS.Util Brig.API.OAuth - Brig.API.Properties Brig.API.Public Brig.API.Public.Swagger Brig.API.Types @@ -111,7 +110,6 @@ library Brig.Data.LoginCode Brig.Data.MLS.KeyPackage Brig.Data.Nonce - Brig.Data.Properties Brig.Data.Types Brig.Data.User Brig.DeleteQueue.Interpreter @@ -397,7 +395,6 @@ executable brig-integration API.User.Connection API.User.Handles API.User.PasswordReset - API.User.Property API.User.RichInfo API.User.Util API.UserPendingActivation diff --git a/services/brig/src/Brig/API/Error.hs b/services/brig/src/Brig/API/Error.hs index 9c120e6d5b5..bba2cd54e2a 100644 --- a/services/brig/src/Brig/API/Error.hs +++ b/services/brig/src/Brig/API/Error.hs @@ -210,9 +210,6 @@ certEnrollmentError MissingName = StdError $ Wai.mkError status400 "missing-name fedError :: FederationError -> HttpError fedError = StdError . federationErrorToWai -propDataError :: PropertiesDataError -> HttpError -propDataError TooManyProperties = StdError tooManyProperties - clientDataError :: ClientDataError -> HttpError clientDataError TooManyClients = StdError (errorToWai @'E.TooManyClients) clientDataError (ClientReAuthError e) = reauthError e @@ -250,15 +247,6 @@ verificationCodeThrottledError (VerificationCodeThrottled t) = -- WAI Errors ----------------------------------------------------------------- -tooManyProperties :: Wai.Error -tooManyProperties = Wai.mkError status403 "too-many-properties" "Too many properties" - -propertyKeyTooLarge :: Wai.Error -propertyKeyTooLarge = Wai.mkError status403 "property-key-too-large" "The property key is too large." - -propertyValueTooLarge :: Wai.Error -propertyValueTooLarge = Wai.mkError status403 "property-value-too-large" "The property value is too large" - clientCapabilitiesCannotBeRemoved :: Wai.Error clientCapabilitiesCannotBeRemoved = Wai.mkError status409 "client-capabilities-cannot-be-removed" "You can only add capabilities to a client, not remove them." diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 1e05d13e6cf..1f764b4b126 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -105,6 +105,7 @@ import Wire.EmailSending (EmailSending) import Wire.EmailSubsystem (EmailSubsystem) import Wire.GalleyAPIAccess (GalleyAPIAccess, ShowOrHideInvitationUrl (..)) import Wire.NotificationSubsystem +import Wire.PropertySubsystem import Wire.Rpc import Wire.Sem.Concurrency import Wire.Sem.Paging.Cassandra (InternalPaging) @@ -137,7 +138,8 @@ servantSitemap :: Member (UserPendingActivationStore p) r, Member EmailSending r, Member EmailSubsystem r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -187,7 +189,8 @@ accountAPI :: Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, Member EmailSubsystem r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => ServerT BrigIRoutes.AccountAPI (Handler r) accountAPI = @@ -513,7 +516,8 @@ deleteUserNoAuthH :: Member UserKeyStore r, Member (Input (Local ())) r, Member (Input UTCTime) r, - Member (ConnectionStore InternalPaging) r + Member (ConnectionStore InternalPaging) r, + Member PropertySubsystem r ) => UserId -> (Handler r) DeleteUserResponse diff --git a/services/brig/src/Brig/API/Properties.hs b/services/brig/src/Brig/API/Properties.hs deleted file mode 100644 index 814b899962a..00000000000 --- a/services/brig/src/Brig/API/Properties.hs +++ /dev/null @@ -1,54 +0,0 @@ --- 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 Brig.API.Properties - ( PropertiesDataError (..), - setProperty, - deleteProperty, - clearProperties, - Data.lookupProperty, - Data.lookupPropertyKeys, - Data.lookupPropertyKeysAndValues, - ) -where - -import Brig.App -import Brig.Data.Properties (PropertiesDataError) -import Brig.Data.Properties qualified as Data -import Brig.IO.Intra qualified as Intra -import Control.Error -import Data.Id -import Imports -import Polysemy -import Wire.API.Properties -import Wire.API.UserEvent -import Wire.NotificationSubsystem - -setProperty :: (Member NotificationSubsystem r) => UserId -> ConnId -> PropertyKey -> PropertyValue -> ExceptT PropertiesDataError (AppT r) () -setProperty u c k v = do - wrapClientE $ Data.insertProperty u k (propertyRaw v) - lift $ liftSem $ Intra.onPropertyEvent u c (PropertySet k (propertyValue v)) - -deleteProperty :: (Member NotificationSubsystem r) => UserId -> ConnId -> PropertyKey -> AppT r () -deleteProperty u c k = do - wrapClient $ Data.deleteProperty u k - liftSem $ Intra.onPropertyEvent u c (PropertyDeleted k) - -clearProperties :: (Member NotificationSubsystem r) => UserId -> ConnId -> AppT r () -clearProperties u c = do - wrapClient $ Data.clearProperties u - liftSem $ Intra.onPropertyEvent u c PropertiesCleared diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 2c849ed62c1..fb2b03b23f6 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -32,7 +32,6 @@ import Brig.API.Error import Brig.API.Handler import Brig.API.MLS.KeyPackages import Brig.API.OAuth (oauthAPI) -import Brig.API.Properties qualified as API import Brig.API.Public.Swagger import Brig.API.Types import Brig.API.User qualified as API @@ -67,9 +66,7 @@ import Control.Lens (view, (.~), (?~), (^.)) import Control.Monad.Catch (throwM) import Control.Monad.Except import Data.Aeson hiding (json) -import Data.Bifunctor import Data.ByteString (fromStrict, toStrict) -import Data.ByteString.Lazy qualified as Lazy import Data.ByteString.Lazy.Char8 qualified as LBS import Data.ByteString.UTF8 qualified as UTF8 import Data.Code qualified as Code @@ -88,10 +85,7 @@ import Data.OpenApi qualified as S import Data.Qualified import Data.Range import Data.Schema () -import Data.Text qualified as Text -import Data.Text.Ascii qualified as Ascii import Data.Text.Encoding qualified as Text -import Data.Text.Lazy (pack) import Data.Time.Clock (UTCTime) import Data.ZAuth.Token qualified as ZAuth import FileEmbedLzma @@ -160,6 +154,7 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess import Wire.NotificationSubsystem import Wire.PasswordStore (PasswordStore, lookupHashedPassword) +import Wire.PropertySubsystem import Wire.Sem.Concurrency import Wire.Sem.Jwk (Jwk) import Wire.Sem.Now (Now) @@ -307,7 +302,8 @@ servantSitemap :: Member (UserPendingActivationStore p) r, Member EmailSubsystem r, Member EmailSending r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => ServerT BrigAPI (Handler r) servantSitemap = @@ -421,13 +417,13 @@ servantSitemap = propertiesAPI :: ServerT PropertiesAPI (Handler r) propertiesAPI = - ( Named @"set-property" setProperty - :<|> Named @"delete-property" deleteProperty - :<|> Named @"clear-properties" clearProperties - :<|> Named @"get-property" getProperty - :<|> Named @"list-property-keys" listPropertyKeys + ( Named @"set-property" setPropertyH + :<|> Named @"delete-property" deletePropertyH + :<|> Named @"clear-properties" clearPropertiesH + :<|> Named @"get-property" getPropertyH + :<|> Named @"list-property-keys" listPropertyKeysH ) - :<|> Named @"list-properties" listPropertyKeysAndValues + :<|> Named @"list-properties" listPropertyKeysAndValuesH mlsAPI :: ServerT MLSAPI (Handler r) mlsAPI = @@ -476,61 +472,23 @@ servantSitemap = --------------------------------------------------------------------------- -- Handlers -setProperty :: (Member NotificationSubsystem r) => UserId -> ConnId -> Public.PropertyKey -> Public.RawPropertyValue -> Handler r () -setProperty u c key raw = do - checkPropertyKey key - val <- safeParsePropertyValue raw - API.setProperty u c key val !>> propDataError - -checkPropertyKey :: Public.PropertyKey -> Handler r () -checkPropertyKey k = do - maxKeyLen <- fromMaybe defMaxKeyLen <$> view (settings . propertyMaxKeyLen) - let keyText = Ascii.toText (Public.propertyKeyName k) - when (Text.compareLength keyText (fromIntegral maxKeyLen) == GT) $ - throwStd propertyKeyTooLarge - --- | Parse a 'PropertyValue' from a bytestring. This is different from 'FromJSON' in that --- checks the byte size of the input, and fails *without consuming all of it* if that size --- exceeds the settings. -safeParsePropertyValue :: Public.RawPropertyValue -> Handler r Public.PropertyValue -safeParsePropertyValue raw = do - maxValueLen <- fromMaybe defMaxValueLen <$> view (settings . propertyMaxValueLen) - let lbs = Lazy.take (maxValueLen + 1) (Public.rawPropertyBytes raw) - unless (Lazy.length lbs <= maxValueLen) $ - throwStd propertyValueTooLarge - hoistEither $ first (StdError . badRequest . pack) (propertyValueFromRaw raw) - -propertyValueFromRaw :: Public.RawPropertyValue -> Either String Public.PropertyValue -propertyValueFromRaw raw = - Public.PropertyValue raw - <$> eitherDecode (Public.rawPropertyBytes raw) - -parseStoredPropertyValue :: Public.RawPropertyValue -> Handler r Public.PropertyValue -parseStoredPropertyValue raw = case propertyValueFromRaw raw of - Right value -> pure value - Left e -> do - Log.err $ - Log.msg (Log.val "Failed to parse a stored property value") - . Log.field "raw_value" (Public.rawPropertyBytes raw) - . Log.field "parse_error" e - throwStd internalServerError - -deleteProperty :: (Member NotificationSubsystem r) => UserId -> ConnId -> Public.PropertyKey -> Handler r () -deleteProperty u c k = lift (API.deleteProperty u c k) - -clearProperties :: (Member NotificationSubsystem r) => UserId -> ConnId -> Handler r () -clearProperties u c = lift (API.clearProperties u c) - -getProperty :: UserId -> Public.PropertyKey -> Handler r (Maybe Public.RawPropertyValue) -getProperty u k = lift . wrapClient $ API.lookupProperty u k - -listPropertyKeys :: UserId -> Handler r [Public.PropertyKey] -listPropertyKeys u = lift $ wrapClient (API.lookupPropertyKeys u) - -listPropertyKeysAndValues :: UserId -> Handler r Public.PropertyKeysAndValues -listPropertyKeysAndValues u = do - keysAndVals <- fmap Map.fromList . lift $ wrapClient (API.lookupPropertyKeysAndValues u) - Public.PropertyKeysAndValues <$> traverse parseStoredPropertyValue keysAndVals +setPropertyH :: (Member PropertySubsystem r) => UserId -> ConnId -> Public.PropertyKey -> Public.RawPropertyValue -> Handler r () +setPropertyH u c key raw = lift . liftSem $ setProperty u c key raw + +deletePropertyH :: (Member PropertySubsystem r) => UserId -> ConnId -> Public.PropertyKey -> Handler r () +deletePropertyH u c k = lift . liftSem $ deleteProperty u c k + +clearPropertiesH :: (Member PropertySubsystem r) => UserId -> ConnId -> Handler r () +clearPropertiesH u c = lift . liftSem $ clearProperties u c + +getPropertyH :: (Member PropertySubsystem r) => UserId -> Public.PropertyKey -> Handler r (Maybe Public.RawPropertyValue) +getPropertyH u k = lift . liftSem $ lookupProperty u k + +listPropertyKeysH :: (Member PropertySubsystem r) => UserId -> Handler r [Public.PropertyKey] +listPropertyKeysH u = lift . liftSem $ getPropertyKeys u + +listPropertyKeysAndValuesH :: (Member PropertySubsystem r) => UserId -> Handler r Public.PropertyKeysAndValues +listPropertyKeysAndValuesH u = lift . liftSem $ getAllProperties u getPrekeyUnqualifiedH :: (Member DeleteQueue r) => @@ -1238,7 +1196,8 @@ deleteSelfUser :: Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, Member EmailSubsystem r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => UserId -> Public.DeleteUser -> @@ -1255,7 +1214,8 @@ verifyDeleteUser :: Member UserKeyStore r, Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => Public.VerifyDeleteUser -> Handler r () diff --git a/services/brig/src/Brig/API/Types.hs b/services/brig/src/Brig/API/Types.hs index 2152214961c..6e6259f3202 100644 --- a/services/brig/src/Brig/API/Types.hs +++ b/services/brig/src/Brig/API/Types.hs @@ -22,7 +22,6 @@ module Brig.API.Types Activation (..), ActivationError (..), ClientDataError (..), - PropertiesDataError (..), AuthError (..), ReAuthError (..), LegalHoldLoginError (..), @@ -33,7 +32,6 @@ where import Brig.Data.Activation (Activation (..), ActivationError (..)) import Brig.Data.Client (ClientDataError (..)) -import Brig.Data.Properties (PropertiesDataError (..)) import Brig.Data.User (AuthError (..), ReAuthError (..)) import Brig.Types.Intra import Data.Code diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index da5333d7088..543ec6d839e 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -84,7 +84,6 @@ import Brig.Data.Activation qualified as Data import Brig.Data.Client qualified as Data import Brig.Data.Connection (countConnections) import Brig.Data.Connection qualified as Data -import Brig.Data.Properties qualified as Data import Brig.Data.User import Brig.Data.User qualified as Data import Brig.Effects.BlacklistStore (BlacklistStore) @@ -152,6 +151,7 @@ import Wire.Error import Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.NotificationSubsystem import Wire.PasswordStore (PasswordStore, lookupHashedPassword, upsertHashedPassword) +import Wire.PropertySubsystem as PropertySubsystem import Wire.Sem.Concurrency import Wire.Sem.Paging.Cassandra (InternalPaging) import Wire.UserKeyStore @@ -927,7 +927,8 @@ deleteSelfUser :: Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, Member EmailSubsystem r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => UserId -> Maybe PlainTextPassword6 -> @@ -998,7 +999,8 @@ verifyDeleteUser :: Member UserStore r, Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, - Member VerificationCodeSubsystem r + Member VerificationCodeSubsystem r, + Member PropertySubsystem r ) => VerifyDeleteUser -> ExceptT DeleteUserError (AppT r) () @@ -1024,7 +1026,8 @@ ensureAccountDeleted :: Member (Input (Local ())) r, Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r, - Member UserStore r + Member UserStore r, + Member PropertySubsystem r ) => UserId -> AppT r DeleteUserResult @@ -1033,7 +1036,7 @@ ensureAccountDeleted uid = do case mbAcc of Nothing -> pure NoUser Just acc -> do - probs <- wrapClient $ Data.lookupPropertyKeysAndValues uid + probs <- liftSem $ getPropertyKeys uid let accIsDeleted = accountStatus acc == Deleted clients <- wrapClient $ Data.lookupClients uid @@ -1073,7 +1076,8 @@ deleteAccount :: Member (Input (Local ())) r, Member UserStore r, Member (Input UTCTime) r, - Member (ConnectionStore InternalPaging) r + Member (ConnectionStore InternalPaging) r, + Member PropertySubsystem r ) => UserAccount -> Sem r () @@ -1084,7 +1088,7 @@ deleteAccount (accountUser -> user) = do -- Free unique keys for_ (userEmail user) $ deleteKeyForUser uid . mkEmailKey - embed $ Data.clearProperties uid + PropertySubsystem.onUserDeleted uid deleteUser user diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 13158e6f03d..3e8a92f48d5 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -14,7 +14,7 @@ import Brig.Effects.PublicKeyBundle import Brig.Effects.SFT (SFT, interpretSFT) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) -import Brig.IO.Intra (runUserEvents) +import Brig.IO.Intra (runEvents) import Brig.Options (ImplicitNoFederationRestriction (federationDomainConfig), federationDomainConfigs, federationStrategy) import Brig.Options qualified as Opt import Cassandra qualified as Cas @@ -43,6 +43,7 @@ import Wire.EmailSending.SMTP import Wire.EmailSubsystem import Wire.EmailSubsystem.Interpreter import Wire.Error +import Wire.Events import Wire.FederationAPIAccess qualified import Wire.FederationAPIAccess.Interpreter (FederationAPIAccessConfig (..), interpretFederationAPIAccess) import Wire.GalleyAPIAccess (GalleyAPIAccess) @@ -56,6 +57,10 @@ import Wire.PasswordResetCodeStore (PasswordResetCodeStore) import Wire.PasswordResetCodeStore.Cassandra (interpretClientToIO, passwordResetCodeStoreToCassandra) import Wire.PasswordStore (PasswordStore) import Wire.PasswordStore.Cassandra (interpretPasswordStore) +import Wire.PropertyStore +import Wire.PropertyStore.Cassandra +import Wire.PropertySubsystem +import Wire.PropertySubsystem.Interpreter import Wire.Rpc import Wire.Sem.Concurrency import Wire.Sem.Concurrency.IO @@ -69,7 +74,6 @@ import Wire.Sem.Random import Wire.Sem.Random.IO import Wire.SessionStore import Wire.SessionStore.Cassandra (interpretSessionStoreCassandra) -import Wire.UserEvents import Wire.UserKeyStore import Wire.UserKeyStore.Cassandra import Wire.UserStore @@ -87,12 +91,14 @@ type BrigCanonicalEffects = UserSubsystem, EmailSubsystem, VerificationCodeSubsystem, + PropertySubsystem, DeleteQueue, - UserEvents, + Wire.Events.Events, Error UserSubsystemError, Error AuthenticationSubsystemError, Error Wire.API.Federation.Error.FederationError, Error VerificationCodeSubsystemError, + Error PropertySubsystemError, Error HttpError, Wire.FederationAPIAccess.FederationAPIAccess Wire.API.Federation.Client.FederatorClient, HashPassword, @@ -101,6 +107,7 @@ type BrigCanonicalEffects = SessionStore, PasswordStore, VerificationCodeStore, + PropertyStore, SFT, ConnectionStore InternalPaging, Input VerificationCodeThrottleTTL, @@ -149,6 +156,12 @@ runBrigToIO e (AppT ma) = do http2Manager = e ^. App.http2Manager, requestId = e ^. App.requestId } + propertySubsystemConfig = + PropertySubsystemConfig + { maxKeyLength = fromMaybe Opt.defMaxKeyLen $ e ^. settings . Opt.propertyMaxKeyLen, + maxValueLength = fromMaybe Opt.defMaxValueLen $ e ^. settings . Opt.propertyMaxValueLen, + maxProperties = 16 + } ( either throwM pure <=< ( runFinal . unsafelyPerformConcurrency @@ -182,6 +195,7 @@ runBrigToIO e (AppT ma) = do . runInputConst (e ^. settings . to Opt.set2FACodeGenerationDelaySecs . to fromIntegral) . connectionStoreToCassandra . interpretSFT (e ^. httpManager) + . interpretPropertyStoreCassandra (e ^. casClient) . interpretVerificationCodeStoreCassandra (e ^. casClient) . interpretPasswordStore (e ^. casClient) . interpretSessionStoreCassandra (e ^. casClient) @@ -190,12 +204,14 @@ runBrigToIO e (AppT ma) = do . runHashPassword . interpretFederationAPIAccess federationApiAccessConfig . rethrowHttpErrorIO + . mapError propertySubsystemErrorToHttpError . mapError verificationCodeSubsystemErrorToHttpError . mapError (StdError . federationErrorToWai) . mapError authenticationSubsystemErrorToHttpError . mapError userSubsystemErrorToHttpError - . runUserEvents + . runEvents . runDeleteQueue (e ^. internalEvents) + . interpretPropertySubsystem propertySubsystemConfig . interpretVerificationCodeSubsystem . emailSubsystemInterpreter (e ^. usrTemplates) (e ^. templateBranding) . runUserSubsystem userSubsystemConfig diff --git a/services/brig/src/Brig/Data/Properties.hs b/services/brig/src/Brig/Data/Properties.hs deleted file mode 100644 index 6fd099d8620..00000000000 --- a/services/brig/src/Brig/Data/Properties.hs +++ /dev/null @@ -1,95 +0,0 @@ --- 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 Brig.Data.Properties - ( PropertiesDataError (..), - insertProperty, - deleteProperty, - clearProperties, - lookupProperty, - lookupPropertyKeys, - lookupPropertyKeysAndValues, - ) -where - -import Cassandra -import Control.Error -import Data.Id -import Imports -import Wire.API.Properties - -maxProperties :: Int64 -maxProperties = 16 - -data PropertiesDataError - = TooManyProperties - -insertProperty :: - (MonadClient m) => - UserId -> - PropertyKey -> - RawPropertyValue -> - ExceptT PropertiesDataError m () -insertProperty u k v = do - n <- lift . fmap (maybe 0 runIdentity) . retry x1 $ query1 propertyCount (params LocalQuorum (Identity u)) - unless (n < maxProperties) $ - throwE TooManyProperties - lift . retry x5 $ write propertyInsert (params LocalQuorum (u, k, v)) - -deleteProperty :: (MonadClient m) => UserId -> PropertyKey -> m () -deleteProperty u k = retry x5 $ write propertyDelete (params LocalQuorum (u, k)) - -clearProperties :: (MonadClient m) => UserId -> m () -clearProperties u = retry x5 $ write propertyReset (params LocalQuorum (Identity u)) - -lookupProperty :: (MonadClient m) => UserId -> PropertyKey -> m (Maybe RawPropertyValue) -lookupProperty u k = - fmap runIdentity - <$> retry x1 (query1 propertySelect (params LocalQuorum (u, k))) - -lookupPropertyKeys :: (MonadClient m) => UserId -> m [PropertyKey] -lookupPropertyKeys u = - map runIdentity - <$> retry x1 (query propertyKeysSelect (params LocalQuorum (Identity u))) - -lookupPropertyKeysAndValues :: (MonadClient m) => UserId -> m [(PropertyKey, RawPropertyValue)] -lookupPropertyKeysAndValues u = - retry x1 (query propertyKeysValuesSelect (params LocalQuorum (Identity u))) - -------------------------------------------------------------------------------- --- Queries - -propertyInsert :: PrepQuery W (UserId, PropertyKey, RawPropertyValue) () -propertyInsert = "INSERT INTO properties (user, key, value) VALUES (?, ?, ?)" - -propertyDelete :: PrepQuery W (UserId, PropertyKey) () -propertyDelete = "DELETE FROM properties where user = ? and key = ?" - -propertyReset :: PrepQuery W (Identity UserId) () -propertyReset = "DELETE FROM properties where user = ?" - -propertySelect :: PrepQuery R (UserId, PropertyKey) (Identity RawPropertyValue) -propertySelect = "SELECT value FROM properties where user = ? and key = ?" - -propertyKeysSelect :: PrepQuery R (Identity UserId) (Identity PropertyKey) -propertyKeysSelect = "SELECT key FROM properties where user = ?" - -propertyKeysValuesSelect :: PrepQuery R (Identity UserId) (PropertyKey, RawPropertyValue) -propertyKeysValuesSelect = "SELECT key, value FROM properties where user = ?" - -propertyCount :: PrepQuery R (Identity UserId) (Identity Int64) -propertyCount = "SELECT COUNT(*) FROM properties where user = ?" diff --git a/services/brig/src/Brig/IO/Intra.hs b/services/brig/src/Brig/IO/Intra.hs index 650940bac87..716272ccf62 100644 --- a/services/brig/src/Brig/IO/Intra.hs +++ b/services/brig/src/Brig/IO/Intra.hs @@ -26,7 +26,7 @@ module Brig.IO.Intra onClientEvent, -- * user subsystem interpretation for user events - runUserEvents, + runEvents, -- * Conversations createConnectConv, @@ -99,12 +99,12 @@ import Wire.API.Team.Member qualified as Team import Wire.API.User import Wire.API.User.Client import Wire.API.UserEvent +import Wire.Events import Wire.NotificationSubsystem import Wire.Rpc import Wire.Sem.Logger qualified as Log import Wire.Sem.Paging qualified as P import Wire.Sem.Paging.Cassandra (InternalPaging) -import Wire.UserEvents ----------------------------------------------------------------------------- -- Event Handlers @@ -126,7 +126,7 @@ onUserEvent orig conn e = *> dispatchNotifications orig conn e *> embed (journalEvent orig e) -runUserEvents :: +runEvents :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member TinyLog r, @@ -134,10 +134,11 @@ runUserEvents :: Member (Input UTCTime) r, Member (ConnectionStore InternalPaging) r ) => - InterpreterFor UserEvents r -runUserEvents = interpret \case + InterpreterFor Events r +runEvents = interpret \case -- FUTUREWORK(mangoiv): should this be in another module? GenerateUserEvent uid mconnid event -> onUserEvent uid mconnid event + GeneratePropertyEvent uid connid event -> onPropertyEvent uid connid event onConnectionEvent :: (Member NotificationSubsystem r) => diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index 912d5241c01..899381faa23 100644 --- a/services/brig/src/Brig/InternalEvent/Process.hs +++ b/services/brig/src/Brig/InternalEvent/Process.hs @@ -39,6 +39,7 @@ import Polysemy.TinyLog as Log import System.Logger.Class (field, msg, val, (~~)) import Wire.API.UserEvent import Wire.NotificationSubsystem +import Wire.PropertySubsystem import Wire.Sem.Delay import Wire.Sem.Paging.Cassandra (InternalPaging) import Wire.UserKeyStore @@ -57,7 +58,8 @@ onEvent :: Member UserKeyStore r, Member (Input UTCTime) r, Member UserStore r, - Member (ConnectionStore InternalPaging) r + Member (ConnectionStore InternalPaging) r, + Member PropertySubsystem r ) => InternalNotification -> Sem r () diff --git a/services/brig/test/integration/API/User.hs b/services/brig/test/integration/API/User.hs index 59e79905156..d791df93082 100644 --- a/services/brig/test/integration/API/User.hs +++ b/services/brig/test/integration/API/User.hs @@ -27,7 +27,6 @@ import API.User.Client qualified import API.User.Connection qualified import API.User.Handles qualified import API.User.PasswordReset qualified -import API.User.Property qualified import API.User.RichInfo qualified import API.User.Util import Bilge hiding (accept, timeout) @@ -69,7 +68,6 @@ tests conf fbc p b c ch g n aws db userJournalWatcher = do API.User.Connection.tests cl at p b c g fbc db, API.User.Handles.tests cl at conf p b c g, API.User.PasswordReset.tests db cl at conf p b c g, - API.User.Property.tests cl at conf p b c g, API.User.RichInfo.tests cl at conf p b c g ] diff --git a/services/brig/test/integration/API/User/Property.hs b/services/brig/test/integration/API/User/Property.hs deleted file mode 100644 index 071ea2d356d..00000000000 --- a/services/brig/test/integration/API/User/Property.hs +++ /dev/null @@ -1,170 +0,0 @@ --- 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 API.User.Property - ( tests, - ) -where - -import API.User.Util -import Bilge hiding (accept, timeout) -import Bilge.Assert -import Brig.Options -import Brig.Options qualified as Opt -import Data.Aeson -import Data.ByteString.Char8 qualified as C -import Data.String.Conversions -import Data.Text qualified as T -import Imports -import Network.Wai.Utilities.Error qualified as Error -import Test.Tasty hiding (Timeout) -import Util -import Wire.API.User - -tests :: ConnectionLimit -> Opt.Timeout -> Opt.Opts -> Manager -> Brig -> Cannon -> Galley -> TestTree -tests _cl _at opts p b _c _g = - testGroup - "property" - [ test p "put/get /properties/:key - 200" $ testSetGetProperty b, - test p "delete /properties/:key - 200" $ testDeleteProperty b, - test p "get /properties - 200" $ testListPropertyKeys b, - test p "get /properties-values - 200" $ testListPropertyKeysAndValues b, - test p "delete /properties - 200" $ testClearProperties b, - test p "put /properties/:key - 403" $ testPropertyLimits opts b, - test p "size limits" $ testSizeLimits opts b - ] - -testSetGetProperty :: Brig -> Http () -testSetGetProperty brig = do - u <- randomUser brig - setProperty brig (userId u) "foo" objectProp - !!! const 200 === statusCode - getProperty brig (userId u) "foo" !!! do - const 200 === statusCode - const (Just objectProp) === responseJsonMaybe - -- String Literals - setProperty brig (userId u) "foo" (String "foo") - !!! const 200 === statusCode - getProperty brig (userId u) "foo" !!! do - const 200 === statusCode - const (Just "\"foo\"") === responseBody - -- Boolean Literals - setProperty brig (userId u) "foo" (Bool True) - !!! const 200 === statusCode - getProperty brig (userId u) "foo" !!! do - const 200 === statusCode - const (Just "true") === responseBody - -- Numeric Literals - setProperty brig (userId u) "foo" (Number 42) - !!! const 200 === statusCode - getProperty brig (userId u) "foo" !!! do - const 200 === statusCode - const (Just "42") === responseBody - where - objectProp = - object - [ "key.1" .= ("val1" :: Text), - "key.2" .= ("val2" :: Text) - ] - -testDeleteProperty :: Brig -> Http () -testDeleteProperty brig = do - u <- randomUser brig - setProperty brig (userId u) "foo" (Bool True) - !!! const 200 === statusCode - deleteProperty brig (userId u) "foo" - !!! const 200 === statusCode - getProperty brig (userId u) "foo" - !!! const 404 === statusCode - -testListPropertyKeys :: Brig -> Http () -testListPropertyKeys = - testListProperties' - "/properties" - (toJSON ["bar" :: Text, "foo"]) - -testListPropertyKeysAndValues :: Brig -> Http () -testListPropertyKeysAndValues = - testListProperties' - "/properties-values" - (object ["bar" .= String "hello", "foo" .= True]) - -testListProperties' :: ByteString -> Value -> Brig -> Http () -testListProperties' endpoint rval brig = do - u <- randomUser brig - setProperty brig (userId u) "foo" (Bool True) - !!! const 200 === statusCode - setProperty brig (userId u) "bar" (String "hello") - !!! const 200 === statusCode - get (brig . path endpoint . zUser (userId u)) !!! do - const 200 === statusCode - const (Just rval) === responseJsonMaybe - -testClearProperties :: Brig -> Http () -testClearProperties brig = do - u <- randomUser brig - setProperty brig (userId u) "foo" (Bool True) - !!! const 200 === statusCode - setProperty brig (userId u) "bar" (String "hello") - !!! const 200 === statusCode - delete (brig . path "/properties" . zUser (userId u) . zConn "conn") - !!! const 200 === statusCode - getProperty brig (userId u) "foo" - !!! const 404 === statusCode - getProperty brig (userId u) "bar" - !!! const 404 === statusCode - -testPropertyLimits :: Opt.Opts -> Brig -> Http () -testPropertyLimits opts brig = do - u <- randomUser brig - let maxKeyLen = fromIntegral $ fromMaybe defMaxKeyLen . setPropertyMaxKeyLen $ optSettings opts - maxValueLen = fromIntegral $ fromMaybe defMaxValueLen . setPropertyMaxValueLen $ optSettings opts - -- Maximum key length - setProperty brig (userId u) (C.replicate (maxKeyLen + 1) 'x') (String "y") !!! do - const 403 === statusCode - const (Just "property-key-too-large") === fmap Error.label . responseJsonMaybe - -- Maximum value length - setProperty brig (userId u) "foo" (String (T.replicate (maxValueLen + 1) "x")) !!! do - const 403 === statusCode - const (Just "property-value-too-large") === fmap Error.label . responseJsonMaybe - -- Maximum count - forM_ [1 .. 16 :: Int] $ \i -> - setProperty brig (userId u) ("foo" <> C.pack (show i)) (Number (fromIntegral i)) - !!! const 200 === statusCode - setProperty brig (userId u) "bar" (String "hello") !!! do - const 403 === statusCode - const (Just "too-many-properties") === fmap Error.label . responseJsonMaybe - -testSizeLimits :: (HasCallStack) => Opt.Opts -> Brig -> Http () -testSizeLimits opts brig = do - let maxKeyLen = fromIntegral $ fromMaybe defMaxKeyLen . setPropertyMaxKeyLen $ optSettings opts - maxValueLen = fromIntegral $ fromMaybe defMaxValueLen . setPropertyMaxValueLen $ optSettings opts - badKey = cs $ replicate (maxKeyLen + 2) '_' - okKey = cs $ replicate (maxKeyLen - 2) '_' - -- we use String Values here that have an encoding that is 2 characters longer than - -- the decoded string value (because of the quotes). - badValue = String . cs $ replicate maxValueLen '_' - okValue = String . cs $ replicate (maxValueLen - 3) '_' - u <- randomUser brig - setProperty brig (userId u) okKey okValue - !!! const 200 === statusCode - setProperty brig (userId u) badKey okValue - !!! const 403 === statusCode - setProperty brig (userId u) okKey badValue - !!! const 403 === statusCode - setProperty brig (userId u) badKey badValue - !!! const 403 === statusCode