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