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
+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