diff --git a/postgrest.cabal b/postgrest.cabal index 631b4b124..bf88ceb16 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -179,7 +179,7 @@ test-suite spec Feature.ExtraSearchPathSpec Feature.HtmlRawOutputSpec Feature.InsertSpec - Feature.IgnoreAclOpenApiSpec + Feature.IgnorePrivOpenApiSpec Feature.JsonOperatorSpec Feature.MultipleSchemaSpec Feature.NoJwtSpec diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index bc79abc89..8426ef2e7 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -28,6 +28,7 @@ import System.Posix.Types (FileMode) import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as LBS +import qualified Data.HashMap.Strict as Map import qualified Data.Set as Set import qualified Hasql.DynamicStatements.Snippet as SQL import qualified Hasql.Pool as SQL @@ -465,15 +466,15 @@ handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest _) = do body <- lift $ case configOpenApiMode of - OAFollowACL -> + OAFollowPriv -> OpenAPI.encode conf dbStructure <$> SQL.statement tSchema (DbStructure.accessibleTables configDbPreparedStatements) <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements) <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) - OAIgnoreACL -> + OAIgnorePriv -> OpenAPI.encode conf dbStructure - (DbStructure.dbTables dbStructure) - (DbStructure.dbProcs dbStructure) + (filter (\x -> tableSchema x == tSchema) $ DbStructure.dbTables dbStructure) + (Map.filterWithKey (\(QualifiedIdentifier sch _) _ -> sch == tSchema) $ DbStructure.dbProcs dbStructure) <$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) OADisabled -> pure mempty diff --git a/src/PostgREST/CLI.hs b/src/PostgREST/CLI.hs index fc9d2f782..503323b2e 100644 --- a/src/PostgREST/CLI.hs +++ b/src/PostgREST/CLI.hs @@ -192,11 +192,11 @@ exampleConfigFile = |## when none is provided, 660 is applied by default |# server-unix-socket-mode = "660" | - |## determine if swagger output should follow or ignore ACL constraints or be disabled entirely - |## admitted values: follow-acl, ignore-acl, disabled - |openapi-mode = "follow-acl" + |## determine if the OpenAPI output should follow or ignore role privileges or be disabled entirely + |## admitted values: follow-privileges, ignore-privileges, disabled + |openapi-mode = "follow-privileges" | - |## base url for swagger output + |## base url for the OpenAPI output |openapi-server-proxy-uri = "" | |## choose a secret, JSON Web Key (or set) to enable JWT auth diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 8ba7d43e1..be25bfaec 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -103,13 +103,13 @@ instance Show LogLevel where show LogWarn = "warn" show LogInfo = "info" -data OpenAPIMode = OAFollowACL | OAIgnoreACL | OADisabled +data OpenAPIMode = OAFollowPriv | OAIgnorePriv | OADisabled deriving Eq instance Show OpenAPIMode where - show OAFollowACL = "follow-acl" - show OAIgnoreACL = "ignore-acl" - show OADisabled = "disabled" + show OAFollowPriv = "follow-privileges" + show OAIgnorePriv = "ignore-privileges" + show OADisabled = "disabled" -- | Dump the config toText :: AppConfig -> Text @@ -262,11 +262,11 @@ parser optPath env dbSettings = parseOpenAPIMode :: C.Key -> C.Parser C.Config OpenAPIMode parseOpenAPIMode k = optString k >>= \case - Nothing -> pure OAFollowACL - Just "follow-acl" -> pure OAFollowACL - Just "ignore-acl" -> pure OAIgnoreACL - Just "disabled" -> pure OADisabled - Just _ -> fail "Invalid openapi-mode. Check your configuration." + Nothing -> pure OAFollowPriv + Just "follow-privileges" -> pure OAFollowPriv + Just "ignore-privileges" -> pure OAIgnorePriv + Just "disabled" -> pure OADisabled + Just _ -> fail "Invalid openapi-mode. Check your configuration." parseOpenAPIServerProxyURI :: C.Key -> C.Parser C.Config (Maybe Text) parseOpenAPIServerProxyURI k = diff --git a/test/Feature/IgnoreAclOpenApiSpec.hs b/test/Feature/IgnoreAclOpenApiSpec.hs index 7305d4613..9e9f29af7 100644 --- a/test/Feature/IgnoreAclOpenApiSpec.hs +++ b/test/Feature/IgnoreAclOpenApiSpec.hs @@ -1,4 +1,4 @@ -module Feature.IgnoreAclOpenApiSpec where +module Feature.IgnorePrivOpenApiSpec where import Control.Lens ((^?)) @@ -32,6 +32,17 @@ spec = describe "OpenAPI Ignore ACL" $ do liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] + it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do + r1 <- simpleBody <$> get "/" + let tableKey1 = r1 ^? key "paths" . key "/children" + + liftIO $ tableKey1 `shouldBe` Nothing + + r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] "" + let tableKey2 = r2 ^? key "paths" . key "/children" + + liftIO $ tableKey2 `shouldNotBe` Nothing + describe "RPC" $ do it "includes privileged function even if user does not have permission" $ do @@ -41,3 +52,14 @@ spec = describe "OpenAPI Ignore ACL" $ do . nth 0 liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|] + + it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do + r1 <- simpleBody <$> get "/" + let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below" + + liftIO $ funcKey1 `shouldBe` Nothing + + r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] "" + let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below" + + liftIO $ funcKey2 `shouldNotBe` Nothing diff --git a/test/Feature/IgnorePrivOpenApiSpec.hs b/test/Feature/IgnorePrivOpenApiSpec.hs new file mode 100644 index 000000000..8ff6791f4 --- /dev/null +++ b/test/Feature/IgnorePrivOpenApiSpec.hs @@ -0,0 +1,65 @@ +module Feature.IgnorePrivOpenApiSpec where + +import Control.Lens ((^?)) + +import Data.Aeson.Lens +import Data.Aeson.QQ + +import Network.HTTP.Types +import Network.Wai (Application) +import Network.Wai.Test (SResponse (..)) + +import Test.Hspec hiding (pendingWith) +import Test.Hspec.Wai + +import Protolude hiding (get) +import SpecHelper + +spec :: SpecWith ((), Application) +spec = describe "OpenAPI Ignore Privileges" $ do + it "root path returns a valid openapi spec" $ do + validateOpenApiResponse [("Accept", "application/openapi+json")] + request methodHead "/" (acceptHdrs "application/openapi+json") "" + `shouldRespondWith` "" { matchStatus = 200 } + + describe "table" $ do + + it "includes privileged table even if user does not have permission" $ do + r <- simpleBody <$> get "/" + let tableTag = r ^? key "paths" . key "/authors_only" + . key "post" . key "tags" + . nth 0 + + liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] + + it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do + r1 <- simpleBody <$> get "/" + let tableKey1 = r1 ^? key "paths" . key "/children" + + liftIO $ tableKey1 `shouldBe` Nothing + + r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] "" + let tableKey2 = r2 ^? key "paths" . key "/children" + + liftIO $ tableKey2 `shouldNotBe` Nothing + + describe "RPC" $ do + + it "includes privileged function even if user does not have permission" $ do + r <- simpleBody <$> get "/" + let funcTag = r ^? key "paths" . key "/rpc/privileged_hello" + . key "post" . key "tags" + . nth 0 + + liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|] + + it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do + r1 <- simpleBody <$> get "/" + let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below" + + liftIO $ funcKey1 `shouldBe` Nothing + + r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] "" + let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below" + + liftIO $ funcKey2 `shouldNotBe` Nothing diff --git a/test/Main.hs b/test/Main.hs index 2ece4a75a..8a6344f6a 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -32,7 +32,7 @@ import qualified Feature.DisabledOpenApiSpec import qualified Feature.EmbedDisambiguationSpec import qualified Feature.ExtraSearchPathSpec import qualified Feature.HtmlRawOutputSpec -import qualified Feature.IgnoreAclOpenApiSpec +import qualified Feature.IgnorePrivOpenApiSpec import qualified Feature.InsertSpec import qualified Feature.JsonOperatorSpec import qualified Feature.MultipleSchemaSpec @@ -95,7 +95,6 @@ main = do let withApp = app testCfg maxRowsApp = app testMaxRowsCfg disabledOpenApi = app testDisabledOpenApiCfg - ignoreAclOpenApi = app testIgnoreAclOpenApiCfg proxyApp = app testProxyCfg noJwtApp = app testCfgNoJWT binaryJwtApp = app testCfgBinaryJWT @@ -112,6 +111,7 @@ main = do unicodeApp = appDbs testUnicodeCfg nonexistentSchemaApp = appDbs testNonexistentSchemaCfg multipleSchemaApp = appDbs testMultipleSchemaCfg + ignorePrivOpenApi = appDbs testIgnorePrivOpenApiCfg let analyze :: IO () analyze = do @@ -161,8 +161,8 @@ main = do describe "Feature.DisabledOpenApiSpec" Feature.DisabledOpenApiSpec.spec -- this test runs with openapi-mode set to ignore-acl - parallel $ before ignoreAclOpenApi $ - describe "Feature.IgnoreAclOpenApiSpec" Feature.IgnoreAclOpenApiSpec.spec + parallel $ before ignorePrivOpenApi $ + describe "Feature.IgnorePrivOpenApiSpec" Feature.IgnorePrivOpenApiSpec.spec -- this test runs with a proxy parallel $ before proxyApp $ diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 8584c050b..b325e1b78 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -96,7 +96,7 @@ _baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in , configJwtSecret = secret , configJwtSecretIsBase64 = False , configLogLevel = LogCrit - , configOpenApiMode = OAFollowACL + , configOpenApiMode = OAFollowPriv , configOpenApiServerProxyUri = Nothing , configRawMediaTypes = [] , configServerHost = "localhost" @@ -128,8 +128,8 @@ testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 } testDisabledOpenApiCfg :: Text -> AppConfig testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled } -testIgnoreAclOpenApiCfg :: Text -> AppConfig -testIgnoreAclOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OAIgnoreACL } +testIgnorePrivOpenApiCfg :: Text -> AppConfig +testIgnorePrivOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OAIgnorePriv, configDbSchemas = fromList ["test", "v1"] } testProxyCfg :: Text -> AppConfig testProxyCfg testDbConn = (testCfg testDbConn) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" } diff --git a/test/fixtures/roles.sql b/test/fixtures/roles.sql index 2e566cdaf..332f40e21 100644 --- a/test/fixtures/roles.sql +++ b/test/fixtures/roles.sql @@ -59,3 +59,4 @@ 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.openapi_mode = 'disabled'; diff --git a/test/io-tests/configs/expected/aliases.config b/test/io-tests/configs/expected/aliases.config index cff99f29f..bff40f099 100644 --- a/test/io-tests/configs/expected/aliases.config +++ b/test/io-tests/configs/expected/aliases.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"aliased\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" -openapi-mode = "follow-acl" +openapi-mode = "follow-privileges" openapi-server-proxy-uri = "" raw-media-types = "" server-host = "!4" diff --git a/test/io-tests/configs/expected/boolean-numeric.config b/test/io-tests/configs/expected/boolean-numeric.config index 70f0ce53c..38ed48e56 100644 --- a/test/io-tests/configs/expected/boolean-numeric.config +++ b/test/io-tests/configs/expected/boolean-numeric.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" -openapi-mode = "follow-acl" +openapi-mode = "follow-privileges" openapi-server-proxy-uri = "" raw-media-types = "" server-host = "!4" diff --git a/test/io-tests/configs/expected/boolean-string.config b/test/io-tests/configs/expected/boolean-string.config index 70f0ce53c..38ed48e56 100644 --- a/test/io-tests/configs/expected/boolean-string.config +++ b/test/io-tests/configs/expected/boolean-string.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" -openapi-mode = "follow-acl" +openapi-mode = "follow-privileges" openapi-server-proxy-uri = "" raw-media-types = "" server-host = "!4" diff --git a/test/io-tests/configs/expected/defaults.config b/test/io-tests/configs/expected/defaults.config index dc1d281b2..e7a072f1a 100644 --- a/test/io-tests/configs/expected/defaults.config +++ b/test/io-tests/configs/expected/defaults.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = false log-level = "error" -openapi-mode = "follow-acl" +openapi-mode = "follow-privileges" openapi-server-proxy-uri = "" raw-media-types = "" server-host = "!4" diff --git a/test/io-tests/configs/expected/no-defaults-with-db-other-authenticator.config b/test/io-tests/configs/expected/no-defaults-with-db-other-authenticator.config index 412c4ed46..78bb16597 100644 --- a/test/io-tests/configs/expected/no-defaults-with-db-other-authenticator.config +++ b/test/io-tests/configs/expected/no-defaults-with-db-other-authenticator.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"other\".\"role\"" jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret-is-base64 = true log-level = "info" -openapi-mode = "ignore-acl" +openapi-mode = "disabled" openapi-server-proxy-uri = "https://otherexample.org/api" raw-media-types = "application/vnd.pgrst.other-db-config" server-host = "0.0.0.0" diff --git a/test/io-tests/configs/expected/no-defaults-with-db.config b/test/io-tests/configs/expected/no-defaults-with-db.config index 56d438179..7c615a0c8 100644 --- a/test/io-tests/configs/expected/no-defaults-with-db.config +++ b/test/io-tests/configs/expected/no-defaults-with-db.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"a\".\"role\"" jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret-is-base64 = true log-level = "info" -openapi-mode = "ignore-acl" +openapi-mode = "ignore-privileges" openapi-server-proxy-uri = "https://example.org/api" raw-media-types = "application/vnd.pgrst.db-config" server-host = "0.0.0.0" diff --git a/test/io-tests/configs/expected/no-defaults.config b/test/io-tests/configs/expected/no-defaults.config index b4c9b6f18..a675c7ef3 100644 --- a/test/io-tests/configs/expected/no-defaults.config +++ b/test/io-tests/configs/expected/no-defaults.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"user\"[0].\"real-role\"" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5" jwt-secret-is-base64 = true log-level = "info" -openapi-mode = "ignore-acl" +openapi-mode = "ignore-privileges" openapi-server-proxy-uri = "https://postgrest.org" raw-media-types = "application/vnd.pgrst.config" server-host = "0.0.0.0" diff --git a/test/io-tests/configs/expected/types.config b/test/io-tests/configs/expected/types.config index 3e0efba92..4d2df4d28 100644 --- a/test/io-tests/configs/expected/types.config +++ b/test/io-tests/configs/expected/types.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = false log-level = "error" -openapi-mode = "follow-acl" +openapi-mode = "follow-privileges" openapi-server-proxy-uri = "" raw-media-types = "" server-host = "!4" diff --git a/test/io-tests/configs/no-defaults-env.yaml b/test/io-tests/configs/no-defaults-env.yaml index c6942479f..6fab3ac58 100644 --- a/test/io-tests/configs/no-defaults-env.yaml +++ b/test/io-tests/configs/no-defaults-env.yaml @@ -19,7 +19,7 @@ PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"' PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5 PGRST_JWT_SECRET_IS_BASE64: true PGRST_LOG_LEVEL: info -PGRST_OPENAPI_MODE: 'ignore-acl' +PGRST_OPENAPI_MODE: 'ignore-privileges' PGRST_OPENAPI_SERVER_PROXY_URI: 'https://postgrest.org' PGRST_RAW_MEDIA_TYPES: application/vnd.pgrst.config PGRST_SERVER_HOST: 0.0.0.0 diff --git a/test/io-tests/configs/no-defaults.config b/test/io-tests/configs/no-defaults.config index 01d47f90f..f2b107b68 100644 --- a/test/io-tests/configs/no-defaults.config +++ b/test/io-tests/configs/no-defaults.config @@ -17,7 +17,7 @@ jwt-role-claim-key = ".user[0].\"real-role\"" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5" jwt-secret-is-base64 = true log-level = "info" -openapi-mode = "ignore-acl" +openapi-mode = "ignore-privileges" openapi-server-proxy-uri = "https://postgrest.org" raw-media-types = "application/vnd.pgrst.config" server-host = "0.0.0.0" diff --git a/test/io-tests/fixtures.yaml b/test/io-tests/fixtures.yaml index 838943b7d..7e28bc887 100644 --- a/test/io-tests/fixtures.yaml +++ b/test/io-tests/fixtures.yaml @@ -174,3 +174,8 @@ invalidroleclaimkeys: - '.my_role;;domain' - '.#$$%&$%/' - '1234' + +invalidopenapimodes: + - 'follow-' + - 'ignore-' + - '.#$$%&$%/' diff --git a/test/io-tests/test_io.py b/test/io-tests/test_io.py index 15be81b08..0cc2fd528 100644 --- a/test/io-tests/test_io.py +++ b/test/io-tests/test_io.py @@ -425,6 +425,21 @@ def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv): print(line) +@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"]) +def test_invalid_openapi_mode(invalidopenapimodes, defaultenv): + "Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code." + env = { + **defaultenv, + "PGRST_OPENAPI_MODE": invalidopenapimodes, + } + + with pytest.raises(PostgrestError): + dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env) + for line in dump.split("\n"): + if line.startswith("openapi-mode"): + print(line) + + def test_iat_claim(defaultenv): """ A claim with an 'iat' (issued at) attribute should be successful.