graphql-engine/server/src-test/Hasura/Server/AuthSpec.hs
Karthikeyan Chinnakonda 92026b769f [Preview] Inherited roles for postgres read queries
fixes #3868

docker image - `hasura/graphql-engine:inherited-roles-preview-48b73a2de`

Note:

To be able to use the inherited roles feature, the graphql-engine should be started with the env variable `HASURA_GRAPHQL_EXPERIMENTAL_FEATURES` set to `inherited_roles`.

Introduction
------------

This PR implements the idea of multiple roles as presented in this [paper](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/FGALanguageICDE07.pdf). The multiple roles feature in this PR can be used via inherited roles. An inherited role is a role which can be created by combining multiple singular roles. For example, if there are two roles `author` and `editor` configured in the graphql-engine, then we can create a inherited role with the name of `combined_author_editor` role which will combine the select permissions of the `author` and `editor` roles and then make GraphQL queries using the `combined_author_editor`.

How are select permissions of different roles are combined?
------------------------------------------------------------

A select permission includes 5 things:

1. Columns accessible to the role
2. Row selection filter
3. Limit
4. Allow aggregation
5. Scalar computed fields accessible to the role

 Suppose there are two roles, `role1` gives access to the `address` column with row filter `P1` and `role2` gives access to both the `address` and the `phone` column with row filter `P2` and we create a new role `combined_roles` which combines `role1` and `role2`.

Let's say the following GraphQL query is queried with the `combined_roles` role.

```graphql
query {
   employees {
     address
     phone
   }
}
```

This will translate to the following SQL query:

```sql

 select
    (case when (P1 or P2) then address else null end) as address,
    (case when P2 then phone else null end) as phone
 from employee
 where (P1 or P2)
```

The other parameters of the select permission will be combined in the following manner:

1. Limit - Minimum of the limits will be the limit of the inherited role
2. Allow aggregations - If any of the role allows aggregation, then the inherited role will allow aggregation
3. Scalar computed fields - same as table column fields, as in the above example

APIs for inherited roles:
----------------------

1. `add_inherited_role`

