diff --git a/CHANGELOG.md b/CHANGELOG.md index 70022f903c..e8cdde19f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. From versio - Add config `client-error-verbosity` to customize error verbosity by @taimoorzaeem in #4088, #3980, #3824 - Add `Vary` header to responses by @develop7 in #4609 - Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751 +- Add config `jwt-schema-claim-key` for schema selection in JWT by @taimoorzaeem in #4608 ### Changed diff --git a/docs/references/auth.rst b/docs/references/auth.rst index 30551abeb9..84782d688a 100644 --- a/docs/references/auth.rst +++ b/docs/references/auth.rst @@ -49,7 +49,7 @@ JWT Authentication ------------------ We use `JSON Web Tokens `_ to authenticate API requests, this allows us to be stateless and not require database lookups for verification. -As you'll recall a JWT contains a list of cryptographically signed claims. All claims are allowed but PostgREST cares specifically about a claim called role (configurable with :ref:`jwt_role_extract`). +As you'll recall a JWT contains a list of cryptographically signed claims. All claims are allowed but PostgREST cares specifically about ``role`` and ``schema`` claim (configurable with :ref:`jwt_role_and_schema_extract`). .. code:: json @@ -219,12 +219,28 @@ It's recommended to leave the JWT cache enabled as our load tests indicate ~20% - JWTs that pass :ref:`jwt_signature` are cached, regardless if they pass :ref:`jwt_claims_validation`. We do this to ensure responses stays fast under common failure cases (such as expired JWTs). - You can use the :ref:`server-timing_header` to see the performance benefit of JWT caching. -.. _jwt_role_extract: +.. _jwt_role_and_schema_extract: -JWT Role Extraction -------------------- +JWT Role and Schema Extraction +------------------------------ -A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key`. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak. +A JSPath DSL that specifies the location of the :code:`role` and :code:`schema` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key` and :ref:`jwt-schema-claim-key` respectively. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak. + +Usage examples: + +.. code:: + + # {"postgrest":{"roles": ["other", "author"], "schema": ["v1", "test"]}} + # the DSL accepts characters that are alphanumerical or one of "_$@" as keys + jwt-role-claim-key = ".postgrest.roles[1]" + jwt-schema-claim-key = ".postgrest.schema[0]" + +See :ref:`jspath_dsl_grammar` for more details on how to specify the location. + +.. _jspath_dsl_grammar: + +JSPath DSL Grammar +~~~~~~~~~~~~~~~~~~ The DSL follows the `JSONPath `_ expression grammar with extended string comparison operators. Supported operators are: @@ -234,7 +250,7 @@ The DSL follows the `JSONPath `_ expres - ``==^`` selects the first array element that ends with the right operand - ``*==`` selects the first array element that contains the right operand -The selected role value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python `_. Negative index values are also supported. The syntax is as: +The selected value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python `_. Negative index values are also supported. The syntax is as: - ``[a:b]`` take slice from index ``a`` up to ``b`` - ``[a:]`` take slice from index ``a`` to end @@ -249,10 +265,6 @@ Usage examples: .. code:: bash - # {"postgrest":{"roles": ["other", "author"]}} - # the DSL accepts characters that are alphanumerical or one of "_$@" as keys - jwt-role-claim-key = ".postgrest.roles[1]" - # {"https://www.example.com/role": { "key": "author" }} # non-alphanumerical characters can go inside quotes(escaped in the config value) jwt-role-claim-key = ".\"https://www.example.com/role\".key" @@ -271,6 +283,8 @@ Usage examples: jwt-role-claim-key = ".postgrest.wlcg[0][1:]" jwt-role-claim-key = ".postgrest.wlcg[1][1:-1]" +These examples also apply to :ref:`jwt-schema-claim-key`. + .. note:: The string comparison operators are implemented as a custom extension to the JSPath and does not strictly follow the `RFC 9535 `_. diff --git a/docs/references/configuration.rst b/docs/references/configuration.rst index 48e1a92753..0445487551 100644 --- a/docs/references/configuration.rst +++ b/docs/references/configuration.rst @@ -662,7 +662,22 @@ jwt-role-claim-key *For backwards compatibility, this config parameter is also available without prefix as "role-claim-key".* - See :ref:`jwt_role_extract` on how to specify key paths and usage examples. + See :ref:`jwt_role_and_schema_extract` on how to specify key paths and usage examples. + +.. _jwt-schema-claim-key: + +jwt-schema-claim-key +-------------------- + + =============== ================================= + **Type** String + **Default** .schema + **Reloadable** Y + **Environment** PGRST_JWT_SCHEMA_CLAIM_KEY + **In-Database** pgrst.jwt_schema_claim_key + =============== ================================= + + See :ref:`jwt_role_and_schema_extract` on how to specify key paths and usage examples. .. _jwt-secret: diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 6cf7f3c7cf..6deb55ee1e 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -30,6 +30,7 @@ import PostgREST.ApiRequest.Types (Action (..), DbAction (..), InvokeMethod (..), Mutation (..), Payload (..), RequestBody, Resource (..)) +import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Config (AppConfig (..), OpenAPIMode (..)) import PostgREST.Config.Database (TimezoneNames) @@ -76,10 +77,10 @@ data ApiRequest = ApiRequest { } -- | Examines HTTP request and translates it into user intent. -userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest -userApiRequest conf prefs req reqBody = do +userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> AuthResult -> RequestBody -> Either ApiRequestError ApiRequest +userApiRequest conf prefs req authResult reqBody = do resource <- getResource conf $ pathInfo req - (schema, negotiatedByProfile) <- getSchema conf hdrs method + (schema, negotiatedByProfile) <- getSchema conf hdrs method authResult act <- getAction resource schema method qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req (topLevelRange, ranges) <- getRanges method qPrms hdrs @@ -151,14 +152,25 @@ getAction resource schema method = where qi = QualifiedIdentifier schema - -getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool) -getSchema AppConfig{configDbSchemas} hdrs method = do - case profile of - Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema p $ toList configDbSchemas - | otherwise -> Right (p, True) - Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated +-- | +-- We get schema in the following order: +-- 1. Check JWT for a schema claim +-- 2. If no schema claim, then check "Accept-Profile" and "Content-Profile" headers +-- 3. If profile headers not sent, then default to first schema in "db-schemas" +getSchema :: AppConfig -> RequestHeaders -> ByteString -> AuthResult -> Either ApiRequestError (Schema, Bool) +getSchema AppConfig{configDbSchemas} hdrs method AuthResult{authSchema} = do + case authSchema of + Just s -> checkSchemaAcceptable (decodeUtf8 s) False + Nothing -> + case profile of + Just p -> checkSchemaAcceptable p True + Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated where + checkSchemaAcceptable :: Text -> Bool -> Either ApiRequestError (Schema,Bool) + checkSchemaAcceptable schema isNegotiated + | schema `notElem` configDbSchemas = Left $ UnacceptableSchema schema $ toList configDbSchemas + | otherwise = Right (schema, isNegotiated) + defaultSchema = NonEmptyList.head configDbSchemas profile = case method of -- POST/PATCH/PUT/DELETE don't use the same header as per the spec diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 2938e61843..a2bfb048f8 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -177,7 +177,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe timezones = dbTimezones sCache prefs = ApiRequest.userPreferences conf req timezones - (parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body + (parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req authResult body (planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest diff --git a/src/PostgREST/Auth/Jwt.hs b/src/PostgREST/Auth/Jwt.hs index e63664e045..e598243039 100644 --- a/src/PostgREST/Auth/Jwt.hs +++ b/src/PostgREST/Auth/Jwt.hs @@ -8,8 +8,8 @@ This module provides functions to deal with JWT parsing and validation (http://j {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE ImpredicativeTypes #-} {-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE QuantifiedConstraints #-} +{-# LANGUAGE RecordWildCards #-} module PostgREST.Auth.Jwt ( parseAndDecodeClaims @@ -110,14 +110,16 @@ parseToken secret tkn = do jwtDecodeError _ = JwtDecodeErr UnreachableDecodeError parseClaims :: (MonadError Error m, MonadIO m) => AppConfig -> UTCTime -> JSON.Object -> m AuthResult -parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims = do +parseClaims cfg@AppConfig{..} time mclaims = do validateClaims time (audMatchesCfg cfg) mclaims -- role defaults to anon if not specified in jwt role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $ unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole + let schema = unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtSchemaClaimKey pure AuthResult { authClaims = mclaims , authRole = role + , authSchema = schema } where unquoted :: JSON.Value -> BS.ByteString diff --git a/src/PostgREST/Auth/Types.hs b/src/PostgREST/Auth/Types.hs index cd0d06e524..167b5f6835 100644 --- a/src/PostgREST/Auth/Types.hs +++ b/src/PostgREST/Auth/Types.hs @@ -6,10 +6,13 @@ import qualified Data.Aeson as JSON import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS +import Protolude + -- | -- Parse and store result for JWT Claims. Can be accessed in -- db through GUCs (for RLS etc) data AuthResult = AuthResult { authClaims :: KM.KeyMap JSON.Value , authRole :: BS.ByteString + , authSchema :: Maybe BS.ByteString } diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index d18a8da57c..cff2ee7484 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -61,7 +61,8 @@ import PostgREST.Config.Database (RoleIsolationLvl, RoleSettings) import PostgREST.Config.JSPath (FilterExp (..), JSPath, JSPathExp (..), dumpJSPath, - pRoleClaimKey) + pRoleClaimKey, + pSchemaClaimKey) import PostgREST.Config.Proxy (Proxy (..), isMalformedProxyUri, toURI) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..), @@ -102,6 +103,7 @@ data AppConfig = AppConfig , configJWKS :: Maybe JwkSet , configJwtAudience :: Maybe Text , configJwtRoleClaimKey :: JSPath + , configJwtSchemaClaimKey :: JSPath , configJwtSecret :: Maybe BS.ByteString , configJwtSecretIsBase64 :: Bool , configJwtCacheMaxEntries :: Int @@ -187,6 +189,7 @@ toText conf = ,("db-uri", q . configDbUri) ,("jwt-aud", q . fromMaybe mempty . configJwtAudience) ,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey) + ,("jwt-schema-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtSchemaClaimKey) ,("jwt-secret", q . T.decodeUtf8 . showJwtSecret) ,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64) ,("jwt-cache-max-entries", show . configJwtCacheMaxEntries) @@ -300,6 +303,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = <*> pure Nothing <*> optStringOrURI "jwt-aud" <*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key" + <*> parseSchemaClaimKey "jwt-schema-claim-key" <*> (fmap encodeUtf8 <$> optString "jwt-secret") <*> (fromMaybe False <$> optWithAlias (optBool "jwt-secret-is-base64") @@ -421,6 +425,12 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = Nothing -> pure [JSPKey "role"] Just rck -> either (fail . show) pure $ pRoleClaimKey rck + parseSchemaClaimKey :: C.Key -> C.Parser C.Config JSPath + parseSchemaClaimKey k = + optString k >>= \case + Nothing -> pure [JSPKey "schema"] + Just sck -> either (fail . show) pure $ pSchemaClaimKey sck + parseCORSAllowedOrigins k = optString k >>= \case Nothing -> pure Nothing @@ -741,6 +751,9 @@ exampleConfigFile = S.unlines , "" , "## Jspath to the role claim key" , "jwt-role-claim-key = \".role\"" + , "" + , "## Jspath to the schema claim key" + , "jwt-schema-claim-key = \".schema\"" , "" , "## Choose a secret, JSON Web Key (or set) to enable JWT auth" , "## (use \"@filename\" to load from separate file)" diff --git a/src/PostgREST/Config/Database.hs b/src/PostgREST/Config/Database.hs index 7c77f9fdd3..e43b88d747 100644 --- a/src/PostgREST/Config/Database.hs +++ b/src/PostgREST/Config/Database.hs @@ -61,6 +61,7 @@ dbSettingsNames = ,"db_hoisted_tx_settings" ,"jwt_aud" ,"jwt_role_claim_key" + ,"jwt_schema_claim_key" ,"jwt_secret" ,"jwt_secret_is_base64" ,"jwt_cache_max_lifetime" diff --git a/src/PostgREST/Config/JSPath.hs b/src/PostgREST/Config/JSPath.hs index 8116a2d21f..ce3ea9fa49 100644 --- a/src/PostgREST/Config/JSPath.hs +++ b/src/PostgREST/Config/JSPath.hs @@ -6,6 +6,7 @@ module PostgREST.Config.JSPath , FilterExp(..) , dumpJSPath , pRoleClaimKey + , pSchemaClaimKey , walkJSPath ) where @@ -91,6 +92,11 @@ pRoleClaimKey :: Text -> Either Text JSPath pRoleClaimKey selStr = mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr) +-- Used for the config value "jwt-schema-claim-key" +pSchemaClaimKey :: Text -> Either Text JSPath +pSchemaClaimKey selStr = + mapLeft show $ P.parse pJSPath ("failed to parse jwt-schema-claim-key value (" <> toS selStr <> ")") (toS selStr) + pJSPath :: P.Parser JSPath pJSPath = P.many1 pJSPathExp <* P.eof diff --git a/test/io/configs/expected/aliases.config b/test/io/configs/expected/aliases.config index d280c6254c..ac00540886 100644 --- a/test/io/configs/expected/aliases.config +++ b/test/io/configs/expected/aliases.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"aliased\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = true jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/boolean-numeric.config b/test/io/configs/expected/boolean-numeric.config index 7f1ac07a14..64626813ed 100644 --- a/test/io/configs/expected/boolean-numeric.config +++ b/test/io/configs/expected/boolean-numeric.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"role\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = true jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/boolean-string.config b/test/io/configs/expected/boolean-string.config index 7f1ac07a14..64626813ed 100644 --- a/test/io/configs/expected/boolean-string.config +++ b/test/io/configs/expected/boolean-string.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"role\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = true jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/defaults.config b/test/io/configs/expected/defaults.config index ddd9364c28..427125d435 100644 --- a/test/io/configs/expected/defaults.config +++ b/test/io/configs/expected/defaults.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"role\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/jspath-str-op-dump1.config b/test/io/configs/expected/jspath-str-op-dump1.config index 25fd233463..dd85bbd150 100644 --- a/test/io/configs/expected/jspath-str-op-dump1.config +++ b/test/io/configs/expected/jspath-str-op-dump1.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"roles\"[?(@ == \"role1\")]" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/jspath-str-op-dump2.config b/test/io/configs/expected/jspath-str-op-dump2.config index b53bf827ed..831b1ece18 100644 --- a/test/io/configs/expected/jspath-str-op-dump2.config +++ b/test/io/configs/expected/jspath-str-op-dump2.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"roles\"[?(@ != \"role1\")]" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/jspath-str-op-dump3.config b/test/io/configs/expected/jspath-str-op-dump3.config index bd1bed9936..b922cfe500 100644 --- a/test/io/configs/expected/jspath-str-op-dump3.config +++ b/test/io/configs/expected/jspath-str-op-dump3.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"roles\"[?(@ ^== \"role1\")]" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/jspath-str-op-dump4.config b/test/io/configs/expected/jspath-str-op-dump4.config index b169f03a22..cbd0b0b9c3 100644 --- a/test/io/configs/expected/jspath-str-op-dump4.config +++ b/test/io/configs/expected/jspath-str-op-dump4.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"roles\"[?(@ ==^ \"role1\")]" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/jspath-str-op-dump5.config b/test/io/configs/expected/jspath-str-op-dump5.config index 13596cc813..d3a75639b4 100644 --- a/test/io/configs/expected/jspath-str-op-dump5.config +++ b/test/io/configs/expected/jspath-str-op-dump5.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"roles\"[?(@ *== \"role1\")]" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config index c47bb402ed..ccbfa3c7a2 100644 --- a/test/io/configs/expected/no-defaults-with-db-other-authenticator.config +++ b/test/io/configs/expected/no-defaults-with-db-other-authenticator.config @@ -23,6 +23,7 @@ db-tx-end = "rollback-allow-override" db-uri = "postgresql://" jwt-aud = "https://otherexample.org" jwt-role-claim-key = ".\"other\".\"pre_config_role\"" +jwt-schema-claim-key = ".\"other\".\"pre_config_schema\"" jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE" jwt-secret-is-base64 = false jwt-cache-max-entries = 86400 diff --git a/test/io/configs/expected/no-defaults-with-db.config b/test/io/configs/expected/no-defaults-with-db.config index 4363f3262c..ae06764b1e 100644 --- a/test/io/configs/expected/no-defaults-with-db.config +++ b/test/io/configs/expected/no-defaults-with-db.config @@ -23,6 +23,7 @@ db-tx-end = "commit-allow-override" db-uri = "postgresql://" jwt-aud = "https://example.org" jwt-role-claim-key = ".\"a\".\"role\"" +jwt-schema-claim-key = ".\"a\".\"schema\"" jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE" jwt-secret-is-base64 = false jwt-cache-max-entries = 86400 diff --git a/test/io/configs/expected/no-defaults.config b/test/io/configs/expected/no-defaults.config index 3156287737..55f00e3d5d 100644 --- a/test/io/configs/expected/no-defaults.config +++ b/test/io/configs/expected/no-defaults.config @@ -23,6 +23,7 @@ db-tx-end = "rollback-allow-override" db-uri = "tmp_db" jwt-aud = "https://postgrest.org" jwt-role-claim-key = ".\"user\"[0].\"real-role\"" +jwt-schema-claim-key = ".\"user\"[0].\"real-schema\"" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ=" jwt-secret-is-base64 = true jwt-cache-max-entries = 86400 diff --git a/test/io/configs/expected/types.config b/test/io/configs/expected/types.config index fac4d596df..a962b34a5c 100644 --- a/test/io/configs/expected/types.config +++ b/test/io/configs/expected/types.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"role\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/expected/utf-8.config b/test/io/configs/expected/utf-8.config index 7f29a498c9..66c5c1ce75 100644 --- a/test/io/configs/expected/utf-8.config +++ b/test/io/configs/expected/utf-8.config @@ -23,6 +23,7 @@ db-tx-end = "commit" db-uri = "postgresql://" jwt-aud = "" jwt-role-claim-key = ".\"role\"" +jwt-schema-claim-key = ".\"schema\"" jwt-secret = "" jwt-secret-is-base64 = false jwt-cache-max-entries = 1000 diff --git a/test/io/configs/no-defaults-env.yaml b/test/io/configs/no-defaults-env.yaml index ce9280e1e5..7709ff248c 100644 --- a/test/io/configs/no-defaults-env.yaml +++ b/test/io/configs/no-defaults-env.yaml @@ -26,6 +26,7 @@ PGRST_DB_URI: tmp_db PGRST_DB_USE_LEGACY_GUCS: false PGRST_JWT_AUD: 'https://postgrest.org' PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"' +PGRST_JWT_SCHEMA_CLAIM_KEY: '.user[0]."real-schema"' PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ= PGRST_JWT_SECRET_IS_BASE64: true PGRST_JWT_CACHE_MAX_ENTRIES: 86400 diff --git a/test/io/configs/no-defaults.config b/test/io/configs/no-defaults.config index 6bb1cec158..5fc2bd6dfa 100644 --- a/test/io/configs/no-defaults.config +++ b/test/io/configs/no-defaults.config @@ -23,6 +23,7 @@ db-tx-end = "rollback-allow-override" db-uri = "tmp_db" jwt-aud = "https://postgrest.org" jwt-role-claim-key = ".user[0].\"real-role\"" +jwt-schema-claim-key = ".user[0].\"real-schema\"" jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ=" jwt-secret-is-base64 = true jwt-cache-max-entries = 86400 diff --git a/test/io/fixtures/db_config.sql b/test/io/fixtures/db_config.sql index 112271919f..84679882d1 100644 --- a/test/io/fixtures/db_config.sql +++ b/test/io/fixtures/db_config.sql @@ -18,6 +18,7 @@ ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override' ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org'; ALTER ROLE db_config_authenticator SET pgrst.jwt_cache_max_entries = '86400'; ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"'; +ALTER ROLE db_config_authenticator SET pgrst.jwt_schema_claim_key = '."a"."schema"'; ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE'; ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false'; ALTER ROLE db_config_authenticator SET pgrst.not_existing = 'should be ignored'; @@ -92,6 +93,7 @@ begin if current_user = 'other_authenticator' then perform set_config('pgrst.jwt_role_claim_key', '."other"."pre_config_role"', true) + , set_config('pgrst.jwt_schema_claim_key', '."other"."pre_config_schema"', true) , set_config('pgrst.db_anon_role', 'pre_config_role', true) , set_config('pgrst.db_schemas', 'will be overriden with the above ALTER ROLE.. db_schemas', true) , set_config('pgrst.db_tx_end', 'rollback-allow-override', true); diff --git a/test/io/fixtures/fixtures.yaml b/test/io/fixtures/fixtures.yaml index 50fe14cdc1..29fb49a7b3 100644 --- a/test/io/fixtures/fixtures.yaml +++ b/test/io/fixtures/fixtures.yaml @@ -251,6 +251,29 @@ jwtaudroleclaims: aud: [postgrest_test_author, postgrest_test_invalid] expected_status: 401 +schemaclaims: + - key: '.schema' + data: + schema: v1 + expected_status: 200 + - key: '.schemas[0]' + data: + schemas: [v1, test] + expected_status: 200 + - key: '.schemas[?(@ ^== "v")]' # schema that starts with v + data: + schemas: [test, v1] + expected_status: 200 + - key: '.schemas[100]' # out of bounds + data: + schemas: [test, v1] + expected_status: 404 + - key: '.schemas.non_existent' + data: + schemas: + non_existent: v100 # doesn't exist in fixtures + expected_status: 406 + invalidroleclaimkeys: - 'role.other' - '.role##' diff --git a/test/io/fixtures/privileges.sql b/test/io/fixtures/privileges.sql index 62fbe55df3..ecc79dcf2a 100644 --- a/test/io/fixtures/privileges.sql +++ b/test/io/fixtures/privileges.sql @@ -4,6 +4,7 @@ GRANT USAGE ON SCHEMA test TO postgrest_test_anonymous; GRANT SELECT ON authors_only TO postgrest_test_author; GRANT SELECT ON projects TO postgrest_test_anonymous, postgrest_test_w_superuser_settings; GRANT SELECT ON directors, films TO postgrest_test_anonymous, postgrest_test_w_superuser_settings; +GRANT SELECT ON v1.planets TO postgrest_test_anonymous; GRANT ALL ON cats TO postgrest_test_anonymous; GRANT ALL ON items_w_isolation_level TO postgrest_test_anonymous, postgrest_test_repeatable_read, postgrest_test_serializable; diff --git a/test/io/fixtures/schema.sql b/test/io/fixtures/schema.sql index 630fca3cf3..15ddca1a4b 100644 --- a/test/io/fixtures/schema.sql +++ b/test/io/fixtures/schema.sql @@ -8,6 +8,16 @@ CREATE TABLE projects AS SELECT FROM generate_series(1,5); CREATE TABLE cats(id uuid primary key, name text); CREATE TABLE items AS SELECT x AS id FROM generate_series(1,5) x; +CREATE TABLE v1.planets ( + id int primary key, + name text +); + +TRUNCATE TABLE v1.planets CASCADE; +INSERT INTO v1.planets +VALUES (1, 'venus'), + (2, 'mars'); + -- directors and films table can be used for resource embedding tests CREATE TABLE directors ( id int primary key, diff --git a/test/io/test_auth.py b/test/io/test_auth.py index 82ed07706e..dd348c87dd 100644 --- a/test/io/test_auth.py +++ b/test/io/test_auth.py @@ -189,6 +189,24 @@ def test_role_claim_key(roleclaim, defaultenv): assert response.status_code == roleclaim["expected_status"] +@pytest.mark.parametrize( + "schemaclaim", FIXTURES["schemaclaims"], ids=lambda claim: claim["key"] +) +def test_jwt_schema_claim_key(schemaclaim, defaultenv): + "Schema from JWT should be selected" + env = { + **defaultenv, + "PGRST_JWT_SCHEMA_CLAIM_KEY": schemaclaim["key"], + "PGRST_JWT_SECRET": SECRET, + "PGRST_DB_SCHEMAS": "test, v1", + } + headers = jwtauthheader(schemaclaim["data"], SECRET) + + with run(env=env) as postgrest: + response = postgrest.session.get("/planets", headers=headers) + assert response.status_code == schemaclaim["expected_status"] + + @pytest.mark.parametrize( "jwtaudroleclaim", FIXTURES["jwtaudroleclaims"], diff --git a/test/observability/ObsHelper.hs b/test/observability/ObsHelper.hs index fb897390e8..f8601ff63c 100644 --- a/test/observability/ObsHelper.hs +++ b/test/observability/ObsHelper.hs @@ -97,6 +97,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configJWKS = rightToMaybe $ parseSecret secret , configJwtAudience = Nothing , configJwtRoleClaimKey = [JSPKey "role"] + , configJwtSchemaClaimKey = [JSPKey "schema"] , configJwtSecret = Just secret , configJwtSecretIsBase64 = False , configJwtCacheMaxEntries = 10 diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 6b50ec5a4d..a1b1b645bc 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -138,6 +138,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configJWKS = rightToMaybe $ parseSecret secret , configJwtAudience = Nothing , configJwtRoleClaimKey = [JSPKey "role"] + , configJwtSchemaClaimKey = [JSPKey "schema"] , configJwtSecret = Just secret , configJwtSecretIsBase64 = False , configJwtCacheMaxEntries = 10