Compare commits

..
14 Commits
Author SHA1 Message Date
Taimoor Zaeem 02d83d1c01 test(io): move remaining tests in test_io.py to their modules
We had just 3 tests remaining in test_io.py. This commit moves them to
their modules. So we have:

* test_graceful_shutdown.py

* test_zero_downtime.py

* test_pg_internal.py

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-13 03:42:29 +05:00
Taimoor Zaeem e89e0bc255 test(io): move config related behavior tests to test_config.py
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-13 03:42:29 +05:00
Taimoor Zaeem 9e20e5df90 test(io): move logs and observations tests to test_log.py
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-13 03:42:29 +05:00
Taimoor Zaeem 2122dcef97 test(io): move connection related tests to test_connection.py
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-13 03:42:29 +05:00
Taimoor Zaeem d84d00be8e test(io): move reloading related tests to test_reloading.py
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-13 03:42:29 +05:00
Taimoor ZaeemandSteve Chavez 3a034fcc2a chore: add changelog entry for v16.1
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-12 10:14:34 -05:00
Michał KłeczekandSteve Chavez a2a592a617 fix: JWT validation uses wrong current time due to a bug in auto-update
Upgrade auto-update to 0.2.7 which contains a fix to a bug causing some threads not seeing updates to the cached values.
2026-08-10 17:20:56 -05:00
Taimoor ZaeemandWolfgang Walther 89fe63fce0 nix(release): automate updating source file links in the docs
Closes #5133.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-10 19:14:04 +00:00
steve-chavez f21949a517 chore(changelog): missed entry for ghcr 2026-08-07 19:15:50 -05:00
steve-chavez 6ead34e70b chore(changelog): add integrations section
Also clarify some parts.
2026-08-07 16:17:56 -05:00
steve-chavezandWolfgang Walther e3206262d2 chore(changelog): make it in postgres style
This is to make it easier for users to understand the changes,
much like PostgreSQL does for its changelog: https://www.postgresql.org/docs/19/release-19.html#RELEASE-19-SERVER
(see how they classify "Optimizer", "General Performance", etc).

