Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. From versio
- Add `Vary` header to responses by @develop7 in #4609
- Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751
- Log when the pool is released during schema cache reload on `log-level=debug` by @mkleczek in #4668
- Add config `jwt-schema-claim-key` for schema selection in JWT by @taimoorzaeem in #4608

### Fixed

Expand Down
3 changes: 2 additions & 1 deletion docs/postgrest.dict
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ lte
macOS
misprediction
multi
multitenancy
namespace
namespaced
Nanos
Expand Down Expand Up @@ -200,4 +201,4 @@ webuser
wfts
www
debouncing
deduplicates
deduplicates
16 changes: 16 additions & 0 deletions docs/references/api/schemas.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Only the selected schema gets added to the `search_path <https://www.postgresql.

These headers are based on the "Content Negotiation by Profile" spec: https://www.w3.org/TR/dx-prof-conneg

.. _profile_headers:

GET/HEAD
~~~~~~~~

Expand All @@ -65,6 +67,20 @@ For POST, PATCH, PUT and DELETE, select the schema with ``Content-Profile``.

You can also select the schema for :ref:`functions` and :ref:`open-api`.

.. _jwt_schema_extract:

JWT Schema Extraction
~~~~~~~~~~~~~~~~~~~~~

The schema can be extracted from the JWT claims using the :ref:`jwt-schema-claim-key`. This feature can be used for schema-based multitenancy. It allows hidden schema selection without exposing the schema in :ref:`profile headers <profile_headers>`. The schema specified in JWT takes precedence over profile headers.

.. code::

# {"postgrest":{"schema": "secret_tenant"}}
jwt-schema-claim-key = ".postgrest.schema"

It follows the same JSPath rules as :ref:`jwt_role_extract` to specify the location of the ``schema`` key.

Restricted schemas
~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions docs/references/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,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 <https://www.rfc-editor.org/rfc/rfc9535.html>`_.
Expand Down
15 changes: 15 additions & 0 deletions docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,21 @@ jwt-role-claim-key

See :ref:`jwt_role_extract` on how to specify key paths and usage examples.

.. _jwt-schema-claim-key:

jwt-schema-claim-key
--------------------

=============== =================================
**Type** String
**Default** .schema
Copy link
Copy Markdown
Member

@steve-chavez steve-chavez Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, this one almost slips by. It should be empty by default, consider:

  • User upgrades to next major
  • Some of their JWTs somehow contained a schema key (unrelated to the database schema)
  • Now they'll run into some requests failing saying "schema X does not exist" and be confused about it.

So users should be conscious and careful about this key name. Maybe we even suggest an example JWT containing a db_schema key. (just schema can mean lots of different things)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, good catch. 👍

**Reloadable** Y
**Environment** PGRST_JWT_SCHEMA_CLAIM_KEY
**In-Database** pgrst.jwt_schema_claim_key
=============== =================================

See :ref:`jwt_schema_extract` on how to specify key paths and usage examples.

.. _jwt-secret:

jwt-secret
Expand Down
32 changes: 22 additions & 10 deletions src/PostgREST/ApiRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Comment thread
steve-chavez marked this conversation as resolved.
defaultSchema = NonEmptyList.head configDbSchemas
profile = case method of
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/App.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/PostgREST/Auth/Jwt.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/PostgREST/Auth/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 14 additions & 1 deletion src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (..),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions src/PostgREST/Config/Database.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions src/PostgREST/Config/JSPath.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module PostgREST.Config.JSPath
, FilterExp(..)
, dumpJSPath
, pRoleClaimKey
, pSchemaClaimKey
, walkJSPath
) where

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/aliases.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/boolean-numeric.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/boolean-string.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump1.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump2.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump3.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump4.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump5.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/no-defaults-with-db.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/no-defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/types.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/utf-8.config
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/no-defaults-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading