-- 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 Web.Scim.Schema.PatchOp where import Control.Applicative import Control.Monad.Except import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.:?), (.=)) import qualified Data.Aeson.Types as Aeson import Data.Attoparsec.ByteString (Parser, endOfInput, parseOnly) import Data.Bifunctor (first) import qualified Data.CaseInsensitive as CI import Data.Text (Text) import Data.Text.Encoding (encodeUtf8) import Web.Scim.AttrName (AttrName (..)) import Web.Scim.Filter (AttrPath (..), SubAttr (..), ValuePath (..), pAttrPath, pSubAttr, pValuePath, rAttrPath, rSubAttr, rValuePath) import Web.Scim.Schema.Common (lowerKey) import Web.Scim.Schema.Error import Web.Scim.Schema.Schema (Schema (PatchOp20)) import Web.Scim.Schema.UserTypes (UserTypes (supportedSchemas)) newtype PatchOp tag = PatchOp {getOperations :: [Operation]} deriving (Eq, Show) -- | The 'Path' attribute value is a 'String' containing an attribute path -- describing the target of the operation. It is OPTIONAL -- for 'Op's "add" and "replace", and is REQUIRED for "remove". See -- relevant operation sections below for details. -- -- TODO(arianvp): When value is an array, it needs special handling. -- e.g. primary fields need to be negated and whatnot. -- We currently do not do that :) -- -- NOTE: When the path contains a schema, this schema must be implicitly added -- to the list of schemas on the result type data Operation = Operation { op :: Op, path :: Maybe Path, value :: Maybe Value } deriving (Eq, Show) data Op = Add | Replace | Remove deriving (Eq, Show, Enum, Bounded) -- | PATH = attrPath / valuePath [subAttr] data Path = NormalPath AttrPath | IntoValuePath ValuePath (Maybe SubAttr) deriving (Eq, Show) parsePath :: [Schema] -> Text -> Either String Path parsePath schemas' = parseOnly (pPath schemas' <* endOfInput) . encodeUtf8 -- | PATH = attrPath / valuePath [subAttr] pPath :: [Schema] -> Parser Path pPath schemas' = IntoValuePath <$> pValuePath schemas' <*> optional pSubAttr <|> NormalPath <$> pAttrPath schemas' rPath :: Path -> Text rPath (NormalPath attrPath) = rAttrPath attrPath rPath (IntoValuePath valuePath subAttr) = rValuePath valuePath <> maybe "" rSubAttr subAttr -- TODO(arianvp): According to the SCIM spec we should throw an InvalidPath -- error when the path is invalid syntax. this is a bit hard to do though as we -- can't control what errors FromJSON throws :/ instance UserTypes tag => FromJSON (PatchOp tag) where parseJSON = withObject "PatchOp" $ \v -> do let o = KeyMap.fromList . map (first lowerKey) . KeyMap.toList $ v schemas' :: [Schema] <- o .: "schemas" guard $ PatchOp20 `elem` schemas' operations <- Aeson.explicitParseField (Aeson.listParser $ operationFromJSON (supportedSchemas @tag)) o "operations" pure $ PatchOp operations instance ToJSON (PatchOp tag) where toJSON (PatchOp operations) = object ["operations" .= operations, "schemas" .= [PatchOp20]] -- TODO: Azure wants us to be case-insensitive on _values_ as well here. We currently do not -- comply with that. operationFromJSON :: [Schema] -> Value -> Aeson.Parser Operation operationFromJSON schemas' = withObject "Operation" $ \v -> do let o = KeyMap.fromList . map (first lowerKey) . KeyMap.toList $ v Operation <$> (o .: "op") <*> Aeson.explicitParseFieldMaybe (pathFromJSON schemas') o "path" <*> (o .:? "value") pathFromJSON :: [Schema] -> Value -> Aeson.Parser Path pathFromJSON schemas' = withText "Path" $ either fail pure . parsePath schemas' instance ToJSON Operation where toJSON (Operation op' path' value') = object $ ("op" .= op') : optionalField "path" path' ++ optionalField "value" value' where optionalField fname = \case Nothing -> [] Just x -> [fname .= x] instance FromJSON Op where parseJSON = withText "Op" $ \op' -> case CI.foldCase op' of "add" -> pure Add "replace" -> pure Replace "remove" -> pure Remove _ -> fail "unknown operation" instance ToJSON Op where toJSON Add = String "add" toJSON Replace = String "replace" toJSON Remove = String "remove" instance ToJSON Path where toJSON = String . rPath -- | A very coarse description of what it means to be 'Patchable' -- I do not like it. We should handhold people using this library more class Patchable a where applyOperation :: (MonadError ScimError m) => a -> Operation -> m a instance Patchable (KeyMap.KeyMap Text) where applyOperation theMap (Operation Remove (Just (NormalPath (AttrPath _schema (AttrName attrName) _subAttr))) _) = pure $ KeyMap.delete (Key.fromText attrName) theMap applyOperation theMap (Operation _AddOrReplace (Just (NormalPath (AttrPath _schema (AttrName attrName) _subAttr))) (Just (String val))) = pure $ KeyMap.insert (Key.fromText attrName) val theMap applyOperation _ _ = throwError $ badRequest InvalidValue $ Just "Unsupported operation"