restrict openapi spec based on sql grants

This commit is contained in:
2026-08-15 21:41:45 +02:00
parent a8feaadc01
commit 4a5d626112
11 changed files with 209 additions and 54 deletions
+4
View File
@@ -4,6 +4,10 @@ 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
+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`.
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
+1
View File
@@ -78,6 +78,7 @@ library
PostgREST.Network
PostgREST.Observation
PostgREST.Query
PostgREST.Query.OpenApi
PostgREST.Query.PreQuery
PostgREST.Query.QueryBuilder
PostgREST.Query.SqlFragment
+15 -12
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.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)
@@ -44,6 +43,7 @@ import PostgREST.Plan (ActionPlan (..), CrudPlan (..),
DbActionPlan (..), InfoPlan (..),
InspectPlan (..))
import PostgREST.Query (MainQuery (..))
import PostgREST.Query.OpenApi (TableAccess (..), TablesAccess)
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, RoutineMap, Maybe Text))
| MaybeDbResult InspectPlan (Maybe (TablesMap, TablesAccess, RoutineMap, Maybe Text))
| NoDbResult InfoPlan
-- | Standard result set format used for the mqMain query
@@ -174,33 +174,36 @@ actionResult MainQuery{mqOpenAPI=(tblsQ, funcsQ, schQ)} (MayUseDb plan@InspectPl
mainActionQuery = lift $
case configOpenApiMode of
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
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
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, routs, schDesc))
pure $ MaybeDbResult plan (Just (tbls, mempty, 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 =
decodeTablesAccess :: HD.Result TablesAccess
decodeTablesAccess =
let
row = QualifiedIdentifier
<$> column HD.text
<*> column HD.text
row = (,) <$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
<*> (TableAccess
<$> arrayColumn HD.text
<*> arrayColumn HD.text
<*> arrayColumn HD.text
<*> column HD.bool)
in
S.fromList <$> HD.rowList row
HM.fromList <$> HD.rowList row
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,
+29
View File
@@ -0,0 +1,29 @@
{-|
Module : PostgREST.Query.OpenApi
Description : Types for reflecting the role privileges on the OpenAPI output.
-}
module PostgREST.Query.OpenApi
( TableAccess (..)
, TablesAccess
) where
import qualified Data.HashMap.Strict as HM
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
+26 -1
View File
@@ -598,7 +598,32 @@ accessibleTables :: Text -> SQL.Snippet
accessibleTables schema = SQL.sql (encodeUtf8 [trimming|
SELECT
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
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 (\(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]
in
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader iSchema iNegotiatedByProfile)) rsBody
+71 -40
View File
@@ -27,8 +27,9 @@ 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 (QualifiedIdentifier (..))
import PostgREST.SchemaCache.Identifiers (FieldName, QualifiedIdentifier (..))
import PostgREST.SchemaCache.Relationship (Cardinality (..), Relationship (..),
RelationshipsMap)
import PostgREST.SchemaCache.Routine (FuncVolatility (..), Routine (..),
@@ -38,18 +39,27 @@ import PostgREST.SchemaCache.Table (Column (..), Table (..), TablesMap,
import Protolude hiding (Proxy, get)
encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables procs schemaDescription =
encode :: (Text, Text) -> AppConfig -> SchemaCache -> TablesMap -> TablesAccess -> HM.HashMap k [Routine] -> Maybe Text -> LBS.ByteString
encode versions conf sCache tables access procs schemaDescription =
JSON.encode $
postgrestSpec
versions
(dbRelationships sCache)
(concat $ HM.elems procs)
(snd <$> HM.toList tables)
(fmap (\(_, t) -> (t, accessFor access t)) (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
@@ -97,14 +107,19 @@ parseDefault colType colDefault =
where
wrapInQuotations text = "\"" <> text <> "\""
makeTableDef :: RelationshipsMap -> Table -> (Text, Schema)
makeTableDef rels t =
let tn = tableName t in
(tn, (mempty :: Schema)
& description .~ tableDescription t
& type_ ?~ SwaggerObject
& properties .~ fromList (makeProperty t rels <$> tableColumnsList t)
& required .~ fmap colName (filter (not . colNullable) $ tableColumnsList t))
makeTableDef :: RelationshipsMap -> (Table, TableAccess) -> (Text, Schema)
makeTableDef rels (t, access) =
(tn, (mempty :: Schema)
& 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)
accessibleCols :: Table -> [FieldName] -> [Column]
accessibleCols t cols = filter ((`elem` cols) . colName) (tableColumnsList t)
makeProperty :: Table -> RelationshipsMap -> Column -> (Text, Referenced Schema)
makeProperty tbl rels col = (colName col, Inline s)
@@ -222,8 +237,8 @@ makeProcPostParams pd =
, Ref $ Reference "preferParams"
]
makeParamDefs :: [Table] -> [(Text, Param)]
makeParamDefs ti =
makeParamDefs :: RelationshipsMap -> [(Table, TableAccess)] -> [(Text, Param)]
makeParamDefs rels tis =
-- TODO: create Prefer for each method (GET, PATCH, etc.)
[ ("preferParams", makePreferParam ["params"])
, ("preferReturn", makePreferParam ["return"])
@@ -280,17 +295,27 @@ makeParamDefs ti =
& in_ .~ ParamQuery
& type_ ?~ SwaggerString))
]
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) (tableColumnsList t)
| t <- ti
<> concat [ makeObjectBody rels t access <> makeRowFilters (tableName t) (accessibleCols t (taSelectCols access))
| (t, access) <- tis
]
makeObjectBody :: Text -> (Text, Param)
makeObjectBody tn =
("body." <> tn, (mempty :: Param)
& name .~ tn
& description ?~ tn
& required ?~ False
& schema .~ ParamBody (Ref (Reference tn)))
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)
& 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 tn c =
@@ -305,8 +330,8 @@ makeRowFilter tn c =
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
makeRowFilters tn = fmap (makeRowFilter tn)
makePathItem :: Table -> (FilePath, PathItem)
makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t)
makePathItem :: (Table, TableAccess) -> (FilePath, PathItem)
makePathItem (t, access) = ("/" ++ T.unpack tn, p)
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
@@ -327,20 +352,26 @@ makePathItem t = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t
)
)
postOp = tOp
& parameters .~ fmap ref ["body." <> tn, "select", "preferPost"]
& parameters .~ fmap ref [bodyParam, "select", "preferPost"]
& at 201 ?~ "Created"
patchOp = tOp
& parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])
& parameters .~ fmap ref (rs <> [patchBodyParam, "preferReturn"])
& at 204 ?~ "No Content"
deletOp = tOp
& parameters .~ fmap ref (rs <> ["preferReturn"])
& at 204 ?~ "No Content"
pr = (mempty :: PathItem) & get ?~ getOp
pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp
p False = pr
p True = pw
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)
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
makeProcPathItem :: Routine -> (FilePath, PathItem)
@@ -375,9 +406,9 @@ makeRootPathItem = ("/", p)
pr = (mempty :: PathItem) & get ?~ getOp
p = pr
makePathItems :: [Routine] -> [Table] -> InsOrdHashMap FilePath PathItem
makePathItems pds ti = fromList $ makeRootPathItem :
fmap makePathItem ti ++ fmap makeProcPathItem pds
makePathItems :: [Routine] -> [(Table, TableAccess)] -> InsOrdHashMap FilePath PathItem
makePathItems pds tis = fromList $ makeRootPathItem :
fmap makePathItem tis ++ fmap makeProcPathItem pds
makeSecurityDefinitions :: Text -> Bool -> SecurityDefinitions
makeSecurityDefinitions secName allow
@@ -387,8 +418,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] -> (Text, Text, Integer, Text) -> Maybe Text -> Bool -> Swagger
postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecurityDef = (mempty :: Swagger)
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)
& basePath ?~ T.unpack b
& schemes ?~ [s']
& info .~ ((mempty :: Info)
@@ -399,9 +430,9 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu
& description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/references/api.html"))
& host .~ h'
& definitions .~ fromList (makeTableDef rels <$> ti)
& parameters .~ fromList (makeParamDefs ti)
& paths .~ makePathItems pds ti
& definitions .~ fromList (makeTableDef rels <$> tis)
& parameters .~ fromList (makeParamDefs rels tis)
& paths .~ makePathItems pds tis
& produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
+52
View File
@@ -222,6 +222,58 @@ 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 "/"
+3
View File
@@ -28,10 +28,13 @@ 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
+5
View File
@@ -1926,6 +1926,11 @@ 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