graphql-engine/server/src-lib/Hasura/Eventing/HTTP.hs
Swann Moreau 04a87970f4 server: sanitise event trigger logs (fix #1233)
- sanitise the response field in event trigger and scheduled trigger logs, removing the body and the headers
- flatten the log structure to include the event id at `$.detail.event_id` instead of `$.detail.context.event_id`

the log format changes as follows:
```diff
diff --git a/log b/log
index 96127e0..1fb909a 100644
--- a/log
+++ b/log
@@ -1,48 +1,15 @@
 {
   "detail": {
-    "context": {
-      "event_id": "b9d4e627-6029-43f2-9d46-31c532b07070"
-    },
+    "event_id": "adcc8dcd-2f32-4554-bd55-90c787aee137",
     "request": {
       "size": 416
     },
     "response": {
-      "body": "{\n  \"args\": {}, \n  \"data\": \"{\\\"event\\\":{\\\"session_variables\\\":{\\\"x-hasura-role\\\":\\\"admin\\\"},\\\"op\\\":\\\"INSERT\\\",\\\"data\\\":{\\\"old\\\":null,\\\"new\\\":{\\\"name\\\":\\\"someotheranimal\\\",\\\"id\\\":3}},\\\"trace_context\\\":{\\\"trace_id\\\":\\\"e8237c03de151634\\\",\\\"span_id\\\":\\\"8c5f8952e9e06da8\\\"}},\\\"created_at\\\":\\\"2021-05-06T07:52:58.796611Z\\\",\\\"id>
-      "headers": [
-        {
-          "name": "Date",
-          "value": "Thu, 06 May 2021 07:53:00 GMT"
-        },
-        {
-          "name": "Content-Type",
-          "value": "application/json"
-        },
-        {
-          "name": "Content-Length",
-          "value": "1692"
-        },
-        {
-          "name": "Connection",
-          "value": "keep-alive"
-        },
-        {
-          "name": "Server",
-          "value": "gunicorn/19.9.0"
-        },
-        {
-          "name": "Access-Control-Allow-Origin",
-          "value": "*"
-        },
-        {
-          "name": "Access-Control-Allow-Credentials",
-          "value": "true"
-        }
-      ],
-      "size": 1692,
+      "size": 1719,
       "status": 200
     }
   },
   "level": "info",
-  "timestamp": "2021-05-06T13:23:00.376+0530",
+  "timestamp": "2021-05-06T13:25:14.481+0530",
   "type": "event-trigger"
 }
```

GitOrigin-RevId: d9622de366737da04dc6d9ff73238be16ec9305e
2021-05-12 12:10:53 +00:00

411 lines
12 KiB
Haskell

{-|
= Hasura.Eventing.HTTP
This module is an utility module providing HTTP utilities for
"Hasura.Eventing.EventTriggers" and "Hasura.Eventing.ScheduledTriggers".
The event triggers and scheduled triggers share the event delivery
mechanism using the 'tryWebhook' function defined in this module.
-}
module Hasura.Eventing.HTTP
( HTTPErr(..)
, HTTPResp(..)
, tryWebhook
, runHTTP
, isNetworkError
, isNetworkErrorHC
, logHTTPForET
, logHTTPForST
, ExtraLogContext(..)
, RequestDetails (..)
, EventId
, Invocation(..)
, InvocationVersion
, Response(..)
, ResponseLogBehavior(..)
, WebhookRequest(..)
, WebhookResponse(..)
, ClientError(..)
, isClientError
, mkClientErr
, mkWebhookReq
, mkResp
, LogEnvHeaders
, encodeHeader
, decodeHeader
, getRetryAfterHeaderFromHTTPErr
, getRetryAfterHeaderFromResp
, parseRetryHeaderValue
, TriggerTypes(..)
, invocationVersionET
, invocationVersionST
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Lazy as HML
import qualified Data.TByteString as TBS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TE
import qualified Data.Time.Clock as Time
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import Control.Exception (try)
import Data.Aeson
import Data.Aeson.TH
import Data.Either
import Data.Has
import Data.Int (Int64)
import Hasura.Logging
import Hasura.Prelude
import Hasura.RQL.DDL.Headers
import Hasura.RQL.Types.EventTrigger
import Hasura.Tracing
type LogEnvHeaders = Bool
retryAfterHeader :: CI.CI Text
retryAfterHeader = "Retry-After"
data WebhookRequest
= WebhookRequest
{ _rqPayload :: Value
, _rqHeaders :: [HeaderConf]
, _rqVersion :: Text
}
$(deriveToJSON hasuraJSON{omitNothingFields=True} ''WebhookRequest)
data WebhookResponse
= WebhookResponse
{ _wrsBody :: TBS.TByteString
, _wrsHeaders :: [HeaderConf]
, _wrsStatus :: Int
}
$(deriveToJSON hasuraJSON{omitNothingFields=True} ''WebhookResponse)
newtype ClientError = ClientError { _ceMessage :: TBS.TByteString}
$(deriveToJSON hasuraJSON{omitNothingFields=True} ''ClientError)
type InvocationVersion = Text
invocationVersionET :: InvocationVersion
invocationVersionET = "2"
invocationVersionST :: InvocationVersion
invocationVersionST = "1"
-- | There are two types of events: EventType (for event triggers) and ScheduledType (for scheduled triggers)
data TriggerTypes = EventType | ScheduledType
data Response (a :: TriggerTypes) =
ResponseHTTP WebhookResponse | ResponseError ClientError
instance ToJSON (Response 'EventType) where
toJSON (ResponseHTTP resp) = object
[ "type" .= String "webhook_response"
, "data" .= toJSON resp
, "version" .= invocationVersionET
]
toJSON (ResponseError err) = object
[ "type" .= String "client_error"
, "data" .= toJSON err
, "version" .= invocationVersionET
]
instance ToJSON (Response 'ScheduledType) where
toJSON (ResponseHTTP resp) = object
[ "type" .= String "webhook_response"
, "data" .= toJSON resp
, "version" .= invocationVersionST
]
toJSON (ResponseError err) = object
[ "type" .= String "client_error"
, "data" .= toJSON err
, "version" .= invocationVersionST
]
data Invocation (a :: TriggerTypes)
= Invocation
{ iEventId :: EventId
, iStatus :: Int
, iRequest :: WebhookRequest
, iResponse :: Response a
}
data ExtraLogContext
= ExtraLogContext
{ elEventCreatedAt :: Maybe Time.UTCTime
, elEventId :: EventId
} deriving (Show, Eq)
data HTTPResp (a :: TriggerTypes)
= HTTPResp
{ hrsStatus :: !Int
, hrsHeaders :: ![HeaderConf]
, hrsBody :: !TBS.TByteString
, hrsSize :: !Int64
} deriving (Show, Eq)
$(deriveToJSON hasuraJSON{omitNothingFields=True} ''HTTPResp)
instance ToEngineLog (HTTPResp 'EventType) Hasura where
toEngineLog resp = (LevelInfo, eventTriggerLogType, toJSON resp)
instance ToEngineLog (HTTPResp 'ScheduledType) Hasura where
toEngineLog resp = (LevelInfo, scheduledTriggerLogType, toJSON resp)
data HTTPErr (a :: TriggerTypes)
= HClient !HTTP.HttpException
| HParse !HTTP.Status !String
| HStatus !(HTTPResp a)
| HOther !String
deriving (Show)
instance ToJSON (HTTPErr a) where
toJSON err = toObj $ case err of
(HClient e) -> ("client", toJSON $ show e)
(HParse st e) ->
( "parse"
, toJSON (HTTP.statusCode st, show e)
)
(HStatus resp) ->
("status", toJSON resp)
(HOther e) -> ("internal", toJSON $ show e)
where
toObj :: (Text, Value) -> Value
toObj (k, v) = object [ "type" .= k
, "detail" .= v]
instance ToEngineLog (HTTPErr 'EventType) Hasura where
toEngineLog err = (LevelError, eventTriggerLogType, toJSON err)
instance ToEngineLog (HTTPErr 'ScheduledType) Hasura where
toEngineLog err = (LevelError, scheduledTriggerLogType, toJSON err)
mkHTTPResp :: HTTP.Response LBS.ByteString -> HTTPResp a
mkHTTPResp resp =
HTTPResp
{ hrsStatus = HTTP.statusCode $ HTTP.responseStatus resp
, hrsHeaders = map decodeHeader $ HTTP.responseHeaders resp
, hrsBody = TBS.fromLBS respBody
, hrsSize = LBS.length respBody
}
where
respBody = HTTP.responseBody resp
decodeBS = TE.decodeUtf8With TE.lenientDecode
decodeHeader (hdrName, hdrVal)
= HeaderConf (decodeBS $ CI.original hdrName) (HVValue (decodeBS hdrVal))
newtype RequestDetails
= RequestDetails { _rdSize :: Int64 }
$(deriveToJSON hasuraJSON ''RequestDetails)
-- TODO(swann): move elsewhere? it could be useful more generally
data ResponseLogBehavior = LogSanitisedResponse | LogEntireResponse
deriving (Show, Eq)
data HTTPRespExtra (a :: TriggerTypes)
= HTTPRespExtra
{ _hreResponse :: !(Either (HTTPErr a) (HTTPResp a))
, _hreContext :: !ExtraLogContext
, _hreRequest :: !RequestDetails
, _hreLogResponse :: !ResponseLogBehavior
-- ^ Whether to log the entire response, including the body and the headers,
-- which may contain sensitive information.
}
instance ToJSON (HTTPRespExtra a) where
toJSON (HTTPRespExtra resp ctxt req logResp) =
case resp of
Left errResp -> object
[ "response" .= toJSON errResp
, "request" .= toJSON req
, "event_id" .= elEventId ctxt
]
Right okResp -> object
[ "response" .= case logResp of
LogEntireResponse -> toJSON okResp
LogSanitisedResponse -> sanitisedRespJSON okResp
, "request" .= toJSON req
, "event_id" .= elEventId ctxt
]
where
sanitisedRespJSON v
= Object $ HML.fromList
[ "size" .= hrsSize v
, "status" .= hrsStatus v
]
instance ToEngineLog (HTTPRespExtra 'EventType) Hasura where
toEngineLog resp = (LevelInfo, eventTriggerLogType, toJSON resp)
instance ToEngineLog (HTTPRespExtra 'ScheduledType) Hasura where
toEngineLog resp = (LevelInfo, scheduledTriggerLogType, toJSON resp)
isNetworkError :: HTTPErr a -> Bool
isNetworkError = \case
HClient he -> isNetworkErrorHC he
_ -> False
isNetworkErrorHC :: HTTP.HttpException -> Bool
isNetworkErrorHC = \case
HTTP.HttpExceptionRequest _ (HTTP.ConnectionFailure _) -> True
HTTP.HttpExceptionRequest _ HTTP.ConnectionTimeout -> True
HTTP.HttpExceptionRequest _ HTTP.ResponseTimeout -> True
_ -> False
anyBodyParser :: HTTP.Response LBS.ByteString -> Either (HTTPErr a) (HTTPResp a)
anyBodyParser resp = do
let httpResp = mkHTTPResp resp
if respCode >= HTTP.status200 && respCode < HTTP.status300
then return httpResp
else throwError $ HStatus httpResp
where
respCode = HTTP.responseStatus resp
data HTTPReq
= HTTPReq
{ _hrqMethod :: !String
, _hrqUrl :: !String
, _hrqPayload :: !(Maybe Value)
, _hrqTry :: !Int
, _hrqDelay :: !(Maybe Int)
} deriving (Show, Eq)
$(deriveJSON hasuraJSON{omitNothingFields=True} ''HTTPReq)
instance ToEngineLog HTTPReq Hasura where
toEngineLog req = (LevelInfo, eventTriggerLogType, toJSON req)
logHTTPForET
:: ( MonadReader r m
, Has (Logger Hasura) r
, MonadIO m
)
=> Either (HTTPErr 'EventType) (HTTPResp 'EventType)
-> ExtraLogContext
-> RequestDetails
-> ResponseLogBehavior
-> m ()
logHTTPForET eitherResp extraLogCtx reqDetails logResp = do
logger :: Logger Hasura <- asks getter
unLogger logger $ HTTPRespExtra eitherResp extraLogCtx reqDetails logResp
logHTTPForST
:: ( MonadReader r m
, Has (Logger Hasura) r
, MonadIO m
)
=> Either (HTTPErr 'ScheduledType) (HTTPResp 'ScheduledType)
-> ExtraLogContext
-> RequestDetails
-> ResponseLogBehavior
-> m ()
logHTTPForST eitherResp extraLogCtx reqDetails logResp = do
logger :: Logger Hasura <- asks getter
unLogger logger $ HTTPRespExtra eitherResp extraLogCtx reqDetails logResp
runHTTP :: (MonadIO m) => HTTP.Manager -> HTTP.Request -> m (Either (HTTPErr a) (HTTPResp a))
runHTTP manager req = do
res <- liftIO $ try $ HTTP.httpLbs req manager
return $ either (Left . HClient) anyBodyParser res
tryWebhook ::
( MonadReader r m
, Has HTTP.Manager r
, MonadIO m
, MonadError (HTTPErr a) m
, MonadTrace m
)
=> [HTTP.Header]
-> HTTP.ResponseTimeout
-> LBS.ByteString
-- ^ the request body. It is passed as a 'BL.Bytestring' because we need to
-- log the request size. As the logging happens outside the function, we pass
-- it the final request body, instead of 'Value'
-> String
-> m (HTTPResp a)
tryWebhook headers timeout payload webhook = do
initReqE <- liftIO $ try $ HTTP.parseRequest webhook
manager <- asks getter
case initReqE of
Left excp -> throwError $ HClient excp
Right initReq -> do
let req =
initReq
{ HTTP.method = "POST"
, HTTP.requestHeaders = headers
, HTTP.requestBody = HTTP.RequestBodyLBS payload
, HTTP.responseTimeout = timeout
}
tracedHttpRequest req $ \req' -> do
eitherResp <- runHTTP manager req'
onLeft eitherResp throwError
mkResp :: Int -> TBS.TByteString -> [HeaderConf] -> Response a
mkResp status payload headers =
let wr = WebhookResponse payload headers status
in ResponseHTTP wr
mkClientErr :: TBS.TByteString -> Response a
mkClientErr message =
let cerr = ClientError message
in ResponseError cerr
mkWebhookReq :: Value -> [HeaderConf] -> InvocationVersion -> WebhookRequest
mkWebhookReq payload headers = WebhookRequest payload headers
isClientError :: Int -> Bool
isClientError status = status >= 1000
encodeHeader :: EventHeaderInfo -> HTTP.Header
encodeHeader (EventHeaderInfo hconf cache) =
let (HeaderConf name _) = hconf
ciname = CI.mk $ TE.encodeUtf8 name
value = TE.encodeUtf8 cache
in (ciname, value)
decodeHeader
:: LogEnvHeaders -> [EventHeaderInfo] -> (HTTP.HeaderName, BS.ByteString)
-> HeaderConf
decodeHeader logenv headerInfos (hdrName, hdrVal)
= let name = decodeBS $ CI.original hdrName
getName ehi = let (HeaderConf name' _) = ehiHeaderConf ehi
in name'
mehi = find (\hi -> getName hi == name) headerInfos
in case mehi of
Nothing -> HeaderConf name (HVValue (decodeBS hdrVal))
Just ehi -> if logenv
then HeaderConf name (HVValue (ehiCachedValue ehi))
else ehiHeaderConf ehi
where
decodeBS = TE.decodeUtf8With TE.lenientDecode
getRetryAfterHeaderFromHTTPErr :: HTTPErr a -> Maybe Text
getRetryAfterHeaderFromHTTPErr (HStatus resp) = getRetryAfterHeaderFromResp resp
getRetryAfterHeaderFromHTTPErr _ = Nothing
getRetryAfterHeaderFromResp :: HTTPResp a -> Maybe Text
getRetryAfterHeaderFromResp resp =
let mHeader =
find
(\(HeaderConf name _) -> CI.mk name == retryAfterHeader)
(hrsHeaders resp)
in case mHeader of
Just (HeaderConf _ (HVValue value)) -> Just value
_ -> Nothing
parseRetryHeaderValue :: Text -> Maybe Int
parseRetryHeaderValue hValue =
let seconds = readMaybe $ T.unpack hValue
in case seconds of
Nothing -> Nothing
Just sec ->
if sec > 0
then Just sec
else Nothing