accept a new argument claims_namespace_path in JWT config (#4365)

* add new optional field `claims_namespace_path` in JWT config

* return value when empty array is found in executeJSONPath

* update the docs related to claims_namespace_path

* improve encodeJSONPath, add property tests for parseJSONPath

* throw error if both claims_namespace_path and claims_namespace are set

* refactor the Data.Parser.JsonPath to Data.Parser.JSONPathSpec

* update the JWT docs

Co-Authored-By: Marion Schleifer <marion@hasura.io>

Co-authored-by: Marion Schleifer <marion@hasura.io>
Co-authored-by: rakeshkky <12475069+rakeshkky@users.noreply.github.com>
Co-authored-by: Tirumarai Selvan <tirumarai.selvan@gmail.com>
This commit is contained in:
Karthikeyan Chinnakonda 2020-04-16 12:15:21 +05:30 committed by GitHub
parent 2aa971ae83
commit a26bc80496
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 347 additions and 120 deletions

View File

@ -318,6 +318,20 @@ kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
##########
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH ADMIN SECRET AND JWT (with claims_namespace_path) #####################################>\n"
TEST_TYPE="jwt-with-claims-namespace-path"
export HASURA_GRAPHQL_JWT_SECRET="$(jq -n --arg key "$(cat $OUTPUT_FOLDER/ssl/jwt_public.key)" '{ type: "RS512", key: $key , claims_namespace_path: "$.hasuraClaims"}')"
run_hge_with_args serve
wait_for_port 8080
pytest -n 1 -vv --hge-urls "$HGE_URL" --pg-urls "$HASURA_GRAPHQL_DATABASE_URL" --hge-key="$HASURA_GRAPHQL_ADMIN_SECRET" --hge-jwt-key-file="$OUTPUT_FOLDER/ssl/jwt_private.key" --hge-jwt-conf="$HASURA_GRAPHQL_JWT_SECRET" test_jwt.py
kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
# test with CORS modes

View File

@ -32,6 +32,7 @@ The order, collapsed state of columns and page size is now persisted across page
- server: fix an edge case where some events wouldn't be processed because of internal erorrs (#4213)
- server: fix downgrade not working to version v1.1.1 (#4354)
- server: `type` field is not required if `jwk_url` is provided in JWT config
- server: add a new field `claims_namespace_path` which accepts a JSON Path for looking up hasura claim in the JWT token (#4349)
## `v1.2.0-beta.3`

View File

@ -92,7 +92,8 @@ etc.) JWT claims, as well as Hasura specific claims inside a custom namespace
(or key) i.e. ``https://hasura.io/jwt/claims``.
The ``https://hasura.io/jwt/claims`` is the custom namespace where all Hasura
specific claims have to be present. This value can be configured in the JWT
specific claims have to be present. This value can be configured using
``claims_namespace`` or ``claims_namespace_path`` in the JWT
config while starting the server.
**Note**: ``x-hasura-default-role`` and ``x-hasura-allowed-roles`` are
@ -129,6 +130,7 @@ JSON object:
"key": "<optional-key-as-string>",
"jwk_url": "<optional-url-to-refresh-jwks>",
"claims_namespace": "<optional-key-name-in-claims>",
"claims_namespace_path":"<optional-json-path-to-the-claims>",
"claims_format": "json|stringified_json",
"audience": <optional-string-or-list-of-strings-to-verify-audience>,
"issuer": "<optional-string-to-verify-issuer>"
@ -220,6 +222,38 @@ inside which the Hasura specific claims will be present, e.g. ``https://mydomain
**Default value** is: ``https://hasura.io/jwt/claims``.
``claims_namespace_path``
^^^^^^^^^^^^^^^^^^^^^^^^^
An optional JSON path value to the Hasura claims in the JWT token.
Example values are ``$.hasura.claims`` or ``$`` (i.e. root of the payload)
The JWT token should be in this format if the ``claims_namespace_path`` is
set to ``$.hasura.claims``:
.. code-block:: json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"hasura": {
"claims": {
"x-hasura-allowed-roles": ["editor","user", "mod"],
"x-hasura-default-role": "user",
"x-hasura-user-id": "1234567890",
"x-hasura-org-id": "123",
"x-hasura-custom": "custom-value"
}
}
}
.. note::
The JWT config can only have one of ``claims_namespace`` or ``claims_namespace_path``
values set. If neither keys are set, then the default value of
``claims_namespace`` i.e. https://hasura.io/jwt/claims will be used.
``claims_format``
^^^^^^^^^^^^^^^^^

View File

@ -231,6 +231,7 @@ library
, Hasura.Server.PGDump
-- Exposed for testing:
, Hasura.Server.Telemetry.Counters
, Data.Parser.JSONPath
, Hasura.RQL.Types
, Hasura.RQL.Types.Run
@ -374,7 +375,6 @@ library
, Data.List.Extended
, Data.HashMap.Strict.Extended
, Data.HashMap.Strict.InsOrd.Extended
, Data.Parser.JSONPath
, Data.Sequence.NonEmpty
, Data.TByteString
, Data.Text.Extended
@ -426,11 +426,13 @@ test-suite graphql-engine-tests
, time
, transformers-base
, unordered-containers
, text
hs-source-dirs: src-test
main-is: Main.hs
other-modules:
Data.Parser.CacheControlSpec
Data.Parser.URLTemplate
Data.Parser.JSONPathSpec
Data.TimeSpec
Hasura.IncrementalSpec
Hasura.RQL.MetadataSpec

View File

@ -8,50 +8,45 @@ import Control.Applicative ((<|>))
import Data.Aeson.Internal (JSONPath, JSONPathElement (..))
import Data.Attoparsec.Text
import Data.Bool (bool)
import Data.Char (isDigit)
import qualified Data.Text as T
import Prelude hiding (takeWhile)
import Text.Read (readMaybe)
import qualified Data.Text as T
parseKey :: Parser T.Text
parseKey = do
firstChar <- letter
<?> "the first character of property name must be a letter."
name <- many' (letter
<|> digit
<|> satisfy (`elem` ("-_" :: String))
)
return $ T.pack (firstChar:name)
parseSimpleKeyText :: Parser T.Text
parseSimpleKeyText = takeWhile1 (inClass "a-zA-Z0-9_-")
parseIndex :: Parser Int
parseIndex = skip (== '[') *> anyChar >>= parseDigits
where
parseDigits :: Char -> Parser Int
parseDigits firstDigit
| firstDigit == ']' = fail "empty array index"
| not $ isDigit firstDigit =
fail $ "invalid array index: " ++ [firstDigit]
| otherwise = do
remain <- many' (notChar ']')
skip (== ']')
let content = firstDigit:remain
case (readMaybe content :: Maybe Int) of
Nothing -> fail $ "invalid array index: " ++ content
Just v -> return v
parseKey :: Parser JSONPathElement
parseKey = Key <$>
( (char '.' *> parseSimpleKeyText) -- Parse `.key`
<|> T.pack <$> ((string ".['" <|> string "['") *> manyTill anyChar (string "']")) -- Parse `['key']` or `.['key']`
<|> fail "invalid key element"
)
parseElement :: Parser JSONPathElement
parseElement = do
dotLen <- T.length <$> takeWhile (== '.')
if dotLen > 1
then fail "multiple dots in json path"
else peekChar >>= \case
Nothing -> fail "empty json path"
Just '[' -> Index <$> parseIndex
_ -> Key <$> parseKey
parseIndex :: Parser JSONPathElement
parseIndex = Index <$>
( ((char '[' *> manyTill anyChar (char ']')) >>= maybe (fail "invalid array index") pure . readMaybe) -- Parse `[Int]`
<|> fail "invalid index element"
)
parseElements :: Parser JSONPath
parseElements = skipWhile (== '$') *> many1 parseElement
parseElements = skipWhile (== '$') *> parseRemaining
where
parseFirstKey = Key <$> parseSimpleKeyText
parseElements' = many1 (parseIndex <|> parseKey)
parseRemaining = do
maybeFirstChar <- peekChar
case maybeFirstChar of
Nothing -> pure []
Just firstChar ->
-- If first char is not any of '.' and '[', then parse first key
-- Eg:- Parse "key1.key2[0]"
if firstChar `notElem` (".[" :: String) then do
firstKey <- parseFirstKey
remainingElements <- parseElements'
pure $ firstKey:remainingElements
else parseElements'
-- | Parse jsonpath String value
parseJSONPath :: T.Text -> Either String JSONPath
parseJSONPath = parseResult . parse parseElements
where
@ -64,6 +59,6 @@ parseJSONPath = parseResult . parse parseElements
Left $ invalidMessage remain
else
Right r
invalidMessage s = "invalid property name: " ++ T.unpack s
++ ". Accept letters, digits, underscore (_) or hyphen (-) only"
++ ". Use single quotes enclosed in bracket if there are any special characters"

