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.
This commit is contained in:
@@ -318,7 +318,7 @@ data ResolverContext = ResolverContext
|
||||
}
|
||||
|
||||
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 toTsV=
|
||||
@@ -499,15 +499,16 @@ expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
|
||||
adjustContext context fromQI _ = context{qi=fromQI}
|
||||
|
||||
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 the '*' select if either of the below are true:
|
||||
-- * The target table has columns the request role cannot read.
|
||||
-- * There is an aggregate function in this ReadPlan's sub-tree.
|
||||
-- * The target table has at least one data representation.
|
||||
-- We ignore '*' selects that have an aggregate function attached, unless it's a `COUNT(*)` for a Spread Embed,
|
||||
-- we tag it as "full row" in that case.
|
||||
| hasStarSelect && hasLimitedPrivileges = rp{select = concatMap (expandStarSelectField (isJust spread) accessibleColumns) selectFields}
|
||||
| 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
|
||||
where
|
||||
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
|
||||
@@ -517,6 +518,9 @@ expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@R
|
||||
knownColumns = knownColumnsInContext ctx
|
||||
hasLimitedPrivileges = accessibleColumns /= knownColumns
|
||||
accessibleColumns = accessibleColumnsInContext ctx
|
||||
rlsFields = case HM.lookup qi tables of
|
||||
Just tbl -> rlsSelectFields tbl
|
||||
Nothing -> []
|
||||
|
||||
hasOutputRep :: Column -> Bool
|
||||
hasOutputRep col = HM.member (colNominalType col, outputType) representations
|
||||
@@ -528,6 +532,23 @@ expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@R
|
||||
[sel { csField = fld { cfFullRow = True } }]
|
||||
expandStarSelectField _ _ selectField = [selectField]
|
||||
|
||||
-- | Synthetic can_edit/can_delete select fields for a table that has row-level
|
||||
-- security policies restricting UPDATE/DELETE. These carry the raw qualifier
|
||||
-- expression and are rendered as computed columns on `select *`.
|
||||
rlsSelectFields :: Table -> [CoercibleSelectField]
|
||||
rlsSelectFields tbl = catMaybes [rlsField "can_edit" (tableRlsEditQual tbl), rlsField "can_delete" (tableRlsDeleteQual tbl)]
|
||||
where
|
||||
rlsField :: FieldName -> Maybe Text -> Maybe CoercibleSelectField
|
||||
rlsField name qual = do
|
||||
expr <- qual
|
||||
pure CoercibleSelectField
|
||||
{ csField = (unknownField name []) { cfIRType = "boolean", cfBaseType = "boolean", cfExpression = Just expr }
|
||||
, csAggFunction = Nothing
|
||||
, csAggCast = Nothing
|
||||
, csCast = Nothing
|
||||
, csAlias = Just name
|
||||
}
|
||||
|
||||
-- | The columns of the current table that the request role can SELECT. Falls
|
||||
-- back to all known columns when no access info is available or the role has
|
||||
-- no SELECT privilege on any column.
|
||||
@@ -947,7 +968,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
||||
-- where_ = [
|
||||
-- CoercibleStmnt (
|
||||
-- CoercibleFilter {
|
||||
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False},
|
||||
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False, cfExpression = Nothing},
|
||||
-- opExpr = op
|
||||
-- }
|
||||
-- )
|
||||
@@ -964,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.
|
||||
--
|
||||
-- >>> 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.
|
||||
--
|
||||
|
||||
@@ -47,10 +47,11 @@ data CoercibleField = CoercibleField
|
||||
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
|
||||
, cfDefault :: Maybe Text
|
||||
, cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spread resources.
|
||||
, cfExpression :: Maybe Text -- ^ Raw SQL expression for a computed field (e.g. RLS-derived can_edit/can_delete). When present the field is rendered as this expression instead of a table column.
|
||||
} deriving (Eq, Show)
|
||||
|
||||
unknownField :: FieldName -> JsonPath -> CoercibleField
|
||||
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False
|
||||
unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False Nothing
|
||||
|
||||
-- | Like an API request LogicTree, but with coercible field information.
|
||||
data CoercibleLogicTree
|
||||
|
||||
@@ -180,7 +180,7 @@ callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScal
|
||||
KeyParams [] -> "FROM " <> callIt mempty
|
||||
KeyParams prms -> case arguments of
|
||||
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
|
||||
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False) <$> 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)
|
||||
|
||||
callIt :: SQL.Snippet -> SQL.Snippet
|
||||
|
||||
@@ -249,6 +249,7 @@ pgFmtField table cf = case cfToTsVector cf of
|
||||
_ -> fmtFld
|
||||
where
|
||||
fmtFld = case cf of
|
||||
CoercibleField{cfExpression=Just expr} -> SQL.sql (encodeUtf8 expr)
|
||||
CoercibleField{cfFullRow=True} -> pgFmtIdent (qiName table)
|
||||
CoercibleField{cfName=fn, cfJsonPath=[]} -> pgFmtColumn table fn
|
||||
CoercibleField{cfName=fn, cfToJson=doToJson, cfJsonPath=jp} | doToJson -> "to_jsonb(" <> pgFmtColumn table fn <> ")" <> pgFmtJsonPath jp
|
||||
|
||||
@@ -150,6 +150,7 @@ querySchemaCache pgVer conf@AppConfig{..} = do
|
||||
m2oRels <- sqlTimedStmt gucRels mempty allM2OandO2ORels
|
||||
funcs <- sqlTimedStmt gucFuncs conf (allFunctions pgVer configDbPreparedStatements)
|
||||
cRels <- sqlTimedStmt gucCRels mempty allComputedRels
|
||||
rlsPols <- sqlTimedStmt gucRLS conf allRlsPolicies
|
||||
reps <- sqlTimedStmt gucDReps conf dataRepresentations
|
||||
mHdlers <- sqlTimedStmt gucMHdrs conf mediaHandlers
|
||||
|
||||
@@ -161,10 +162,11 @@ querySchemaCache pgVer conf@AppConfig{..} = do
|
||||
else pure Nothing
|
||||
|
||||
let tabsWViewsPks = addViewPrimaryKeys tabs keyDeps
|
||||
tabsWithRls = addRlsQuals tabsWViewsPks (combineRlsPolicies rlsPols)
|
||||
rels = addInverseRels $ addM2MRels tabsWViewsPks $ addViewM2OAndO2ORels keyDeps m2oRels
|
||||
|
||||
return (removeInternal schemas $ SchemaCache {
|
||||
dbTables = tabsWViewsPks
|
||||
dbTables = tabsWithRls
|
||||
, dbRelationships = getOverrideRelationshipsMap rels cRels
|
||||
, dbRoutines = funcs
|
||||
, dbRepresentations = reps
|
||||
@@ -243,6 +245,8 @@ decodeTables =
|
||||
<*> nullableCompositeField HD.int4
|
||||
<*> nullableCompositeField HD.text
|
||||
<*> compositeFieldArray HD.text))
|
||||
<*> pure (Nothing :: Maybe Text)
|
||||
<*> pure (Nothing :: Maybe Text)
|
||||
|
||||
|
||||
parseCols :: HD.Row [Column] -> HD.Row ColumnMap
|
||||
@@ -838,6 +842,88 @@ allComputedRels =
|
||||
column HD.bool <*>
|
||||
column HD.bool
|
||||
|
||||
-- | A row-level security policy of an exposed table, gathered from pg_policies.
|
||||
data RlsPolicyRow = RlsPolicyRow
|
||||
{ rlsTable :: QualifiedIdentifier
|
||||
, rlsRowSec :: Bool
|
||||
, rlsCmd :: Text -- ^ "w" (UPDATE), "d" (DELETE), "*" (ALL)
|
||||
, rlsPermiss :: Bool
|
||||
, rlsQual :: Maybe Text -- ^ USING qualifier; Nothing means the policy has no USING restriction
|
||||
}
|
||||
|
||||
-- | Returns the UPDATE/DELETE RLS policies of the exposed tables, so that the
|
||||
-- planner can surface per-row can_edit/can_delete fields on SELECT *.
|
||||
allRlsPolicies :: SQL.Statement AppConfig [RlsPolicyRow]
|
||||
allRlsPolicies =
|
||||
SQL.Statement sql params decodeRlsPolicies True
|
||||
where
|
||||
params = map escapeIdent . toList . configDbSchemas >$< arrayParam HE.text
|
||||
sql = encodeUtf8 [trimming|
|
||||
SELECT
|
||||
n.nspname::text AS table_schema,
|
||||
c.relname::text AS table_name,
|
||||
c.relrowsecurity AS row_security,
|
||||
p.polcmd::text AS cmd,
|
||||
p.polpermissive AS permissive,
|
||||
pg_get_expr(p.polqual, p.polrelid) AS qual
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_policy p ON p.polrelid = c.oid
|
||||
WHERE c.relkind IN ('r','p')
|
||||
AND c.relnamespace = ANY($$1::regnamespace[])
|
||||
AND p.polcmd IN ('w','d','*')
|
||||
ORDER BY n.nspname, c.relname|]
|
||||
|
||||
decodeRlsPolicies :: HD.Result [RlsPolicyRow]
|
||||
decodeRlsPolicies =
|
||||
HD.rowList rlsRow
|
||||
where
|
||||
rlsRow = RlsPolicyRow
|
||||
<$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
|
||||
<*> column HD.bool
|
||||
<*> column HD.text
|
||||
<*> column HD.bool
|
||||
<*> nullableColumn HD.text
|
||||
|
||||
-- | Combines the per-command RLS policies of each table into the SQL expression
|
||||
-- used to compute can_edit/can_delete. Following PostgreSQL semantics, multiple
|
||||
-- permissive policies combine with OR and restrictive ones with AND.
|
||||
combineRlsPolicies :: [RlsPolicyRow] -> HM.HashMap QualifiedIdentifier (Maybe Text, Maybe Text)
|
||||
combineRlsPolicies rows = HM.fromList $ mapMaybe toEntry $ HM.toList byTable
|
||||
where
|
||||
byTable = HM.fromListWith (<>) [ (rlsTable r, [r]) | r <- rows ]
|
||||
|
||||
toEntry (qi, rs)
|
||||
| not (any rlsRowSec rs) = Nothing
|
||||
| isNothing editQ && isNothing delQ = Nothing
|
||||
| otherwise = Just (qi, (editQ, delQ))
|
||||
where
|
||||
editQ = combineCmd ["w", "*"] rs
|
||||
delQ = combineCmd ["d", "*"] rs
|
||||
|
||||
combineCmd cmds rs
|
||||
| null policies = Nothing
|
||||
| null permissiveQs = Nothing
|
||||
| otherwise = Just $ wrapQual combined
|
||||
where
|
||||
policies = [ r | r <- rs, rlsCmd r `elem` cmds ]
|
||||
permissiveQs = [ fromMaybe "true" (rlsQual r) | r <- policies, rlsPermiss r ]
|
||||
restrictiveQs = [ fromMaybe "true" (rlsQual r) | r <- policies, not (rlsPermiss r) ]
|
||||
permissiveExpr = T.intercalate " OR " permissiveQs
|
||||
combined = case restrictiveQs of
|
||||
[] -> permissiveExpr
|
||||
_ -> "(" <> permissiveExpr <> ") AND (" <> T.intercalate " AND " restrictiveQs <> ")"
|
||||
|
||||
wrapQual q = "COALESCE(" <> q <> ", false)"
|
||||
|
||||
-- | Attaches the combined RLS qualifiers to the corresponding tables.
|
||||
addRlsQuals :: TablesMap -> HM.HashMap QualifiedIdentifier (Maybe Text, Maybe Text) -> TablesMap
|
||||
addRlsQuals tabs rlsMap = HM.mapWithKey setRls tabs
|
||||
where
|
||||
setRls qi tbl = case HM.lookup qi rlsMap of
|
||||
Nothing -> tbl
|
||||
Just (editQ, delQ) -> tbl { tableRlsEditQual = editQ, tableRlsDeleteQual = delQ }
|
||||
|
||||
-- | Returns all the views' primary keys and foreign keys dependencies
|
||||
allViewsKeyDependencies :: SQL.Statement AppConfig [ViewKeyDependency]
|
||||
allViewsKeyDependencies =
|
||||
@@ -1178,15 +1264,15 @@ extractTimings = SQL.Statement sql HE.noParams decodeThem True
|
||||
qFrag setting = "extract('milliseconds' from current_setting('pgrst." <> setting <> "', false)::interval)::text"
|
||||
sql = "SELECT " <> BS.intercalate ","
|
||||
[ qFrag gucTbls, qFrag gucKDeps, qFrag gucRels
|
||||
, qFrag gucFuncs, qFrag gucCRels, qFrag gucDReps
|
||||
, qFrag gucMHdrs
|
||||
, qFrag gucFuncs, qFrag gucCRels, qFrag gucRLS
|
||||
, qFrag gucDReps, qFrag gucMHdrs
|
||||
]
|
||||
decodeThem :: HD.Result QueryTimings
|
||||
decodeThem = HD.singleRow $
|
||||
QueryTimings
|
||||
<$> column HD.text <*> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> column HD.text <*> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column HD.text <*> column HD.text
|
||||
|
||||
data QueryTimings = QueryTimings
|
||||
{ qtTables :: Text
|
||||
@@ -1194,6 +1280,7 @@ data QueryTimings = QueryTimings
|
||||
, qtRels :: Text
|
||||
, qtFuncs :: Text
|
||||
, qtCRels :: Text
|
||||
, qtRls :: Text
|
||||
, qtDReps :: Text
|
||||
, qtMHdrs :: Text
|
||||
} deriving (Show)
|
||||
@@ -1205,15 +1292,17 @@ queryTimingsWLabels qt =
|
||||
, (gucRels, qtRels qt)
|
||||
, (gucFuncs, qtFuncs qt)
|
||||
, (gucCRels, qtCRels qt)
|
||||
, (gucRLS, qtRls qt)
|
||||
, (gucDReps, qtDReps qt)
|
||||
, (gucMHdrs, qtMHdrs qt)
|
||||
]
|
||||
|
||||
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucDReps, gucMHdrs :: ByteString
|
||||
gucTbls, gucKDeps, gucRels, gucFuncs, gucCRels, gucRLS, gucDReps, gucMHdrs :: ByteString
|
||||
gucTbls = "tables"
|
||||
gucKDeps = "keydeps"
|
||||
gucRels = "rels"
|
||||
gucFuncs = "funcs"
|
||||
gucCRels = "comprels"
|
||||
gucRLS = "rls"
|
||||
gucDReps = "dreps"
|
||||
gucMHdrs = "mhandlers"
|
||||
|
||||
@@ -34,6 +34,13 @@ data Table = Table
|
||||
-- single-element list.
|
||||
, tableUniqueCols :: [[FieldName]]
|
||||
, tableColumns :: ColumnMap
|
||||
, tableRlsEditQual :: Maybe Text
|
||||
-- ^ Combined RLS UPDATE USING qualifier, COALESCE-wrapped. Nothing means
|
||||
-- the table has no row-level edit restriction to surface (RLS disabled or
|
||||
-- no applicable UPDATE policy).
|
||||
, tableRlsDeleteQual :: Maybe Text
|
||||
-- ^ Combined RLS DELETE USING qualifier, COALESCE-wrapped. Nothing means
|
||||
-- the table has no row-level delete restriction to surface.
|
||||
}
|
||||
deriving (Show, Generic, JSON.ToJSON)
|
||||
|
||||
|
||||
@@ -7,6 +7,42 @@
|
||||
tableIsView: false
|
||||
tableName: authors_only
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: no_rls_items
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
name:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: name
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: no_rls_items
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -39,6 +75,53 @@
|
||||
tableName: cats
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: rls_items
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
account_id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: account_id
|
||||
colNominalType: bigint
|
||||
colNullable: true
|
||||
colType: bigint
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
name:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: name
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: rls_items
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
|
||||
->> 'account_id'::text))::bigint = account_id), false)
|
||||
tableRlsEditQual: COALESCE(((((current_setting('request.jwt.claims'::text, true))::json
|
||||
->> 'account_id'::text))::bigint = account_id), false)
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -70,6 +153,8 @@
|
||||
tableIsView: true
|
||||
tableName: items_w_isolation_level
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -102,6 +187,8 @@
|
||||
tableName: directors
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -115,6 +202,8 @@
|
||||
tableIsView: false
|
||||
tableName: projects
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -128,6 +217,8 @@
|
||||
tableIsView: true
|
||||
tableName: infinite_recursion
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: false
|
||||
@@ -187,6 +278,8 @@
|
||||
tableName: awards
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -228,6 +321,8 @@
|
||||
tableName: films
|
||||
tablePKCols:
|
||||
- id
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
@@ -250,6 +345,8 @@
|
||||
tableIsView: false
|
||||
tableName: items
|
||||
tablePKCols: []
|
||||
tableRlsDeleteQual: null
|
||||
tableRlsEditQual: null
|
||||
tableSchema: public
|
||||
tableUniqueCols: []
|
||||
tableUpdatable: true
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
@@ -268,3 +268,34 @@ $$ language sql;
|
||||
create or replace function get_work_mem() returns text as $$
|
||||
select current_setting('work_mem', true);
|
||||
$$ language sql;
|
||||
|
||||
-- RLS fixtures for testing can_edit/can_delete computed fields
|
||||
create table rls_items(
|
||||
id int primary key,
|
||||
account_id bigint,
|
||||
name text
|
||||
);
|
||||
|
||||
alter table rls_items enable row level security;
|
||||
|
||||
create policy rls_items_select on rls_items for select
|
||||
using (
|
||||
account_id is null
|
||||
or (current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id
|
||||
);
|
||||
|
||||
create policy rls_items_update on rls_items for update
|
||||
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
|
||||
|
||||
create policy rls_items_delete on rls_items for delete
|
||||
using ((current_setting('request.jwt.claims', true)::json ->> 'account_id')::bigint = account_id);
|
||||
|
||||
insert into rls_items(id, account_id, name) values (1, 1, 'own'), (2, null, 'public'), (3, 2, 'other');
|
||||
|
||||
-- no RLS at all: can_edit/can_delete must be omitted
|
||||
create table no_rls_items(
|
||||
id int primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
insert into no_rls_items(id, name) values (1, 'a'), (2, 'b');
|
||||
|
||||
+1
-1
@@ -456,7 +456,7 @@ def test_schema_cache_query_timings_log(level, defaultenv):
|
||||
"PGRST_LOG_LEVEL": level,
|
||||
}
|
||||
log_pattern = re.compile(
|
||||
r".+: tables: [\d.]+ ms, keydeps: [\d.]+ ms, rels: [\d.]+ ms, funcs: [\d.]+ ms, comprels: [\d.]+ ms, 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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user