diff --git a/CHANGELOG.md b/CHANGELOG.md index 09aa9af85e..f45f5efe44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. From versio - Log host, port and pg version of listener database connection by @mkleczek in #4617 #4618 - Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957 + Removed unnecessary double count when building the `Content-Range`. +- Add `Prefer: timeout` header for per-request `statement_timeout` by @taimoorzaeem in #4381 ### Fixed diff --git a/docs/references/api/preferences.rst b/docs/references/api/preferences.rst index 96dd4f48fb..ecf232464a 100644 --- a/docs/references/api/preferences.rst +++ b/docs/references/api/preferences.rst @@ -15,6 +15,7 @@ The following preferences are supported. - ``Prefer: missing``. See :ref:`prefer_missing`. - ``Prefer: max-affected``, See :ref:`prefer_max_affected`. - ``Prefer: tx``. See :ref:`prefer_tx`. +- ``Prefer: timeout``. See :ref:`prefer_timeout`. .. _prefer_handling: @@ -296,3 +297,62 @@ With :ref:`RPC `, the preference is honored completely on the basis o .. note:: It is important for functions to return ``SETOF`` or ``TABLE`` when called with ``max-affected`` preference. A violation of this would cause a :ref:`PGRST128 ` error. + +.. _prefer_timeout: + +Timeout +======= + +You can set `statement_timeout `_ for the request using this preference. This works in combination with ``handling=strict`` preference in the same header. + +The header only accepts integer value indicating the ``seconds`` that are set as timeout value. To demonstrate, see the following example: + +.. code-block:: postgres + + CREATE FUNCTION test.sleep(seconds) + RETURNS VOID AS $$ + SELECT pg_sleep(seconds); + $$ LANGUAGE SQL; + +.. code-block:: bash + + curl -i "http://localhost:3000/rpc/sleep?seconds=5" \ + -H "Prefer: handling=strict, timeout=2" + +.. code-block:: http + + HTTP/1.1 500 Internal Server Error + +.. code-block:: json + + { + "code": "57014", + "details": null, + "hint": null, + "message": "canceling statement due to statement timeout" + } + +It is important to note the timeout value cannot exceed the ``statement_timeout`` set :ref:`per-role `. This restriction prevents misuse of this feature. PostgREST returns a :ref:`PGRST129 ` error in this case. + +.. code-block:: postgres + + ALTER ROLE postgrest_test_anonymous SET statement_timeout = '3s'; + +.. code-block:: bash + + curl -i "http://localhost:3000/rpc/sleep?seconds=4" \ + -H "Prefer: handling=strict, timeout=5" + +.. code-block:: http + + HTTP/1.1 400 Bad Request + Content-Type: application/json; charset=utf-8 + +.. code-block:: json + + { + "code": "PGRST129", + "message": "Timeout preference value cannot exceed statement_timeout of role", + "details": "Timeout preferred: 5s, statement_timeout of role 'postgrest_test_anonymous': 3s", + "hint": null + } diff --git a/docs/references/errors.rst b/docs/references/errors.rst index 1802828579..e5c205d57c 100644 --- a/docs/references/errors.rst +++ b/docs/references/errors.rst @@ -271,6 +271,10 @@ Related to the HTTP request elements. | | | See :ref:`prefer_max_affected`. | | PGRST128 | | | +---------------+-------------+-------------------------------------------------------------+ +| .. _pgrst129: | 400 | ``timeout`` preference exceeds ``statement_timeout`` value | +| | | of role. See :ref:`prefer_timeout`. | +| PGRST129 | | | ++---------------+-------------+-------------------------------------------------------------+ .. _pgrst2**: diff --git a/src/PostgREST/ApiRequest/Preferences.hs b/src/PostgREST/ApiRequest/Preferences.hs index 55369efbb1..c15a41d666 100644 --- a/src/PostgREST/ApiRequest/Preferences.hs +++ b/src/PostgREST/ApiRequest/Preferences.hs @@ -6,7 +6,7 @@ -- -- [1] https://datatracker.ietf.org/doc/html/rfc7240 -- -{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE RecordWildCards #-} module PostgREST.ApiRequest.Preferences ( Preferences(..) , PreferCount(..) @@ -17,6 +17,7 @@ module PostgREST.ApiRequest.Preferences , PreferTransaction(..) , PreferTimezone(..) , PreferMaxAffected(..) + , PreferTimeout(..) , fromHeaders , shouldCount , shouldExplainCount @@ -43,6 +44,7 @@ import Protolude -- >>> deriving instance Show PreferHandling -- >>> deriving instance Show PreferTimezone -- >>> deriving instance Show PreferMaxAffected +-- >>> deriving instance Show PreferTimeout -- >>> deriving instance Show Preferences -- | Preferences recognized by the application. @@ -56,6 +58,7 @@ data Preferences , preferHandling :: Maybe PreferHandling , preferTimezone :: Maybe PreferTimezone , preferMaxAffected :: Maybe PreferMaxAffected + , preferTimeout :: Maybe PreferTimeout , invalidPrefs :: [ByteString] } @@ -77,12 +80,13 @@ data Preferences -- ( PreferTimezone "America/Los_Angeles" ) -- , preferMaxAffected = Just -- ( PreferMaxAffected 100 ) +-- , preferTimeout = Nothing -- , invalidPrefs = [] -- } -- -- Multiple headers can also be used: -- --- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999")] +-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid"), ("Prefer", "max-affected=5999"), ("Prefer", "timeout=10")] -- Preferences -- { preferResolution = Just IgnoreDuplicates -- , preferRepresentation = Nothing @@ -93,6 +97,8 @@ data Preferences -- , preferTimezone = Nothing -- , preferMaxAffected = Just -- ( PreferMaxAffected 5999 ) +-- , preferTimeout = Just +-- ( PreferTimeout 10 ) -- , invalidPrefs = [ "invalid" ] -- } -- @@ -124,6 +130,7 @@ data Preferences -- , preferHandling = Just Strict -- , preferTimezone = Nothing -- , preferMaxAffected = Nothing +-- , preferTimeout = Nothing -- , invalidPrefs = [ "anything" ] -- } -- @@ -138,7 +145,8 @@ fromHeaders allowTxDbOverride acceptedTzNames headers = , preferHandling = parsePrefs [Strict, Lenient] , preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing , preferMaxAffected = PreferMaxAffected <$> maxAffectedPref - , invalidPrefs = filter isUnacceptable prefs + , preferTimeout = PreferTimeout <$> timeoutPref + , invalidPrefs = filter (not . isPrefValid) prefs } where mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString] @@ -159,10 +167,13 @@ fromHeaders allowTxDbOverride acceptedTzNames headers = isTimezonePrefAccepted = ((S.member . decodeUtf8 <$> timezonePref) <*> pure acceptedTzNames) == Just True maxAffectedPref = listStripPrefix "max-affected=" prefs >>= readMaybe . BS.unpack + timeoutPref = listStripPrefix "timeout=" prefs >>= readMaybe . BS.unpack - isUnacceptable p = p `notElem` acceptedPrefs && - (isNothing (BS.stripPrefix "timezone=" p) || not isTimezonePrefAccepted) && - isNothing (BS.stripPrefix "max-affected=" p) + isPrefValid p = + p `elem` acceptedPrefs || + (isJust (BS.stripPrefix "timezone=" p) && isTimezonePrefAccepted) || + isJust (BS.stripPrefix "max-affected=" p) || + isJust (BS.stripPrefix "timeout=" p) parsePrefs :: ToHeaderValue a => [a] -> Maybe a parsePrefs vals = @@ -171,8 +182,9 @@ fromHeaders allowTxDbOverride acceptedTzNames headers = prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref)) + prefAppliedHeader :: Preferences -> Maybe HTTP.Header -prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone, preferMaxAffected } = +prefAppliedHeader Preferences{..} = if null prefsVals then Nothing else Just (HTTP.hPreferenceApplied, combined) @@ -187,6 +199,7 @@ prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferCou , toHeaderValue <$> preferHandling , toHeaderValue <$> preferTimezone , if preferHandling == Just Strict then toHeaderValue <$> preferMaxAffected else Nothing + , if preferHandling == Just Strict then toHeaderValue <$> preferTimeout else Nothing ] -- | @@ -289,3 +302,10 @@ newtype PreferMaxAffected = PreferMaxAffected Int64 instance ToHeaderValue PreferMaxAffected where toHeaderValue (PreferMaxAffected n) = "max-affected=" <> show n + +-- | +-- Statement Timeout per request +newtype PreferTimeout = PreferTimeout Int64 + +instance ToHeaderValue PreferTimeout where + toHeaderValue (PreferTimeout n) = "timeout=" <> show n diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 411694fd1e..0256ba75c5 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -147,7 +147,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe prefs = ApiRequest.userPreferences conf req timezones (parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestError $ ApiRequest.userApiRequest conf prefs req body - (planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache + (planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq authResult sCache let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest tx = MainTx.mainTx mainQ conf authResult apiReq plan sCache diff --git a/src/PostgREST/AppState.hs b/src/PostgREST/AppState.hs index ed2eef3fc5..643ca4bf2c 100644 --- a/src/PostgREST/AppState.hs +++ b/src/PostgREST/AppState.hs @@ -451,17 +451,17 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do Right x -> pure x else pure mempty - (roleSettings, roleIsolationLvl) <- + (roleSettings, roleTimeoutSettings, roleIsolationLvl) <- if configDbConfig conf then do rSettings <- usePool appState (queryRoleSettings pgVer (configDbPreparedStatements conf)) case rSettings of Left e -> do observer $ QueryRoleSettingsErrorObs e - pure (mempty, mempty) + pure (mempty, mempty, mempty) Right x -> pure x else pure mempty - readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleIsolationLvl >>= \case + readAppConfig dbSettings (configFilePath conf) (Just $ configDbUri conf) roleSettings roleTimeoutSettings roleIsolationLvl >>= \case Left err -> if startingUp then panic err -- die on invalid config if the program is starting up diff --git a/src/PostgREST/CLI.hs b/src/PostgREST/CLI.hs index a635aa0814..f5b63f2afb 100644 --- a/src/PostgREST/CLI.hs +++ b/src/PostgREST/CLI.hs @@ -30,7 +30,7 @@ import Protolude main :: CLI -> IO () main CLI{cliCommand, cliPath} = do conf <- - either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty + either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty mempty case cliCommand of Client adminCmd -> runClientCommand conf adminCmd Run runCmd -> runAppCommand conf runCmd diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index a5706a74d8..da7a1ec242 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -57,7 +57,8 @@ import System.Environment (getEnvironment) import System.Posix.Types (FileMode) import PostgREST.Config.Database (RoleIsolationLvl, - RoleSettings) + RoleSettings, + RoleTimeoutSettings) import PostgREST.Config.JSPath (FilterExp (..), JSPath, JSPathExp (..), dumpJSPath, pRoleClaimKey) @@ -117,6 +118,10 @@ data AppConfig = AppConfig , configAdminServerHost :: Text , configAdminServerPort :: Maybe Int , configRoleSettings :: RoleSettings + -- Cached statement timeout settings converted to number of seconds. They + -- are never applied, only used to check max allowed timeout for a role + -- when "Prefer: timeout=" header is used. + , configRoleTimeoutSettings :: RoleTimeoutSettings , configRoleIsoLvl :: RoleIsolationLvl , configInternalSCQuerySleep :: Maybe Int32 , configInternalSCLoadSleep :: Maybe Int32 @@ -227,13 +232,13 @@ instance JustIfMaybe a (Maybe a) where -- | Reads and parses the config and overrides its parameters from env vars, -- files or db settings. -readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> RoleSettings -> RoleIsolationLvl -> IO (Either Text AppConfig) -readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do +readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> RoleSettings -> RoleTimeoutSettings -> RoleIsolationLvl -> IO (Either Text AppConfig) +readAppConfig dbSettings optPath prevDbUri roleSettings roleTimeoutSettings roleIsolationLvl = do env <- readPGRSTEnvironment -- if no filename provided, start with an empty map to read config from environment conf <- maybe (return $ Right M.empty) loadConfig optPath - case C.runParser (parser optPath env dbSettings roleSettings roleIsolationLvl) =<< mapLeft show conf of + case C.runParser (parser optPath env dbSettings roleSettings roleTimeoutSettings roleIsolationLvl) =<< mapLeft show conf of Left err -> return . Left $ "Error in config " <> err Right parsedConfig -> @@ -250,8 +255,8 @@ readAppConfig dbSettings optPath prevDbUri roleSettings roleIsolationLvl = do readSecretFile =<< readDbUriFile prevDbUri parsedConfig -parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig -parser optPath env dbSettings roleSettings roleIsolationLvl = +parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> RoleTimeoutSettings -> RoleIsolationLvl -> C.Parser C.Config AppConfig +parser optPath env dbSettings roleSettings roleTimeoutSettings roleIsolationLvl = AppConfig <$> parseAppSettings "app.settings" <*> (fromMaybe False <$> optBool "db-aggregates-enabled") @@ -305,6 +310,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = (optString "server-host")) <*> parseAdminServerPort "admin-server-port" <*> pure roleSettings + <*> pure roleTimeoutSettings <*> pure roleIsolationLvl <*> optInt "internal-schema-cache-query-sleep" <*> optInt "internal-schema-cache-load-sleep" diff --git a/src/PostgREST/Config/Database.hs b/src/PostgREST/Config/Database.hs index aff4b5b8a3..6e9b63956e 100644 --- a/src/PostgREST/Config/Database.hs +++ b/src/PostgREST/Config/Database.hs @@ -6,6 +6,7 @@ module PostgREST.Config.Database , queryPgVersion , queryRoleSettings , RoleSettings + , RoleTimeoutSettings , RoleIsolationLvl , TimezoneNames , toIsolationLevel @@ -29,6 +30,7 @@ import NeatInterpolation (trimming) import Protolude type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString)) +type RoleTimeoutSettings = HM.HashMap ByteString Int64 type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel type TimezoneNames = Set Text -- cache timezone names for prefer timezone= @@ -131,7 +133,7 @@ queryDbSettings preConfFunc prepared = |]::Text decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text -queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleIsolationLvl) +queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleTimeoutSettings, RoleIsolationLvl) queryRoleSettings pgVer prepared = let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared @@ -155,33 +157,46 @@ queryRoleSettings pgVer prepared = SELECT rolname, value FROM kv_settings WHERE key = 'default_transaction_isolation' + ), + role_timeout_setting AS ( + SELECT rolname, extract(epoch from value::interval)::int as value -- transform to seconds + FROM kv_settings + WHERE key = 'statement_timeout' ) select kv.rolname, i.value as iso_lvl, - coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings + coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings, + r.value as role_timeout from kv_settings kv join pg_settings ps on ps.name = kv.key and (ps.context = 'user' ${hasParameterPrivilege}) left join iso_setting i on i.rolname = kv.rolname - group by kv.rolname, i.value; + left join role_timeout_setting r on r.rolname = kv.rolname + group by kv.rolname, i.value, r.value; |] hasParameterPrivilege | pgVer >= pgVersion150 = "or has_parameter_privilege(current_user::regrole::oid, ps.name, 'set')" | otherwise = "" - processRows :: [(Text, Maybe Text, [(Text, Text)])] -> (RoleSettings, RoleIsolationLvl) + processRows :: [(Text, Maybe Text, [(Text, Text)], Maybe Int64)] -> (RoleSettings, RoleTimeoutSettings, RoleIsolationLvl) processRows rs = let - rowsWRoleSettings = [ (x, z) | (x, _, z) <- rs ] - rowsWIsolation = [ (x, y) | (x, Just y, _) <- rs ] + rowsWRoleSettings = [ (x, z) | (x, _, z, _) <- rs ] + rowsWRoleTimeoutSettings = [ (x, z') | (x, _, _, Just z') <- rs ] + rowsWIsolation = [ (x, y) | (x, Just y, _, _) <- rs ] in ( HM.fromList $ bimap encodeUtf8 (HM.fromList . ((encodeUtf8 *** encodeUtf8) <$>)) <$> rowsWRoleSettings + , HM.fromList $ first encodeUtf8 <$> rowsWRoleTimeoutSettings , HM.fromList $ (encodeUtf8 *** toIsolationLevel) <$> rowsWIsolation ) - rows :: HD.Result [(Text, Maybe Text, [(Text, Text)])] - rows = HD.rowList $ (,,) <$> column HD.text <*> nullableColumn HD.text <*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text) + rows :: HD.Result [(Text, Maybe Text, [(Text, Text)], Maybe Int64)] + rows = HD.rowList $ (,,,) + <$> column HD.text + <*> nullableColumn HD.text + <*> compositeArrayColumn ((,) <$> compositeField HD.text <*> compositeField HD.text) + <*> nullableColumn HD.int8 column :: HD.Value a -> HD.Row a column = HD.column . HD.nonNullable diff --git a/src/PostgREST/Error.hs b/src/PostgREST/Error.hs index 7d6a01d087..a0ffd53944 100644 --- a/src/PostgREST/Error.hs +++ b/src/PostgREST/Error.hs @@ -99,6 +99,7 @@ data ApiRequestError | InvalidResourcePath | OpenAPIDisabled | MaxAffectedRpcViolation + | TimeoutConstraintError Int64 ByteString ByteString deriving Show data QPError = QPError Text Text @@ -142,6 +143,7 @@ instance PgrstError ApiRequestError where status InvalidResourcePath = HTTP.status404 status OpenAPIDisabled = HTTP.status404 status MaxAffectedRpcViolation = HTTP.status400 + status TimeoutConstraintError{} = HTTP.status400 headers _ = mempty @@ -189,6 +191,7 @@ instance ErrorBody ApiRequestError where code OpenAPIDisabled = "PGRST126" code NotImplemented{} = "PGRST127" code MaxAffectedRpcViolation = "PGRST128" + code TimeoutConstraintError{} = "PGRST129" -- MESSAGE: Text message (QueryParamError (QPError msg _)) = msg @@ -215,6 +218,7 @@ instance ErrorBody ApiRequestError where message OpenAPIDisabled = "Root endpoint metadata is disabled" message (NotImplemented _) = "Feature not implemented" message MaxAffectedRpcViolation = "Function must return SETOF or TABLE when max-affected preference is used with handling=strict" + message TimeoutConstraintError{} = "Timeout preference value cannot exceed statement_timeout of role" -- DETAILS: Maybe JSON.Value details (QueryParamError (QPError _ dets)) = Just $ JSON.String dets @@ -230,6 +234,7 @@ instance ErrorBody ApiRequestError where details (InvalidPreferences prefs) = Just $ JSON.String $ T.decodeUtf8 ("Invalid preferences: " <> BS.intercalate ", " prefs) details (MaxAffectedViolationError n) = Just $ JSON.String $ T.unwords ["The query affects", show n, "rows"] details (NotImplemented details') = Just $ JSON.String details' + details (TimeoutConstraintError t rt role) = Just $ JSON.String $ "Timeout preferred: " <> show t <> "s, statement_timeout of role '" <> T.decodeUtf8 role <> "': " <> T.decodeUtf8 rt details _ = Nothing diff --git a/src/PostgREST/Plan.hs b/src/PostgREST/Plan.hs index e0d632ee40..0e3535b5c0 100644 --- a/src/PostgREST/Plan.hs +++ b/src/PostgREST/Plan.hs @@ -38,6 +38,7 @@ import Data.Maybe (fromJust) import Data.Tree (Tree (..)) import PostgREST.ApiRequest (ApiRequest (..)) +import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Config (AppConfig (..)) import PostgREST.Error (ApiRequestError (..), Error (..), @@ -144,29 +145,43 @@ data InfoPlan | RoutineInfoPlan Routine -- info about function | SchemaInfoPlan -- info about schema cache -actionPlan :: Action -> AppConfig -> ApiRequest -> SchemaCache -> Either Error ActionPlan -actionPlan act conf apiReq sCache = case act of - ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq sCache +actionPlan :: Action -> AppConfig -> ApiRequest -> AuthResult -> SchemaCache -> Either Error ActionPlan +actionPlan act conf apiReq authResult sCache = case act of + ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq authResult sCache ActRelationInfo ident -> pure . NoDb $ RelInfoPlan ident ActRoutineInfo ident inv -> let crPln = callReadPlan ident conf sCache apiReq inv in NoDb . RoutineInfoPlan . crProc <$> crPln ActSchemaInfo -> pure $ NoDb SchemaInfoPlan -dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> SchemaCache -> Either Error DbActionPlan -dbActionPlan dbAct conf apiReq sCache = case dbAct of - ActRelationRead identifier headersOnly -> - toDbActPlan <$> wrappedReadPlan identifier conf sCache apiReq headersOnly - ActRelationMut identifier mut -> - toDbActPlan <$> mutateReadPlan mut apiReq identifier conf sCache - ActRoutine identifier invMethod -> - toDbActPlan <$> callReadPlan identifier conf sCache apiReq invMethod - ActSchemaRead tSchema headersOnly -> - MayUseDb <$> inspectPlan apiReq headersOnly tSchema - where - toDbActPlan pl = case pMedia pl of - MTVndPlan{} -> DbCrud True pl - _ -> DbCrud False pl +dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> AuthResult -> SchemaCache -> Either Error DbActionPlan +dbActionPlan dbAct conf apiReq authResult sCache = do + failPreferTimeout (preferTimeout $ iPreferences apiReq) conf authResult + case dbAct of + ActRelationRead identifier headersOnly -> + toDbActPlan <$> wrappedReadPlan identifier conf sCache apiReq headersOnly + ActRelationMut identifier mut -> + toDbActPlan <$> mutateReadPlan mut apiReq identifier conf sCache + ActRoutine identifier invMethod -> + toDbActPlan <$> callReadPlan identifier conf sCache apiReq invMethod + ActSchemaRead tSchema headersOnly -> + MayUseDb <$> inspectPlan apiReq headersOnly tSchema + where + toDbActPlan pl = case pMedia pl of + MTVndPlan{} -> DbCrud True pl + _ -> DbCrud False pl + +-- | Fail when Prefer: timeout value is greater than the statement_timeout +-- setting of the role. +failPreferTimeout :: Maybe PreferTimeout -> AppConfig -> AuthResult -> Either Error () +failPreferTimeout Nothing _ _ = Right () +failPreferTimeout (Just (PreferTimeout t)) AppConfig{..} AuthResult{..} = + case roleTimeoutInSecs of + Nothing -> Right () + Just rt -> unless (t <= rt) $ Left $ ApiRequestError $ TimeoutConstraintError t (fromJust roleTimeout) authRole + where + roleTimeout = HM.lookup "statement_timeout" =<< HM.lookup authRole configRoleSettings + roleTimeoutInSecs = HM.lookup authRole configRoleTimeoutSettings wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do diff --git a/src/PostgREST/Query/PreQuery.hs b/src/PostgREST/Query/PreQuery.hs index d3a35bee3f..1240651d32 100644 --- a/src/PostgREST/Query/PreQuery.hs +++ b/src/PostgREST/Query/PreQuery.hs @@ -17,7 +17,9 @@ import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql) import PostgREST.ApiRequest (ApiRequest (..)) -import PostgREST.ApiRequest.Preferences (PreferTimezone (..), +import PostgREST.ApiRequest.Preferences (PreferHandling (..), + PreferTimeout (..), + PreferTimezone (..), Preferences (..)) import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Config (AppConfig (..)) @@ -35,11 +37,11 @@ import Protolude hiding (Handler) -- sets transaction variables txVarQuery :: DbActionPlan -> AppConfig -> AuthResult -> ApiRequest -> SQL.Snippet -txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} = +txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{iPreferences=Preferences{..}, ..} = -- To ensure `GRANT SET ON PARAMETER TO authenticator` works, the role settings must be set before the impersonated role. -- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045 "select " <> intercalateSnippet ", " ( - searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ funcSettingsSql ++ appSettingsSql + searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ timeoutSql ++ funcSettingsSql ++ appSettingsSql ) where methodSql = setConfigWithConstantName ("request.method", iMethod) @@ -50,7 +52,12 @@ txVarQuery dbActPlan AppConfig{..} AuthResult{..} ApiRequest{..} = roleSql = [setConfigWithConstantName ("role", authRole)] roleSettingsSql = setConfigWithDynamicName <$> HM.toList (fromMaybe mempty $ HM.lookup authRole configRoleSettings) appSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> configAppSettings - timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences + timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) preferTimezone + timeoutSql = maybe mempty (\(PreferTimeout t) -> + if preferHandling == Just Strict + then [setConfigWithConstantName ("statement_timeout", show t <> "s")] + else mempty + ) preferTimeout funcSettingsSql = setConfigWithDynamicName . join bimap toUtf8 <$> funcSettings searchPathSql = let schemas = escapeIdentList (iSchema : configDbExtraSearchPath) in diff --git a/src/PostgREST/Response.hs b/src/PostgREST/Response.hs index 8498e7925f..767a6d4e67 100644 --- a/src/PostgREST/Response.hs +++ b/src/PostgREST/Response.hs @@ -299,4 +299,4 @@ responsePreferences plan ApiRequest{iPreferences=Preferences{..}, iQueryParams=Q CallReadPlan{} -> preferMaxAffected _ -> Nothing - in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' [] + in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' preferTimeout [] diff --git a/test/io/fixtures/roles.sql b/test/io/fixtures/roles.sql index 3685f8b7c0..71b77a1360 100644 --- a/test/io/fixtures/roles.sql +++ b/test/io/fixtures/roles.sql @@ -1,18 +1,22 @@ DROP ROLE IF EXISTS postgrest_test_anonymous, postgrest_test_author, postgrest_test_serializable, postgrest_test_repeatable_read, - postgrest_test_w_superuser_settings; + postgrest_test_w_superuser_settings, postgrest_test_timeout_ms, + postgrest_test_timeout_s; CREATE ROLE postgrest_test_anonymous; CREATE ROLE postgrest_test_author; CREATE ROLE postgrest_test_serializable; CREATE ROLE postgrest_test_repeatable_read; CREATE ROLE postgrest_test_w_superuser_settings; +CREATE ROLE postgrest_test_timeout_ms; +CREATE ROLE postgrest_test_timeout_s; GRANT postgrest_test_anonymous, postgrest_test_author, postgrest_test_serializable, postgrest_test_repeatable_read, - postgrest_test_w_superuser_settings TO :PGUSER; + postgrest_test_w_superuser_settings, postgrest_test_timeout_ms, + postgrest_test_timeout_s TO :PGUSER; ALTER ROLE :PGUSER SET pgrst.db_anon_role = 'postgrest_test_anonymous'; ALTER ROLE postgrest_test_serializable SET default_transaction_isolation = 'serializable'; @@ -23,3 +27,6 @@ ALTER ROLE postgrest_test_w_superuser_settings SET log_min_messages = 'fatal'; ALTER ROLE postgrest_test_anonymous SET statement_timeout TO '2s'; ALTER ROLE postgrest_test_author SET statement_timeout TO '10s'; + +ALTER ROLE postgrest_test_timeout_ms SET statement_timeout TO '10ms'; +ALTER ROLE postgrest_test_timeout_s SET statement_timeout TO '10s'; diff --git a/test/io/test_prefer_header.py b/test/io/test_prefer_header.py new file mode 100644 index 0000000000..e24c4ea5a4 --- /dev/null +++ b/test/io/test_prefer_header.py @@ -0,0 +1,103 @@ +"Prefer: timeout header tests for PostgREST (involves IO)" + +from config import SECRET +from util import jwtauthheader +from postgrest import run + + +def test_prefer_timeout_header_with_strict_handling(defaultenv): + "Test Prefer: timeout header with handling=strict" + + with run(env=defaultenv) as postgrest: + # Fails when hits timeout + headers = {"Prefer": "handling=strict, timeout=1"} + response = postgrest.session.get("/rpc/sleep?seconds=2", headers=headers) + assert response.status_code == 500 + assert response.json() == { + "code": "57014", + "message": "canceling statement due to statement timeout", + "details": None, + "hint": None, + } + + # Fails when sleep equals the timeout + headers = {"Prefer": "handling=strict, timeout=2"} + response = postgrest.session.get("/rpc/sleep?seconds=2", headers=headers) + assert response.status_code == 500 + assert response.json() == { + "code": "57014", + "message": "canceling statement due to statement timeout", + "details": None, + "hint": None, + } + + # Succeeds when timeout is not hit + headers = {"Prefer": "handling=strict, timeout=2"} + response = postgrest.session.get("/rpc/sleep?seconds=1", headers=headers) + assert response.status_code == 204 + assert response.headers["Preference-Applied"] == "handling=strict, timeout=2" + + +def test_prefer_timeout_header_with_lenient_handling(defaultenv): + "Test Prefer: timeout header with handling=lenient" + + with run(env=defaultenv) as postgrest: + # Timeout header is ignored for lenient handling + headers = {"Prefer": "handling=lenient, timeout=2"} + response = postgrest.session.get("/rpc/sleep?seconds=1", headers=headers) + + assert response.status_code == 204 + assert response.headers["Preference-Applied"] == "handling=lenient" + + +def test_prefer_timeout_header_with_role_timeout(defaultenv): + "Test Prefer: timeout header with role timeout" + + env = { + **defaultenv, + "PGRST_JWT_SECRET": SECRET, + } + + with run(env=env) as postgrest: + # should fail when timeout is more than role's statement timeout + authheader = jwtauthheader({"role": "postgrest_test_timeout_ms"}, SECRET) + headers = { + **authheader, + "Prefer": "handling=strict, timeout=5", # role timeout is 10ms, so this should fail + } + + response = postgrest.session.get("/rpc/sleep?seconds=2", headers=headers) + assert response.status_code == 400 + assert response.json() == { + "code": "PGRST129", + "message": "Timeout preference value cannot exceed statement_timeout of role", + "details": "Timeout preferred: 5s, statement_timeout of role 'postgrest_test_timeout_ms': 10ms", + "hint": None, + } + + # should fail when timeout is more than role's statement timeout + authheader = jwtauthheader({"role": "postgrest_test_timeout_s"}, SECRET) + headers = { + **authheader, + "Prefer": "handling=strict, timeout=15", # role timeout is 10s, so this should fail + } + + response = postgrest.session.get("/rpc/sleep?seconds=2", headers=headers) + assert response.json() == { + "code": "PGRST129", + "message": "Timeout preference value cannot exceed statement_timeout of role", + "details": "Timeout preferred: 15s, statement_timeout of role 'postgrest_test_timeout_s': 10s", + "hint": None, + } + assert response.status_code == 400 + + # should succeed when timeout is less than role's statement timeout + authheader = jwtauthheader({"role": "postgrest_test_timeout_s"}, SECRET) + headers = { + **authheader, + "Prefer": "handling=strict, timeout=5", # role timeout is 10s, so this should succeed + } + + response = postgrest.session.get("/rpc/sleep?seconds=2", headers=headers) + assert response.status_code == 204 + assert response.headers["Preference-Applied"] == "handling=strict, timeout=5" diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 3c48a1134d..a925dfcc29 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -156,6 +156,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configAdminServerHost = "localhost" , configAdminServerPort = Nothing , configRoleSettings = mempty + , configRoleTimeoutSettings = mempty , configRoleIsoLvl = mempty , configInternalSCQuerySleep = Nothing , configInternalSCLoadSleep = Nothing