View File

@ -82,12 +82,12 @@ renderURLTemplate template = do
-- QuickCheck generators
instance Arbitrary Variable where
arbitrary = Variable . T.pack <$> listOf1 (elements $ alphaNumerics <> "-_")
arbitrary = Variable . T.pack <$> listOf1 (elements $ alphaNumerics <> " -_")
instance Arbitrary URLTemplate where
arbitrary = URLTemplate <$> listOf (oneof [genText, genVariable])
where
genText = (TIText . T.pack) <$> listOf1 (elements $ alphaNumerics <> "://")
genText = (TIText . T.pack) <$> listOf1 (elements $ alphaNumerics <> " ://")
genVariable = TIVariable <$> arbitrary
genURLTemplate :: Gen URLTemplate

View File

@ -72,7 +72,7 @@ import qualified GHC.Clock as Clock
import qualified Test.QuickCheck as QC
alphaNumerics :: String
alphaNumerics = ['a'..'z'] ++ ['A'..'Z'] ++ "0123456789 "
alphaNumerics = ['a'..'z'] ++ ['A'..'Z'] ++ "0123456789"
instance Arbitrary Text where
arbitrary = T.pack <$> QC.listOf (QC.elements alphaNumerics)

View File

@ -5,6 +5,7 @@ module Hasura.RQL.Types.Error
, QErr(..)
, encodeQErr
, encodeGQLErr
, encodeJSONPath
, noInternalQErrEnc
, err400
, err404
@ -197,8 +198,10 @@ encodeJSONPath = format "$"
format pfx (Key key:parts) = format (pfx ++ "." ++ formatKey key) parts
formatKey key
| T.any (=='.') key = "['" ++ T.unpack key ++ "']"
| otherwise = T.unpack key
| T.any specialChar key = "['" ++ T.unpack key ++ "']"
| otherwise = T.unpack key
where
specialChar = flip notElem (alphaNumerics ++ "_-")
instance Q.FromPGConnErr QErr where
fromPGConnErr c =

