Fix some OpenAPI issues (#970)

* Fix #933, update externals docs url to current version

* Fix #962, openApi don't err on nonexistent schema

* Fix #954, make OpenAPI rpc output dependent on user privileges
This commit is contained in:
Steve Chávez
2017-09-25 23:09:52 -07:00
committed by Joe Nelson
parent cf19ad0369
commit 4ba27d84a4
13 changed files with 176 additions and 82 deletions
+3
View File
@@ -13,6 +13,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
- #968, Treat blank proxy uri as missing - @begriffs - #968, Treat blank proxy uri as missing - @begriffs
- #933, OpenAPI externals docs url to current version - @steve-chavez
- #962, OpenAPI don't err on nonexistent schema - @steve-chavez
- #954, make OpenAPI rpc output dependent on user privileges - @steve-chavez
## [0.4.3.0] - 2017-09-06 ## [0.4.3.0] - 2017-09-06
+1
View File
@@ -127,6 +127,7 @@ Test-Suite spec
, Feature.UnicodeSpec , Feature.UnicodeSpec
, Feature.AndOrParamsSpec , Feature.AndOrParamsSpec
, Feature.RpcSpec , Feature.RpcSpec
, Feature.NonexistentSchemaSpec
, SpecHelper , SpecHelper
, TestTypes , TestTypes
Build-Depends: aeson Build-Depends: aeson
+2 -2
View File
@@ -268,8 +268,8 @@ app dbStructure conf apiRequest =
uri Nothing = ("http", host, port, "/") uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy uri' = uri proxy
encodeApi ti sd = encodeOpenAPI (M.elems allProcs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure) encodeApi ti sd procs = encodeOpenAPI (M.elems procs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure)
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
_ -> return notFound _ -> return notFound
+6 -1
View File
@@ -14,6 +14,7 @@ turned in configurable behaviour if needed.
Other hardcoded options such as the minimum version number also belong here. Other hardcoded options such as the minimum version number also belong here.
-} -}
module PostgREST.Config ( prettyVersion module PostgREST.Config ( prettyVersion
, docsVersion
, readOptions , readOptions
, corsPolicy , corsPolicy
, minimumPgVersion , minimumPgVersion
@@ -33,7 +34,7 @@ import Data.Configurator.Types (Value(..))
import Data.List (lookup) import Data.List (lookup)
import Data.Monoid import Data.Monoid
import Data.Scientific (floatingOrInteger) import Data.Scientific (floatingOrInteger)
import Data.Text (strip, intercalate, lines) import Data.Text (strip, intercalate, lines, dropAround)
import Data.Text.Encoding (encodeUtf8) import Data.Text.Encoding (encodeUtf8)
import Data.Text.IO (hPutStrLn) import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch) import Data.Version (versionBranch)
@@ -92,6 +93,10 @@ corsPolicy req = case lookup "origin" headers of
prettyVersion :: Text prettyVersion :: Text
prettyVersion = intercalate "." $ map show $ versionBranch version prettyVersion = intercalate "." $ map show $ versionBranch version
-- | Version number used in docs
docsVersion :: Text
docsVersion = "v" <> dropAround (== '.') (dropAround (/= '.') prettyVersion)
-- | Function to read and parse options from the command line -- | Function to read and parse options from the command line
readOptions :: IO AppConfig readOptions :: IO AppConfig
readOptions = do readOptions = do
+31 -24
View File
@@ -6,6 +6,7 @@
module PostgREST.DbStructure ( module PostgREST.DbStructure (
getDbStructure getDbStructure
, accessibleTables , accessibleTables
, accessibleProcs
, schemaDescription , schemaDescription
) where ) where
@@ -35,7 +36,7 @@ getDbStructure schema = do
syns <- H.query () $ allSynonyms cols syns <- H.query () $ allSynonyms cols
rels <- H.query () $ allRelations tabs cols rels <- H.query () $ allRelations tabs cols
keys <- H.query () $ allPrimaryKeys tabs keys <- H.query () $ allPrimaryKeys tabs
procs <- H.query schema accessibleProcs procs <- H.query schema allProcs
let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels
cols' = addForeignKeys rels' cols cols' = addForeignKeys rels' cols
@@ -100,24 +101,21 @@ decodeSynonyms cols =
<*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text
<*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription) decodeProcs :: HD.Result (M.HashMap Text ProcDescription)
accessibleProcs = decodeProcs =
H.statement sql (HE.value HE.text) M.fromList . map addName <$> HD.rowsList tblRow
(M.fromList . map addName <$> where
HD.rowsList ( tblRow = ProcDescription
ProcDescription <$> HD.value HD.text <$> HD.value HD.text
<*> HD.nullableValue HD.text <*> HD.nullableValue HD.text
<*> (parseArgs <$> HD.value HD.text) <*> (parseArgs <$> HD.value HD.text)
<*> (parseRetType <$> <*> (parseRetType
HD.value HD.text <*> <$> HD.value HD.text
HD.value HD.text <*> <*> HD.value HD.text
HD.value HD.bool <*> <*> HD.value HD.bool
HD.value HD.char) <*> HD.value HD.char)
<*> (parseVolatility <$> <*> (parseVolatility <$> HD.value HD.char)
HD.value HD.char)
)
) True
where
addName :: ProcDescription -> (Text, ProcDescription) addName :: ProcDescription -> (Text, ProcDescription)
addName pd = (pdName pd, pd) addName pd = (pdName pd, pd)
@@ -147,12 +145,20 @@ accessibleProcs =
_ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange _ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange
parseVolatility :: Char -> ProcVolatility parseVolatility :: Char -> ProcVolatility
parseVolatility 'i' = Immutable parseVolatility v | v == 'i' = Immutable
parseVolatility 's' = Stable | v == 's' = Stable
parseVolatility 'v' = Volatile | otherwise = Volatile -- only 'v' can happen here
parseVolatility _ = Volatile -- should not happen, but be pessimistic
sql = [q| allProcs :: H.Query Schema (M.HashMap Text ProcDescription)
allProcs = H.statement (toS procsSqlQuery) (HE.value HE.text) decodeProcs True
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription)
accessibleProcs = H.statement (toS sql) (HE.value HE.text) decodeProcs True
where
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
procsSqlQuery :: SqlQuery
procsSqlQuery = [q|
SELECT p.proname as "proc_name", SELECT p.proname as "proc_name",
d.description as "proc_description", d.description as "proc_description",
pg_get_function_arguments(p.oid) as "args", pg_get_function_arguments(p.oid) as "args",
@@ -167,11 +173,12 @@ accessibleProcs =
JOIN pg_namespace tn ON tn.oid = t.typnamespace JOIN pg_namespace tn ON tn.oid = t.typnamespace
LEFT JOIN pg_class comp ON comp.oid = t.typrelid LEFT JOIN pg_class comp ON comp.oid = t.typrelid
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
WHERE pn.nspname = $1|] WHERE pn.nspname = $1
|]
schemaDescription :: H.Query Schema (Maybe Text) schemaDescription :: H.Query Schema (Maybe Text)
schemaDescription = schemaDescription =
H.statement sql (HE.value HE.text) (HD.singleRow $ HD.nullableValue HD.text) True H.statement sql (HE.value HE.text) (join <$> HD.maybeRow (HD.nullableValue HD.text)) True
where where
sql = [q| sql = [q|
select select
+2 -2
View File
@@ -22,7 +22,7 @@ import Protolude hiding ((&), Proxy, get, intercalate, dr
import Data.Swagger import Data.Swagger
import PostgREST.ApiRequest (ContentType(..)) import PostgREST.ApiRequest (ContentType(..))
import PostgREST.Config (prettyVersion) import PostgREST.Config (prettyVersion, docsVersion)
import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..), import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..),
PrimaryKey(..), Proxy(..), ProcDescription(..), toMime) PrimaryKey(..), Proxy(..), ProcDescription(..), toMime)
@@ -260,7 +260,7 @@ postgrestSpec pds ti (s, h, p, b) sd pks = (mempty :: Swagger)
& description ?~ d) & description ?~ d)
& externalDocs ?~ ((mempty :: ExternalDocs) & externalDocs ?~ ((mempty :: ExternalDocs)
& description ?~ "PostgREST Documentation" & description ?~ "PostgREST Documentation"
& url .~ URL "https://postgrest.com/en/latest/api.html") & url .~ URL ("https://postgrest.com/en/" <> docsVersion <> "/api.html"))
& host .~ h' & host .~ h'
& definitions .~ fromList (map (makeTableDef pks) ti) & definitions .~ fromList (map (makeTableDef pks) ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
+11 -2
View File
@@ -1,6 +1,5 @@
module Feature.AuthSpec where module Feature.AuthSpec where
-- {{{ Imports
import Text.Heredoc import Text.Heredoc
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
@@ -11,7 +10,6 @@ import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Protolude hiding (get)
-- }}}
spec :: SpecWith Application spec :: SpecWith Application
spec = describe "authorization" $ do spec = describe "authorization" $ do
@@ -39,6 +37,17 @@ spec = describe "authorization" $ do
, matchHeaders = [] , matchHeaders = []
} }
it "denies execution on functions that anonymous does not own" $
post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
it "allows execution on a function that postgrest_test_author owns" $
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
`shouldRespondWith` [json|"Privileged hello to jdoe"|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "returns jwt functions as jwt tokens" $ it "returns jwt functions as jwt tokens" $
request methodPost "/rpc/login" [single] request methodPost "/rpc/login" [single]
[json| { "id": "jdoe", "pass": "1234" } |] [json| { "id": "jdoe", "pass": "1234" } |]
+15
View File
@@ -0,0 +1,15 @@
module Feature.NonexistentSchemaSpec where
import Network.Wai (Application)
import Protolude hiding (get)
import Test.Hspec
import Test.Hspec.Wai
spec :: SpecWith Application
spec =
describe "Non existent api schema" $ do
it "succeeds when requesting root path" $
get "/" `shouldRespondWith` 200
it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
get "/nonexistent_table" `shouldRespondWith` 404
+40 -2
View File
@@ -4,7 +4,9 @@ import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Network.HTTP.Types import Network.HTTP.Types
import PostgREST.Config (docsVersion)
import Control.Lens ((^?)) import Control.Lens ((^?))
import Data.Aeson.Types (Value (..))
import Data.Aeson.Lens import Data.Aeson.Lens
import Data.Aeson.QQ import Data.Aeson.QQ
@@ -27,7 +29,14 @@ spec = do
(acceptHdrs "application/openapi+json") "" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` 415 `shouldRespondWith` 415
describe "table" $ it "includes postgrest.com current version api docs" $ do
r <- simpleBody <$> get "/"
let docsUrl = r ^? key "externalDocs" . key "url"
liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.com/en/" <> docsVersion <> "/api.html"))
describe "table" $ do
it "includes paths to tables" $ do it "includes paths to tables" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -108,7 +117,21 @@ spec = do
} }
|] |]
describe "RPC" $ it "doesn't include privileged table for anonymous" $ do
r <- simpleBody <$> get "/"
let tablePath = r ^? key "paths" . key "/authors_only"
liftIO $ tablePath `shouldBe` Nothing
it "includes table if user has permission" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
r <- simpleBody <$> request methodGet "/" [auth] ""
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 body schema for arguments" $ do it "includes body schema for arguments" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -162,6 +185,21 @@ spec = do
} }
|] |]
it "doesn't include privileged function for anonymous" $ do
r <- simpleBody <$> get "/"
let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"
liftIO $ funcPath `shouldBe` Nothing
it "includes function if user has permission" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
r <- simpleBody <$> request methodGet "/" [auth] ""
let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
. key "post" . key "tags"
. nth 0
liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
describe "Allow header" $ do describe "Allow header" $ do
it "includes read/write verbs for writeable table" $ do it "includes read/write verbs for writeable table" $ do
+7
View File
@@ -27,6 +27,7 @@ import qualified Feature.UnicodeSpec
import qualified Feature.ProxySpec import qualified Feature.ProxySpec
import qualified Feature.AndOrParamsSpec import qualified Feature.AndOrParamsSpec
import qualified Feature.RpcSpec import qualified Feature.RpcSpec
import qualified Feature.NonexistentSchemaSpec
import Protolude import Protolude
@@ -46,6 +47,7 @@ main = do
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure () noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure ()
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure () binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure ()
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure () asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ pure ()
let reset = resetDb testDbConn let reset = resetDb testDbConn
hspec $ do hspec $ do
@@ -75,6 +77,10 @@ main = do
beforeAll_ reset . before asymJwkApp $ beforeAll_ reset . before asymJwkApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with a nonexistent db-schema
beforeAll_ reset . before nonexistentSchemaApp $
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
where where
specs = map (uncurry describe) [ specs = map (uncurry describe) [
("Feature.AuthSpec" , Feature.AuthSpec.spec) ("Feature.AuthSpec" , Feature.AuthSpec.spec)
@@ -88,4 +94,5 @@ main = do
, ("Feature.SingularSpec" , Feature.SingularSpec.spec) , ("Feature.SingularSpec" , Feature.SingularSpec.spec)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec) , ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec) , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)
, ("Feature.NonexistentSchemaSpec" , Feature.NonexistentSchemaSpec.spec)
] ]
+3
View File
@@ -101,6 +101,9 @@ testCfgAsymJWK testDbConn = (testCfg testDbConn) {
[str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|] [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
} }
testNonexistentSchemaCfg :: Text -> AppConfig
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
setupDb :: Text -> IO () setupDb :: Text -> IO ()
setupDb dbConn = do setupDb dbConn = do
loadFixture dbConn "database" loadFixture dbConn "database"
+3
View File
@@ -74,3 +74,6 @@ GRANT ALL ON TABLE authors_only TO postgrest_test_author;
GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous; GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous; GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous; GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
+3
View File
@@ -1247,6 +1247,9 @@ create function test.test() returns table(test text, value int) as $$
values ('hello', 1); values ('hello', 1);
$$ language sql; $$ language sql;
create function test.privileged_hello(name text) returns text as $$
select 'Privileged hello to ' || $1;
$$ language sql;
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --