diff --git a/main/Main.hs b/main/Main.hs index a66c9c07e..b1453df7a 100644 --- a/main/Main.hs +++ b/main/Main.hs @@ -66,8 +66,15 @@ main = do -- read command/path from commad line CLI{cliCommand, cliPath} <- readCLIShowHelp env - -- build the 'AppConfig' from the config file path - conf <- either panic identity <$> readConfig mempty env cliPath + -- build the 'AppConfig' from the config file path and env vars + pathEnvConf <- either panic identity <$> readAppConfig mempty env cliPath Nothing Nothing + + -- read external files + dbUriFile <- readDbUriFile $ configDbUri pathEnvConf + secretFile <- readSecretFile $ configJwtSecret pathEnvConf + + -- add the external files to AppConfig + conf <- either panic identity <$> readAppConfig mempty env cliPath dbUriFile secretFile -- These are config values that can't be reloaded at runtime. Reloading some of them would imply restarting the web server. let @@ -104,10 +111,19 @@ main = do -- Config that can change at runtime refConf <- newIORef conf - let configRereader startingUp = reReadConfig startingUp pool gucConfigEnabled env cliPath refConf + let + -- re-reads config file + db config + dbConfigReReader startingUp = when gucConfigEnabled $ + reReadConfig startingUp pool gucConfigEnabled env cliPath refConf dbUriFile secretFile + -- re-reads jwt-secret external file + config file + db config + fullConfigReReader = + reReadConfig False pool gucConfigEnabled env cliPath refConf + dbUriFile =<< -- db-uri external file could be re-read, but it doesn't make sense as db-uri is not reloadable + readSecretFile (configJwtSecret pathEnvConf) - -- re-read and override the config if db-load-guc-config is true - when gucConfigEnabled $ configRereader True + -- Override the config with config options from the db + -- TODO: the same operation is repeated on connectionWorker, ideally this would be done only once, but dump CmdDumpConfig needs it for tests. + dbConfigReReader True case cliCommand of CmdDumpConfig -> @@ -126,7 +142,8 @@ main = do -- This is passed to the connectionWorker method so it can kill the main thread if the PostgreSQL's version is not supported. mainTid <- myThreadId - let connWorker = connectionWorker mainTid pool refConf refDbStructure refIsWorkerOn (dbChannelEnabled, mvarConnectionStatus) + let connWorker = connectionWorker mainTid pool refConf refDbStructure refIsWorkerOn (dbChannelEnabled, mvarConnectionStatus) $ + dbConfigReReader False -- Sets the initial refDbStructure connWorker @@ -149,13 +166,13 @@ main = do -- Re-read the config on SIGUSR2 void $ installHandler sigUSR2 ( - Catch $ configRereader False + Catch fullConfigReReader ) Nothing #endif -- reload schema cache + config on NOTIFY when dbChannelEnabled $ - listener dbUri dbChannel pool refConf refDbStructure mvarConnectionStatus connWorker $ configRereader False + listener dbUri dbChannel pool refConf refDbStructure mvarConnectionStatus connWorker fullConfigReReader -- ask for the OS time at most once per second getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime} @@ -211,7 +228,8 @@ connectionWorker -> IORef Bool -- ^ Used as a binary Semaphore -> (Bool, MVar ConnectionStatus) -- ^ For interacting with the LISTEN channel -> IO () -connectionWorker mainTid pool refConf refDbStructure refIsWorkerOn (dbChannelEnabled, mvarConnectionStatus) = do + -> IO () +connectionWorker mainTid pool refConf refDbStructure refIsWorkerOn (dbChannelEnabled, mvarConnectionStatus) dbCfReader = do isWorkerOn <- readIORef refIsWorkerOn unless isWorkerOn $ do -- Prevents multiple workers to be running at the same time. Could happen on too many SIGUSR1s. atomicWriteIORef refIsWorkerOn True @@ -227,6 +245,7 @@ connectionWorker mainTid pool refConf refDbStructure refIsWorkerOn (dbChannelEna NotConnected -> return () -- Unreachable because connectionStatus will keep trying to connect Connected actualPgVersion -> do -- Procede with initialization putStrLn ("Connection successful" :: Text) + dbCfReader -- this could be fail because the connection drops, but the loadSchemaCache will pick the error and retry again scStatus <- loadSchemaCache pool actualPgVersion refConf refDbStructure case scStatus of SCLoaded -> pure () -- do nothing and proceed if the load was successful @@ -272,15 +291,6 @@ connectionStatus pool = putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..." return itShould -loadDbSettings :: P.Pool -> IO [(Text, Text)] -loadDbSettings pool = do - result <- P.use pool $ HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty dbSettingsStatement - case result of - Left e -> do - hPutStrLn stderr ("An error ocurred when trying to query database settings for the config parameters:\n" <> show e :: Text) - pure [] - Right x -> pure x - -- | Load the DbStructure by using a connection from the pool. loadSchemaCache :: P.Pool -> PgVersion -> IORef AppConfig -> IORef (Maybe DbStructure) -> IO SCacheStatus loadSchemaCache pool actualPgVersion refConf refDbStructure = do @@ -340,20 +350,29 @@ listener dbUri dbChannel pool refConf refDbStructure mvarConnectionStatus connWo errorMessage = "Could not listen for notifications on the " <> dbChannel <> " channel" :: Text retryMessage = "Retrying listening for notifications on the " <> dbChannel <> " channel.." :: Text --- | Re-reads the config at runtime. -reReadConfig :: Bool -> P.Pool -> Bool -> Environment -> Maybe FilePath -> IORef AppConfig -> IO () -reReadConfig startingUp pool gucConfigEnabled env path refConf = do - dbSettings <- if gucConfigEnabled then loadDbSettings pool else pure [] - readConfig dbSettings env path >>= \case +-- | Re-reads the config plus config options from the db +reReadConfig :: Bool -> P.Pool -> Bool -> Environment -> Maybe FilePath -> IORef AppConfig -> Maybe Text -> Maybe BS.ByteString -> IO () +reReadConfig startingUp pool gucConfigEnabled env path refConf dbUriFile secretFile = do + dbSettings <- if gucConfigEnabled then loadDbSettings else pure [] + readAppConfig dbSettings env path dbUriFile secretFile >>= \case Left err -> if startingUp then panic err -- die on invalid config if the program is starting up - else hPutStrLn stderr $ "Failed config reload. " <> err + else hPutStrLn stderr $ "Failed config load. " <> err Right conf -> do atomicWriteIORef refConf conf if startingUp then pass - else putStrLn ("Config reloaded" :: Text) + else putStrLn ("Config loaded" :: Text) + where + loadDbSettings :: IO [(Text, Text)] + loadDbSettings = do + result <- P.use pool $ HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty dbSettingsStatement + case result of + Left e -> do + hPutStrLn stderr ("An error ocurred when trying to query database settings for the config parameters:\n" <> show e :: Text) + pure [] + Right x -> pure x -- | Dump DbStructure schema to JSON dumpSchema :: P.Pool -> AppConfig -> IO LBS.ByteString diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 2f47bf189..b581d3e5e 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -31,7 +31,9 @@ module PostgREST.Config , Environment , readCLIShowHelp , readEnvironment - , readConfig + , readAppConfig + , readDbUriFile + , readSecretFile , parseSecret ) where @@ -331,9 +333,9 @@ instance JustIfMaybe a a where instance JustIfMaybe a (Maybe a) where justIfMaybe a = Just a --- | Parse the config file -readAppConfig :: [(Text, Text)] -> Environment -> Maybe FilePath -> IO (Either Text AppConfig) -readAppConfig dbSettings env optPath = do +-- | Reads and parses the config and overrides its parameters from env vars, files or db settings. +readAppConfig :: [(Text, Text)] -> Environment -> Maybe FilePath -> Maybe Text -> Maybe B.ByteString -> IO (Either Text AppConfig) +readAppConfig dbSettings env optPath dbUriFile secretFile = do -- Now read the actual config file conf <- case optPath of -- Both C.ParseError and IOError are shown here @@ -345,6 +347,10 @@ readAppConfig dbSettings env optPath = do where parseConfig = + let pB64 = fromMaybe False <$> optWithAlias (optBool "jwt-secret-is-base64") + (optBool "secret-is-base64") + pSec = parseJwtSecret "jwt-secret" =<< pB64 + in AppConfig <$> parseAppSettings "app.settings" <*> reqString "db-anon-role" @@ -366,13 +372,12 @@ readAppConfig dbSettings env optPath = do <*> (fromMaybe True <$> optBool "db-load-guc-config") <*> parseTxEnd "db-tx-end" snd <*> parseTxEnd "db-tx-end" fst - <*> reqString "db-uri" - <*> pure Nothing + <*> parseDbUri "db-uri" + <*> (fmap parseSecret <$> pSec) <*> parseJwtAudience "jwt-aud" <*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key" - <*> (fmap encodeUtf8 <$> optString "jwt-secret") - <*> (fromMaybe False <$> optWithAlias (optBool "jwt-secret-is-base64") - (optBool "secret-is-base64")) + <*> pSec + <*> pB64 <*> parseLogLevel "log-level" <*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri" <*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types") @@ -381,6 +386,25 @@ readAppConfig dbSettings env optPath = do <*> (fmap unpack <$> optString "server-unix-socket") <*> parseSocketFileMode "server-unix-socket-mode" + parseDbUri :: C.Key -> C.Parser C.Config Text + parseDbUri k = flip fromMaybe dbUriFile <$> reqString k + + parseJwtSecret :: C.Key -> Bool -> C.Parser C.Config (Maybe B.ByteString) + parseJwtSecret k isB64 = optString k >>= \case + Nothing -> pure Nothing + Just sec -> + let secStr = encodeUtf8 sec + secFile = fromMaybe secStr secretFile + -- replace because the JWT is actually base64url encoded which must be turned into just base64 before decoding. + replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "=" + willBeFile = isPrefixOf "@" (toS secStr) && isNothing secretFile + in + if isB64 && not willBeFile -- don't decode in bas64 if the secret will be a file or it will err. The secFile will be filled with the file contents in a later stage. + then case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 secFile of + Left errMsg -> fail errMsg + Right bs -> pure $ Just bs + else pure $ Just secFile + parseAppSettings :: C.Key -> C.Parser C.Config [(Text, Text)] parseAppSettings key = addFromEnv . fmap (fmap coerceText) <$> C.subassocs key C.value where @@ -515,90 +539,6 @@ readAppConfig dbSettings env optPath = do splitOnCommas (C.String s) = strip <$> splitOn "," s splitOnCommas _ = [] --- | Reads the config and overrides its parameters from files, env vars or db settings. -readConfig :: [(Text, Text)] -> Environment -> Maybe FilePath -> IO (Either Text AppConfig) -readConfig dbSettings env path = - readAppConfig dbSettings env path >>= \case - Left err -> pure $ Left err - Right appConf -> do - conf <- loadDbUriFile =<< loadSecretFile appConf - pure $ Right $ conf { configJWKS = parseSecret <$> configJwtSecret conf} - -type Environment = M.Map [Char] Text - -readEnvironment :: IO Environment -readEnvironment = getEnvironment <&> pgrst - where - pgrst env = M.filterWithKey (\k _ -> "PGRST_" `isPrefixOf` k) $ M.map pack $ M.fromList env - -{-| - The purpose of this function is to load the JWT secret from a file if - configJwtSecret is actually a filepath and replaces some characters if the JWT - is base64 encoded. - - The reason some characters need to be replaced is because JWT is actually - base64url encoded which must be turned into just base64 before decoding. - - To check if the JWT secret is provided is in fact a file path, it must be - decoded as 'Text' to be processed. - - decodeUtf8: Decode a ByteString containing UTF-8 encoded text that is known to - be valid. --} -loadSecretFile :: AppConfig -> IO AppConfig -loadSecretFile conf = extractAndTransform mSecret - where - mSecret = decodeUtf8 <$> configJwtSecret conf - isB64 = configJwtSecretIsBase64 conf - -- - -- The Text (variable name secret) here is mSecret from above which is the JWT - -- decoded as Utf8 - -- - -- stripPrefix: Return the suffix of the second string if its prefix matches - -- the entire first string. - -- - -- The configJwtSecret is a filepath instead of the JWT secret itself if the - -- secret has @ as its prefix. - extractAndTransform :: Maybe Text -> IO AppConfig - extractAndTransform Nothing = return conf - extractAndTransform (Just secret) = - fmap setSecret $ - transformString isB64 =<< - case stripPrefix "@" secret of - Nothing -> return . encodeUtf8 $ secret - Just filename -> chomp <$> BS.readFile (toS filename) - where - chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs) - -- - -- Turns the Base64url encoded JWT into Base64 - transformString :: Bool -> ByteString -> IO ByteString - transformString False t = return t - transformString True t = - case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of - Left errMsg -> panic $ pack errMsg - Right bs -> return bs - setSecret bs = conf {configJwtSecret = Just bs} - -- - -- replace: Replace every occurrence of one substring with another - replaceUrlChars = - replace "_" "/" . replace "-" "+" . replace "." "=" - -{- - Load database uri from a separate file if `db-uri` is a filepath. --} -loadDbUriFile :: AppConfig -> IO AppConfig -loadDbUriFile conf = extractDbUri mDbUri - where - mDbUri = configDbUri conf - extractDbUri :: Text -> IO AppConfig - extractDbUri dbUri = - fmap setDbUri $ - case stripPrefix "@" dbUri of - Nothing -> return dbUri - Just filename -> strip <$> readFile (toS filename) - setDbUri dbUri = conf {configDbUri = dbUri} - - {-| Parse `jwt-secret` configuration option and turn into a JWKSet. @@ -615,3 +555,26 @@ parseSecret bytes = maybeJWK = JSON.decode (toS bytes) :: Maybe JWK secret = JWT.JWKSet [JWT.fromKeyMaterial keyMaterial] keyMaterial = JWT.OctKeyMaterial . JWT.OctKeyParameters $ JOSE.Base64Octets bytes + +type Environment = M.Map [Char] Text + +readEnvironment :: IO Environment +readEnvironment = getEnvironment <&> pgrst + where + pgrst env = M.filterWithKey (\k _ -> "PGRST_" `isPrefixOf` k) $ M.map pack $ M.fromList env + +-- | Read the JWT secret from a file if configJwtSecret is actually a filepath(has @ as its prefix). +-- | To check if the JWT secret is provided is in fact a file path, it must be decoded as 'Text' to be processed. +readSecretFile :: Maybe B.ByteString -> IO (Maybe B.ByteString) +readSecretFile mSecret = + case (stripPrefix "@" . decodeUtf8) =<< mSecret of + Nothing -> return Nothing + Just filename -> Just . chomp <$> BS.readFile (toS filename) + where + chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs) + +-- | Read database uri from a separate file if `db-uri` is a filepath. +readDbUriFile :: Text -> IO (Maybe Text) +readDbUriFile dbUri = case stripPrefix "@" dbUri of + Nothing -> return Nothing + Just filename -> Just . strip <$> readFile (toS filename) diff --git a/test/fixtures/roles.sql b/test/fixtures/roles.sql index f0c7537b3..5abeaf9f1 100644 --- a/test/fixtures/roles.sql +++ b/test/fixtures/roles.sql @@ -7,55 +7,55 @@ CREATE ROLE postgrest_test_author; GRANT postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author TO :USER; -- reloadable config options for io tests -ALTER ROLE postgrest_test_authenticator SET pgrst."jwt_aud" = 'https://example.org'; -ALTER ROLE postgrest_test_authenticator SET pgrst."openapi_server_proxy_uri" = 'https://example.org/api'; -ALTER ROLE postgrest_test_authenticator SET pgrst."raw_media_types" = 'application/vnd.pgrst.db-config'; -ALTER ROLE postgrest_test_authenticator SET pgrst."jwt_secret" = 'REALLYREALLYREALLYREALLYVERYSAFE'; -ALTER ROLE postgrest_test_authenticator SET pgrst."jwt_secret_is_base64" = 'true'; -ALTER ROLE postgrest_test_authenticator SET pgrst."jwt_role_claim_key" = '."a"."role"'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_tx_end" = 'commit-allow-override'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_schemas" = 'test, tenant1, tenant2'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_root_spec" = 'root'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_prepared_statements" = 'false'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_pre_request" = 'test.custom_headers'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_max_rows" = '1000'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_extra_search_path" = 'public, extensions'; +ALTER ROLE postgrest_test_authenticator SET pgrst.jwt_aud = 'https://example.org'; +ALTER ROLE postgrest_test_authenticator SET pgrst.openapi_server_proxy_uri = 'https://example.org/api'; +ALTER ROLE postgrest_test_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.db-config'; +ALTER ROLE postgrest_test_authenticator SET pgrst.jwt_secret = 'REALLYREALLYREALLYREALLYVERYSAFE'; +ALTER ROLE postgrest_test_authenticator SET pgrst.jwt_secret_is_base64 = 'true'; +ALTER ROLE postgrest_test_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_tx_end = 'commit-allow-override'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_schemas = 'test, tenant1, tenant2'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_root_spec = 'root'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_prepared_statements = 'false'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_pre_request = 'test.custom_headers'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_max_rows = '1000'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_extra_search_path = 'public, extensions'; -- override with database specific setting -ALTER ROLE postgrest_test_authenticator IN DATABASE :DBNAME SET pgrst."jwt_secret" = 'OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE'; -ALTER ROLE postgrest_test_authenticator IN DATABASE :DBNAME SET pgrst."db_extra_search_path" = 'public, extensions, private'; +ALTER ROLE postgrest_test_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE'; +ALTER ROLE postgrest_test_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private'; -- other database settings that should be ignored DROP DATABASE IF EXISTS other; CREATE DATABASE other; -ALTER ROLE postgrest_test_authenticator IN DATABASE other SET pgrst."db_max_rows" = '1111'; +ALTER ROLE postgrest_test_authenticator IN DATABASE other SET pgrst.db_max_rows = '1111'; -- non-reloadable configs for io tests -ALTER ROLE postgrest_test_authenticator SET pgrst."server_host" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."server_port" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."server_unix_socket" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."server_unix_socket_mode" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."log_level" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_anon_role" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_uri" = 'postgresql://ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_channel_enabled" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_channel" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_pool" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_pool_timeout" = 'ignored'; -ALTER ROLE postgrest_test_authenticator SET pgrst."db_load_guc_config" = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.server_host = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.server_port = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.server_unix_socket = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.server_unix_socket_mode = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.log_level = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_anon_role = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_uri = 'postgresql://ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_channel_enabled = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_channel = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_pool = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_pool_timeout = 'ignored'; +ALTER ROLE postgrest_test_authenticator SET pgrst.db_load_guc_config = 'ignored'; -- other authenticator reloadable config options for io tests CREATE ROLE other_authenticator LOGIN NOINHERIT; -ALTER ROLE other_authenticator SET pgrst."jwt_aud" = 'https://otherexample.org'; -ALTER ROLE other_authenticator SET pgrst."openapi_server_proxy_uri" = 'https://otherexample.org/api'; -ALTER ROLE other_authenticator SET pgrst."raw_media_types" = 'application/vnd.pgrst.other-db-config'; -ALTER ROLE other_authenticator SET pgrst."jwt_secret" = 'ODERREALLYREALLYREALLYREALLYVERYSAFE'; -ALTER ROLE other_authenticator SET pgrst."jwt_secret_is_base64" = 'true'; -ALTER ROLE other_authenticator SET pgrst."jwt_role_claim_key" = '."other"."role"'; -ALTER ROLE other_authenticator SET pgrst."db_tx_end" = 'rollback-allow-override'; -ALTER ROLE other_authenticator SET pgrst."db_schemas" = 'test, other_tenant1, other_tenant2'; -ALTER ROLE other_authenticator SET pgrst."db_root_spec" = 'other_root'; -ALTER ROLE other_authenticator SET pgrst."db_prepared_statements" = 'false'; -ALTER ROLE other_authenticator SET pgrst."db_pre_request" = 'test.other_custom_headers'; -ALTER ROLE other_authenticator SET pgrst."db_max_rows" = '100'; -ALTER ROLE other_authenticator SET pgrst."db_extra_search_path" = 'public, extensions, other'; +ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org'; +ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api'; +ALTER ROLE other_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.other-db-config'; +ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE'; +ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true'; +ALTER ROLE other_authenticator SET pgrst.jwt_role_claim_key = '."other"."role"'; +ALTER ROLE other_authenticator SET pgrst.db_tx_end = 'rollback-allow-override'; +ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2'; +ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root'; +ALTER ROLE other_authenticator SET pgrst.db_prepared_statements = 'false'; +ALTER ROLE other_authenticator SET pgrst.db_pre_request = 'test.other_custom_headers'; +ALTER ROLE other_authenticator SET pgrst.db_max_rows = '100'; +ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other'; diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 80182382c..637d0a5e7 100644 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1928,7 +1928,7 @@ select * from pg_catalog.pg_prepared_statements; create or replace function change_max_rows_config(val int, notify bool default false) returns void as $_$ begin execute format($$ - alter role postgrest_test_authenticator set pgrst."db_max_rows" = %L; + alter role postgrest_test_authenticator set pgrst.db_max_rows = %L; $$, val); if notify then perform pg_notify('pgrst', 'reload config'); @@ -1937,13 +1937,13 @@ end $_$ volatile security definer language plpgsql ; create or replace function reset_max_rows_config() returns void as $_$ begin - alter role postgrest_test_authenticator set pgrst."db_max_rows" = '1000'; + alter role postgrest_test_authenticator set pgrst.db_max_rows = '1000'; end $_$ volatile security definer language plpgsql ; create or replace function change_db_schema_and_full_reload(schemas text) returns void as $_$ begin execute format($$ - alter role postgrest_test_authenticator set pgrst."db_schemas" = %L; + alter role postgrest_test_authenticator set pgrst.db_schemas = %L; $$, schemas); perform pg_notify('pgrst', 'reload config'); perform pg_notify('pgrst', 'reload schema'); @@ -1951,19 +1951,24 @@ end $_$ volatile security definer language plpgsql ; create or replace function v1.reset_db_schema_config() returns void as $_$ begin - alter role postgrest_test_authenticator set pgrst."db_schemas" = 'test'; + alter role postgrest_test_authenticator set pgrst.db_schemas = 'test'; perform pg_notify('pgrst', 'reload config'); perform pg_notify('pgrst', 'reload schema'); end $_$ volatile security definer language plpgsql ; create or replace function test.invalid_role_claim_key_reload() returns void as $_$ begin - alter role postgrest_test_authenticator set pgrst."jwt_role_claim_key" = 'test'; + alter role postgrest_test_authenticator set pgrst.jwt_role_claim_key = 'test'; perform pg_notify('pgrst', 'reload config'); end $_$ volatile security definer language plpgsql ; create or replace function test.reset_invalid_role_claim_key() returns void as $_$ begin - alter role postgrest_test_authenticator set pgrst."jwt_role_claim_key" = '."a"."role"'; + alter role postgrest_test_authenticator set pgrst.jwt_role_claim_key = '."a"."role"'; perform pg_notify('pgrst', 'reload config'); end $_$ volatile security definer language plpgsql ; + +create or replace function test.reload_pgrst_config() returns void as $_$ +begin + perform pg_notify('pgrst', 'reload config'); +end $_$ language plpgsql ; diff --git a/test/io-tests/configs/sigusr2-settings-external-secret.config b/test/io-tests/configs/sigusr2-settings-external-secret.config new file mode 100644 index 000000000..a498f5e0a --- /dev/null +++ b/test/io-tests/configs/sigusr2-settings-external-secret.config @@ -0,0 +1,5 @@ +db-pool = 1 + +jwt-secret = "$(JWT_SECRET_FILE)" +jwt-secret-is-base64 = false +db-load-guc-config = false diff --git a/test/io-tests/test_io.py b/test/io-tests/test_io.py index 2b1332ce4..bfdb5dadf 100644 --- a/test/io-tests/test_io.py +++ b/test/io-tests/test_io.py @@ -88,6 +88,7 @@ def defaultenv(): "PGRST_DB_SCHEMAS": os.environ["PGRST_DB_SCHEMAS"], "PGRST_DB_ANON_ROLE": os.environ["PGRST_DB_ANON_ROLE"], "PGRST_DB_LOAD_GUC_CONFIG": "false", + "PGRST_LOG_LEVEL": "info", } @@ -336,6 +337,7 @@ def test_stable_config(tmp_path, config, defaultenv): "ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value', "POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock", "POSTGREST_TEST_PORT": "80", + "JWT_SECRET_FILE": "a_file", } # Some configs expect input from stdin, at least on base64. @@ -508,6 +510,53 @@ def test_jwt_secret_reload(tmp_path, defaultenv): assert response.status_code == 200 +def test_jwt_secret_external_file_reload(tmp_path, defaultenv): + "JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY." + config = CONFIGSDIR / "sigusr2-settings-external-secret.config" + + headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) + + external_secret_file = tmp_path / "jwt-secret-config" + external_secret_file.write_text("invalid" * 5) + + env = { + **defaultenv, + "JWT_SECRET_FILE": f"@{external_secret_file}", + "PGRST_DB_CHANNEL_ENABLED": "true", + } + + with run(config, env=env) as postgrest: + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + # change external file + external_secret_file.write_text(SECRET) + + # SIGUSR1 doesn't reload external files + postgrest.process.send_signal(signal.SIGUSR1) + time.sleep(0.1) + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + # reload config and external file with SIGUSR2 + postgrest.process.send_signal(signal.SIGUSR2) + time.sleep(0.1) + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 200 + + # change external file to wrong value again + external_secret_file.write_text("invalid" * 5) + + # reload config and external file with NOTIFY + postgrest.session.post("/rpc/reload_pgrst_config") + time.sleep(0.1) + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + def test_db_schema_reload(tmp_path, defaultenv): "DB schema should be reloaded when PostgREST is sent SIGUSR2." config = (CONFIGSDIR / "sigusr2-settings.config").read_text()