Merge pull request #590 from begriffs/proper-403
Return proper 401/403 when access denied
This commit is contained in:
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Support column/node renaming `alias:column` - @ruslantalpa
|
- Support column/node renaming `alias:column` - @ruslantalpa
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Return 401 or 403 for access denied rather than 404 - @begriffs
|
||||||
- Omit Content-Type header for empty body - @begriffs
|
- Omit Content-Type header for empty body - @begriffs
|
||||||
- Prevent role from being changed twice - @begriffs
|
- Prevent role from being changed twice - @begriffs
|
||||||
- Use read-only transaction for read requests - @ruslantalpa
|
- Use read-only transaction for read requests - @ruslantalpa
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
|
|||||||
, Action(..), Target(..)
|
, Action(..), Target(..)
|
||||||
, PreferRepresentation (..)
|
, PreferRepresentation (..)
|
||||||
, userApiRequest)
|
, userApiRequest)
|
||||||
import PostgREST.Auth (tokenJWT)
|
import PostgREST.Auth (tokenJWT, jwtClaims, containsRole)
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.DbStructure
|
import PostgREST.DbStructure
|
||||||
import PostgREST.Error (errResponse, pgErrResponse)
|
import PostgREST.Error (errResponse, pgErrResponse)
|
||||||
@@ -71,10 +71,12 @@ postgrest conf refDbStructure pool =
|
|||||||
|
|
||||||
let schema = cs $ configSchema conf
|
let schema = cs $ configSchema conf
|
||||||
apiRequest = userApiRequest schema req body
|
apiRequest = userApiRequest schema req body
|
||||||
handleReq = runWithClaims conf time (app dbStructure conf) apiRequest
|
eClaims = jwtClaims (configJwtSecret conf) (iJWT apiRequest) time
|
||||||
|
authed = containsRole eClaims
|
||||||
|
handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest
|
||||||
txMode = transactionMode $ iAction apiRequest
|
txMode = transactionMode $ iAction apiRequest
|
||||||
|
|
||||||
resp <- either pgErrResponse id <$> P.use pool
|
resp <- either (pgErrResponse authed) id <$> P.use pool
|
||||||
(HT.run handleReq HT.ReadCommitted txMode)
|
(HT.run handleReq HT.ReadCommitted txMode)
|
||||||
respond resp
|
respond resp
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ very simple authentication system inside the PostgreSQL database.
|
|||||||
-}
|
-}
|
||||||
module PostgREST.Auth (
|
module PostgREST.Auth (
|
||||||
claimsToSQL
|
claimsToSQL
|
||||||
|
, containsRole
|
||||||
, jwtClaims
|
, jwtClaims
|
||||||
, tokenJWT
|
, tokenJWT
|
||||||
) where
|
) where
|
||||||
@@ -80,3 +81,10 @@ tokenJWT secret (Array arr) =
|
|||||||
jcs = parseMaybe parseJSON obj :: Maybe JWT.JWTClaimsSet in
|
jcs = parseMaybe parseJSON obj :: Maybe JWT.JWTClaimsSet in
|
||||||
JWT.encodeSigned JWT.HS256 secret $ fromMaybe JWT.def jcs
|
JWT.encodeSigned JWT.HS256 secret $ fromMaybe JWT.def jcs
|
||||||
tokenJWT secret _ = tokenJWT secret emptyArray
|
tokenJWT secret _ = tokenJWT secret emptyArray
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Whether a response from jwtClaims contains a role claim
|
||||||
|
-}
|
||||||
|
containsRole :: Either Text (M.HashMap Text Value) -> Bool
|
||||||
|
containsRole (Left _) = False
|
||||||
|
containsRole (Right claims) = M.member "role" claims
|
||||||
|
|||||||
+15
-10
@@ -21,9 +21,15 @@ import Network.Wai (Response, responseLBS)
|
|||||||
errResponse :: HT.Status -> Text -> Response
|
errResponse :: HT.Status -> Text -> Response
|
||||||
errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"])
|
errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"])
|
||||||
|
|
||||||
pgErrResponse :: P.UsageError -> Response
|
pgErrResponse :: Bool -> P.UsageError -> Response
|
||||||
pgErrResponse e = responseLBS (httpStatus e)
|
pgErrResponse authed e =
|
||||||
[(hContentType, "application/json")] (JSON.encode e)
|
let status = httpStatus authed e
|
||||||
|
jsonType = (hContentType, "application/json")
|
||||||
|
wwwAuth = ("WWW-Authenticate", "Bearer")
|
||||||
|
hdrs = if status == HT.status401
|
||||||
|
then [jsonType, wwwAuth]
|
||||||
|
else [jsonType] in
|
||||||
|
responseLBS status hdrs (JSON.encode e)
|
||||||
|
|
||||||
instance JSON.ToJSON P.UsageError where
|
instance JSON.ToJSON P.UsageError where
|
||||||
toJSON (P.ConnectionError e) = JSON.object [
|
toJSON (P.ConnectionError e) = JSON.object [
|
||||||
@@ -60,10 +66,9 @@ instance JSON.ToJSON H.Error where
|
|||||||
"message" .= ("Database client error"::String),
|
"message" .= ("Database client error"::String),
|
||||||
"details" .= (fmap cs d::Maybe T.Text)]
|
"details" .= (fmap cs d::Maybe T.Text)]
|
||||||
|
|
||||||
httpStatus :: P.UsageError -> HT.Status
|
httpStatus :: Bool -> P.UsageError -> HT.Status
|
||||||
httpStatus (P.ConnectionError _) =
|
httpStatus _ (P.ConnectionError _) = HT.status500
|
||||||
HT.status500
|
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
||||||
httpStatus (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
|
||||||
case cs c of
|
case cs c of
|
||||||
'0':'8':_ -> HT.status503 -- pg connection err
|
'0':'8':_ -> HT.status503 -- pg connection err
|
||||||
'0':'9':_ -> HT.status500 -- triggered action exception
|
'0':'9':_ -> HT.status500 -- triggered action exception
|
||||||
@@ -88,7 +93,7 @@ httpStatus (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
|||||||
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
||||||
'X':'X':_ -> HT.status500 -- internal Error
|
'X':'X':_ -> HT.status500 -- internal Error
|
||||||
"42P01" -> HT.status404 -- undefined table
|
"42P01" -> HT.status404 -- undefined table
|
||||||
"42501" -> HT.status404 -- insufficient privilege
|
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
|
||||||
_ -> HT.status400
|
_ -> HT.status400
|
||||||
httpStatus (P.SessionError (H.ResultError _)) = HT.status500
|
httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500
|
||||||
httpStatus (P.SessionError (H.ClientError _)) = HT.status503
|
httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503
|
||||||
|
|||||||
+12
-15
@@ -7,7 +7,6 @@ import Data.Aeson (Value (..))
|
|||||||
import qualified Data.HashMap.Strict as M
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import Data.Text
|
import Data.Text
|
||||||
import Data.Time.Clock (NominalDiffTime)
|
|
||||||
import qualified Hasql.Transaction as H
|
import qualified Hasql.Transaction as H
|
||||||
|
|
||||||
import Network.HTTP.Types.Header (hAccept)
|
import Network.HTTP.Types.Header (hAccept)
|
||||||
@@ -19,28 +18,26 @@ import Network.Wai.Middleware.Gzip (def, gzip)
|
|||||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||||
|
|
||||||
import PostgREST.ApiRequest (ApiRequest(..), pickContentType)
|
import PostgREST.ApiRequest (ApiRequest(..), pickContentType)
|
||||||
import PostgREST.Auth (jwtClaims, claimsToSQL)
|
import PostgREST.Auth (claimsToSQL)
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
import PostgREST.Error (errResponse)
|
import PostgREST.Error (errResponse)
|
||||||
|
|
||||||
import Prelude hiding (concat, null)
|
import Prelude hiding (concat, null)
|
||||||
|
|
||||||
runWithClaims :: AppConfig -> NominalDiffTime ->
|
runWithClaims :: AppConfig -> Either Text (M.HashMap Text Value) ->
|
||||||
(ApiRequest -> H.Transaction Response) ->
|
(ApiRequest -> H.Transaction Response) ->
|
||||||
ApiRequest -> H.Transaction Response
|
ApiRequest -> H.Transaction Response
|
||||||
runWithClaims conf time app req = do
|
runWithClaims conf eClaims app req =
|
||||||
let eClaims = jwtClaims jwtSecret (iJWT req) time
|
case eClaims of
|
||||||
case eClaims of
|
Left e -> clientErr e
|
||||||
Left e -> clientErr e
|
Right claims ->
|
||||||
Right claims ->
|
if M.null claims && not (null $ iJWT req)
|
||||||
if M.null claims && not (null $ iJWT req)
|
then clientErr "Invalid JWT"
|
||||||
then clientErr "Invalid JWT"
|
else do
|
||||||
else do
|
-- role claim defaults to anon if not specified in jwt
|
||||||
-- role claim defaults to anon if not specified in jwt
|
H.sql . mconcat . claimsToSQL $ M.union claims (M.singleton "role" anon)
|
||||||
H.sql . mconcat . claimsToSQL $ M.union claims (M.singleton "role" anon)
|
app req
|
||||||
app req
|
|
||||||
where
|
where
|
||||||
jwtSecret = configJwtSecret conf
|
|
||||||
anon = String . cs $ configAnonRole conf
|
anon = String . cs $ configAnonRole conf
|
||||||
clientErr = return . errResponse status400
|
clientErr = return . errResponse status400
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,29 @@ import Network.Wai (Application)
|
|||||||
spec :: SpecWith Application
|
spec :: SpecWith Application
|
||||||
spec = describe "authorization" $ do
|
spec = describe "authorization" $ do
|
||||||
|
|
||||||
it "hides tables that anonymous does not own" $
|
it "denies access to tables that anonymous does not own" $
|
||||||
get "/authors_only" `shouldRespondWith` 404
|
get "/authors_only" `shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| {
|
||||||
|
"hint":null,
|
||||||
|
"details":null,
|
||||||
|
"code":"42501",
|
||||||
|
"message":"permission denied for relation authors_only"} |]
|
||||||
|
, matchStatus = 401
|
||||||
|
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "denies access to tables that postgrest_test_author does not own" $
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" in
|
||||||
|
request methodGet "/private_table" [auth] ""
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| {
|
||||||
|
"hint":null,
|
||||||
|
"details":null,
|
||||||
|
"code":"42501",
|
||||||
|
"message":"permission denied for relation private_table"} |]
|
||||||
|
, matchStatus = 403
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
it "returns jwt functions as jwt tokens" $
|
it "returns jwt functions as jwt tokens" $
|
||||||
post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]
|
post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ spec = do
|
|||||||
, {"schema":"test","name":"comments","insertable":true}
|
, {"schema":"test","name":"comments","insertable":true}
|
||||||
, {"schema":"test","name":"complex_items","insertable":true}
|
, {"schema":"test","name":"complex_items","insertable":true}
|
||||||
, {"schema":"test","name":"compound_pk","insertable":true}
|
, {"schema":"test","name":"compound_pk","insertable":true}
|
||||||
|
, {"schema":"test","name":"empty_table","insertable":true}
|
||||||
, {"schema":"test","name":"filtered_tasks","insertable":true}
|
, {"schema":"test","name":"filtered_tasks","insertable":true}
|
||||||
, {"schema":"test","name":"ghostBusters","insertable":true}
|
, {"schema":"test","name":"ghostBusters","insertable":true}
|
||||||
, {"schema":"test","name":"has_count_column","insertable":false}
|
, {"schema":"test","name":"has_count_column","insertable":false}
|
||||||
|
|||||||
Vendored
+1
@@ -17,6 +17,7 @@ GRANT ALL ON TABLE
|
|||||||
, comments
|
, comments
|
||||||
, complex_items
|
, complex_items
|
||||||
, compound_pk
|
, compound_pk
|
||||||
|
, empty_table
|
||||||
, has_count_column
|
, has_count_column
|
||||||
, has_fk
|
, has_fk
|
||||||
, insertable_view_with_join
|
, insertable_view_with_join
|
||||||
|
|||||||
Vendored
+7
@@ -458,6 +458,13 @@ CREATE TABLE empty_table (
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: private_table; Type: TABLE; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE private_table ();
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: has_count_column; Type: VIEW; Schema: test; Owner: -
|
-- Name: has_count_column; Type: VIEW; Schema: test; Owner: -
|
||||||
--
|
--
|
||||||
|
|||||||
Reference in New Issue
Block a user