We classify features, fixes and deprecation in subsections.
Breaking changes are put inside a "Migration to v16" guide.
2026-08-07 19:59:15 +00:00
Taimoor Zaeem a3f36d6184 test: fix failing test of legacy target names warning
Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2026-08-07 22:47:09 +05:00
renovate[bot]andWolfgang Walther 6cc1698c05 chore(deps): update vmactions/freebsd-vm action to v1.5.3 2026-08-07 07:43:06 +00:00
Wolfgang Walther 77d6055683 chore: bump version to 17 2026-08-07 09:33:46 +02:00
29 changed files with 128 additions and 839 deletions
-4
View File
@@ -4,10 +4,6 @@ All notable changes to this project will be documented in this file. From versio
## Unreleased
### Fixed
- The OpenAPI output now reflects table privileges: only the granted HTTP methods are exposed (e.g. `SELECT` grants `GET`, `INSERT` grants `POST`) and column-level grants filter the columns shown on table definitions and row filters.
## [16.1] - 2026-08-10
### Fixed
+1 -1
View File
@@ -49,7 +49,7 @@ source_suffix = ".rst"
master_doc = "index"
# This is overridden by readthedocs with the version tag anyway
version = "16"
version = "devel"
# To avoid repetition in <title> we set this to an empty string.
release = ""
+11 -11
View File
@@ -31,60 +31,60 @@ This section talks briefly about various important modules.
Main
----
The starting point of the program is `Main.hs <https://github.com/PostgREST/postgrest/blob/v16/src/executable/Main.hs>`_.
The starting point of the program is `Main.hs <https://github.com/PostgREST/postgrest/blob/main/src/executable/Main.hs>`_.
CLI
---
Main then calls `CLI.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/CLI.hs>`_, which is in charge of :ref:`cli`.
Main then calls `CLI.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/CLI.hs>`_, which is in charge of :ref:`cli`.
App
---
`App.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/App.hs>`_ is then in charge of composing the different modules.
`App.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/App.hs>`_ is then in charge of composing the different modules.
Auth
----
`Auth.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/Auth.hs>`_ is in charge of :ref:`authn`.
`Auth.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/Auth.hs>`_ is in charge of :ref:`authn`.
Api Request
-----------
`ApiRequest.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/ApiRequest.hs>`_ is in charge of parsing the URL query string (following PostgREST syntax), the request headers, and the request body.
`ApiRequest.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/ApiRequest.hs>`_ is in charge of parsing the URL query string (following PostgREST syntax), the request headers, and the request body.
A request might be rejected at this level if it's invalid. For example when providing an unknown media type to PostgREST or using an unknown HTTP method.
Plan
----
Using the Schema Cache, `Plan.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/Plan.hs>`_ generates an internal AST, filling out-of-band SQL details (like an ``ON CONFLICT (pk)`` clause) required to complete the user request.
Using the Schema Cache, `Plan.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/Plan.hs>`_ generates an internal AST, filling out-of-band SQL details (like an ``ON CONFLICT (pk)`` clause) required to complete the user request.
A request might be rejected at this level if it's invalid. For example when doing resource embedding on a nonexistent resource.
Query
-----
`Query.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/Query.hs>`_ generates the SQL queries (parametrized and prepared) required to satisfy the user request.
`Query.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/Query.hs>`_ generates the SQL queries (parametrized and prepared) required to satisfy the user request.
Only at this stage a connection from the pool might be used.
Schema Cache
------------
`SchemaCache.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/SchemaCache.hs>`_ is in charge of :ref:`schema_cache`.
`SchemaCache.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/SchemaCache.hs>`_ is in charge of :ref:`schema_cache`.
Config
------
`Config.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/Config.hs>`_ is in charge of :ref:`configuration`.
`Config.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/Config.hs>`_ is in charge of :ref:`configuration`.
Admin
-----
`Admin.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/Admin.hs>`_ is in charge of the :ref:`admin_server`.
`Admin.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/Admin.hs>`_ is in charge of the :ref:`admin_server`.
Listener
--------
`Reload.hs <https://github.com/PostgREST/postgrest/blob/v16/src/library/PostgREST/AppState/Reload.hs>`_ is in charge of the :ref:`listener`.
`Reload.hs <https://github.com/PostgREST/postgrest/blob/main/src/library/PostgREST/AppState/Reload.hs>`_ is in charge of the :ref:`listener`.
-2
View File
@@ -9,8 +9,6 @@ PostgREST automatically serves a full `OpenAPI <https://www.openapis.org/>`_ des
By default, this output depends on the permissions of the role that is contained in the JWT role claim (or the :ref:`db-anon-role` if no JWT is sent). If you need to show all the endpoints disregarding the role's permissions, set the :ref:`openapi-mode` config to :code:`ignore-privileges`.
When following privileges, the output reflects both the granted HTTP methods and columns: a relation with only ``SELECT`` will only expose ``GET``, a relation with only ``INSERT`` will only expose ``POST``, and column-level grants limit the columns shown on the table definitions and row filters.
For extra customization, the OpenAPI output contains a "description" field for every `SQL comment <https://www.postgresql.org/docs/current/sql-comment.html>`_ on any database object. For instance,
.. code-block:: postgres
+4 -1
View File
@@ -45,7 +45,10 @@ let
echo "Updating docs/conf.py ..."
sed -i -E "s/^(version = ).*$/\1\"$new_docs_version\"/" docs/conf.py > /dev/null
git add postgrest.cabal docs/conf.py > /dev/null
echo "Updating Haskell source file links ..."
sed -i -E "s#(github\.com/PostgREST/postgrest/blob)/main/#\1/$new_version/#g" docs/explanations/architecture.rst
git add postgrest.cabal docs/conf.py docs/explanations/architecture.rst > /dev/null
}
today_date_for_changelog="$(date '+%Y-%m-%d')"
+1 -2
View File
@@ -1,6 +1,6 @@
cabal-version: 3.0
name: postgrest
version: 16.1
version: 17
synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes
for tables, views, and functions, supporting all HTTP methods that security
@@ -78,7 +78,6 @@ library
PostgREST.Network
PostgREST.Observation
PostgREST.Query
PostgREST.Query.OpenApi
PostgREST.Query.PreQuery
PostgREST.Query.QueryBuilder
PostgREST.Query.SqlFragment
+1 -34
View File
@@ -31,10 +31,6 @@ import Network.Wai.Handler.Warp (defaultSettings, setBeforeMainLoop, setHost,
setOnException, setPort, setServerName)
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Transaction as SQL
import qualified Hasql.Transaction.Sessions as SQL
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import qualified Network.Wai.Header as WaiHeader
@@ -52,7 +48,6 @@ import qualified PostgREST.Response as Response
import qualified PostgREST.Unix as Unix (installSignalHandlers)
import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Types (Action (..), DbAction (..))
import PostgREST.AppState (AppState)
import PostgREST.AppState.Reload (runListener)
import PostgREST.Auth.Types (AuthResult (..))
@@ -60,8 +55,6 @@ import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error)
import PostgREST.Network (resolveSocketToAddress)
import PostgREST.Observation (Observation (..))
import PostgREST.Query.OpenApi (TablesAccess, tablesAccessStatement)
import PostgREST.Query.SqlFragment (setConfigWithConstantName)
import PostgREST.Response.Performance (ServerTiming (..), serverTimingHeader)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.TimeIt (timeItT)
@@ -214,8 +207,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
body <- liftIO $ Wai.strictRequestBody req
(parseTime, apiReq@ApiRequest{..}) <- withTiming conf $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body
tableAccess <- liftIO $ getTablesAccess appState apiReq authResult
(planTime, plan) <- withTiming conf $ liftEither $ Plan.actionPlan iAction conf apiReq tableAccess sCache
(planTime, plan) <- withTiming conf $ liftEither $ Plan.actionPlan iAction conf apiReq sCache
let warnings = Plan.legacyWarnings plan
legacyWarnMsg = "Embedded resource was referenced by relation name even though it has an alias. This is deprecated and will stop working in a future release."
@@ -277,31 +269,6 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
in
[(hWarning, "299 " <> pgrstVer <> " \"" <> encodeUtf8 warnMsg <> "\"")]
-- | Fetch the privileges the request role has on the tables of the requested
-- schema, so that the planner can restrict the default "select *" to the
-- columns the role can actually read. Returns an empty map when the request
-- doesn't need it or when the query fails (degrading to the previous behavior).
getTablesAccess :: AppState -> ApiRequest -> AuthResult -> IO TablesAccess
getTablesAccess appState ApiRequest{iAction, iSchema} AuthResult{authRole} =
case iAction of
ActDb ActRelationRead{} -> query
ActDb ActRelationMut{} -> query
ActDb ActRoutine{} -> query
_ -> pure mempty
where
query = do
result <- AppState.usePool appState $
SQL.transactionNoRetry SQL.ReadCommitted SQL.Read $ do
SQL.statement mempty (roleStatement authRole)
SQL.statement mempty (tablesAccessStatement iSchema)
pure $ fromRight mempty result
roleStatement role =
SQL.dynamicallyParameterized
("select " <> setConfigWithConstantName ("role", role))
HD.noResult
False
withTiming :: (MonadError e m, MonadIO m) => AppConfig -> m a -> m (Maybe Double, a)
withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled
then do
+15 -6
View File
@@ -19,6 +19,7 @@ import qualified Data.Aeson.Lens as L
import qualified Data.ByteString as BS hiding (break)
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S
import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Session as SQL (Session)
@@ -43,7 +44,6 @@ import PostgREST.Plan (ActionPlan (..), CrudPlan (..),
DbActionPlan (..), InfoPlan (..),
InspectPlan (..))
import PostgREST.Query (MainQuery (..))
import PostgREST.Query.OpenApi (TablesAccess, decodeTablesAccess)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (Routine (..), RoutineMap)
@@ -60,7 +60,7 @@ data MainTx
data DbResult
= DbCrudResult CrudPlan ResultSet
| DbPlanResult MediaType BS.ByteString
| MaybeDbResult InspectPlan (Maybe (TablesMap, TablesAccess, RoutineMap, Maybe Text))
| MaybeDbResult InspectPlan (Maybe (TablesMap, RoutineMap, Maybe Text))
| NoDbResult InfoPlan
-- | Standard result set format used for the mqMain query
@@ -174,25 +174,34 @@ actionResult MainQuery{mqOpenAPI=(tblsQ, funcsQ, schQ)} (MayUseDb plan@InspectPl
mainActionQuery = lift $
case configOpenApiMode of
OAFollowPriv -> do
tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeTablesAccess configDbPreparedStatements
tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeAccessibleIdentifiers configDbPreparedStatements
accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ SchemaCache.decodeFuncs configDbPreparedStatements
schDesc <- SQL.statement mempty $ SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements
let tbls = HM.filterWithKey (\qi _ -> HM.member qi tableAccess) $ SchemaCache.dbTables sCache
let tbls = HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache
pure $ MaybeDbResult plan (Just (tbls, tableAccess, accFuncs, schDesc))
pure $ MaybeDbResult plan (Just (tbls, accFuncs, schDesc))
OAIgnorePriv -> do
schDesc <- SQL.statement mempty (SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements)
let tbls = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbTables sCache)
routs = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbRoutines sCache)
pure $ MaybeDbResult plan (Just (tbls, mempty, routs, schDesc))
pure $ MaybeDbResult plan (Just (tbls, routs, schDesc))
OADisabled ->
pure $ MaybeDbResult plan Nothing
decodeSchemaDesc :: HD.Result (Maybe Text)
decodeSchemaDesc = join <$> HD.rowMaybe (nullableColumn HD.text)
decodeAccessibleIdentifiers :: HD.Result (S.Set QualifiedIdentifier)
decodeAccessibleIdentifiers =
let
row = QualifiedIdentifier
<$> column HD.text
<*> column HD.text
in
S.fromList <$> HD.rowList row
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
+31 -69
View File
@@ -45,7 +45,6 @@ import PostgREST.Error (ApiRequestError (..), Error (..),
SchemaCacheError (..))
import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan.Negotiate (negotiateContent)
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange,
@@ -168,23 +167,23 @@ readPlanWarning :: ReadPlan -> Maybe (Text, Text)
readPlanWarning ReadPlan{relName, relAlias = Just alias, relIsLegacyTargetNameMatch = True} = Just (relName, alias)
readPlanWarning _ = Nothing
actionPlan :: Action -> AppConfig -> ApiRequest -> TablesAccess -> SchemaCache -> Either Error ActionPlan
actionPlan act conf apiReq tAccess sCache = case act of
ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq tAccess sCache
actionPlan :: Action -> AppConfig -> ApiRequest -> SchemaCache -> Either Error ActionPlan
actionPlan act conf apiReq sCache = case act of
ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq sCache
ActRelationInfo ident -> pure . NoDb $ RelInfoPlan ident
ActRoutineInfo ident inv ->
let crPln = callReadPlan ident conf tAccess sCache apiReq inv in
let crPln = callReadPlan ident conf sCache apiReq inv in
NoDb . RoutineInfoPlan . crProc <$> crPln
ActSchemaInfo -> pure $ NoDb SchemaInfoPlan
dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> TablesAccess -> SchemaCache -> Either Error DbActionPlan
dbActionPlan dbAct conf apiReq tAccess sCache = case dbAct of
dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> SchemaCache -> Either Error DbActionPlan
dbActionPlan dbAct conf apiReq sCache = case dbAct of
ActRelationRead identifier headersOnly ->
toDbActPlan <$> wrappedReadPlan identifier conf tAccess sCache apiReq headersOnly
toDbActPlan <$> wrappedReadPlan identifier conf sCache apiReq headersOnly
ActRelationMut identifier mut ->
toDbActPlan <$> mutateReadPlan mut apiReq identifier conf tAccess sCache
toDbActPlan <$> mutateReadPlan mut apiReq identifier conf sCache
ActRoutine identifier invMethod ->
toDbActPlan <$> callReadPlan identifier conf tAccess sCache apiReq invMethod
toDbActPlan <$> callReadPlan identifier conf sCache apiReq invMethod
ActSchemaRead tSchema headersOnly ->
MayUseDb <$> inspectPlan apiReq headersOnly tSchema
where
@@ -192,32 +191,32 @@ dbActionPlan dbAct conf apiReq tAccess sCache = case dbAct of
MTVndPlan{} -> DbCrud True pl
_ -> DbCrud False pl
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
wrappedReadPlan identifier conf tAccess sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
qi <- findTable identifier sCache
rPlan <- readPlan qi conf tAccess sCache apiRequest
rPlan <- readPlan qi conf sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read handler mediaType headersOnly qi
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> Either Error CrudPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf tAccess sCache = do
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
qi <- findTable identifier sCache
rPlan <- readPlan qi conf tAccess sCache apiRequest
rPlan <- readPlan qi conf sCache apiRequest
mPlan <- mutatePlan mutation qi apiRequest sCache rPlan
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right ()
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
return $ MutateReadPlan rPlan mPlan SQL.Write handler mediaType mutation qi
callReadPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CrudPlan
callReadPlan identifier conf tAccess sCache apiRequest@ApiRequest{iPreferences=Preferences{preferHandling, invalidPrefs, preferMaxAffected},..} invMethod = do
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CrudPlan
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{preferHandling, invalidPrefs, preferMaxAffected},..} invMethod = do
let paramKeys = case invMethod of
InvRead _ -> S.fromList $ fst <$> qsParams'
Inv -> iColumns
proc@Function{..} <- mapLeft SchemaCacheErr $
findProc identifier paramKeys (dbRoutines sCache) iContentMediaType (invMethod == Inv)
let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
rPlan <- readPlan relIdentifier conf tAccess sCache apiRequest
rPlan <- readPlan relIdentifier conf sCache apiRequest
let args = case (invMethod, iContentMediaType) of
(InvRead _, _) -> DirectArgs $ toRpcParams proc qsParams'
(Inv, MTUrlEncoded) -> DirectArgs $ maybe mempty (toRpcParams proc . payArray) iPayload
@@ -314,11 +313,10 @@ data ResolverContext = ResolverContext
, representations :: RepresentationsMap
, qi :: QualifiedIdentifier -- ^ The table we're currently attending; changes as we recurse into joins etc.
, outputType :: Text -- ^ The output type for the response payload; e.g. "csv", "json", "binary".
, tablesAccess :: TablesAccess -- ^ Privileges the request role has on the exposed tables.
}
resolveColumnField :: Column -> Maybe ToTsVector -> CoercibleField
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False Nothing
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False
resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField
resolveTableFieldName table fieldName toTsV=
@@ -378,11 +376,11 @@ resolveQueryInputField ctx field opExpr = withTextParse ctx $ resolveTypeOrUnkno
-- | Builds the ReadPlan tree on a number of stages.
-- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding.
readPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates, configUrlUseLegacyTargetNames} tAccess SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates, configUrlUseLegacyTargetNames} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
let
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV.
ctx = ResolverContext dbTables dbRepresentations qi "json" tAccess
ctx = ResolverContext dbTables dbRepresentations qi "json"
in
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
addToManyOrderSelects =<<
@@ -478,8 +476,7 @@ knownColumnsInContext ResolverContext{..} =
-- | Expand "select *" into explicit field names of the table in the following situations:
-- * When there are data representations present.
-- * When there is an aggregate function in a given ReadPlan or its parent.
-- * When the ReadPlan is a to-many spread relationship.
-- * When the default select(when no "select" is given) would include columns the request role cannot read.
-- * When the ReadPlan is a to-many spread relationship
expandStars :: ResolverContext -> ReadPlanTree -> Either Error ReadPlanTree
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
where
@@ -499,16 +496,13 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
adjustContext context fromQI _ = context{qi=fromQI}
expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
expandStarsForTable ctx@ResolverContext{representations, outputType, tables, qi} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread}
-- We expand the '*' select if either of the below are true:
-- * The target table has columns the request role cannot read.
-- * There is an aggregate function in this ReadPlan's sub-tree.
-- * The target table has at least one data representation.
expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread}
-- We expand if either of the below are true:
-- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree.
-- * We have a '*' select AND the target table has at least one data representation.
-- We ignore '*' selects that have an aggregate function attached, unless it's a `COUNT(*)` for a Spread Embed,
-- we tag it as "full row" in that case.
| hasStarSelect && hasLimitedPrivileges = rp{select = concatMap (expandStarSelectField (isJust spread) accessibleColumns) selectFields <> rlsFields}
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread) knownColumns) selectFields <> rlsFields}
| hasStarSelect = rp{select = selectFields <> rlsFields}
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread) knownColumns) selectFields}
| otherwise = rp
where
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
@@ -516,11 +510,6 @@ expandStarsForTable ctx@ResolverContext{representations, outputType, tables, qi}
shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count)
hasDataRepresentation = any hasOutputRep knownColumns
knownColumns = knownColumnsInContext ctx
hasLimitedPrivileges = accessibleColumns /= knownColumns
accessibleColumns = accessibleColumnsInContext ctx
rlsFields = case HM.lookup qi tables of
Just tbl -> rlsSelectFields tbl
Nothing -> []
hasOutputRep :: Column -> Bool
hasOutputRep col = HM.member (colNominalType col, outputType) representations
@@ -532,33 +521,6 @@ expandStarsForTable ctx@ResolverContext{representations, outputType, tables, qi}
[sel { csField = fld { cfFullRow = True } }]
expandStarSelectField _ _ selectField = [selectField]
-- | Synthetic can_edit/can_delete select fields for a table that has row-level
-- security policies restricting UPDATE/DELETE. These carry the raw qualifier
-- expression and are rendered as computed columns on `select *`.
rlsSelectFields :: Table -> [CoercibleSelectField]
rlsSelectFields tbl = catMaybes [rlsField "can_edit" (tableRlsEditQual tbl), rlsField "can_delete" (tableRlsDeleteQual tbl)]
where
rlsField :: FieldName -> Maybe Text -> Maybe CoercibleSelectField
rlsField name qual = do
expr <- qual
pure CoercibleSelectField
{ csField = (unknownField name []) { cfIRType = "boolean", cfBaseType = "boolean", cfExpression = Just expr }
, csAggFunction = Nothing
, csAggCast = Nothing
, csCast = Nothing
, csAlias = Just name
}
-- | The columns of the current table that the request role can SELECT. Falls
-- back to all known columns when no access info is available or the role has
-- no SELECT privilege on any column.
accessibleColumnsInContext :: ResolverContext -> [Column]
accessibleColumnsInContext ctx@ResolverContext{qi=tblQi, tablesAccess} =
case HM.lookup tblQi tablesAccess of
Just (TableAccess selCols _ _ _) | not (null selCols) ->
filter (\col -> colName col `elem` selCols) (knownColumnsInContext ctx)
_ -> knownColumnsInContext ctx
-- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either Error ReadPlanTree
treeRestrictRange _ (ActDb (ActRelationMut _ _)) request = Right request
@@ -968,7 +930,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- where_ = [
-- CoercibleStmnt (
-- CoercibleFilter {
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False, cfExpression = Nothing},
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False},
-- opExpr = op
-- }
-- )
@@ -985,7 +947,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
--
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False, cfExpression = Nothing}, opExpr = OpExpr True (Is IsNull)})]
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})]
--
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
--
@@ -1097,7 +1059,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
Left $ ApiRequestErr InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic returnings
where
ctx = ResolverContext dbTables dbRepresentations qi "json" mempty
ctx = ResolverContext dbTables dbRepresentations qi "json"
confCols = fromMaybe pkCols qsOnConflict
QueryParams.QueryParams{..} = iQueryParams
returnings =
+1 -2
View File
@@ -47,11 +47,10 @@ data CoercibleField = CoercibleField
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
, cfDefault :: Maybe Text
, cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spread resources.
, cfExpression :: Maybe Text -- ^ Raw SQL expression for a computed field (e.g. RLS-derived can_edit/can_delete). When present the field is rendered as this expression instead of a table column.
} deriving (Eq, Show)
unknownField :: FieldName -> JsonPath -> CoercibleField
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False Nothing
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False
-- | Like an API request LogicTree, but with coercible field information.
data CoercibleLogicTree
-60
View File
@@ -1,60 +0,0 @@
{-|
Module : PostgREST.Query.OpenApi
Description : Types for reflecting the role privileges on the OpenAPI output.
-}
module PostgREST.Query.OpenApi
( TableAccess (..)
, TablesAccess
, tablesAccessStatement
, decodeTablesAccess
) where
import qualified Data.HashMap.Strict as HM
import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Statement as SQL
import qualified PostgREST.Query.SqlFragment as SqlFragment
import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import Protolude
-- | Privileges that a role has on a relation, used to reflect them on the OpenAPI output.
data TableAccess = TableAccess
{ taSelectCols :: [FieldName]
-- ^ columns the role can SELECT
, taInsertCols :: [FieldName]
-- ^ columns the role can INSERT into
, taUpdateCols :: [FieldName]
-- ^ columns the role can UPDATE
, taDelete :: Bool
-- ^ whether the role can DELETE rows
}
deriving (Show, Eq)
type TablesAccess = HM.HashMap QualifiedIdentifier TableAccess
-- | Statement that returns the privileges the current role has on each
-- accessible relation of the given schema.
tablesAccessStatement :: Text -> SQL.Statement () TablesAccess
tablesAccessStatement schema =
SQL.dynamicallyParameterized (SqlFragment.accessibleTables schema) decodeTablesAccess False
decodeTablesAccess :: HD.Result TablesAccess
decodeTablesAccess =
let
row = (,) <$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (TableAccess
<$> arrayColumn HD.text
<*> arrayColumn HD.text
<*> arrayColumn HD.text
<*> column HD.bool)
in
HM.fromList <$> HD.rowList row
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
arrayColumn :: HD.Value a -> HD.Row [a]
arrayColumn = column . HD.listArray . HD.nonNullable
+1 -1
View File
@@ -180,7 +180,7 @@ callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScal
KeyParams [] -> "FROM " <> callIt mempty
KeyParams prms -> case arguments of
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False Nothing) <$> prms) False True False <> ", " <>
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False) <$> prms) False True False <> ", " <>
"LATERAL " <> callIt (fmtParams prms)
callIt :: SQL.Snippet -> SQL.Snippet
+1 -27
View File
@@ -249,7 +249,6 @@ pgFmtField table cf = case cfToTsVector cf of
_ -> fmtFld
where
fmtFld = case cf of
CoercibleField{cfExpression=Just expr} -> SQL.sql (encodeUtf8 expr)
CoercibleField{cfFullRow=True} -> pgFmtIdent (qiName table)
CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn
CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
@@ -599,32 +598,7 @@ accessibleTables :: Text -> SQL.Snippet
accessibleTables schema = SQL.sql (encodeUtf8 [trimming|
SELECT
n.nspname AS table_schema,
c.relname AS table_name,
COALESCE((
SELECT array_agg(a.attname ORDER BY a.attnum)
FROM pg_attribute a
WHERE a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
AND has_column_privilege(c.oid, a.attnum, 'SELECT')
), '{}') AS select_cols,
COALESCE((
SELECT array_agg(a.attname ORDER BY a.attnum)
FROM pg_attribute a
WHERE a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
AND has_column_privilege(c.oid, a.attnum, 'INSERT')
), '{}') AS insert_cols,
COALESCE((
SELECT array_agg(a.attname ORDER BY a.attnum)
FROM pg_attribute a
WHERE a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
AND has_column_privilege(c.oid, a.attnum, 'UPDATE')
), '{}') AS update_cols,
has_table_privilege(c.oid, 'DELETE') AS has_delete
c.relname AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('v','r','m','f','p')
+1 -1
View File
@@ -202,7 +202,7 @@ actionResponse (DbPlanResult media plan) ctxApiRequest _ _ _ =
actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) ApiRequest{..} versions conf sCache =
let
rsBody = maybe mempty (\(tbls, tblAccess, procs, schDesc) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache tbls tblAccess procs schDesc) body
rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body
cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody]
in
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody
+38 -98
View File
@@ -27,11 +27,10 @@ import PostgREST.Config (AppConfig (..), Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.MediaType
import PostgREST.Network (escapeHostName)
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..),
Relationship (..), RelationshipsMap)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..),
RoutineParam (..))
import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
@@ -39,27 +38,18 @@ import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
import Protolude hiding (Proxy, get)
encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> TablesAccess -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables access procs schemaDescription =
encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables procs schemaDescription =
JSON.encode $
postgrestSpec
versions
(dbRelationships sCache)
(concat $ HM.elems procs)
(fmap (\(_, t) -> (t, accessFor access t)) (HM.toList tables))
(snd <$> HM.toList tables)
(proxyUri conf)
schemaDescription
(configOpenApiSecurityActive conf)
-- | Get the access privileges for a table. When the table is not present in the
-- map(ignore-privileges mode), assume the role has full access to it.
accessFor :: TablesAccess -> Table -> TableAccess
accessFor access t =
fromMaybe fullAccess (HM.lookup (QualifiedIdentifier (tableSchema t) (tableName t)) access)
where
fullAccess = TableAccess allCols allCols allCols True
allCols = colName <$> tableColumnsList t
makeMimeList :: [MediaType] -> MimeList
makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs
@@ -107,41 +97,14 @@ parseDefault colType colDefault =
where
wrapInQuotations text = "\"" <> text <> "\""
makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema)
makeTableDef rels (t, access) =
makeTableDef :: RelationshipsMap -> Table -> (Text, Schema)
makeTableDef rels t =
let tn = tableName t in
(tn, (mempty :: Schema)
& description .~ tblDescription
& description .~ tableDescription t
& type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> cols)
& required .~ fmap colName (filter (not . colNullable) cols))
where
tn = tableName t
cols = accessibleCols t (taSelectCols access)
tblDescription = case m2mMarkers t rels of
[] -> tableDescription t
ms -> Just $ maybe "" (`T.append` "\n\n") (tableDescription t) <> T.intercalate "\n" ms
-- | Emits markers for the many-to-many relationships of a table, so that clients
-- can render these relations. The marker includes the target table(embedding key),
-- the junction table and the junction columns referencing source and target.
m2mMarkers :: Table -> RelationshipsMap -> [Text]
m2mMarkers tbl rels = mapMaybe m2mMarker searchedRels
where
searchedRels = fromMaybe mempty $ HM.lookup (QualifiedIdentifier (tableSchema tbl) (tableName tbl), tableSchema tbl) rels
m2mMarker Relationship{relForeignTable, relCardinality=M2M junction} =
Just $ T.intercalate ""
[ "<m2m table='", qiName relForeignTable
, "' junction='", qiName (junTable junction)
, "' source='", junctionSourceCol junction
, "' target='", junctionTargetCol junction
, "'/>"
]
m2mMarker _ = Nothing
junctionSourceCol junction = maybe mempty snd (headMay $ junColsSource junction)
junctionTargetCol junction = maybe mempty snd (headMay $ junColsTarget junction)
accessibleCols :: Table -> [FieldName] -> [Column]
accessibleCols t cols = filter ((`elem` cols) . colName) (tableColumnsList t)
& properties .~ fromList (makeProperty t rels <$> tableColumnsList t)
& required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t))
makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
makeProperty tbl rels col = (colName col, Inline s)
@@ -166,18 +129,11 @@ makeProperty tbl rels col = (colName col, Inline s)
(\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
pk :: Bool
pk = colName col `elem` tablePKCols tbl
uniqueNotes :: [Text]
uniqueNotes = mapMaybe uniqueNote (filter (colName col `elem`) (tableUniqueCols tbl))
where
uniqueNote cols
| length cols == 1 = Just "This is a Unique column.<unique/>"
| otherwise = Just $ "This is part of a composite unique constraint.<unique cols='" <> T.intercalate "," cols <> "'/>"
n = catMaybes
[ Just "Note:"
, if pk then Just "This is a Primary Key.<pk/>" else Nothing
, fk
]
<> uniqueNotes
<> catMaybes [fk]
d =
if length n > 1 then
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
@@ -266,8 +222,8 @@ makeProcPostParams pd =
, Ref $ Reference "preferParams"
]
makeParamDefs :: RelationshipsMap -> [(Table, TableAccess)] -> [(Text, Param)]
makeParamDefs rels tis =
makeParamDefs :: [Table] -> [(Text, Param)]
makeParamDefs ti =
-- TODO: create Prefer for each method (GET, PATCH, etc.)
[ ("preferParams", makePreferParam ["params"])
, ("preferReturn", makePreferParam ["return"])
@@ -324,27 +280,17 @@ makeParamDefs rels tis =
& in_ .~ ParamQuery
& type_ ?~ SwaggerString))
]
<> concat [ makeObjectBody rels t access <> makeRowFilters (tableName t) (accessibleCols t (taSelectCols access))
| (t, access) <- tis
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t)
| t <- ti
]
makeObjectBody :: RelationshipsMap -> Table -> TableAccess -> [(Text, Param)]
makeObjectBody rels t access =
[ ("body." <> tn, makeBodyParam (taInsertCols access))
, ("body." <> tn <> ".patch", makeBodyParam (taUpdateCols access))
]
where
tn = tableName t
makeBodyParam cols = (mempty :: Param)
makeObjectBody :: Text -> (Text, Param)
makeObjectBody tn =
("body." <> tn, (mempty :: Param)
& name .~ tn
& description ?~ tn
& required ?~ False
& schema .~ ParamBody (Inline bodySchema)
where
bodySchema = (mempty :: Schema)
& type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> accessibleCols t cols)
& required .~ fmap colName (filter (not . colNullable) (accessibleCols t cols))
& schema .~ ParamBody (Ref (Reference tn)))
makeRowFilter :: Text -> Column -> (Text, Param)
makeRowFilter tn c =
@@ -359,8 +305,8 @@ makeRowFilter tn c =
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
makeRowFilters tn = fmap (makeRowFilter tn)
makePathItem :: (Table, TableAccess) -> (FilePath, PathItem)
makePathItem (t, access) = ("/" ++ T.unpack tn, p)
makePathItem :: Table -> (FilePath, PathItem)
makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t)
where
-- Use first line of table description as summary; rest as description (if present)
-- We strip leading newlines from description so that users can include a blank line between summary and description
@@ -381,26 +327,20 @@ makePathItem (t, access) = ("/" ++ T.unpack tn, p)
)
)
postOp = tOp
& parameters .~ fmap ref [bodyParam, "select", "preferPost"]
& parameters .~ fmap ref ["body." <> tn, "select", "preferPost"]
& at 201 ?~ "Created"
patchOp = tOp
& parameters .~ fmap ref (rs <> [patchBodyParam, "preferReturn"])
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])
& at 204 ?~ "No Content"
deletOp = tOp
& parameters .~ fmap ref (rs <> ["preferReturn"])
& at 204 ?~ "No Content"
p = (mempty :: PathItem)
& get .~ (if not (null selCols) then Just getOp else Nothing)
& post .~ (if tableInsertable t && not (null insCols) then Just postOp else Nothing)
& patch .~ (if tableUpdatable t && not (null updCols) then Just patchOp else Nothing)
& delete .~ (if tableDeletable t && taDelete access then Just deletOp else Nothing)
pr = (mempty :: PathItem) & get ?~ getOp
pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp
p False = pr
p True = pw
tn = tableName t
selCols = accessibleCols t (taSelectCols access)
insCols = accessibleCols t (taInsertCols access)
updCols = accessibleCols t (taUpdateCols access)
rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- selCols ]
bodyParam = "body." <> tn
patchBodyParam = "body." <> tn <> ".patch"
rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumnsList t ]
ref = Ref . Reference
makeProcPathItem :: Routine -> (FilePath, PathItem)
@@ -435,9 +375,9 @@ makeRootPathItem = ("/", p)
pr = (mempty :: PathItem) & get ?~ getOp
p = pr
makePathItems :: [Routine] -> [(Table, TableAccess)] -> InsOrdHashMap FilePath PathItem
makePathItems pds tis = fromList $ makeRootPathItem :
fmap makePathItem tis ++ fmap makeProcPathItem pds
makePathItems :: [Routine] -> [Table] -> InsOrdHashMap FilePath PathItem
makePathItems pds ti = fromList $ makeRootPathItem :
fmap makePathItem ti ++ fmap makeProcPathItem pds
makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions
makeSecurityDefinitions secName allow
@@ -447,8 +387,8 @@ makeSecurityDefinitions secName allow
secSchType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader)
secSchDescription = Just "Add the token prepending \"Bearer \" (without quotes) to it"
postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [(Table, TableAccess)] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger
postgrestSpec (prettyVersion, docsVersion) rels pds tis (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger)
postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [Table] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger
postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger)
& basePath ?~ T.unpack b
& schemes ?~ [s']
& info .~ ((mempty :: Info)
@@ -459,9 +399,9 @@ postgrestSpec (prettyVersion, docsVersion) rels pds tis (s, h, p, b) sd allowSec
& description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/references/api.html"))
& host .~ h'
& definitions .~ fromList (makeTableDef rels <$> tis)
& parameters .~ fromList (makeParamDefs rels tis)
& paths .~ makePathItems pds tis
& definitions .~ fromList (makeTableDef rels <$> ti)
& parameters .~ fromList (makeParamDefs ti)
& paths .~ makePathItems pds ti
& produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
+5 -122
View File
@@ -150,7 +150,6 @@ querySchemaCache pgVer conf@AppConfig{..} = do
m2oRels <- sqlTimedStmt gucRels mempty allM2OandO2ORels
funcs <- sqlTimedStmt gucFuncs conf (allFunctions pgVer configDbPreparedStatements)
cRels <- sqlTimedStmt gucCRels mempty allComputedRels
rlsPols <- sqlTimedStmt gucRLS conf allRlsPolicies
reps <- sqlTimedStmt gucDReps conf dataRepresentations
mHdlers <- sqlTimedStmt gucMHdrs conf mediaHandlers
@@ -162,11 +161,10 @@ querySchemaCache pgVer conf@AppConfig{..} = do
else pure Nothing
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
tabsWithRls = addRlsQuals tabsWViewsPks (combineRlsPolicies rlsPols)
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
return (removeInternal schemas $ SchemaCache {
dbTables = tabsWithRls
dbTables = tabsWViewsPks
, dbRelationships = getOverrideRelationshipsMap rels cRels
, dbRoutines = funcs
, dbRepresentations = reps
@@ -234,7 +232,6 @@ decodeTables =
<*> column HD.bool
<*> column HD.bool
<*> arrayColumn HD.text
<*> column (HD.refine parseUniqueCols HD.jsonb)
<*> parseCols (compositeArrayColumn
(Column
<$> compositeField HD.text
@@ -245,19 +242,11 @@ decodeTables =
<*> nullableCompositeField HD.int4
<*> nullableCompositeField HD.text
<*> compositeFieldArray HD.text))
<*> pure (Nothing :: Maybe Text)
<*> pure (Nothing :: Maybe Text)
parseCols :: HD.Row [Column] -> HD.Row ColumnMap
parseCols = fmap (HMI.fromList . map (\col@Column{colName} -> (colName, col)))
parseUniqueCols :: JSON.Value -> Either Text [[FieldName]]
parseUniqueCols val =
case JSON.fromJSON val of
JSON.Success cols -> Right cols
JSON.Error err -> Left ("Invalid unique columns: " <> T.pack err)
decodeRels :: HD.Result [Relationship]
decodeRels =
HD.rowList relRow
@@ -687,25 +676,6 @@ tablesSqlQuery pgVer =
AND NOT pg_is_other_temp_schema(r.relnamespace)
AND NOT a.attisdropped
GROUP BY r.oid
),
tbl_unique_cols AS (
SELECT
r.oid AS relid,
jsonb_agg(cols ORDER BY c.oid) AS unique_cols
FROM pg_class r
JOIN pg_constraint c
ON r.oid = c.conrelid
JOIN LATERAL (
SELECT jsonb_agg(a.attname::text ORDER BY k.ord) AS cols
FROM unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = r.oid AND a.attnum = k.attnum
) col_info ON TRUE
WHERE
c.contype = 'u'
AND r.relkind IN ('r', 'p')
AND r.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
AND NOT pg_is_other_temp_schema(r.relnamespace)
GROUP BY r.oid
)
SELECT
n.nspname AS table_schema,
@@ -739,13 +709,11 @@ tablesSqlQuery pgVer =
)
) AS deletable,
coalesce(tpks.pk_cols, '{}') as pk_cols,
coalesce(tunq.unique_cols, '[]'::jsonb) as unique_cols,
coalesce(cols_agg.columns, '{}') as columns
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_description d on d.objoid = c.oid and d.objsubid = 0 and d.classoid = 'pg_class'::regclass
LEFT JOIN tbl_pk_cols tpks ON c.oid = tpks.relid
LEFT JOIN tbl_unique_cols tunq ON c.oid = tunq.relid
LEFT JOIN columns_agg cols_agg ON c.oid = cols_agg.relid
WHERE c.relkind IN ('v','r','m','f','p')
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
@@ -842,88 +810,6 @@ allComputedRels =
column HD.bool <*>
column HD.bool
-- | A row-level security policy of an exposed table, gathered from pg_policies.
data RlsPolicyRow = RlsPolicyRow
{ rlsTable :: QualifiedIdentifier
, rlsRowSec :: Bool
, rlsCmd :: Text -- ^ "w" (UPDATE), "d" (DELETE), "*" (ALL)
, rlsPermiss :: Bool
, rlsQual :: Maybe Text -- ^ USING qualifier; Nothing means the policy has no USING restriction
}
-- | Returns the UPDATE/DELETE RLS policies of the exposed tables, so that the
-- planner can surface per-row can_edit/can_delete fields on SELECT *.
allRlsPolicies :: SQL.Statement AppConfig [RlsPolicyRow]
allRlsPolicies =
SQL.Statement sql params decodeRlsPolicies True
where
params = map escapeIdent . toList . configDbSchemas >$< arrayParam HE.text
sql = encodeUtf8 [trimming|
SELECT
n.nspname::text AS table_schema,
c.relname::text AS table_name,
c.relrowsecurity AS row_security,
p.polcmd::text AS cmd,
p.polpermissive AS permissive,
pg_get_expr(p.polqual, p.polrelid) AS qual
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_policy p ON p.polrelid = c.oid
WHERE c.relkind IN ('r','p')
AND c.relnamespace = ANY($$1::regnamespace[])
AND p.polcmd IN ('w','d','*')
ORDER BY n.nspname, c.relname|]
decodeRlsPolicies :: HD.Result [RlsPolicyRow]
decodeRlsPolicies =
HD.rowList rlsRow
where
rlsRow = RlsPolicyRow
<$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> column HD.bool
<*> column HD.text
<*> column HD.bool
<*> nullableColumn HD.text
-- | Combines the per-command RLS policies of each table into the SQL expression
-- used to compute can_edit/can_delete. Following PostgreSQL semantics, multiple
-- permissive policies combine with OR and restrictive ones with AND.
combineRlsPolicies :: [RlsPolicyRow] -> HM.HashMap QualifiedIdentifier (Maybe Text, Maybe Text)
combineRlsPolicies rows = HM.fromList $ mapMaybe toEntry $ HM.toList byTable
where
byTable = HM.fromListWith (<>) [ (rlsTable r, [r]) | r <- rows ]
toEntry (qi, rs)
| not (any rlsRowSec rs) = Nothing
| isNothing editQ && isNothing delQ = Nothing
| otherwise = Just (qi, (editQ, delQ))
where
editQ = combineCmd ["w", "*"] rs
delQ = combineCmd ["d", "*"] rs
combineCmd cmds rs
| null policies = Nothing
| null permissiveQs = Nothing
| otherwise = Just $ wrapQual combined
where
policies = [ r | r <- rs, rlsCmd r `elem` cmds ]
permissiveQs = [ fromMaybe "true" (rlsQual r) | r <- policies, rlsPermiss r ]
restrictiveQs = [ fromMaybe "true" (rlsQual r) | r <- policies, not (rlsPermiss r) ]
permissiveExpr = T.intercalate " OR " permissiveQs
combined = case restrictiveQs of
[] -> permissiveExpr
_ -> "(" <> permissiveExpr <> ") AND (" <> T.intercalate " AND " restrictiveQs <> ")"
wrapQual q = "COALESCE(" <> q <> ", false)"
-- | Attaches the combined RLS qualifiers to the corresponding tables.
addRlsQuals :: TablesMap -> HM.HashMap QualifiedIdentifier (Maybe Text, Maybe Text) -> TablesMap
addRlsQuals tabs rlsMap = HM.mapWithKey setRls tabs
where
setRls qi tbl = case HM.lookup qi rlsMap of
Nothing -> tbl
Just (editQ, delQ) -> tbl { tableRlsEditQual = editQ, tableRlsDeleteQual = delQ }
-- | Returns all the views' primary keys and foreign keys dependencies
allViewsKeyDependencies :: SQL.Statement AppConfig [ViewKeyDependency]
allViewsKeyDependencies =
@@ -1264,15 +1150,15 @@ extractTimings = SQL.Statement sql HE.noParams decodeThem True
qFrag setting = "extract('milliseconds' from current_setting('pgrst." <> setting <> "', false)::interval)::text"
sql = "SELECT " <> BS.intercalate ","
[ qFrag gucTbls, qFrag gucKDeps, qFrag gucRels
, qFrag gucFuncs, qFrag gucCRels, qFrag gucRLS
, qFrag gucDReps, qFrag gucMHdrs
, qFrag gucFuncs, qFrag gucCRels, qFrag gucDReps
, qFrag gucMHdrs
]
decodeThem :: HD.Result QueryTimings
decodeThem = HD.singleRow $
QueryTimings
<$> column HD.text <*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text
<*> column HD.text
data QueryTimings = QueryTimings
{ qtTables :: Text
@@ -1280,7 +1166,6 @@ data QueryTimings = QueryTimings
, qtRels :: Text
, qtFuncs :: Text
, qtCRels :: Text
, qtRls :: Text
, qtDReps :: Text
, qtMHdrs :: Text
} deriving (Show)
@@ -1292,17 +1177,15 @@ queryTimingsWLabels qt =
, (gucRels, qtRels qt)
, (gucFuncs, qtFuncs qt)
, (gucCRels, qtCRels qt)
, (gucRLS, qtRls qt)
, (gucDReps, qtDReps qt)
, (gucMHdrs, qtMHdrs qt)
]
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucRLS, gucDReps, gucMHdrs :: ByteString
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucDReps, gucMHdrs :: ByteString
gucTbls = "tables"
gucKDeps = "keydeps"
gucRels = "rels"
gucFuncs = "funcs"
gucCRels = "comprels"
gucRLS = "rls"
gucDReps = "dreps"
gucMHdrs = "mhandlers"
@@ -29,18 +29,7 @@ data Table = Table
, tableUpdatable :: Bool
, tableDeletable :: Bool
, tablePKCols :: [FieldName]
-- ^ Each element is the position-ordered column list of a unique
-- constraint. A single-column unique constraint is represented by a
-- single-element list.
, tableUniqueCols :: [[FieldName]]
, tableColumns :: ColumnMap
, tableRlsEditQual :: Maybe Text
-- ^ Combined RLS UPDATE USING qualifier, COALESCE-wrapped. Nothing means
-- the table has no row-level edit restriction to surface (RLS disabled or
-- no applicable UPDATE policy).
, tableRlsDeleteQual :: Maybe Text
-- ^ Combined RLS DELETE USING qualifier, COALESCE-wrapped. Nothing means
-- the table has no row-level delete restriction to surface.
}
deriving (Show, Generic, JSON.ToJSON)
@@ -7,44 +7,7 @@
tableIsView: false
tableName: authors_only
tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: no_rls_items
qiSchema: public
- tableColumns:
id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: id
colNominalType: integer
colNullable: false
colType: integer
name:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: name
colNominalType: text
colNullable: true
colType: text
tableDeletable: true
tableDescription: null
tableInsertable: true
tableIsView: false
tableName: no_rls_items
tablePKCols:
- id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: cats
@@ -75,55 +38,7 @@
tableName: cats
tablePKCols:
- id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: rls_items
qiSchema: public
- tableColumns:
account_id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: account_id
colNominalType: bigint
colNullable: true
colType: bigint
id:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: id
colNominalType: integer
colNullable: false
colType: integer
name:
colDefault: null
colDescription: null
colEnum: []
colMaxLen: null
colName: name
colNominalType: text
colNullable: true
colType: text
tableDeletable: true
tableDescription: null
tableInsertable: true
tableIsView: false
tableName: rls_items
tablePKCols:
- id
tableRlsDeleteQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
->> 'account_id'::text))::bigint = account_id), false)
tableRlsEditQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
->> 'account_id'::text))::bigint = account_id), false)
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: items_w_isolation_level
@@ -153,10 +68,7 @@
tableIsView: true
tableName: items_w_isolation_level
tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: directors
@@ -187,10 +99,7 @@
tableName: directors
tablePKCols:
- id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: projects
@@ -202,10 +111,7 @@
tableIsView: false
tableName: projects
tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: infinite_recursion
@@ -217,10 +123,7 @@
tableIsView: true
tableName: infinite_recursion
tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: false
- - qiName: awards
@@ -278,10 +181,7 @@
tableName: awards
tablePKCols:
- id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: films
@@ -321,10 +221,7 @@
tableName: films
tablePKCols:
- id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
- - qiName: items
@@ -345,8 +242,5 @@
tableIsView: false
tableName: items
tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public
tableUniqueCols: []
tableUpdatable: true
-5
View File
@@ -7,8 +7,3 @@ GRANT SELECT ON directors, films, awards TO postgrest_test_anonymous, postgrest_
GRANT ALL ON cats TO postgrest_test_anonymous;
GRANT ALL ON items_w_isolation_level TO postgrest_test_anonymous, postgrest_test_repeatable_read, postgrest_test_serializable;
GRANT SELECT ON rls_items TO postgrest_test_author;
GRANT UPDATE(name) ON rls_items TO postgrest_test_author;
GRANT DELETE ON rls_items TO postgrest_test_author;
GRANT SELECT ON no_rls_items TO postgrest_test_author;
-31
View File
@@ -268,34 +268,3 @@ $$ language sql;
create or replace function get_work_mem() returns text as $$
select current_setting('work_mem', true);
$$ language sql;
-- RLS fixtures for testing can_edit/can_delete computed fields
create table rls_items(
id int primary key,
account_id bigint,
name text
);
alter table rls_items enable row level security;
create policy rls_items_select on rls_items for select
using (
account_id is null
or (current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id
);
create policy rls_items_update on rls_items for update
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
create policy rls_items_delete on rls_items for delete
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
insert into rls_items(id, account_id, name) values (1, 1, 'own'), (2, null, 'public'), (3, 2, 'other');
-- no RLS at all: can_edit/can_delete must be omitted
create table no_rls_items(
id int primary key,
name text
);
insert into no_rls_items(id, name) values (1, 'a'), (2, 'b');
+1 -1
View File
@@ -239,7 +239,7 @@ def test_pool_acquisition_timeout(level, defaultenv, metapostgrest):
assert data["message"] == "Timed out acquiring connection from connection pool."
# ensure the message appears on the logs as well
output = sorted(drain_stdout(postgrest))
output = sorted(postgrest.read_stdout(nlines=10))
if level == "crit":
assert len(output) == 0
+4 -4
View File
@@ -44,7 +44,7 @@ def test_log_level(level, defaultenv):
response = postgrest.session.get("/")
assert response.status_code == 200
output = drain_stdout(postgrest)
output = postgrest.read_stdout(nlines=9)
if level == "crit":
assert len(output) == 0
@@ -82,7 +82,7 @@ def test_log_level(level, defaultenv):
r'- - postgrest_test_anonymous \[.+\] "GET / HTTP/1.1" 200 \d+ "" "python-requests/.+"',
],
)
assert len(output) > 3
assert len(output) == 9
assert any("Connection" and "is available" in line for line in output)
assert any("Connection" and "is used" in line for line in output)
@@ -403,7 +403,7 @@ def test_db_error_logging_to_stderr(level, defaultenv, metapostgrest):
assert response.status_code == 500
# ensure the message appears on the logs
output = drain_stdout(postgrest)
output = postgrest.read_stdout(nlines=8)
if level == "crit":
assert len(output) == 0
@@ -456,7 +456,7 @@ def test_schema_cache_query_timings_log(level, defaultenv):
"PGRST_LOG_LEVEL": level,
}
log_pattern = re.compile(
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, rls: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms"
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms"
)
with run(env=env, no_startup_stdout=False) as postgrest:
-63
View File
@@ -1,63 +0,0 @@
from config import SECRET
from postgrest import run
from util import jwtauthheader
def author_headers(account_id):
"Authorization header for postgrest_test_author with the given account id."
return jwtauthheader(
{"role": "postgrest_test_author", "account_id": account_id}, SECRET
)
def test_rls_can_edit_can_delete(defaultenv):
"select * on an RLS table exposes can_edit/can_delete computed from the policies"
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
with run(env=env) as postgrest:
response = postgrest.session.get("/rls_items", headers=author_headers(1))
assert response.status_code == 200
rows = {r["id"]: r for r in response.json()}
# rows visible to account_id=1: own row and the public row
assert set(rows) == {1, 2}
# the own row can be edited and deleted
assert rows[1]["can_edit"] is True
assert rows[1]["can_delete"] is True
# the public row is visible but not editable or deletable
assert rows[2]["can_edit"] is False
assert rows[2]["can_delete"] is False
def test_no_rls_omits_can_edit_can_delete(defaultenv):
"select * on a table without RLS omits the computed columns"
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
with run(env=env) as postgrest:
response = postgrest.session.get("/no_rls_items", headers=author_headers(1))
assert response.status_code == 200
rows = response.json()
assert len(rows) == 2
for row in rows:
assert "can_edit" not in row
assert "can_delete" not in row
def test_rls_columns_not_in_openapi(defaultenv):
"The OpenAPI spec must not advertise the computed columns"
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
with run(env=env) as postgrest:
response = postgrest.session.get("/", headers=author_headers(1))
assert response.status_code == 200
spec = response.json()
properties = spec["definitions"]["rls_items"]["properties"]
assert "can_edit" not in properties
assert "can_delete" not in properties
+1 -134
View File
@@ -222,58 +222,6 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
. nth 0
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
it "reflects table privileges in the HTTP methods" $ do
r <- simpleBody <$> get "/"
let selectonlyGet = r ^? key "paths" . key "/selectonly" . key "get"
selectonlyPost = r ^? key "paths" . key "/selectonly" . key "post"
insertonlyGet = r ^? key "paths" . key "/insertonly" . key "get"
insertonlyPost = r ^? key "paths" . key "/insertonly" . key "post"
insertonlyDelete = r ^? key "paths" . key "/insertonly" . key "delete"
limitedStarsGet = r ^? key "paths" . key "/limited_article_stars" . key "get"
limitedStarsPost = r ^? key "paths" . key "/limited_article_stars" . key "post"
limitedStarsPatch = r ^? key "paths" . key "/limited_article_stars" . key "patch"
limitedStarsDelete = r ^? key "paths" . key "/limited_article_stars" . key "delete"
liftIO $ do
selectonlyGet `shouldNotBe` Nothing
selectonlyPost `shouldBe` Nothing
insertonlyGet `shouldBe` Nothing
insertonlyPost `shouldNotBe` Nothing
insertonlyDelete `shouldBe` Nothing
limitedStarsGet `shouldNotBe` Nothing
limitedStarsPost `shouldNotBe` Nothing
limitedStarsPatch `shouldNotBe` Nothing
limitedStarsDelete `shouldBe` Nothing
it "reflects column privileges in the table definition" $ do
r <- simpleBody <$> get "/"
let appUsersId = r ^? key "definitions" . key "app_users" . key "properties" . key "id"
appUsersEmail = r ^? key "definitions" . key "app_users" . key "properties" . key "email"
appUsersPassword = r ^? key "definitions" . key "app_users" . key "properties" . key "password"
appUsersRequired = r ^? key "definitions" . key "app_users" . key "required"
liftIO $ do
appUsersId `shouldNotBe` Nothing
appUsersEmail `shouldNotBe` Nothing
appUsersPassword `shouldBe` Nothing
appUsersRequired `shouldBe` Just [aesonQQ|["id", "email"]|]
it "reflects column privileges in the rowFilter parameters" $ do
r <- simpleBody <$> get "/"
let filterId = r ^? key "parameters" . key "rowFilter.app_users.id"
filterEmail = r ^? key "parameters" . key "rowFilter.app_users.email"
filterPassword = r ^? key "parameters" . key "rowFilter.app_users.password"
liftIO $ do
filterId `shouldNotBe` Nothing
filterEmail `shouldNotBe` Nothing
filterPassword `shouldBe` Nothing
it "includes a fk description for a O2O relationship" $ do
r <- simpleBody <$> get "/"
@@ -285,91 +233,10 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is a Unique column.<unique/>\nThis is a Foreign Key to `second.id`.<fk table='second' column='id'/>"
"description": "Note:\nThis is a Foreign Key to `second.id`.<fk table='second' column='id'/>"
}
|]
it "includes a unique description for a column with a unique constraint" $ do
r <- simpleBody <$> get "/"
let uniqueKey = r ^? key "definitions" . key "single_unique" . key "properties" . key "unique_key"
liftIO $
uniqueKey `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is a Unique column.<unique/>"
}
|]
it "includes the column list of a composite unique constraint" $ do
r <- simpleBody <$> get "/"
let compoundKey1 = r ^? key "definitions" . key "compound_unique" . key "properties" . key "key1"
compoundKey2 = r ^? key "definitions" . key "compound_unique" . key "properties" . key "key2"
liftIO $ do
compoundKey1 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
compoundKey2 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
it "includes the column list for mixed single and composite unique constraints" $ do
r <- simpleBody <$> get "/"
let uniqueCol = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "id"
compoundKey1 = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "key1"
compoundKey2 = r ^? key "definitions" . key "mixed_unique" . key "properties" . key "key2"
liftIO $ do
uniqueCol `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is a Unique column.<unique/>"
}
|]
compoundKey1 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
compoundKey2 `shouldBe` Just
[aesonQQ|
{
"format": "int32",
"type": "integer",
"description": "Note:\nThis is part of a composite unique constraint.<unique cols='key1,key2'/>"
}
|]
it "includes m2m relationship markers in the table description" $ do
r <- simpleBody <$> get "/"
let beingDescription = r ^? key "definitions" . key "being" . key "description"
liftIO $
beingDescription `shouldBe` Just
[aesonQQ|"<m2m table='part' junction='being_part' source='being' target='part'/>"|]
describe "Foreign table" $
it "includes foreign table properties" $ do
+2 -5
View File
@@ -120,12 +120,9 @@ spec withConfig = withConfig baseCfg $
}
context "table with limited privileges" $ do
it "succeeds deleting the row when return=representation and selecting all columns, returning only the privileged columns" $
it "fails deleting the row when return=representation and selecting all the columns" $
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
`shouldRespondWith` 401
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
+4 -3
View File
@@ -720,10 +720,11 @@ spec withConfig = withConfig baseCfg $ do
, matchHeaders = []
}
it "succeeds inserting if select is not specified, returning only the accessible columns" $
it "fails inserting if select is not specified" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":3,"user_id":1}]|]
{ matchStatus = 201
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith`
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
{ matchStatus = 401
, matchHeaders = []
}
-13
View File
@@ -36,19 +36,6 @@ spec actualPgVersion withConfig = withConfig baseCfg $ do
, matchHeaders = ["Content-Length" <:> "120"]
}
describe "Column-level privileges" $ do
it "selects only the accessible columns when no select is specified" $
get "/app_users?id=eq.1"
`shouldRespondWith`
[json| [{"id":1,"email":"test@123.com"}] |]
{ matchStatus = 200 }
it "can still select the accessible columns explicitly" $
get "/app_users?id=eq.1&select=id,email"
`shouldRespondWith`
[json| [{"id":1,"email":"test@123.com"}] |]
{ matchStatus = 200 }
describe "Filtering response" $ do
it "matches with equality" $
get "/items?id=eq.5"
-3
View File
@@ -28,13 +28,10 @@ REVOKE ALL PRIVILEGES ON TABLE
, authors_only
, insertonly
, limited_article_stars
, selectonly
FROM postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
GRANT SELECT ON TABLE selectonly TO postgrest_test_anonymous;
GRANT USAGE ON SEQUENCE
auto_incrementing_pk_id_seq
, items_id_seq
-14
View File
@@ -1451,15 +1451,6 @@ create table test.compound_unique(
unique(key1, key2)
);
create table test.mixed_unique(
id integer not null,
key1 integer not null,
key2 integer not null,
value text,
unique(id),
unique(key1, key2)
);
create table test.family_tree (
id text not null primary key,
name text not null,
@@ -1935,11 +1926,6 @@ create table app_users (
password text not null
);
create table selectonly (
id integer primary key,
name text
);
create table private.pages (
link int not null unique
, url text