`add_inherited_role` is the [metadata API](https://hasura.io/docs/1.0/graphql/core/api-reference/index.html#schema-metadata-api) to create a new inherited role. It accepts two arguments

`role_name`: the name of the inherited role to be added (String)
`role_set`: list of roles that need to be combined (Array of Strings)

Example:

```json
{
  "type": "add_inherited_role",
  "args": {
      "role_name":"combined_user",
      "role_set":[
          "user",
          "user1"
      ]
  }
}
```

After adding the inherited role, the inherited role can be used like single roles like earlier

Note:

An inherited role can only be created with non-inherited/singular roles.

2. `drop_inherited_role`

The `drop_inherited_role` API accepts the name of the inherited role and drops it from the metadata. It accepts a single argument:

`role_name`: name of the inherited role to be dropped

Example:

```json

{
  "type": "drop_inherited_role",
  "args": {
      "role_name":"combined_user"
  }
}
```

Metadata
---------

The derived roles metadata will be included under the `experimental_features` key while exporting the metadata.

```json
{
  "experimental_features": {
    "derived_roles": [
      {
        "role_name": "manager_is_employee_too",
        "role_set": [
          "employee",
          "manager"
        ]
      }
    ]
  }
}
```

Scope
------

Only postgres queries and subscriptions are supported in this PR.

Important points:
-----------------

1. All columns exposed to an inherited role will be marked as `nullable`, this is done so that cell value nullification can be done.

TODOs
-------

- [ ] Tests
   - [ ] Test a GraphQL query running with a inherited role without enabling inherited roles in experimental features
   - [] Tests for aggregate queries, limit, computed fields, functions, subscriptions (?)
   - [ ] Introspection test with a inherited role (nullability changes in a inherited role)
- [ ] Docs
- [ ] Changelog

Co-authored-by: Vamshi Surabhi <6562944+0x777@users.noreply.github.com>
GitOrigin-RevId: 3b8ee1e11f5ceca80fe294f8c074d42fbccfec63
2021-03-08 11:15:10 +00:00

631 lines
30 KiB
Haskell

{-# LANGUAGE UndecidableInstances #-}
module Hasura.Server.AuthSpec (spec) where
import Hasura.Logging
import Hasura.Prelude
import Hasura.Server.Version
import Control.Lens hiding ((.=))
import Control.Monad.Trans.Control
import Control.Monad.Trans.Managed (lowerManagedT)
import qualified Crypto.JOSE.JWK as Jose
import qualified Crypto.JWT as JWT
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import qualified Data.HashMap.Strict as Map
import Data.Parser.JSONPath
import qualified Network.HTTP.Types as N
import Hasura.RQL.Types
import Hasura.Server.Auth hiding (getUserInfoWithExpTime, processJwt)
import Hasura.Server.Auth.JWT hiding (processJwt)
import Hasura.Server.Auth.WebHook (ReqsText)
import Hasura.Server.Utils
import Hasura.Session
import qualified Hasura.Tracing as Tracing
import Test.Hspec
spec :: Spec
spec = do
getUserInfoWithExpTimeTests
setupAuthModeTests
parseClaimsMapTests
allowedRolesClaimText :: Text
allowedRolesClaimText = sessionVariableToText allowedRolesClaim
defaultRoleClaimText :: Text
defaultRoleClaimText = sessionVariableToText defaultRoleClaim
-- Unit test the core of our authentication code. This doesn't test the details
-- of resolving roles from JWT or webhook.
-- TODO(swann): does this need to also test passing
getUserInfoWithExpTimeTests :: Spec
getUserInfoWithExpTimeTests = describe "getUserInfo" $ do
---- FUNCTION UNDER TEST:
let gqlUserInfoWithExpTime
:: J.Object
-- ^ For JWT, inject the raw claims object as though returned from 'processAuthZHeader'
-- acting on an 'Authorization' header from the request
-> [N.Header] -> AuthMode -> Maybe ReqsText -> IO (Either Code RoleName)
gqlUserInfoWithExpTime claims rawHeaders authMode =
runExceptT
. withExceptT qeCode -- just look at Code for purposes of tests
. fmap _uiRole -- just look at RoleName for purposes of tests
. fmap fst -- disregard Nothing expiration
. getUserInfoWithExpTime_ userInfoFromAuthHook processJwt () () rawHeaders authMode
where
-- mock authorization callbacks:
userInfoFromAuthHook _ _ _hook _reqHeaders _optionalReqs = do
(, Nothing) <$> _UserInfo "hook"
where
-- we don't care about details here; we'll just check role name in tests:
_UserInfo nm =
mkUserInfo (URBFromSessionVariablesFallback $ mkRoleNameE nm)
UAdminSecretNotSent
(mkSessionVariablesHeaders mempty)
processAuthZHeader _jwtCtx _authzHeader =
return (mapKeys mkSessionVariable claims, Nothing)
processJwt = processJwt_ processAuthZHeader (const JHAuthorization)
let getUserInfoWithExpTime
:: J.Object
-- ^ For JWT, inject the raw claims object as though returned from 'processAuthZHeader'
-- acting on an 'Authorization' header from the request
-> [N.Header] -> AuthMode -> IO (Either Code RoleName)
getUserInfoWithExpTime o claims authMode = gqlUserInfoWithExpTime o claims authMode Nothing
let setupAuthMode'E a b c d =
either (const $ error "fixme") id <$> setupAuthMode' a b c d
let ourUnauthRole = mkRoleNameE "an0nymous"
describe "started without admin secret" $ do
it "gives admin by default" $ do
mode <- setupAuthMode'E Nothing Nothing Nothing Nothing
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Right adminRoleName
it "allows any requested role" $ do
mode <- setupAuthMode'E Nothing Nothing Nothing Nothing
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
describe "admin secret only" $ do
describe "unauth role NOT set" $ do
mode <- runIO $ setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing Nothing Nothing
it "accepts when admin secret matches" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode
`shouldReturn` Right adminRoleName
it "accepts when admin secret matches, honoring role request" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), (userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
it "rejects when doesn't match" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(adminSecretHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (adminSecretHeader, "blah")] mode
`shouldReturn` Left AccessDenied
-- with deprecated header:
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (deprecatedAccessKeyHeader, "blah")] mode
`shouldReturn` Left AccessDenied
it "rejects when no secret sent, since no fallback unauth role" $ do
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t"), (userRoleHeader, "admin")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah")] mode
`shouldReturn` Left AccessDenied
describe "unauth role set" $ do
mode <- runIO $
setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing Nothing (Just ourUnauthRole)
it "accepts when admin secret matches" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode
`shouldReturn` Right adminRoleName
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), ("heh", "heh")] mode
`shouldReturn` Right adminRoleName
it "accepts when admin secret matches, honoring role request" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), (userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
it "rejects when doesn't match" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(adminSecretHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (adminSecretHeader, "blah")] mode
`shouldReturn` Left AccessDenied
-- with deprecated header:
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (deprecatedAccessKeyHeader, "blah")] mode
`shouldReturn` Left AccessDenied
it "accepts when no secret sent and unauth role defined" $ do
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Right ourUnauthRole
getUserInfoWithExpTime mempty [("heh", "heh")] mode
`shouldReturn` Right ourUnauthRole
-- FIXME MAYBE (see NOTE (*))
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t")] mode
`shouldReturn` Right ourUnauthRole
-- Unauthorized role is not supported for webhook
describe "webhook" $ do
mode <- runIO $
setupAuthMode'E (Just $ hashAdminSecret "secret") (Just fakeAuthHook) Nothing Nothing
it "accepts when admin secret matches" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode
`shouldReturn` Right adminRoleName
it "accepts when admin secret matches, honoring role request" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), (userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
it "rejects when admin secret doesn't match" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(adminSecretHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (adminSecretHeader, "blah")] mode
`shouldReturn` Left AccessDenied
-- with deprecated header:
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (deprecatedAccessKeyHeader, "blah")] mode
`shouldReturn` Left AccessDenied
it "authenticates with webhook when no admin secret sent" $ do
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Right (mkRoleNameE "hook")
getUserInfoWithExpTime mempty [("blah", "blah")] mode
`shouldReturn` Right (mkRoleNameE "hook")
getUserInfoWithExpTime mempty [(userRoleHeader, "hook")] mode
`shouldReturn` Right (mkRoleNameE "hook")
-- FIXME MAYBE (see NOTE (*))
it "ignores requested role, uses webhook role" $ do
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t"), (userRoleHeader, "admin")] mode
`shouldReturn` Right (mkRoleNameE "hook")
getUserInfoWithExpTime mempty [(userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "hook")
-- helper for generating mocked up verified JWT token claims, as though returned by 'processAuthZHeader':
let unObject l = case J.object l of
J.Object o -> o
_ -> error "impossible"
describe "JWT" $ do
describe "unauth role NOT set" $ do
mode <- runIO $
setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing (Just fakeJWTConfig) Nothing
it "accepts when admin secret matches" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode
`shouldReturn` Right adminRoleName
it "accepts when admin secret matches, honoring role request" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), (userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
it "rejects when admin secret doesn't match" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(adminSecretHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (adminSecretHeader, "blah")] mode
`shouldReturn` Left AccessDenied
-- with deprecated header:
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (deprecatedAccessKeyHeader, "blah")] mode
`shouldReturn` Left AccessDenied
it "rejects when admin secret not sent and no 'Authorization' header" $ do
getUserInfoWithExpTime mempty [("blah", "blah")] mode
`shouldReturn` Left InvalidHeaders
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Left InvalidHeaders
describe "unauth role set" $ do
mode <- runIO $
setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing
(Just fakeJWTConfig) (Just ourUnauthRole)
it "accepts when admin secret matches" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode
`shouldReturn` Right adminRoleName
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), ("heh", "heh")] mode
`shouldReturn` Right adminRoleName
it "accepts when admin secret matches, honoring role request" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "secret"), (userRoleHeader, "r00t")] mode
`shouldReturn` Right (mkRoleNameE "r00t")
it "rejects when admin secret doesn't match" $ do
getUserInfoWithExpTime mempty [(adminSecretHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(adminSecretHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (adminSecretHeader, "blah")] mode
`shouldReturn` Left AccessDenied
-- with deprecated header:
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "bad secret")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [(deprecatedAccessKeyHeader, "")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime mempty [("blah", "blah"), (deprecatedAccessKeyHeader, "blah")] mode
`shouldReturn` Left AccessDenied
it "authorizes as unauth role when no 'Authorization' header" $ do
getUserInfoWithExpTime mempty [("blah", "blah")] mode
`shouldReturn` Right ourUnauthRole
getUserInfoWithExpTime mempty [] mode
`shouldReturn` Right ourUnauthRole
describe "when Authorization header sent, and no admin secret" $ do
modeA <- runIO $ setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing
(Just fakeJWTConfig) (Just ourUnauthRole)
modeB <- runIO $ setupAuthMode'E (Just $ hashAdminSecret "secret") Nothing
(Just fakeJWTConfig) Nothing
-- Here the unauth role does not come into play at all, so map same tests over both modes:
forM_ [(modeA, "with unauth role set"), (modeB, "with unauth role NOT set")] $
\(mode, modeMsg) -> describe modeMsg $ do
it "authorizes successfully with JWT when requested role allowed" $ do
let claim = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaimText .= ("user" :: Text)
]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "editor")] mode
`shouldReturn` Right (mkRoleNameE "editor")
-- Uses the defaultRoleClaimText:
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
`shouldReturn` Right (mkRoleNameE "user")
it "rejects when requested role is not allowed" $ do
let claim = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaimText .= ("user" :: Text)
]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "r00t")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "admin")] mode
`shouldReturn` Left AccessDenied
-- A corner case, but the behavior seems desirable:
it "always rejects when token has empty allowedRolesClaimText" $ do
let claim = unObject [ allowedRolesClaimText .= ([] :: [Text]), defaultRoleClaimText .= ("user" :: Text) ]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "admin")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "user")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
`shouldReturn` Left AccessDenied
it "rejects when token doesn't have proper allowedRolesClaimText and defaultRoleClaimText" $ do
let claim0 = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text]) ]
claim1 = unObject [ defaultRoleClaimText .= ("user" :: Text) ]
claim2 = unObject []
for_ [claim0, claim1, claim2] $ \claim ->
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
`shouldReturn` Left JWTRoleClaimMissing
-- (*) FIXME NOTE (re above):
--
-- Ideally we should always return AccessDenied if the role we would
-- otherwise have returned does not match the requested role (from the
-- 'userRoleHeader').
--
-- This would harden a bit against bugs, makes the spec simpler, but
-- especially is better UX since in the current behavior the user can't be
-- sure which role their query is operating as (and in the worst case we
-- might e.g. delete rows the user didn't intend)
--
-- But this is a breaking change we need to think a little more about;
-- users might be relying on the behavior, e.g. just hardcoding a dev role
-- into clients.
-- Some very basic unit tests of AuthMode construction and error modes
setupAuthModeTests :: Spec
setupAuthModeTests = describe "setupAuthMode" $ do
let secret = hashAdminSecret "secret"
unauthRole = mkRoleNameE "anon"
-- These are all various error cases, except for the AMNoAuth mode:
it "with no admin secret provided" $ do
setupAuthMode' Nothing Nothing Nothing Nothing
`shouldReturn` Right AMNoAuth
-- We insist on an admin secret in order to use webhook or JWT auth:
setupAuthMode' Nothing Nothing (Just fakeJWTConfig) Nothing
`shouldReturn` Left ()
setupAuthMode' Nothing (Just fakeAuthHook) Nothing Nothing
`shouldReturn` Left ()
-- ...and we can't have both:
setupAuthMode' Nothing (Just fakeAuthHook) (Just fakeJWTConfig) Nothing
`shouldReturn` Left ()
-- If the unauthenticated role was set but would otherwise be ignored this
-- should be an error (for now), since users might expect all access to use
-- the specified role. This first case would be the real worrying one:
setupAuthMode' Nothing Nothing Nothing (Just unauthRole)
`shouldReturn` Left ()
setupAuthMode' Nothing Nothing (Just fakeJWTConfig) (Just unauthRole)
`shouldReturn` Left ()
setupAuthMode' Nothing (Just fakeAuthHook) Nothing (Just unauthRole)
`shouldReturn` Left ()
setupAuthMode' Nothing (Just fakeAuthHook) (Just fakeJWTConfig) (Just unauthRole)
`shouldReturn` Left ()
it "with admin secret provided" $ do
setupAuthMode' (Just secret) Nothing Nothing Nothing
`shouldReturn` Right (AMAdminSecret secret Nothing)
setupAuthMode' (Just secret) Nothing Nothing (Just unauthRole)
`shouldReturn` Right (AMAdminSecret secret $ Just unauthRole)
setupAuthMode' (Just secret) Nothing (Just fakeJWTConfig) Nothing >>= \case
Right (AMAdminSecretAndJWT s _ Nothing) -> do
s `shouldBe` secret
_ -> expectationFailure "AMAdminSecretAndJWT"
setupAuthMode' (Just secret) Nothing (Just fakeJWTConfig) (Just unauthRole) >>= \case
Right (AMAdminSecretAndJWT s _ ur) -> do
s `shouldBe` secret
ur `shouldBe` Just unauthRole
_ -> expectationFailure "AMAdminSecretAndJWT"
setupAuthMode' (Just secret) (Just fakeAuthHook) Nothing Nothing
`shouldReturn` Right (AMAdminSecretAndHook secret fakeAuthHook)
-- auth hook can't make use of unauthenticated role for now (no good UX):
setupAuthMode' (Just secret) (Just fakeAuthHook) Nothing (Just unauthRole)
`shouldReturn` Left ()
-- we can't have both:
setupAuthMode' (Just secret) (Just fakeAuthHook) (Just fakeJWTConfig) Nothing
`shouldReturn` Left ()
setupAuthMode' (Just secret) (Just fakeAuthHook) (Just fakeJWTConfig) (Just unauthRole)
`shouldReturn` Left ()
parseClaimsMapTests :: Spec
parseClaimsMapTests = describe "parseClaimMapTests" $ do
let
parseClaimsMap_
:: JWT.ClaimsSet
-> JWTClaims
-> IO (Either Code ClaimsMap)
parseClaimsMap_ claimsSet claims =
runExceptT $ withExceptT qeCode $ parseClaimsMap claimsSet claims
unObject l = case J.object l of
J.Object o -> o
_ -> error "Impossible!"
defaultClaimsMap =
Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "user"))]
describe "JWT configured with namespace" $ do
describe "JWT configured with namespace key, the key is a text value which is expected to be at the root of the JWT token" $ do
it "parses claims map from the JWT token with correct namespace " $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
let obj = unObject $ ["claims_map" .= claimsObj]
claimsSet = mkClaimsSetWithUnregisteredClaims obj
parseClaimsMap_ claimsSet (JCNamespace (ClaimNs "claims_map") defaultClaimsFormat)
`shouldReturn`
Right defaultClaimsMap
it "doesn't parse claims map from the JWT token with wrong namespace " $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
let obj = unObject $ ["claims_map" .= claimsObj]
claimsSet = mkClaimsSetWithUnregisteredClaims obj
parseClaimsMap_ claimsSet (JCNamespace (ClaimNs "wrong_claims_map") defaultClaimsFormat)
`shouldReturn`
Left JWTInvalidClaims
describe "JWT configured with namespace JSON path, JSON path to the claims map" $ do
it "parse claims map from the JWT token using claims namespace JSON Path" $ do
let unregisteredClaims = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
, "sub" .= ("random" :: Text)
, "exp" .= (1626420800 :: Int) -- we ignore these non session variables, in the response
]
claimsSetWithSub =
(JWT.emptyClaimsSet & JWT.claimSub .~ Just "random") & JWT.unregisteredClaims .~ unregisteredClaims
parseClaimsMap_ claimsSetWithSub (JCNamespace (ClaimNsPath (mkJSONPathE "$")) defaultClaimsFormat)
-- "$" JSON path signifies the claims are to be found in the root of the JWT token
`shouldReturn`
Right defaultClaimsMap
it "throws error while attempting to parse claims map from the JWT token with a wrong namespace JSON Path" $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
obj = unObject $ [ "hasura_claims" .= claimsObj ]
claimsSet = mkClaimsSetWithUnregisteredClaims obj
parseClaimsMap_ claimsSet (JCNamespace (ClaimNsPath (mkJSONPathE "$.claims")) defaultClaimsFormat)
`shouldReturn`
Left JWTInvalidClaims
describe "JWT configured with custom JWT claims" $ do
let rolesObj = unObject $
[ "allowed" .= (["user","editor"] :: [Text])
, "default" .= ("user" :: Text)
]
userId = unObject [ "id" .= ("1" :: Text)]
obj = unObject $ [ "roles" .= rolesObj
, "user" .= userId
]
claimsSet = mkClaimsSetWithUnregisteredClaims obj
userIdClaim = mkSessionVariable "x-hasura-user-id"
describe "custom claims with JSON paths to the claim location in the JWT token" $ do
it "parse custom claims values, with correct values" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.default") Nothing
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
otherClaims = Map.fromList
[(userIdClaim, mkCustomOtherClaim (Just "$.user.id") Nothing)]
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim otherClaims
parseClaimsMap_ claimsSet (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "user"))
, (userIdClaim, J.String "1")
])
it "parse custom claims values with session variable mapped to a standard JWT claim (sub)" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.default") Nothing
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
otherClaims = Map.fromList
[(userIdClaim, mkCustomOtherClaim (Just "$.sub") Nothing)]
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim otherClaims
parseClaimsMap_ (claimsSet & JWT.claimSub .~ (Just "2")) (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "user"))
, (userIdClaim, J.String "2")
])
it "throws error when a specified custom claim value is missing" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.wrong_default") Nothing -- wrong path provided
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ claimsSet (JCMap customClaimsMap)
`shouldReturn`
Left JWTRoleClaimMissing
it "doesn't throw an error when the specified custom claim is missing, but the default value is provided" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.wrong_default") (Just "editor")
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ claimsSet (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "editor"))
])
describe "custom claims with literal values" $ do
it "uses the literal custom claim value" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim Nothing (Just "editor")
customAllowedRolesClaim = mkCustomAllowedRoleClaim Nothing (Just ["user", "editor"])
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ JWT.emptyClaimsSet (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "editor"))
])
mkCustomDefaultRoleClaim :: Maybe Text -> Maybe Text -> JWTCustomClaimsMapDefaultRole
mkCustomDefaultRoleClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defRoleName
Nothing -> JWTCustomClaimsMapStatic $ fromMaybe (mkRoleNameE "user") defRoleName
where
defRoleName = mkRoleNameE <$> defVal
mkCustomAllowedRoleClaim :: Maybe Text -> Maybe [Text] -> JWTCustomClaimsMapAllowedRoles
mkCustomAllowedRoleClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defAllowedRoles
Nothing ->
JWTCustomClaimsMapStatic $
fromMaybe (mkRoleNameE <$> ["user", "editor"]) defAllowedRoles
where
defAllowedRoles = fmap mkRoleNameE <$> defVal
-- use for claims other than `x-hasura-default-role` and `x-hasura-allowed-roles`
mkCustomOtherClaim :: Maybe Text -> Maybe Text -> JWTCustomClaimsMapValue
mkCustomOtherClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defVal
Nothing -> JWTCustomClaimsMapStatic $ fromMaybe "default claim value" defVal
fakeJWTConfig :: JWTConfig
fakeJWTConfig =
let jcKeyOrUrl = Left (Jose.fromOctets [])
jcAudience = Nothing
jcIssuer = Nothing
jcClaims = JCNamespace (ClaimNs "") JCFJson
jcAllowedSkew = Nothing
jcHeader = Nothing
in JWTConfig{..}
fakeAuthHook :: AuthHook
fakeAuthHook = AuthHookG "http://fake" AHTGet
mkRoleNameE :: Text -> RoleName
mkRoleNameE = fromMaybe (error "fixme") . mkRoleName
mkJSONPathE :: Text -> JSONPath
mkJSONPathE = either error id . parseJSONPath
newtype NoReporter a = NoReporter { runNoReporter :: IO a }
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadBase IO, MonadBaseControl IO)
instance Tracing.HasReporter NoReporter
setupAuthMode'
:: Maybe AdminSecretHash
-> Maybe AuthHook
-> Maybe JWTConfig
-> Maybe RoleName
-> IO (Either () AuthMode)
setupAuthMode' mAdminSecretHash mWebHook mJwtSecret mUnAuthRole =
withVersion (VersionDev "fake") $
-- just throw away the error message for ease of testing:
fmap (either (const $ Left ()) Right)
$ runNoReporter
$ lowerManagedT
$ runExceptT
$ setupAuthMode mAdminSecretHash mWebHook mJwtSecret mUnAuthRole
-- NOTE: this won't do any http or launch threads if we don't specify JWT URL:
(error "H.Manager") (Logger $ void . return)
mkClaimsSetWithUnregisteredClaims :: HashMap Text J.Value -> JWT.ClaimsSet
mkClaimsSetWithUnregisteredClaims unregisteredClaims
= JWT.emptyClaimsSet & JWT.unregisteredClaims .~ unregisteredClaims