Skip to content

Commit bc2b663

Browse files
committed
Improved database query performance if RLS is enabled
Previously when RLS was enabled it was firing multiple queries just to set up the context, before running the actual query. For DataSync we've already been using a more efficient way of just firing a single query. This commit applies the same approach to the general query interface as well
1 parent f8b07cc commit bc2b663

File tree

2 files changed

+54
-27
lines changed

2 files changed

+54
-27
lines changed

IHP/ModelSupport.hs

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import Data.Scientific
4949
import GHC.Stack
5050
import qualified Numeric
5151
import qualified Data.Text.Encoding as Text
52+
import qualified Data.ByteString.Builder as Builder
5253

5354
-- | Provides the db connection and some IHP-specific db configuration
5455
data ModelContext = ModelContext
@@ -367,7 +368,7 @@ sqlQuery :: (?modelContext :: ModelContext, PG.ToRow q, PG.FromRow r, Show q) =>
367368
sqlQuery theQuery theParameters = do
368369
measureTimeIfLogging
369370
(withDatabaseConnection \connection -> enhanceSqlError theQuery theParameters do
370-
PG.query connection theQuery theParameters
371+
withRLSParams (PG.query connection) theQuery theParameters
371372
)
372373
theQuery
373374
theParameters
@@ -382,28 +383,40 @@ sqlExec :: (?modelContext :: ModelContext, PG.ToRow q, Show q) => Query -> q ->
382383
sqlExec theQuery theParameters = do
383384
measureTimeIfLogging
384385
(withDatabaseConnection \connection -> enhanceSqlError theQuery theParameters do
385-
PG.execute connection theQuery theParameters
386+
withRLSParams (PG.execute connection) theQuery theParameters
386387
)
387388
theQuery
388389
theParameters
389390
{-# INLINABLE sqlExec #-}
390391

392+
-- | Wraps the query with Row level security boilerplate, if a row level security context was provided
393+
--
394+
-- __Example:__
395+
--
396+
-- If a row level security context is given, this will turn a query like the following
397+
--
398+
-- > withRLSParams runQuery "SELECT * FROM projects WHERE id = ?" (Only "..")
399+
--
400+
-- Into the following equivalent:
401+
--
402+
-- > runQuery "SET LOCAL ROLE ?; SET LOCAL rls.ihp_user_id = ?; SELECT * FROM projects WHERE id = ?" ["ihp_authenticated", "<user id>", .."]
403+
--
404+
withRLSParams :: (?modelContext :: ModelContext, PG.ToRow params) => (PG.Query -> [PG.Action] -> result) -> PG.Query -> params -> result
405+
withRLSParams runQuery query params = do
406+
case get #rowLevelSecurity ?modelContext of
407+
Just RowLevelSecurityContext { rlsAuthenticatedRole, rlsUserId } -> do
408+
let query' = "SET LOCAL ROLE ?; SET LOCAL rls.ihp_user_id = ?; " <> query
409+
let params' = [PG.toField (PG.Identifier rlsAuthenticatedRole), PG.toField rlsUserId] <> PG.toRow params
410+
runQuery query' params'
411+
Nothing -> runQuery query (PG.toRow params)
412+
391413
withDatabaseConnection :: (?modelContext :: ModelContext) => (Connection -> IO a) -> IO a
392414
withDatabaseConnection block =
393415
let
394416
ModelContext { connectionPool, transactionConnection, rowLevelSecurity } = ?modelContext
395417
in case transactionConnection of
396418
Just transactionConnection -> block transactionConnection
397-
Nothing ->
398-
-- When row level security is enabled, we need to implicitly wrap the current query in a
399-
-- transaction, as we need to make sure the @SET LOCAL ROLE ihp_authenticated@ and
400-
-- @SET LOCAL rls.ihp_user_id = ..@ queries are executed on the same connection before
401-
-- the actual query has been executed.
402-
case rowLevelSecurity of
403-
Just rowLevelSecurity -> withTransaction do
404-
let (Just connection) = ?modelContext |> get #transactionConnection
405-
block connection
406-
Nothing -> Pool.withResource connectionPool block
419+
Nothing -> Pool.withResource connectionPool block
407420
{-# INLINABLE withDatabaseConnection #-}
408421

409422
-- | Runs a raw sql query which results in a single scalar value such as an integer or string
@@ -469,17 +482,7 @@ withTransaction block = withTransactionConnection do
469482
|> \case
470483
Just connection -> connection
471484
Nothing -> error "withTransaction: transactionConnection not set as expected"
472-
case get #rowLevelSecurity ?modelContext of
473-
-- When starting a new transaction while RLS is enabled, we switch the transaction over
474-
-- to the @ihp_authenticated@ role and also set the @rls.ihp_user_id@ variable.
475-
--
476-
-- This branch is also called from @withDatabaseConnection@, as we
477-
-- automatically wrap all queries in an implicit transaction when RLS is enabled.
478-
Just RowLevelSecurityContext { rlsAuthenticatedRole, rlsUserId } -> PG.withTransaction connection do
479-
sqlExec "SET LOCAL ROLE ?" [PG.Identifier rlsAuthenticatedRole]
480-
sqlExec "SET LOCAL rls.ihp_user_id = ?" (PG.Only rlsUserId)
481-
block
482-
Nothing -> PG.withTransaction connection block
485+
PG.withTransaction connection block
483486
{-# INLINABLE withTransaction #-}
484487

485488
-- | Executes the given block with the main database role and temporarly sidesteps the row level security policies.
@@ -500,9 +503,7 @@ withTransaction block = withTransactionConnection do
500503
withRowLevelSecurityDisabled :: (?modelContext :: ModelContext) => ((?modelContext :: ModelContext) => IO a) -> IO a
501504
withRowLevelSecurityDisabled block = do
502505
let currentModelContext = ?modelContext
503-
case get #rowLevelSecurity currentModelContext of
504-
Just _ -> let ?modelContext = currentModelContext { rowLevelSecurity = Nothing, transactionConnection = Nothing } in block
505-
Nothing -> block
506+
let ?modelContext = currentModelContext { rowLevelSecurity = Nothing } in block
506507
{-# INLINABLE withRowLevelSecurityDisabled #-}
507508

508509
-- | Returns the postgres connection when called within a 'withTransaction' block
@@ -589,7 +590,12 @@ logQuery query parameters time = do
589590
-- NominalTimeDiff is represented as seconds, and doesn't provide a FormatTime option for printing in ms.
590591
-- To get around that we convert to and from a rational so we can format as desired.
591592
let queryTimeInMs = (time * 1000) |> toRational |> fromRational @Double
592-
Log.debug ("Query (" <> tshow queryTimeInMs <> "ms): " <> tshow query <> " " <> tshow parameters)
593+
let formatRLSInfo userId = " { ihp_user_id = " <> userId <> " }"
594+
let rlsInfo = case get #rowLevelSecurity ?context of
595+
Just RowLevelSecurityContext { rlsUserId = PG.Plain rlsUserId } -> formatRLSInfo (cs (Builder.toLazyByteString rlsUserId))
596+
Just RowLevelSecurityContext { rlsUserId = rlsUserId } -> formatRLSInfo (tshow rlsUserId)
597+
Nothing -> ""
598+
Log.debug ("Query (" <> tshow queryTimeInMs <> "ms): " <> tshow query <> " " <> tshow parameters <> rlsInfo)
593599
{-# INLINABLE logQuery #-}
594600

595601
-- | Runs a @DELETE@ query for a record.

Test/ModelSupportSpec.hs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import qualified Data.Aeson as Aeson
1111
import qualified Data.Dynamic as Dynamic
1212
import Text.Read (read)
1313
import Data.Scientific (Scientific)
14+
import qualified Database.PostgreSQL.Simple.ToField as PG
1415

1516
tests = do
1617
describe "ModelSupport" do
@@ -113,6 +114,26 @@ tests = do
113114
(project |> set #name "Test" |> didChange #name) `shouldBe` False
114115
(project |> didChange #id) `shouldBe` False
115116

117+
describe "withRLSParams" do
118+
it "should do nothing if RLS is disabled" do
119+
let query = "select 1"
120+
let params = ([] :: [PG.Action])
121+
let ?modelContext = notConnectedModelContext undefined
122+
(withRLSParams (\query params -> (query, params)) query params) `shouldBe` (query, params)
123+
124+
it "should add the RLS boilerplate if RLS is enabled" do
125+
let query = "select ?"
126+
let params = [PG.toField (1337 :: Int)]
127+
let rowLevelSecurity = RowLevelSecurityContext { rlsAuthenticatedRole = "ihp_authenticated", rlsUserId = PG.toField ("userId" :: Text) }
128+
let ?modelContext = (notConnectedModelContext undefined) { rowLevelSecurity = Just rowLevelSecurity }
129+
(withRLSParams (\query params -> (query, params)) query params) `shouldBe`
130+
( "SET LOCAL ROLE ?; SET LOCAL rls.ihp_user_id = ?; select ?"
131+
, [ PG.EscapeIdentifier "ihp_authenticated", PG.Escape "userId", PG.Plain "1337" ]
132+
)
133+
134+
instance Eq PG.Action where
135+
a == b = tshow a == tshow b
136+
116137
data Project = Project { id :: Int, name :: Text, meta :: MetaBag }
117138
instance SetField "id" Project Int where
118139
setField value project@(Project { id, name, meta }) = project { Test.ModelSupportSpec.id = value, meta = meta { touchedFields = "id":(touchedFields meta) } }

0 commit comments

Comments
 (0)