Compare commits

..
4 Commits
Author SHA1 Message Date
jimmy 7989108b0b feat: expose row-level can_edit/can_delete on select *
Compute per-row editability and deletability from a table's row-level
security policies and return them as synthetic columns so clients can
hide edit/delete affordances for rows the user cannot change.

- Introspect pg_policies and relrowsecurity at schema-cache load and
  combine the UPDATE/DELETE USING qualifiers per table (permissive OR,
  restrictive AND).
- Store the combined qualifiers on Table and inject can_edit/can_delete
  as computed select fields when expanding `select *`, only for
  RLS-enabled tables with a matching policy (COALESCE'd to a boolean).
- Keep the computed columns out of the OpenAPI spec so they are not
  rendered as regular fields.
- Add a cfExpression field to CoercibleField to carry raw SQL
  expressions through the planner to SqlFragment.
2026-08-29 11:30:21 +02:00
jimmy 77ab8f83ac feat: expose unique columns and many-to-many markers in OpenAPI
Add unique constraint and many-to-many relationship metadata to the
generated OpenAPI spec so clients can render them.

- Store unique constraints on Table as tableUniqueCols (mirroring
  tablePKCols) instead of denormalizing them onto each Column.
- Compute unique constraints via a per-table tbl_unique_cols CTE in
  tablesSqlQuery.
- Annotate unique columns and composite unique constraints in property
  descriptions, and emit m2m markers in table descriptions.
2026-08-20 18:03:43 +02:00
jimmy ce7ea53a57 instead of select * inspect schema to get selectable columns 2026-08-16 14:41:13 +02:00
jimmy 4a5d626112 restrict openapi spec based on sql grants 2026-08-15 21:41:45 +02:00
26 changed files with 825 additions and 111 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. From versio
## Unreleased ## 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 ## [16.1] - 2026-08-10
### Fixed ### Fixed
+2
View File
@@ -9,6 +9,8 @@ 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`. 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, 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 .. code-block:: postgres
+1
View File
@@ -78,6 +78,7 @@ library
PostgREST.Network PostgREST.Network
PostgREST.Observation PostgREST.Observation
PostgREST.Query PostgREST.Query
PostgREST.Query.OpenApi
PostgREST.Query.PreQuery PostgREST.Query.PreQuery
PostgREST.Query.QueryBuilder PostgREST.Query.QueryBuilder
PostgREST.Query.SqlFragment PostgREST.Query.SqlFragment
+34 -1
View File
@@ -31,6 +31,10 @@ import Network.Wai.Handler.Warp (defaultSettings, setBeforeMainLoop, setHost,
setOnException, setPort, setServerName) setOnException, setPort, setServerName)
import qualified Data.Text.Encoding as T 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 as Wai
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
import qualified Network.Wai.Header as WaiHeader import qualified Network.Wai.Header as WaiHeader
@@ -48,6 +52,7 @@ import qualified PostgREST.Response as Response
import qualified PostgREST.Unix as Unix (installSignalHandlers) import qualified PostgREST.Unix as Unix (installSignalHandlers)
import PostgREST.ApiRequest (ApiRequest (..)) import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.ApiRequest.Types (Action (..), DbAction (..))
import PostgREST.AppState (AppState) import PostgREST.AppState (AppState)
import PostgREST.AppState.Reload (runListener) import PostgREST.AppState.Reload (runListener)
import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Auth.Types (AuthResult (..))
@@ -55,6 +60,8 @@ import PostgREST.Config (AppConfig (..))
import PostgREST.Error (Error) import PostgREST.Error (Error)
import PostgREST.Network (resolveSocketToAddress) import PostgREST.Network (resolveSocketToAddress)
import PostgREST.Observation (Observation (..)) import PostgREST.Observation (Observation (..))
import PostgREST.Query.OpenApi (TablesAccess, tablesAccessStatement)
import PostgREST.Query.SqlFragment (setConfigWithConstantName)
import PostgREST.Response.Performance (ServerTiming (..), serverTimingHeader) import PostgREST.Response.Performance (ServerTiming (..), serverTimingHeader)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.TimeIt (timeItT) import PostgREST.TimeIt (timeItT)
@@ -207,7 +214,8 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
body <- liftIO $ Wai.strictRequestBody req body <- liftIO $ Wai.strictRequestBody req
(parseTime, apiReq@ApiRequest{..}) <- withTiming conf $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body (parseTime, apiReq@ApiRequest{..}) <- withTiming conf $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body
(planTime, plan) <- withTiming conf $ liftEither $ Plan.actionPlan iAction conf apiReq sCache tableAccess <- liftIO $ getTablesAccess appState apiReq authResult
(planTime, plan) <- withTiming conf $ liftEither $ Plan.actionPlan iAction conf apiReq tableAccess sCache
let warnings = Plan.legacyWarnings plan 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." 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."
@@ -269,6 +277,31 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
in in
[(hWarning, "299 " <> pgrstVer <> " \"" <> encodeUtf8 warnMsg <> "\"")] [(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 :: (MonadError e m, MonadIO m) => AppConfig -> m a -> m (Maybe Double, a)
withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled
then do then do
+6 -15
View File
@@ -19,7 +19,6 @@ import qualified Data.Aeson.Lens as L
import qualified Data.ByteString as BS hiding (break) import qualified Data.ByteString as BS hiding (break)
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.Set as S
import qualified Hasql.Decoders as HD import qualified Hasql.Decoders as HD
import qualified Hasql.DynamicStatements.Statement as SQL import qualified Hasql.DynamicStatements.Statement as SQL
import qualified Hasql.Session as SQL (Session) import qualified Hasql.Session as SQL (Session)
@@ -44,6 +43,7 @@ import PostgREST.Plan (ActionPlan (..), CrudPlan (..),
DbActionPlan (..), InfoPlan (..), DbActionPlan (..), InfoPlan (..),
InspectPlan (..)) InspectPlan (..))
import PostgREST.Query (MainQuery (..)) import PostgREST.Query (MainQuery (..))
import PostgREST.Query.OpenApi (TablesAccess, decodeTablesAccess)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Routine (Routine (..), RoutineMap) import PostgREST.SchemaCache.Routine (Routine (..), RoutineMap)
@@ -60,7 +60,7 @@ data MainTx
data DbResult data DbResult
= DbCrudResult CrudPlan ResultSet = DbCrudResult CrudPlan ResultSet
| DbPlanResult MediaType BS.ByteString | DbPlanResult MediaType BS.ByteString
| MaybeDbResult InspectPlan (Maybe (TablesMap, RoutineMap, Maybe Text)) | MaybeDbResult InspectPlan (Maybe (TablesMap, TablesAccess, RoutineMap, Maybe Text))
| NoDbResult InfoPlan | NoDbResult InfoPlan
-- | Standard result set format used for the mqMain query -- | Standard result set format used for the mqMain query
@@ -174,34 +174,25 @@ actionResult MainQuery{mqOpenAPI=(tblsQ, funcsQ, schQ)} (MayUseDb plan@InspectPl
mainActionQuery = lift $ mainActionQuery = lift $
case configOpenApiMode of case configOpenApiMode of
OAFollowPriv -> do OAFollowPriv -> do
tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeAccessibleIdentifiers configDbPreparedStatements tableAccess <- SQL.statement mempty $ SQL.dynamicallyParameterized tblsQ decodeTablesAccess configDbPreparedStatements
accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ SchemaCache.decodeFuncs configDbPreparedStatements accFuncs <- SQL.statement mempty $ SQL.dynamicallyParameterized funcsQ SchemaCache.decodeFuncs configDbPreparedStatements
schDesc <- SQL.statement mempty $ SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements schDesc <- SQL.statement mempty $ SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements
let tbls = HM.filterWithKey (\qi _ -> S.member qi tableAccess) $ SchemaCache.dbTables sCache let tbls = HM.filterWithKey (\qi _ -> HM.member qi tableAccess) $ SchemaCache.dbTables sCache
pure $ MaybeDbResult plan (Just (tbls, accFuncs, schDesc)) pure $ MaybeDbResult plan (Just (tbls, tableAccess, accFuncs, schDesc))
OAIgnorePriv -> do OAIgnorePriv -> do
schDesc <- SQL.statement mempty (SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements) schDesc <- SQL.statement mempty (SQL.dynamicallyParameterized schQ decodeSchemaDesc configDbPreparedStatements)
let tbls = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbTables sCache) let tbls = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbTables sCache)
routs = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbRoutines sCache) routs = HM.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) (SchemaCache.dbRoutines sCache)
pure $ MaybeDbResult plan (Just (tbls, routs, schDesc)) pure $ MaybeDbResult plan (Just (tbls, mempty, routs, schDesc))
OADisabled -> OADisabled ->
pure $ MaybeDbResult plan Nothing pure $ MaybeDbResult plan Nothing
decodeSchemaDesc :: HD.Result (Maybe Text) decodeSchemaDesc :: HD.Result (Maybe Text)
decodeSchemaDesc = join <$> HD.rowMaybe (nullableColumn HD.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 -- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, -- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
-- PUT /items?id=eq.14 { "id" : 2, .. } is rejected. -- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.
+69 -31
View File
@@ -45,6 +45,7 @@ import PostgREST.Error (ApiRequestError (..), Error (..),
SchemaCacheError (..)) SchemaCacheError (..))
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import PostgREST.Plan.Negotiate (negotiateContent) import PostgREST.Plan.Negotiate (negotiateContent)
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.Query.SqlFragment (sourceCTEName) import PostgREST.Query.SqlFragment (sourceCTEName)
import PostgREST.RangeQuery (NonnegRange, allRange, import PostgREST.RangeQuery (NonnegRange, allRange,
convertToLimitZeroRange, convertToLimitZeroRange,
@@ -167,23 +168,23 @@ readPlanWarning :: ReadPlan -> Maybe (Text, Text)
readPlanWarning ReadPlan{relName, relAlias = Just alias, relIsLegacyTargetNameMatch = True} = Just (relName, alias) readPlanWarning ReadPlan{relName, relAlias = Just alias, relIsLegacyTargetNameMatch = True} = Just (relName, alias)
readPlanWarning _ = Nothing readPlanWarning _ = Nothing
actionPlan :: Action -> AppConfig -> ApiRequest -> SchemaCache -> Either Error ActionPlan actionPlan :: Action -> AppConfig -> ApiRequest -> TablesAccess -> SchemaCache -> Either Error ActionPlan
actionPlan act conf apiReq sCache = case act of actionPlan act conf apiReq tAccess sCache = case act of
ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq sCache ActDb dbAct -> Db <$> dbActionPlan dbAct conf apiReq tAccess sCache
ActRelationInfo ident -> pure . NoDb $ RelInfoPlan ident ActRelationInfo ident -> pure . NoDb $ RelInfoPlan ident
ActRoutineInfo ident inv -> ActRoutineInfo ident inv ->
let crPln = callReadPlan ident conf sCache apiReq inv in let crPln = callReadPlan ident conf tAccess sCache apiReq inv in
NoDb . RoutineInfoPlan . crProc <$> crPln NoDb . RoutineInfoPlan . crProc <$> crPln
ActSchemaInfo -> pure $ NoDb SchemaInfoPlan ActSchemaInfo -> pure $ NoDb SchemaInfoPlan
dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> SchemaCache -> Either Error DbActionPlan dbActionPlan :: DbAction -> AppConfig -> ApiRequest -> TablesAccess -> SchemaCache -> Either Error DbActionPlan
dbActionPlan dbAct conf apiReq sCache = case dbAct of dbActionPlan dbAct conf apiReq tAccess sCache = case dbAct of
ActRelationRead identifier headersOnly -> ActRelationRead identifier headersOnly ->
toDbActPlan <$> wrappedReadPlan identifier conf sCache apiReq headersOnly toDbActPlan <$> wrappedReadPlan identifier conf tAccess sCache apiReq headersOnly
ActRelationMut identifier mut -> ActRelationMut identifier mut ->
toDbActPlan <$> mutateReadPlan mut apiReq identifier conf sCache toDbActPlan <$> mutateReadPlan mut apiReq identifier conf tAccess sCache
ActRoutine identifier invMethod -> ActRoutine identifier invMethod ->
toDbActPlan <$> callReadPlan identifier conf sCache apiReq invMethod toDbActPlan <$> callReadPlan identifier conf tAccess sCache apiReq invMethod
ActSchemaRead tSchema headersOnly -> ActSchemaRead tSchema headersOnly ->
MayUseDb <$> inspectPlan apiReq headersOnly tSchema MayUseDb <$> inspectPlan apiReq headersOnly tSchema
where where
@@ -191,32 +192,32 @@ dbActionPlan dbAct conf apiReq sCache = case dbAct of
MTVndPlan{} -> DbCrud True pl MTVndPlan{} -> DbCrud True pl
_ -> DbCrud False pl _ -> DbCrud False pl
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do wrappedReadPlan identifier conf tAccess sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
qi <- findTable identifier sCache qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest rPlan <- readPlan qi conf tAccess sCache apiRequest
(handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan) (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 () if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right ()
return $ WrappedReadPlan rPlan SQL.Read handler mediaType headersOnly qi return $ WrappedReadPlan rPlan SQL.Read handler mediaType headersOnly qi
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> Either Error CrudPlan
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf tAccess sCache = do
qi <- findTable identifier sCache qi <- findTable identifier sCache
rPlan <- readPlan qi conf sCache apiRequest rPlan <- readPlan qi conf tAccess sCache apiRequest
mPlan <- mutatePlan mutation qi apiRequest sCache rPlan mPlan <- mutatePlan mutation qi apiRequest sCache rPlan
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestErr $ InvalidPreferences invalidPrefs else Right () 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) (handler, mediaType) <- mapLeft ApiRequestErr $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
return $ MutateReadPlan rPlan mPlan SQL.Write handler mediaType mutation qi return $ MutateReadPlan rPlan mPlan SQL.Write handler mediaType mutation qi
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CrudPlan callReadPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CrudPlan
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{preferHandling, invalidPrefs, preferMaxAffected},..} invMethod = do callReadPlan identifier conf tAccess sCache apiRequest@ApiRequest{iPreferences=Preferences{preferHandling, invalidPrefs, preferMaxAffected},..} invMethod = do
let paramKeys = case invMethod of let paramKeys = case invMethod of
InvRead _ -> S.fromList $ fst <$> qsParams' InvRead _ -> S.fromList $ fst <$> qsParams'
Inv -> iColumns Inv -> iColumns
proc@Function{..} <- mapLeft SchemaCacheErr $ proc@Function{..} <- mapLeft SchemaCacheErr $
findProc identifier paramKeys (dbRoutines sCache) iContentMediaType (invMethod == Inv) 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 let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
rPlan <- readPlan relIdentifier conf sCache apiRequest rPlan <- readPlan relIdentifier conf tAccess sCache apiRequest
let args = case (invMethod, iContentMediaType) of let args = case (invMethod, iContentMediaType) of
(InvRead _, _) -> DirectArgs $ toRpcParams proc qsParams' (InvRead _, _) -> DirectArgs $ toRpcParams proc qsParams'
(Inv, MTUrlEncoded) -> DirectArgs $ maybe mempty (toRpcParams proc . payArray) iPayload (Inv, MTUrlEncoded) -> DirectArgs $ maybe mempty (toRpcParams proc . payArray) iPayload
@@ -313,10 +314,11 @@ data ResolverContext = ResolverContext
, representations :: RepresentationsMap , representations :: RepresentationsMap
, qi :: QualifiedIdentifier -- ^ The table we're currently attending; changes as we recurse into joins etc. , 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". , 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 :: Column -> Maybe ToTsVector -> CoercibleField
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False Nothing
resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField
resolveTableFieldName table fieldName toTsV= resolveTableFieldName table fieldName toTsV=
@@ -376,11 +378,11 @@ resolveQueryInputField ctx field opExpr = withTextParse ctx $ resolveTypeOrUnkno
-- | Builds the ReadPlan tree on a number of stages. -- | Builds the ReadPlan tree on a number of stages.
-- | Adds filters, order, limits on its respective nodes. -- | Adds filters, order, limits on its respective nodes.
-- | Adds joins conditions obtained from resource embedding. -- | Adds joins conditions obtained from resource embedding.
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree readPlan :: QualifiedIdentifier -> AppConfig -> TablesAccess -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates, configUrlUseLegacyTargetNames} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest = readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates, configUrlUseLegacyTargetNames} tAccess SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
let let
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV. -- 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" ctx = ResolverContext dbTables dbRepresentations qi "json" tAccess
in in
treeRestrictRange configDbMaxRows (iAction apiRequest) =<< treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
addToManyOrderSelects =<< addToManyOrderSelects =<<
@@ -476,7 +478,8 @@ knownColumnsInContext ResolverContext{..} =
-- | Expand "select *" into explicit field names of the table in the following situations: -- | Expand "select *" into explicit field names of the table in the following situations:
-- * When there are data representations present. -- * When there are data representations present.
-- * When there is an aggregate function in a given ReadPlan or its parent. -- * When there is an aggregate function in a given ReadPlan or its parent.
-- * When the ReadPlan is a to-many spread relationship -- * 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.
expandStars :: ResolverContext -> ReadPlanTree -> Either Error ReadPlanTree expandStars :: ResolverContext -> ReadPlanTree -> Either Error ReadPlanTree
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
where where
@@ -496,13 +499,16 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
adjustContext context fromQI _ = context{qi=fromQI} adjustContext context fromQI _ = context{qi=fromQI}
expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread} expandStarsForTable ctx@ResolverContext{representations, outputType, tables, qi} hasAgg rp@ReadPlan{select=selectFields, relSpread=spread}
-- We expand if either of the below are true: -- We expand the '*' select if either of the below are true:
-- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree. -- * The target table has columns the request role cannot read.
-- * We have a '*' select AND the target table has at least one data representation. -- * There is an aggregate function in this ReadPlan's sub-tree.
-- * 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 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. -- we tag it as "full row" in that case.
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField (isJust spread) knownColumns) selectFields} | 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}
| otherwise = rp | otherwise = rp
where where
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
@@ -510,6 +516,11 @@ expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@R
shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count) shouldExpandOrTag aggFunc = isNothing aggFunc || (isJust spread && aggFunc == Just Count)
hasDataRepresentation = any hasOutputRep knownColumns hasDataRepresentation = any hasOutputRep knownColumns
knownColumns = knownColumnsInContext ctx 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 :: Column -> Bool
hasOutputRep col = HM.member (colNominalType col, outputType) representations hasOutputRep col = HM.member (colNominalType col, outputType) representations
@@ -521,6 +532,33 @@ expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@R
[sel { csField = fld { cfFullRow = True } }] [sel { csField = fld { cfFullRow = True } }]
expandStarSelectField _ _ selectField = [selectField] 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 -- | Enforces the `max-rows` config on the result
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either Error ReadPlanTree treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either Error ReadPlanTree
treeRestrictRange _ (ActDb (ActRelationMut _ _)) request = Right request treeRestrictRange _ (ActDb (ActRelationMut _ _)) request = Right request
@@ -930,7 +968,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- where_ = [ -- where_ = [
-- CoercibleStmnt ( -- CoercibleStmnt (
-- CoercibleFilter { -- CoercibleFilter {
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, -- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False, cfExpression = Nothing},
-- opExpr = op -- opExpr = op
-- } -- }
-- ) -- )
@@ -947,7 +985,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. -- 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 []) -- >>> 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}, 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, cfExpression = Nothing}, 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. -- 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.
-- --
@@ -1059,7 +1097,7 @@ mutatePlan mutation qi ApiRequest{iPreferences=Preferences{..}, ..} SchemaCache{
Left $ ApiRequestErr InvalidFilters Left $ ApiRequestErr InvalidFilters
MutationDelete -> Right $ Delete qi combinedLogic returnings MutationDelete -> Right $ Delete qi combinedLogic returnings
where where
ctx = ResolverContext dbTables dbRepresentations qi "json" ctx = ResolverContext dbTables dbRepresentations qi "json" mempty
confCols = fromMaybe pkCols qsOnConflict confCols = fromMaybe pkCols qsOnConflict
QueryParams.QueryParams{..} = iQueryParams QueryParams.QueryParams{..} = iQueryParams
returnings = returnings =
+2 -1
View File
@@ -47,10 +47,11 @@ data CoercibleField = CoercibleField
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType. , cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
, cfDefault :: Maybe Text , 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. , 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) } deriving (Eq, Show)
unknownField :: FieldName -> JsonPath -> CoercibleField unknownField :: FieldName -> JsonPath -> CoercibleField
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False Nothing
-- | Like an API request LogicTree, but with coercible field information. -- | Like an API request LogicTree, but with coercible field information.
data CoercibleLogicTree data CoercibleLogicTree
+60
View File
@@ -0,0 +1,60 @@
{-|
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 [] -> "FROM " <> callIt mempty
KeyParams prms -> case arguments of KeyParams prms -> case arguments of
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args) DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False) <$> prms) False True False <> ", " <> JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False Nothing) <$> prms) False True False <> ", " <>
"LATERAL " <> callIt (fmtParams prms) "LATERAL " <> callIt (fmtParams prms)
callIt :: SQL.Snippet -> SQL.Snippet callIt :: SQL.Snippet -> SQL.Snippet
+27 -1
View File
@@ -249,6 +249,7 @@ pgFmtField table cf = case cfToTsVector cf of
_ -> fmtFld _ -> fmtFld
where where
fmtFld = case cf of fmtFld = case cf of
CoercibleField{cfExpression=Just expr} -> SQL.sql (encodeUtf8 expr)
CoercibleField{cfFullRow=True} -> pgFmtIdent (qiName table) CoercibleField{cfFullRow=True} -> pgFmtIdent (qiName table)
CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn
CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
@@ -598,7 +599,32 @@ accessibleTables :: Text -> SQL.Snippet
accessibleTables schema = SQL.sql (encodeUtf8 [trimming| accessibleTables schema = SQL.sql (encodeUtf8 [trimming|
SELECT SELECT
n.nspname AS table_schema, n.nspname AS table_schema,
c.relname AS table_name 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
FROM pg_class c FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('v','r','m','f','p') 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 = actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) ApiRequest{..} versions conf sCache =
let let
rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body rsBody = maybe mempty (\(tbls, tblAccess, procs, schDesc) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache tbls tblAccess procs schDesc) body
cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody] cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody]
in in
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody
+103 -43
View File
@@ -27,10 +27,11 @@ import PostgREST.Config (AppConfig (..), Proxy (..),
isMalformedProxyUri, toURI) isMalformedProxyUri, toURI)
import PostgREST.MediaType import PostgREST.MediaType
import PostgREST.Network (escapeHostName) import PostgREST.Network (escapeHostName)
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
import PostgREST.SchemaCache (SchemaCache (..)) import PostgREST.SchemaCache (SchemaCache (..))
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..), import PostgREST.SchemaCache.Relationship (Cardinality (..), Junction (..),
RelationshipsMap) Relationship (..), RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..), import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..),
RoutineParam (..)) RoutineParam (..))
import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap, import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
@@ -38,18 +39,27 @@ import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
import Protolude hiding (Proxy, get) import Protolude hiding (Proxy, get)
encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> TablesAccess -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables procs schemaDescription = encode versions conf sCache tables access procs schemaDescription =
JSON.encode $ JSON.encode $
postgrestSpec postgrestSpec
versions versions
(dbRelationships sCache) (dbRelationships sCache)
(concat $ HM.elems procs) (concat $ HM.elems procs)
(snd <$> HM.toList tables) (fmap (\(_, t) -> (t, accessFor access t)) (HM.toList tables))
(proxyUri conf) (proxyUri conf)
schemaDescription schemaDescription
(configOpenApiSecurityActive conf) (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 :: [MediaType] -> MimeList
makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs
@@ -97,14 +107,41 @@ parseDefault colType colDefault =
where where
wrapInQuotations text = "\"" <> text <> "\"" wrapInQuotations text = "\"" <> text <> "\""
makeTableDef :: RelationshipsMap -> Table -> (Text, Schema) makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema)
makeTableDef rels t = makeTableDef rels (t, access) =
let tn = tableName t in (tn, (mempty :: Schema)
(tn, (mempty :: Schema) & description .~ tblDescription
& description .~ tableDescription t & type_ ?~ SwaggerObject
& type_ ?~ SwaggerObject & properties .~ fromList (makeProperty t rels <$> cols)
& properties .~ fromList (makeProperty t rels <$> tableColumnsList t) & required .~ fmap colName (filter (not . colNullable) cols))
& required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t)) 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)
makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema) makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
makeProperty tbl rels col = (colName col, Inline s) makeProperty tbl rels col = (colName col, Inline s)
@@ -129,11 +166,18 @@ 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 (\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol
pk :: Bool pk :: Bool
pk = colName col `elem` tablePKCols tbl 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 n = catMaybes
[ Just "Note:" [ Just "Note:"
, if pk then Just "This is a Primary Key.<pk/>" else Nothing , if pk then Just "This is a Primary Key.<pk/>" else Nothing
, fk
] ]
<> uniqueNotes
<> catMaybes [fk]
d = d =
if length n > 1 then if length n > 1 then
Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n) Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription col) (T.intercalate "\n" n)
@@ -222,8 +266,8 @@ makeProcPostParams pd =
, Ref $ Reference "preferParams" , Ref $ Reference "preferParams"
] ]
makeParamDefs :: [Table] -> [(Text, Param)] makeParamDefs :: RelationshipsMap -> [(Table, TableAccess)] -> [(Text, Param)]
makeParamDefs ti = makeParamDefs rels tis =
-- TODO: create Prefer for each method (GET, PATCH, etc.) -- TODO: create Prefer for each method (GET, PATCH, etc.)
[ ("preferParams", makePreferParam ["params"]) [ ("preferParams", makePreferParam ["params"])
, ("preferReturn", makePreferParam ["return"]) , ("preferReturn", makePreferParam ["return"])
@@ -280,17 +324,27 @@ makeParamDefs ti =
& in_ .~ ParamQuery & in_ .~ ParamQuery
& type_ ?~ SwaggerString)) & type_ ?~ SwaggerString))
] ]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t) <> concat [ makeObjectBody rels t access <> makeRowFilters (tableName t) (accessibleCols t (taSelectCols access))
| t <- ti | (t, access) <- tis
] ]
makeObjectBody :: Text -> (Text, Param) makeObjectBody :: RelationshipsMap -> Table -> TableAccess -> [(Text, Param)]
makeObjectBody tn = makeObjectBody rels t access =
("body." <> tn, (mempty :: Param) [ ("body." <> tn, makeBodyParam (taInsertCols access))
& name .~ tn , ("body." <> tn <> ".patch", makeBodyParam (taUpdateCols access))
& description ?~ tn ]
& required ?~ False where
& schema .~ ParamBody (Ref (Reference tn))) tn = tableName t
makeBodyParam cols = (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))
makeRowFilter :: Text -> Column -> (Text, Param) makeRowFilter :: Text -> Column -> (Text, Param)
makeRowFilter tn c = makeRowFilter tn c =
@@ -305,8 +359,8 @@ makeRowFilter tn c =
makeRowFilters :: Text -> [Column] -> [(Text, Param)] makeRowFilters :: Text -> [Column] -> [(Text, Param)]
makeRowFilters tn = fmap (makeRowFilter tn) makeRowFilters tn = fmap (makeRowFilter tn)
makePathItem :: Table -> (FilePath, PathItem) makePathItem :: (Table, TableAccess) -> (FilePath, PathItem)
makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t) makePathItem (t, access) = ("/" ++ T.unpack tn, p)
where where
-- Use first line of table description as summary; rest as description (if present) -- 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 -- We strip leading newlines from description so that users can include a blank line between summary and description
@@ -327,20 +381,26 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t
) )
) )
postOp = tOp postOp = tOp
& parameters .~ fmap ref ["body." <> tn, "select", "preferPost"] & parameters .~ fmap ref [bodyParam, "select", "preferPost"]
& at 201 ?~ "Created" & at 201 ?~ "Created"
patchOp = tOp patchOp = tOp
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"]) & parameters .~ fmap ref (rs <> [patchBodyParam, "preferReturn"])
& at 204 ?~ "No Content" & at 204 ?~ "No Content"
deletOp = tOp deletOp = tOp
& parameters .~ fmap ref (rs <> ["preferReturn"]) & parameters .~ fmap ref (rs <> ["preferReturn"])
& at 204 ?~ "No Content" & at 204 ?~ "No Content"
pr = (mempty :: PathItem) & get ?~ getOp p = (mempty :: PathItem)
pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp & get .~ (if not (null selCols) then Just getOp else Nothing)
p False = pr & post .~ (if tableInsertable t && not (null insCols) then Just postOp else Nothing)
p True = pw & patch .~ (if tableUpdatable t && not (null updCols) then Just patchOp else Nothing)
& delete .~ (if tableDeletable t && taDelete access then Just deletOp else Nothing)
tn = tableName t tn = tableName t
rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- tableColumnsList 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"
ref = Ref . Reference ref = Ref . Reference
makeProcPathItem :: Routine -> (FilePath, PathItem) makeProcPathItem :: Routine -> (FilePath, PathItem)
@@ -375,9 +435,9 @@ makeRootPathItem = ("/", p)
pr = (mempty :: PathItem) & get ?~ getOp pr = (mempty :: PathItem) & get ?~ getOp
p = pr p = pr
makePathItems :: [Routine] -> [Table] -> InsOrdHashMap FilePath PathItem makePathItems :: [Routine] -> [(Table, TableAccess)] -> InsOrdHashMap FilePath PathItem
makePathItems pds ti = fromList $ makeRootPathItem : makePathItems pds tis = fromList $ makeRootPathItem :
fmap makePathItem ti ++ fmap makeProcPathItem pds fmap makePathItem tis ++ fmap makeProcPathItem pds
makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions
makeSecurityDefinitions secName allow makeSecurityDefinitions secName allow
@@ -387,8 +447,8 @@ makeSecurityDefinitions secName allow
secSchType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader) secSchType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader)
secSchDescription = Just "Add the token prepending \"Bearer \" (without quotes) to it" secSchDescription = Just "Add the token prepending \"Bearer \" (without quotes) to it"
postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [Table] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger postgrestSpec :: (Text, Text) -> RelationshipsMap -> [Routine] -> [(Table, TableAccess)] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger
postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger) postgrestSpec (prettyVersion, docsVersion) rels pds tis (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger)
& basePath ?~ T.unpack b & basePath ?~ T.unpack b
& schemes ?~ [s'] & schemes ?~ [s']
& info .~ ((mempty :: Info) & info .~ ((mempty :: Info)
@@ -399,9 +459,9 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu
& description ?~ "PostgREST Documentation" & description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/references/api.html")) & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/references/api.html"))
& host .~ h' & host .~ h'
& definitions .~ fromList (makeTableDef rels <$> ti) & definitions .~ fromList (makeTableDef rels <$> tis)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs rels tis)
& paths .~ makePathItems pds ti & paths .~ makePathItems pds tis
& produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV] & produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV] & consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef & securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
+122 -5
View File
@@ -150,6 +150,7 @@ querySchemaCache pgVer conf@AppConfig{..} = do
m2oRels <- sqlTimedStmt gucRels mempty allM2OandO2ORels m2oRels <- sqlTimedStmt gucRels mempty allM2OandO2ORels
funcs <- sqlTimedStmt gucFuncs conf (allFunctions pgVer configDbPreparedStatements) funcs <- sqlTimedStmt gucFuncs conf (allFunctions pgVer configDbPreparedStatements)
cRels <- sqlTimedStmt gucCRels mempty allComputedRels cRels <- sqlTimedStmt gucCRels mempty allComputedRels
rlsPols <- sqlTimedStmt gucRLS conf allRlsPolicies
reps <- sqlTimedStmt gucDReps conf dataRepresentations reps <- sqlTimedStmt gucDReps conf dataRepresentations
mHdlers <- sqlTimedStmt gucMHdrs conf mediaHandlers mHdlers <- sqlTimedStmt gucMHdrs conf mediaHandlers
@@ -161,10 +162,11 @@ querySchemaCache pgVer conf@AppConfig{..} = do
else pure Nothing else pure Nothing
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
tabsWithRls = addRlsQuals tabsWViewsPks (combineRlsPolicies rlsPols)
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
return (removeInternal schemas $ SchemaCache { return (removeInternal schemas $ SchemaCache {
dbTables = tabsWViewsPks dbTables = tabsWithRls
, dbRelationships = getOverrideRelationshipsMap rels cRels , dbRelationships = getOverrideRelationshipsMap rels cRels
, dbRoutines = funcs , dbRoutines = funcs
, dbRepresentations = reps , dbRepresentations = reps
@@ -232,6 +234,7 @@ decodeTables =
<*> column HD.bool <*> column HD.bool
<*> column HD.bool <*> column HD.bool
<*> arrayColumn HD.text <*> arrayColumn HD.text
<*> column (HD.refine parseUniqueCols HD.jsonb)
<*> parseCols (compositeArrayColumn <*> parseCols (compositeArrayColumn
(Column (Column
<$> compositeField HD.text <$> compositeField HD.text
@@ -242,11 +245,19 @@ decodeTables =
<*> nullableCompositeField HD.int4 <*> nullableCompositeField HD.int4
<*> nullableCompositeField HD.text <*> nullableCompositeField HD.text
<*> compositeFieldArray HD.text)) <*> compositeFieldArray HD.text))
<*> pure (Nothing :: Maybe Text)
<*> pure (Nothing :: Maybe Text)
parseCols :: HD.Row [Column] -> HD.Row ColumnMap parseCols :: HD.Row [Column] -> HD.Row ColumnMap
parseCols = fmap (HMI.fromList . map (\col@Column{colName} -> (colName, col))) 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.Result [Relationship]
decodeRels = decodeRels =
HD.rowList relRow HD.rowList relRow
@@ -676,6 +687,25 @@ tablesSqlQuery pgVer =
AND NOT pg_is_other_temp_schema(r.relnamespace) AND NOT pg_is_other_temp_schema(r.relnamespace)
AND NOT a.attisdropped AND NOT a.attisdropped
GROUP BY r.oid 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 SELECT
n.nspname AS table_schema, n.nspname AS table_schema,
@@ -709,11 +739,13 @@ tablesSqlQuery pgVer =
) )
) AS deletable, ) AS deletable,
coalesce(tpks.pk_cols, '{}') as pk_cols, coalesce(tpks.pk_cols, '{}') as pk_cols,
coalesce(tunq.unique_cols, '[]'::jsonb) as unique_cols,
coalesce(cols_agg.columns, '{}') as columns coalesce(cols_agg.columns, '{}') as columns
FROM pg_class c FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace 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 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_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 LEFT JOIN columns_agg cols_agg ON c.oid = cols_agg.relid
WHERE c.relkind IN ('v','r','m','f','p') WHERE c.relkind IN ('v','r','m','f','p')
AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace) AND c.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
@@ -810,6 +842,88 @@ allComputedRels =
column HD.bool <*> column HD.bool <*>
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 -- | Returns all the views' primary keys and foreign keys dependencies
allViewsKeyDependencies :: SQL.Statement AppConfig [ViewKeyDependency] allViewsKeyDependencies :: SQL.Statement AppConfig [ViewKeyDependency]
allViewsKeyDependencies = allViewsKeyDependencies =
@@ -1150,15 +1264,15 @@ extractTimings = SQL.Statement sql HE.noParams decodeThem True
qFrag setting = "extract('milliseconds' from current_setting('pgrst." <> setting <> "', false)::interval)::text" qFrag setting = "extract('milliseconds' from current_setting('pgrst." <> setting <> "', false)::interval)::text"
sql = "SELECT " <> BS.intercalate "," sql = "SELECT " <> BS.intercalate ","
[ qFrag gucTbls, qFrag gucKDeps, qFrag gucRels [ qFrag gucTbls, qFrag gucKDeps, qFrag gucRels
, qFrag gucFuncs, qFrag gucCRels, qFrag gucDReps , qFrag gucFuncs, qFrag gucCRels, qFrag gucRLS
, qFrag gucMHdrs , qFrag gucDReps, qFrag gucMHdrs
] ]
decodeThem :: HD.Result QueryTimings decodeThem :: HD.Result QueryTimings
decodeThem = HD.singleRow $ decodeThem = HD.singleRow $
QueryTimings 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 <*> column HD.text <*> column HD.text <*> column HD.text
<*> column HD.text <*> column HD.text <*> column HD.text
data QueryTimings = QueryTimings data QueryTimings = QueryTimings
{ qtTables :: Text { qtTables :: Text
@@ -1166,6 +1280,7 @@ data QueryTimings = QueryTimings
, qtRels :: Text , qtRels :: Text
, qtFuncs :: Text , qtFuncs :: Text
, qtCRels :: Text , qtCRels :: Text
, qtRls :: Text
, qtDReps :: Text , qtDReps :: Text
, qtMHdrs :: Text , qtMHdrs :: Text
} deriving (Show) } deriving (Show)
@@ -1177,15 +1292,17 @@ queryTimingsWLabels qt =
, (gucRels, qtRels qt) , (gucRels, qtRels qt)
, (gucFuncs, qtFuncs qt) , (gucFuncs, qtFuncs qt)
, (gucCRels, qtCRels qt) , (gucCRels, qtCRels qt)
, (gucRLS, qtRls qt)
, (gucDReps, qtDReps qt) , (gucDReps, qtDReps qt)
, (gucMHdrs, qtMHdrs qt) , (gucMHdrs, qtMHdrs qt)
] ]
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucDReps, gucMHdrs :: ByteString gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucRLS, gucDReps, gucMHdrs :: ByteString
gucTbls = "tables" gucTbls = "tables"
gucKDeps = "keydeps" gucKDeps = "keydeps"
gucRels = "rels" gucRels = "rels"
gucFuncs = "funcs" gucFuncs = "funcs"
gucCRels = "comprels" gucCRels = "comprels"
gucRLS = "rls"
gucDReps = "dreps" gucDReps = "dreps"
gucMHdrs = "mhandlers" gucMHdrs = "mhandlers"
@@ -29,7 +29,18 @@ data Table = Table
, tableUpdatable :: Bool , tableUpdatable :: Bool
, tableDeletable :: Bool , tableDeletable :: Bool
, tablePKCols :: [FieldName] , 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 , 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) deriving (Show, Generic, JSON.ToJSON)
@@ -7,7 +7,44 @@
tableIsView: false tableIsView: false
tableName: authors_only tableName: authors_only
tablePKCols: [] tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public 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 tableUpdatable: true
- - qiName: cats - - qiName: cats
@@ -38,7 +75,55 @@
tableName: cats tableName: cats
tablePKCols: tablePKCols:
- id - id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public 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 tableUpdatable: true
- - qiName: items_w_isolation_level - - qiName: items_w_isolation_level
@@ -68,7 +153,10 @@
tableIsView: true tableIsView: true
tableName: items_w_isolation_level tableName: items_w_isolation_level
tablePKCols: [] tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: directors - - qiName: directors
@@ -99,7 +187,10 @@
tableName: directors tableName: directors
tablePKCols: tablePKCols:
- id - id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: projects - - qiName: projects
@@ -111,7 +202,10 @@
tableIsView: false tableIsView: false
tableName: projects tableName: projects
tablePKCols: [] tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: infinite_recursion - - qiName: infinite_recursion
@@ -123,7 +217,10 @@
tableIsView: true tableIsView: true
tableName: infinite_recursion tableName: infinite_recursion
tablePKCols: [] tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: false tableUpdatable: false
- - qiName: awards - - qiName: awards
@@ -181,7 +278,10 @@
tableName: awards tableName: awards
tablePKCols: tablePKCols:
- id - id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: films - - qiName: films
@@ -221,7 +321,10 @@
tableName: films tableName: films
tablePKCols: tablePKCols:
- id - id
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
- - qiName: items - - qiName: items
@@ -242,5 +345,8 @@
tableIsView: false tableIsView: false
tableName: items tableName: items
tablePKCols: [] tablePKCols: []
tableRlsDeleteQual: null
tableRlsEditQual: null
tableSchema: public tableSchema: public
tableUniqueCols: []
tableUpdatable: true tableUpdatable: true
+5
View File
@@ -7,3 +7,8 @@ GRANT SELECT ON directors, films, awards TO postgrest_test_anonymous, postgrest_
GRANT ALL ON cats TO postgrest_test_anonymous; 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 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,3 +268,34 @@ $$ language sql;
create or replace function get_work_mem() returns text as $$ create or replace function get_work_mem() returns text as $$
select current_setting('work_mem', true); select current_setting('work_mem', true);
$$ language sql; $$ 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." assert data["message"] == "Timed out acquiring connection from connection pool."
# ensure the message appears on the logs as well # ensure the message appears on the logs as well
output = sorted(postgrest.read_stdout(nlines=10)) output = sorted(drain_stdout(postgrest))
if level == "crit": if level == "crit":
assert len(output) == 0 assert len(output) == 0
+4 -4
View File
@@ -44,7 +44,7 @@ def test_log_level(level, defaultenv):
response = postgrest.session.get("/") response = postgrest.session.get("/")
assert response.status_code == 200 assert response.status_code == 200
output = postgrest.read_stdout(nlines=9) output = drain_stdout(postgrest)
if level == "crit": if level == "crit":
assert len(output) == 0 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/.+"', r'- - postgrest_test_anonymous \[.+\] "GET / HTTP/1.1" 200 \d+ "" "python-requests/.+"',
], ],
) )
assert len(output) == 9 assert len(output) > 3
assert any("Connection" and "is available" in line for line in output) assert any("Connection" and "is available" in line for line in output)
assert any("Connection" and "is used" 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 assert response.status_code == 500
# ensure the message appears on the logs # ensure the message appears on the logs
output = postgrest.read_stdout(nlines=8) output = drain_stdout(postgrest)
if level == "crit": if level == "crit":
assert len(output) == 0 assert len(output) == 0
@@ -456,7 +456,7 @@ def test_schema_cache_query_timings_log(level, defaultenv):
"PGRST_LOG_LEVEL": level, "PGRST_LOG_LEVEL": level,
} }
log_pattern = re.compile( log_pattern = re.compile(
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, dreps: [\d.]+ ms, mhandlers: [\d.]+ ms" 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"
) )
with run(env=env, no_startup_stdout=False) as postgrest: with run(env=env, no_startup_stdout=False) as postgrest:
+63
View File
@@ -0,0 +1,63 @@
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
+134 -1
View File
@@ -222,6 +222,58 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
. nth 0 . nth 0
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] 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 it "includes a fk description for a O2O relationship" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -233,10 +285,91 @@ spec withConfig = withConfig baseCfg $ describe "OpenAPI" $ do
{ {
"format": "int32", "format": "int32",
"type": "integer", "type": "integer",
"description": "Note:\nThis is a Foreign Key to `second.id`.<fk table='second' column='id'/>" "description": "Note:\nThis is a Unique column.<unique/>\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" $ describe "Foreign table" $
it "includes foreign table properties" $ do it "includes foreign table properties" $ do
+5 -2
View File
@@ -120,9 +120,12 @@ spec withConfig = withConfig baseCfg $
} }
context "table with limited privileges" $ do context "table with limited privileges" $ do
it "fails deleting the row when return=representation and selecting all the columns" $ it "succeeds deleting the row when return=representation and selecting all columns, returning only the privileged columns" $
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
`shouldRespondWith` 401 `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $ 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")] request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
+3 -4
View File
@@ -720,11 +720,10 @@ spec withConfig = withConfig baseCfg $ do
, matchHeaders = [] , matchHeaders = []
} }
it "fails inserting if select is not specified" $ it "succeeds inserting if select is not specified, returning only the accessible columns" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")] request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":3,"user_id":1}]|]
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|] { matchStatus = 201
{ matchStatus = 401
, matchHeaders = [] , matchHeaders = []
} }
+13
View File
@@ -36,6 +36,19 @@ spec actualPgVersion withConfig = withConfig baseCfg $ do
, matchHeaders = ["Content-Length" <:> "120"] , 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 describe "Filtering response" $ do
it "matches with equality" $ it "matches with equality" $
get "/items?id=eq.5" get "/items?id=eq.5"
+3
View File
@@ -28,10 +28,13 @@ REVOKE ALL PRIVILEGES ON TABLE
, authors_only , authors_only
, insertonly , insertonly
, limited_article_stars , limited_article_stars
, selectonly
FROM postgrest_test_anonymous; FROM postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO 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 GRANT USAGE ON SEQUENCE
auto_incrementing_pk_id_seq auto_incrementing_pk_id_seq
, items_id_seq , items_id_seq
+14
View File
@@ -1451,6 +1451,15 @@ create table test.compound_unique(
unique(key1, key2) 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 ( create table test.family_tree (
id text not null primary key, id text not null primary key,
name text not null, name text not null,
@@ -1926,6 +1935,11 @@ create table app_users (
password text not null password text not null
); );
create table selectonly (
id integer primary key,
name text
);
create table private.pages ( create table private.pages (
link int not null unique link int not null unique
, url text , url text