diff --git a/src/library/PostgREST/Plan.hs b/src/library/PostgREST/Plan.hs index 8c2cfd5f7..6d0ec432e 100644 --- a/src/library/PostgREST/Plan.hs +++ b/src/library/PostgREST/Plan.hs @@ -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. -- diff --git a/src/library/PostgREST/Plan/Types.hs b/src/library/PostgREST/Plan/Types.hs index bb161c13b..c492cf1a7 100644 --- a/src/library/PostgREST/Plan/Types.hs +++ b/src/library/PostgREST/Plan/Types.hs @@ -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() 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 diff --git a/src/library/PostgREST/Query/QueryBuilder.hs b/src/library/PostgREST/Query/QueryBuilder.hs index 2add82f2e..021069ca3 100644 --- a/src/library/PostgREST/Query/QueryBuilder.hs +++ b/src/library/PostgREST/Query/QueryBuilder.hs @@ -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 diff --git a/src/library/PostgREST/Query/SqlFragment.hs b/src/library/PostgREST/Query/SqlFragment.hs index 842ae7189..6587812e1 100644 --- a/src/library/PostgREST/Query/SqlFragment.hs +++ b/src/library/PostgREST/Query/SqlFragment.hs @@ -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 diff --git a/src/library/PostgREST/SchemaCache.hs b/src/library/PostgREST/SchemaCache.hs index a9e14870e..f1ac8946a 100644 --- a/src/library/PostgREST/SchemaCache.hs +++ b/src/library/PostgREST/SchemaCache.hs @@ -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" diff --git a/src/library/PostgREST/SchemaCache/Table.hs b/src/library/PostgREST/SchemaCache/Table.hs index 2b4f82936..dd527ea84 100644 --- a/src/library/PostgREST/SchemaCache/Table.hs +++ b/src/library/PostgREST/SchemaCache/Table.hs @@ -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) diff --git a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml index 195d33d26..fa9f46828 100644 --- a/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml +++ b/test/io/__snapshots__/test_cli/test_schema_cache_snapshot[dbTables].yaml @@ -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 diff --git a/test/io/fixtures/privileges.sql b/test/io/fixtures/privileges.sql index 56398b774..8edc37000 100644 --- a/test/io/fixtures/privileges.sql +++ b/test/io/fixtures/privileges.sql @@ -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; diff --git a/test/io/fixtures/schema.sql b/test/io/fixtures/schema.sql index 4775ee270..dd8f6447a 100644 --- a/test/io/fixtures/schema.sql +++ b/test/io/fixtures/schema.sql @@ -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'); diff --git a/test/io/test_log.py b/test/io/test_log.py index d582d0b41..b83337fa4 100644 --- a/test/io/test_log.py +++ b/test/io/test_log.py @@ -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: diff --git a/test/io/test_rls.py b/test/io/test_rls.py new file mode 100644 index 000000000..99d4af95a --- /dev/null +++ b/test/io/test_rls.py @@ -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