diff --git a/postgrest.cabal b/postgrest.cabal index 7b4ebffff..631b4b124 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -174,10 +174,12 @@ test-suite spec Feature.ConcurrentSpec Feature.CorsSpec Feature.DeleteSpec + Feature.DisabledOpenApiSpec Feature.EmbedDisambiguationSpec Feature.ExtraSearchPathSpec Feature.HtmlRawOutputSpec Feature.InsertSpec + Feature.IgnoreAclOpenApiSpec Feature.JsonOperatorSpec Feature.MultipleSchemaSpec Feature.NoJwtSpec diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 87b82c5ee..bc79abc89 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -53,7 +53,8 @@ import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder import PostgREST.AppState (AppState) import PostgREST.Config (AppConfig (..), - LogLevel (..)) + LogLevel (..), + OpenAPIMode (..)) import PostgREST.Config.PgVersion (PgVersion (..)) import PostgREST.ContentType (ContentType (..)) import PostgREST.DbStructure (DbStructure (..), @@ -463,11 +464,19 @@ handleInvoke invMethod proc context@RequestContext{..} = do handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest _) = do body <- - lift $ - OpenAPI.encode conf dbStructure - <$> SQL.statement tSchema (DbStructure.accessibleTables configDbPreparedStatements) - <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) - <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements) + lift $ case configOpenApiMode of + OAFollowACL -> + OpenAPI.encode conf dbStructure + <$> SQL.statement tSchema (DbStructure.accessibleTables configDbPreparedStatements) + <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements) + <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) + OAIgnoreACL -> + OpenAPI.encode conf dbStructure + (DbStructure.dbTables dbStructure) + (DbStructure.dbProcs dbStructure) + <$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) + OADisabled -> + pure mempty return $ Wai.responseLBS HTTP.status200 diff --git a/src/PostgREST/CLI.hs b/src/PostgREST/CLI.hs index 3c5247bc7..fc9d2f782 100644 --- a/src/PostgREST/CLI.hs +++ b/src/PostgREST/CLI.hs @@ -192,6 +192,10 @@ 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" + | |## base url for swagger output |openapi-server-proxy-uri = "" | diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 764d144d1..8ba7d43e1 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -16,6 +16,7 @@ module PostgREST.Config , JSPath , JSPathExp(..) , LogLevel(..) + , OpenAPIMode(..) , Proxy(..) , toText , isMalformedProxyUri @@ -85,6 +86,7 @@ data AppConfig = AppConfig , configJwtSecret :: Maybe B.ByteString , configJwtSecretIsBase64 :: Bool , configLogLevel :: LogLevel + , configOpenApiMode :: OpenAPIMode , configOpenApiServerProxyUri :: Maybe Text , configRawMediaTypes :: [B.ByteString] , configServerHost :: Text @@ -101,6 +103,14 @@ instance Show LogLevel where show LogWarn = "warn" show LogInfo = "info" +data OpenAPIMode = OAFollowACL | OAIgnoreACL | OADisabled + deriving Eq + +instance Show OpenAPIMode where + show OAFollowACL = "follow-acl" + show OAIgnoreACL = "ignore-acl" + show OADisabled = "disabled" + -- | Dump the config toText :: AppConfig -> Text toText conf = @@ -127,6 +137,7 @@ toText conf = ,("jwt-secret", q . toS . showJwtSecret) ,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64) ,("log-level", q . show . configLogLevel) + ,("openapi-mode", q . show . configOpenApiMode) ,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri) ,("raw-media-types", q . toS . B.intercalate "," . configRawMediaTypes) ,("server-host", q . configServerHost) @@ -220,6 +231,7 @@ parser optPath env dbSettings = (optBool "jwt-secret-is-base64") (optBool "secret-is-base64")) <*> parseLogLevel "log-level" + <*> parseOpenAPIMode "openapi-mode" <*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri" <*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types") <*> (fromMaybe "!4" <$> optString "server-host") @@ -247,6 +259,15 @@ parser optPath env dbSettings = then fail "Invalid server-unix-socket-mode: needs to be between 600 and 777" else pure fileMode + 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." + parseOpenAPIServerProxyURI :: C.Key -> C.Parser C.Config (Maybe Text) parseOpenAPIServerProxyURI k = optString k >>= \case diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs index a93f61a1f..5b63adbed 100644 --- a/src/PostgREST/OpenAPI.hs +++ b/src/PostgREST/OpenAPI.hs @@ -40,8 +40,8 @@ import PostgREST.ContentType import Protolude hiding (Proxy, get, toS) import Protolude.Conv (toS) -encode :: AppConfig -> DbStructure -> [Table] -> Maybe Text -> HashMap.HashMap k [ProcDescription] -> LBS.ByteString -encode conf dbStructure tables schemaDescription procs = +encode :: AppConfig -> DbStructure -> [Table] -> HashMap.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString +encode conf dbStructure tables procs schemaDescription = JSON.encode $ postgrestSpec (dbRelationships dbStructure) diff --git a/src/PostgREST/Request/ApiRequest.hs b/src/PostgREST/Request/ApiRequest.hs index 0c21cb42e..0ea07584a 100644 --- a/src/PostgREST/Request/ApiRequest.hs +++ b/src/PostgREST/Request/ApiRequest.hs @@ -44,7 +44,8 @@ import Network.Wai (Request (..)) import Network.Wai.Parse (parseHttpAccept) import Web.Cookie (parseCookies) -import PostgREST.Config (AppConfig (..)) +import PostgREST.Config (AppConfig (..), + OpenAPIMode (..)) import PostgREST.ContentType (ContentType (..)) import PostgREST.DbStructure (DbStructure (..)) import PostgREST.DbStructure.Identifiers (FieldName, @@ -313,8 +314,9 @@ userApiRequest conf@AppConfig{..} dbStructure req reqBody in case path of [] -> case configDbRootSpec of - Just (QualifiedIdentifier pSch pName) -> TargetProc (callFindProc (if pSch == mempty then schema else pSch) pName) True - Nothing -> TargetDefaultSpec schema + Just (QualifiedIdentifier pSch pName) -> TargetProc (callFindProc (if pSch == mempty then schema else pSch) pName) True + Nothing | configOpenApiMode == OADisabled -> TargetUnknown + | otherwise -> TargetDefaultSpec schema [table] -> TargetIdent $ QualifiedIdentifier schema table ["rpc", pName] -> TargetProc (callFindProc schema pName) False _ -> TargetUnknown diff --git a/test/Feature/DisabledOpenApiSpec.hs b/test/Feature/DisabledOpenApiSpec.hs new file mode 100644 index 000000000..1bd9537d9 --- /dev/null +++ b/test/Feature/DisabledOpenApiSpec.hs @@ -0,0 +1,20 @@ +module Feature.DisabledOpenApiSpec where + +import Network.HTTP.Types +import Network.Wai (Application) + +import Test.Hspec hiding (pendingWith) +import Test.Hspec.Wai + +import Protolude + +spec :: SpecWith ((), Application) +spec = + describe "Disabled OpenApi" $ do + it "does not accept application/openapi+json and responds with 415" $ + request methodGet "/" + [("Accept","application/openapi+json")] "" `shouldRespondWith` 415 + + it "accepts application/json and responds with 404" $ + request methodGet "/" + [("Accept","application/json")] "" `shouldRespondWith` 404 diff --git a/test/Feature/IgnoreAclOpenApiSpec.hs b/test/Feature/IgnoreAclOpenApiSpec.hs new file mode 100644 index 000000000..7305d4613 --- /dev/null +++ b/test/Feature/IgnoreAclOpenApiSpec.hs @@ -0,0 +1,43 @@ +module Feature.IgnoreAclOpenApiSpec 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 ACL" $ 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"|] + + 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"|] diff --git a/test/Main.hs b/test/Main.hs index 41e13793b..2ece4a75a 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -28,9 +28,11 @@ import qualified Feature.BinaryJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec +import qualified Feature.DisabledOpenApiSpec import qualified Feature.EmbedDisambiguationSpec import qualified Feature.ExtraSearchPathSpec import qualified Feature.HtmlRawOutputSpec +import qualified Feature.IgnoreAclOpenApiSpec import qualified Feature.InsertSpec import qualified Feature.JsonOperatorSpec import qualified Feature.MultipleSchemaSpec @@ -92,6 +94,8 @@ main = do let withApp = app testCfg maxRowsApp = app testMaxRowsCfg + disabledOpenApi = app testDisabledOpenApiCfg + ignoreAclOpenApi = app testIgnoreAclOpenApiCfg proxyApp = app testProxyCfg noJwtApp = app testCfgNoJWT binaryJwtApp = app testCfgBinaryJWT @@ -152,6 +156,14 @@ main = do parallel $ before unicodeApp $ describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec + -- this test runs with openapi-mode set to disabled + parallel $ before disabledOpenApi $ + 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 + -- this test runs with a proxy parallel $ before proxyApp $ describe "Feature.ProxySpec" Feature.ProxySpec.spec diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index c1b9394d3..8584c050b 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -24,7 +24,9 @@ import Text.Heredoc import PostgREST.Config (AppConfig (..), JSPathExp (..), - LogLevel (..), parseSecret) + LogLevel (..), + OpenAPIMode (..), + parseSecret) import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import Protolude hiding (toS) import Protolude.Conv (toS) @@ -94,6 +96,7 @@ _baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in , configJwtSecret = secret , configJwtSecretIsBase64 = False , configLogLevel = LogCrit + , configOpenApiMode = OAFollowACL , configOpenApiServerProxyUri = Nothing , configRawMediaTypes = [] , configServerHost = "localhost" @@ -122,6 +125,12 @@ testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList [" testMaxRowsCfg :: Text -> AppConfig 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 } + testProxyCfg :: Text -> AppConfig testProxyCfg testDbConn = (testCfg testDbConn) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" } diff --git a/test/io-tests/configs/expected/aliases.config b/test/io-tests/configs/expected/aliases.config index 0bb492e96..cff99f29f 100644 --- a/test/io-tests/configs/expected/aliases.config +++ b/test/io-tests/configs/expected/aliases.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"aliased\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" +openapi-mode = "follow-acl" 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 dfa34781e..70f0ce53c 100644 --- a/test/io-tests/configs/expected/boolean-numeric.config +++ b/test/io-tests/configs/expected/boolean-numeric.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" +openapi-mode = "follow-acl" 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 dfa34781e..70f0ce53c 100644 --- a/test/io-tests/configs/expected/boolean-string.config +++ b/test/io-tests/configs/expected/boolean-string.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = true log-level = "error" +openapi-mode = "follow-acl" 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 3656b4b29..dc1d281b2 100644 --- a/test/io-tests/configs/expected/defaults.config +++ b/test/io-tests/configs/expected/defaults.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = false log-level = "error" +openapi-mode = "follow-acl" 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 bf6107c60..412c4ed46 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,6 +17,7 @@ jwt-role-claim-key = ".\"other\".\"role\"" jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret-is-base64 = true log-level = "info" +openapi-mode = "ignore-acl" 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 5282774e7..56d438179 100644 --- a/test/io-tests/configs/expected/no-defaults-with-db.config +++ b/test/io-tests/configs/expected/no-defaults-with-db.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"a\".\"role\"" jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret-is-base64 = true log-level = "info" +openapi-mode = "ignore-acl" 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 f79cbccb5..b4c9b6f18 100644 --- a/test/io-tests/configs/expected/no-defaults.config +++ b/test/io-tests/configs/expected/no-defaults.config @@ -17,6 +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-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 178cb0b6f..3e0efba92 100644 --- a/test/io-tests/configs/expected/types.config +++ b/test/io-tests/configs/expected/types.config @@ -17,6 +17,7 @@ jwt-role-claim-key = ".\"role\"" jwt-secret = "" jwt-secret-is-base64 = false log-level = "error" +openapi-mode = "follow-acl" 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 2f8853d43..c6942479f 100644 --- a/test/io-tests/configs/no-defaults-env.yaml +++ b/test/io-tests/configs/no-defaults-env.yaml @@ -19,6 +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_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 a689628da..01d47f90f 100644 --- a/test/io-tests/configs/no-defaults.config +++ b/test/io-tests/configs/no-defaults.config @@ -17,6 +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-server-proxy-uri = "https://postgrest.org" raw-media-types = "application/vnd.pgrst.config" server-host = "0.0.0.0"