View File

@ -6,6 +6,7 @@ module Hasura.Server.Auth.JWT
, Jose.JWKSet (..)
, JWTClaimsFormat (..)
, JwkFetchError (..)
, JWTConfigClaims (..)
, updateJwkRef
, jwkRefreshCtrl
, defaultClaimNs
@ -22,6 +23,7 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, diff
import GHC.AssertNF
import Network.URI (URI)
import Data.Aeson.Internal (JSONPath)
import Data.Parser.CacheControl
import Data.Parser.Expires
import Hasura.HTTP
@ -30,14 +32,16 @@ import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Server.Auth.JWT.Internal (parseHmacKey, parseRsaKey)
import Hasura.Server.Auth.JWT.Logging
import Hasura.Server.Utils (getRequestHeader, userRoleHeader)
import Hasura.Server.Utils (getRequestHeader, userRoleHeader, executeJSONPath)
import Hasura.Server.Version (HasVersion)
import Hasura.RQL.Types.Error (encodeJSONPath)
import qualified Control.Concurrent.Extended as C
import qualified Crypto.JWT as Jose
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J
import qualified Data.Aeson.TH as J
import qualified Data.Aeson.Internal as J
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.CaseInsensitive as CI
@ -47,7 +51,7 @@ import qualified Data.Text.Encoding as T
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wreq as Wreq
import qualified Data.Parser.JSONPath as JSONPath
newtype RawJWT = RawJWT BL.ByteString
@ -59,10 +63,19 @@ data JWTClaimsFormat
$(J.deriveJSON J.defaultOptions { J.sumEncoding = J.ObjectWithSingleField
, J.constructorTagModifier = J.snakeCase . drop 3 } ''JWTClaimsFormat)
data JWTConfigClaims
= ClaimNsPath JSONPath
| ClaimNs T.Text
deriving (Show, Eq)
instance J.ToJSON JWTConfigClaims where
toJSON (ClaimNsPath nsPath) = J.String . T.pack $ encodeJSONPath nsPath
toJSON (ClaimNs ns) = J.String ns
data JWTConfig
= JWTConfig
{ jcKeyOrUrl :: !(Either Jose.JWK URI)
, jcClaimNs :: !(Maybe T.Text)
, jcClaimNs :: !JWTConfigClaims
, jcAudience :: !(Maybe Jose.Audience)
, jcClaimsFormat :: !(Maybe JWTClaimsFormat)
, jcIssuer :: !(Maybe Jose.StringOrURI)
@ -71,7 +84,7 @@ data JWTConfig
data JWTCtx
= JWTCtx
{ jcxKey :: !(IORef Jose.JWKSet)
, jcxClaimNs :: !(Maybe T.Text)
, jcxClaimNs :: !JWTConfigClaims
, jcxAudience :: !(Maybe Jose.Audience)
, jcxClaimsFormat :: !JWTClaimsFormat
, jcxIssuer :: !(Maybe Jose.StringOrURI)
@ -79,7 +92,7 @@ data JWTCtx
instance Show JWTCtx where
show (JWTCtx _ nsM audM cf iss) =
show ["<IORef JWKSet>", show nsM, show audM, show cf, show iss]
show ["<IORef JWKSet>", show nsM,show audM, show cf, show iss]
data HasuraClaims
= HasuraClaims
@ -231,12 +244,15 @@ processAuthZHeader jwtCtx headers authzHeader = do
-- verify the JWT
claims <- liftJWTError invalidJWTError $ verifyJwt jwtCtx $ RawJWT jwt
let claimsNs = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx
claimsFmt = jcxClaimsFormat jwtCtx
let claimsFmt = jcxClaimsFormat jwtCtx
expTimeM = fmap (\(Jose.NumericDate t) -> t) $ claims ^. Jose.claimExp
-- see if the hasura claims key exist in the claims map
let mHasuraClaims = Map.lookup claimsNs $ claims ^. Jose.unregisteredClaims
-- see if the hasura claims key exists in the claims map
let mHasuraClaims =
case jcxClaimNs jwtCtx of
ClaimNs k -> Map.lookup k $ claims ^. Jose.unregisteredClaims
ClaimNsPath path -> parseIValueJsonValue $ executeJSONPath path (J.toJSON $ claims ^. Jose.unregisteredClaims)
hasuraClaimsV <- maybe claimsNotFound return mHasuraClaims
-- get hasura claims value as an object. parse from string possibly
@ -277,12 +293,22 @@ processAuthZHeader jwtCtx headers authzHeader = do
(JCFJson, _) ->
claimsErr "expecting a json object when claims_format is json"
strngfyErr v = "expecting stringified json at: '"
<> fromMaybe defaultClaimNs (jcxClaimNs jwtCtx)
<> "', but found: " <> v
strngfyErr v =
"expecting stringified json at: '"
<> claimsLocation
<> "', but found: " <> v
where
claimsLocation :: Text
claimsLocation =
case jcxClaimNs jwtCtx of
ClaimNsPath path -> T.pack $ "claims_namespace_path " <> encodeJSONPath path
ClaimNs ns -> "claims_namespace " <> ns
claimsErr = throw400 JWTInvalidClaims
parseIValueJsonValue (J.IError _ _) = Nothing
parseIValueJsonValue (J.ISuccess v) = Just v
-- see if there is a x-hasura-role header, or else pick the default role
getCurrentRole defaultRole =
let mUserRole = getRequestHeader userRoleHeader headers
@ -305,8 +331,10 @@ processAuthZHeader jwtCtx headers authzHeader = do
currRoleNotAllowed =
throw400 AccessDenied "Your current role is not in allowed roles"
claimsNotFound = do
let claimsNs = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx
throw400 JWTInvalidClaims $ "claims key: '" <> claimsNs <> "' not found"
let claimsNsError = case jcxClaimNs jwtCtx of
ClaimNsPath path -> T.pack $ "claims not found at claims_namespace_path: '" <> (encodeJSONPath path) <> "'"
ClaimNs ns -> "claims key: '" <> ns <> "' not found"
throw400 JWTInvalidClaims $ claimsNsError
-- parse x-hasura-allowed-roles, x-hasura-default-role from JWT claims
@ -369,14 +397,19 @@ verifyJwt ctx (RawJWT rawJWT) = do
instance J.ToJSON JWTConfig where
toJSON (JWTConfig keyOrUrl claimNs aud claimsFmt iss) =
J.object (jwkFields ++ sharedFields)
J.object (jwkFields ++ sharedFields ++ claimsNsFields)
where
jwkFields = case keyOrUrl of
Left _ -> [ "type" J..= J.String "<TYPE REDACTED>"
, "key" J..= J.String "<JWK REDACTED>" ]
Right url -> [ "jwk_url" J..= url ]
sharedFields = [ "claims_namespace" J..= claimNs
, "claims_format" J..= claimsFmt
claimsNsFields = case claimNs of
ClaimNsPath nsPath ->
["claims_namespace_path" J..= (encodeJSONPath nsPath)]
ClaimNs ns -> ["claims_namespace" J..= J.String ns]
sharedFields = [ "claims_format" J..= claimsFmt
, "audience" J..= aud
, "issuer" J..= iss
]
@ -388,24 +421,33 @@ instance J.FromJSON JWTConfig where
parseJSON = J.withObject "JWTConfig" $ \o -> do
mRawKey <- o J..:? "key"
claimNs <- o J..:? "claims_namespace"
claimsNs <- o J..:? "claims_namespace"
claimsNsPath <- o J..:? "claims_namespace_path"
aud <- o J..:? "audience"
iss <- o J..:? "issuer"
jwkUrl <- o J..:? "jwk_url"
isStrngfd <- o J..:? "claims_format"
hasuraClaimsNs <-
case (claimsNsPath,claimsNs) of
(Nothing, Nothing) -> return $ ClaimNs defaultClaimNs
(Just nsPath, Nothing) -> either failJSONPathParsing (return . ClaimNsPath) . JSONPath.parseJSONPath $ nsPath
(Nothing, Just ns) -> return $ ClaimNs ns
(Just _, Just _) -> fail "claims_namespace and claims_namespace_path both cannot be set"
case (mRawKey, jwkUrl) of
(Nothing, Nothing) -> fail "key and jwk_url both cannot be empty"
(Just _, Just _) -> fail "key, jwk_url both cannot be present"
(Just rawKey, Nothing) -> do
keyType <- o J..: "type"
key <- parseKey rawKey keyType
return $ JWTConfig (Left key) claimNs aud isStrngfd iss
key <- parseKey keyType rawKey
return $ JWTConfig (Left key) hasuraClaimsNs aud isStrngfd iss
(Nothing, Just url) ->
return $ JWTConfig (Right url) claimNs aud isStrngfd iss
return $ JWTConfig (Right url) hasuraClaimsNs aud isStrngfd iss
where
parseKey rawKey keyType =
parseKey keyType rawKey =
case keyType of
"HS256" -> runEither $ parseHmacKey rawKey 256
"HS384" -> runEither $ parseHmacKey rawKey 384
@ -417,4 +459,7 @@ instance J.FromJSON JWTConfig where
_ -> invalidJwk ("Key type: " <> T.unpack keyType <> " is not supported")
runEither = either (invalidJwk . T.unpack) return
invalidJwk msg = fail ("Invalid JWK: " <> msg)
failJSONPathParsing err = fail $ "invalid JSON path claims_namespace_path error: " ++ err

View File

@ -15,8 +15,8 @@ import qualified Hasura.GraphQL.Execute.LiveQuery.Options as LQ
data JWTInfo
= JWTInfo
{ jwtiClaimsNamespace :: !Text
, jwtiClaimsFormat :: !JWTClaimsFormat
{ jwtiClaimsNamespace :: !JWTConfigClaims
, jwtiClaimsFormat :: !JWTClaimsFormat
} deriving (Show, Eq)
$(deriveToJSON (aesonDrop 4 snakeCase) ''JWTInfo)
@ -44,7 +44,6 @@ runGetConfig am isAllowListEnabled liveQueryOpts = ServerConfig
isAllowListEnabled
liveQueryOpts
isAdminSecretSet :: AuthMode -> Bool
isAdminSecretSet = \case
AMNoAuth -> False
@ -62,8 +61,8 @@ isJWTSet = \case
getJWTInfo :: AuthMode -> Maybe JWTInfo
getJWTInfo (AMAdminSecretAndJWT _ jwtCtx _) =
Just $ JWTInfo ns format
Just $ JWTInfo claimsNs format
where
ns = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx
claimsNs = jcxClaimNs jwtCtx
format = jcxClaimsFormat jwtCtx
getJWTInfo _ = Nothing

View File

@ -11,6 +11,7 @@ import Language.Haskell.TH.Syntax (Lift, Q, TExp)
import System.Environment
import System.Exit
import System.Process
import Data.Aeson.Internal
import qualified Data.ByteString as B
import qualified Data.CaseInsensitive as CI
@ -27,6 +28,7 @@ import qualified Network.Wreq as Wreq
import qualified Text.Regex.TDFA as TDFA
import qualified Text.Regex.TDFA.ReadRegex as TDFA
import qualified Text.Regex.TDFA.TDFA as TDFA
import qualified Data.Vector as V
import Hasura.RQL.Instances ()
@ -227,3 +229,16 @@ makeReasonMessage errors showError =
[singleError] -> "because " <> showError singleError
_ -> "for the following reasons:\n" <> T.unlines
(map (("" <>) . showError) errors)
executeJSONPath :: JSONPath -> Value -> IResult Value
executeJSONPath jsonPath = iparse (valueParser jsonPath)
where
valueParser path value = case path of
[] -> pure value
(pathElement:remaining) -> parseWithPathElement pathElement value >>=
((<?> pathElement) . valueParser remaining)
where
parseWithPathElement = \case
Key k -> withObject "Object" (.: k)
Index i -> withArray "Array" $
maybe (fail "Array index out of range") pure . (V.!? i)

View File

@ -0,0 +1,31 @@
module Data.Parser.JSONPathSpec (spec) where
import Hasura.Prelude
import Hasura.RQL.Types (encodeJSONPath)
import Data.Parser.JSONPath
import Test.Hspec
import Test.QuickCheck
import qualified Data.Text as T
spec :: Spec
spec = describe "parseJSONPath" $
it "JSONPath parser" $
withMaxSuccess 1000 $
forAll(resize 20 generateJSONPath) $ \jsonPath ->
let encPath = encodeJSONPath jsonPath
parsedJSONPathE = parseJSONPath $ T.pack encPath
in case parsedJSONPathE of
Left err -> counterexample (err <> ": " <> encPath) False
Right parsedJSONPath -> property $ parsedJSONPath == jsonPath
generateJSONPath :: Gen JSONPath
generateJSONPath = map (either id id) <$> listOf1 genPathElementEither
where
genPathElementEither = do
indexLeft <- Left <$> genIndex
keyRight <- Right <$> genKey
elements [indexLeft, keyRight]
genIndex = Index <$> choose (0, 100)
genKey = (Key . T.pack) <$> listOf1 (elements $ alphaNumerics ++ ".,!@#$%^&*_-?:;|/\"")

View File

@ -26,6 +26,7 @@ import Hasura.Server.Migrate
import Hasura.Server.Version
import qualified Data.Parser.CacheControlSpec as CacheControlParser
import qualified Data.Parser.JSONPathSpec as JsonPath
import qualified Data.Parser.URLTemplate as URLTemplate
import qualified Data.TimeSpec as TimeSpec
import qualified Hasura.IncrementalSpec as IncrementalSpec
@ -57,6 +58,7 @@ unitSpecs :: Spec
unitSpecs = do
describe "Data.Parser.CacheControl" CacheControlParser.spec
describe "Data.Parser.URLTemplate" URLTemplate.spec
describe "Data.Parser.JsonPath" JsonPath.spec
describe "Hasura.Incremental" IncrementalSpec.spec
-- describe "Hasura.RQL.Metadata" MetadataSpec.spec -- Commenting until optimizing the test in CI
describe "Data.Time" TimeSpec.spec

View File

@ -406,7 +406,7 @@ class HGECtx:
def __init__(self, hge_url, pg_url, config):
self.http = requests.Session()
self. hge_key = config.getoption('--hge-key')
self.hge_key = config.getoption('--hge-key')
self.hge_url = hge_url
self.pg_url = pg_url
self.hge_webhook = config.getoption('--hge-webhook')
@ -417,6 +417,8 @@ class HGECtx:
with open(hge_jwt_key_file) as f:
self.hge_jwt_key = f.read()
self.hge_jwt_conf = config.getoption('--hge-jwt-conf')
if self.hge_jwt_conf is not None:
self.hge_jwt_conf_dict = json.loads(self.hge_jwt_conf)
self.webhook_insecure = config.getoption('--test-webhook-insecure')
self.metadata_disabled = config.getoption('--test-metadata-disabled')
self.may_skip_test_teardown = False

View File

@ -4,7 +4,7 @@ status: 400
response:
code: unexpected-payload
error: cannot cast column of type "geography" to type "integer"
path: $.args.where.geog_col.$cast
path: $.args.where.geog_col.['$cast']
query:
type: select
args:

View File

@ -5,7 +5,7 @@ headers:
X-Hasura-User-Id: '1'
status: 400
response:
path: $.args.$set
path: $.args.['$set']
error: role "user" does not have permission to update column "id"
code: permission-denied
query:

View File

@ -4,15 +4,15 @@ status: 400
headers:
X-Hasura-Role: user1
response:
path: "$.args.$set"
error: column "city" is not updatable for role "user1"; its value is predefined in
permission
path: $.args.['$set']
error: column "city" is not updatable for role "user1"; its value is predefined
in permission
code: not-supported
query:
type: update
args:
table: resident
"$set":
$set:
city: hobart
where:
name: clarke

View File

@ -1,5 +1,6 @@
import ruamel.yaml as yaml
import re
import json
class TestConfigAPI():
@ -7,6 +8,8 @@ class TestConfigAPI():
admin_secret = hge_ctx.hge_key
auth_hook = hge_ctx.hge_webhook
jwt_conf = hge_ctx.hge_jwt_conf
if jwt_conf is not None:
jwt_conf_dict = json.loads(hge_ctx.hge_jwt_conf)
headers = {}
if admin_secret is not None:
@ -25,14 +28,18 @@ class TestConfigAPI():
assert body['is_jwt_set'] == (jwt_conf is not None)
if jwt_conf is not None:
claims_namespace = "https://hasura.io/jwt/claims"
if 'claims_namespace' in jwt_conf:
claims_namespace = jwt_conf['claims_namespace']
claims_format = "json"
if 'claims_format' in jwt_conf:
claims_format = jwt_conf['claims_format']
assert body['jwt']['claims_namespace'] == claims_namespace
assert body['jwt']['claims_format'] == claims_format
if 'claims_namespace_path' in jwt_conf_dict:
assert body['jwt']['claims_namespace_path'] == jwt_conf_dict['claims_namespace_path']
assert body['jwt']['claims_format'] == claims_format
else:
claims_namespace = "https://hasura.io/jwt/claims"
if 'claims_namespace' in jwt_conf_dict:
claims_namespace = jwt_conf_dict['claims_namespace']
if 'claims_format' in jwt_conf_dict:
claims_format = jwt_conf_dict['claims_format']
assert body['jwt']['claims_namespace'] == claims_namespace
assert body['jwt']['claims_format'] == claims_format
else:
assert body['jwt'] == None

View File

@ -11,7 +11,7 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from validate import check_query
from validate import check_query, mk_claims_with_namespace_path
from context import PytestConf
@ -42,23 +42,32 @@ def mk_claims(conf, claims):
class TestJWTBasic():
def test_jwt_valid_claims_success(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-allowed-roles': ['user', 'editor'],
'x-hasura-default-role': 'user'
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-allowed-roles': ['user', 'editor'],
'x-hasura-default-role': 'user'
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['url'] = endpoint
self.conf['status'] = 200
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_invalid_role_in_request_header(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-allowed-roles': ['contractor', 'editor'],
'x-hasura-default-role': 'contractor'
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
@ -75,13 +84,18 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_no_allowed_roles_in_claim(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user'
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
@ -98,14 +112,19 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_invalid_allowed_roles_in_claim(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-allowed-roles': 'user',
'x-hasura-default-role': 'user'
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
@ -122,13 +141,18 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_no_default_role(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
@ -145,14 +169,19 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_expired(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
exp = datetime.utcnow() - timedelta(minutes=1)
self.claims['exp'] = round(exp.timestamp())
@ -172,15 +201,19 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_invalid_signature(self, hge_ctx, endpoint):
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
wrong_key = gen_rsa_key()
token = jwt.encode(self.claims, wrong_key, algorithm='HS256').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
@ -198,41 +231,49 @@ class TestJWTBasic():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_no_audience_in_conf(self, hge_ctx, endpoint):
jwt_conf = json.loads(hge_ctx.hge_jwt_conf)
if 'audience' in jwt_conf:
pytest.skip('audience present in conf, skipping testing no audience')
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['aud'] = 'hasura-test-suite'
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['url'] = endpoint
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_no_issuer_in_conf(self, hge_ctx, endpoint):
jwt_conf = json.loads(hge_ctx.hge_jwt_conf)
if 'issuer' in jwt_conf:
pytest.skip('issuer present in conf, skipping testing no issuer')
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['iss'] = 'rubbish-issuer'
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['url'] = endpoint
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
@pytest.fixture(autouse=True)
def transact(self, setup):
@ -240,7 +281,7 @@ class TestJWTBasic():
with open(self.dir + '/user_select_query_unpublished_articles.yaml') as c:
self.conf = yaml.safe_load(c)
curr_time = datetime.utcnow()
exp_time = curr_time + timedelta(hours=1)
exp_time = curr_time + timedelta(hours=10)
self.claims = {
'sub': '1234567890',
'name': 'John Doe',
@ -280,11 +321,16 @@ class TestSubscriptionJwtExpiry(object):
'name': 'John Doe',
'iat': math.floor(curr_time.timestamp())
}
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
exp = curr_time + timedelta(seconds=4)
self.claims['exp'] = round(exp.timestamp())
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
@ -307,27 +353,36 @@ class TestJwtAudienceCheck():
audience = jwt_conf['audience']
audience = audience if isinstance(audience, str) else audience[0]
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
self.claims['aud'] = audience
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['aud'] = audience
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_invalid_audience(self, hge_ctx, endpoint):
jwt_conf = json.loads(hge_ctx.hge_jwt_conf)
if 'audience' not in jwt_conf:
pytest.skip('audience not present in conf, skipping testing audience')
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['aud'] = 'rubbish_audience'
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
@ -346,7 +401,7 @@ class TestJwtAudienceCheck():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
@pytest.fixture(autouse=True)
def transact(self, setup):
@ -379,27 +434,37 @@ class TestJwtIssuerCheck():
pytest.skip('issuer not present in conf, skipping testing issuer')
issuer = jwt_conf['issuer']
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['iss'] = issuer
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
def test_jwt_invalid_issuer(self, hge_ctx, endpoint):
jwt_conf = json.loads(hge_ctx.hge_jwt_conf)
if 'issuer' not in jwt_conf:
pytest.skip('issuer not present in conf, skipping testing issuer')
self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, {
hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, {
'x-hasura-user-id': '1',
'x-hasura-default-role': 'user',
'x-hasura-allowed-roles': ['user'],
})
claims_namespace_path = None
if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict:
claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path']
self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path)
self.claims['iss'] = 'rubbish_issuer'
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
@ -418,7 +483,7 @@ class TestJwtIssuerCheck():
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path)
@pytest.fixture(autouse=True)
def transact(self, setup):

View File

@ -123,9 +123,21 @@ def test_forbidden_webhook(hge_ctx, conf):
})
def mk_claims_with_namespace_path(claims,hasura_claims,namespace_path):
if namespace_path is None:
claims['https://hasura.io/jwt/claims'] = hasura_claims
elif namespace_path == "$.hasuraClaims":
claims['hasuraClaims'] = hasura_claims
else:
raise Exception(
'''claims_namespace_path should not be anything
other than $.hasuraClaims for testing. The
value of claims_namespace_path was {}'''.format(namespace_path))
return claims
# Returns the response received and a bool indicating whether the test passed
# or not (this will always be True unless we are `--accepting`)
def check_query(hge_ctx, conf, transport='http', add_auth=True):
def check_query(hge_ctx, conf, transport='http', add_auth=True, claims_namespace_path=None):
hge_ctx.tests_passed = True
headers = {}
if 'headers' in conf:
@ -150,8 +162,8 @@ def check_query(hge_ctx, conf, transport='http', add_auth=True):
claim = {
"sub": "foo",
"name": "bar",
"https://hasura.io/jwt/claims": hClaims
}
claim = mk_claims_with_namespace_path(claim,hClaims,claims_namespace_path)
headers['Authorization'] = 'Bearer ' + jwt.encode(claim, hge_ctx.hge_jwt_key, algorithm='RS512').decode(
'UTF-8')