Skip to content

Commit 6d2173d

Browse files
committed
add: config jwt-schema-claim-key for schema selection in JWT
It follows the same JSPath grammar as `jwt-role-claim-key`. If the schema is specified in JWT claims, it overides the `Accept-Profile` and `Content-Profile` headers. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent 26ce39b commit 6d2173d

36 files changed

Lines changed: 198 additions & 20 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. From versio
1414
- Add `Vary` header to responses by @develop7 in #4609
1515
- Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751
1616
- Log when the pool is released during schema cache reload on `log-level=debug` by @mkleczek in #4668
17+
- Add config `jwt-schema-claim-key` for schema selection in JWT by @taimoorzaeem in #4608
1718

1819
### Changed
1920

docs/postgrest.dict

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ lte
9292
macOS
9393
misprediction
9494
multi
95+
multitenancy
9596
namespace
9697
namespaced
9798
Nanos
@@ -200,4 +201,4 @@ webuser
200201
wfts
201202
www
202203
debouncing
203-
deduplicates
204+
deduplicates

docs/references/api/schemas.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Only the selected schema gets added to the `search_path <https://www.postgresql.
4141

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

44+
.. _profile_headers:
45+
4446
GET/HEAD
4547
~~~~~~~~
4648

docs/references/auth.rst

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,33 @@ JWT Role Extraction
226226

227227
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.
228228

229+
.. code::
230+
231+
# {"postgrest":{"roles": ["other", "author"]}}
232+
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
233+
jwt-role-claim-key = ".postgrest.roles[1]"
234+
235+
See :ref:`jspath_dsl_grammar` for more details on how to specify the location.
236+
237+
.. _jwt_schema_extract:
238+
239+
JWT Schema Extraction
240+
---------------------
241+
242+
Schema can be specified in JWT claims. It is configured by :ref:`jwt-schema-claim-key`. This feature can be used for JWT-driven schema-based multitenancy. It allows fully hidden schema selection without exposing the schema in :ref:`profile headers <profile_headers>`. The schema specified in JWT takes precedence over profile headers.
243+
244+
.. code::
245+
246+
# {"postgrest":{"schema": "jwt-tenant"}}
247+
jwt-schema-claim-key = ".postgrest.schema"
248+
249+
See :ref:`jspath_dsl_grammar` for more details on how to specify the location.
250+
251+
.. _jspath_dsl_grammar:
252+
253+
JSPath DSL Grammar
254+
~~~~~~~~~~~~~~~~~~
255+
229256
The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expression grammar with extended string comparison operators. Supported operators are:
230257

231258
- ``==`` selects the first array element that exactly matches the right operand
@@ -234,7 +261,7 @@ The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expres
234261
- ``==^`` selects the first array element that ends with the right operand
235262
- ``*==`` selects the first array element that contains the right operand
236263

237-
The selected role value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
264+
The selected value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
238265

239266
- ``[a:b]`` take slice from index ``a`` up to ``b``
240267
- ``[a:]`` take slice from index ``a`` to end
@@ -249,10 +276,6 @@ Usage examples:
249276

250277
.. code:: bash
251278
252-
# {"postgrest":{"roles": ["other", "author"]}}
253-
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
254-
jwt-role-claim-key = ".postgrest.roles[1]"
255-
256279
# {"https://www.example.com/role": { "key": "author" }}
257280
# non-alphanumerical characters can go inside quotes(escaped in the config value)
258281
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
@@ -271,6 +294,8 @@ Usage examples:
271294
jwt-role-claim-key = ".postgrest.wlcg[0][1:]"
272295
jwt-role-claim-key = ".postgrest.wlcg[1][1:-1]"
273296
297+
These examples also apply to :ref:`jwt-schema-claim-key`.
298+
274299
.. note::
275300

276301
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>`_.

docs/references/configuration.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,21 @@ jwt-role-claim-key
664664

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

667+
.. _jwt-schema-claim-key:
668+
669+
jwt-schema-claim-key
670+
--------------------
671+
672+
=============== =================================
673+
**Type** String
674+
**Default** .schema
675+
**Reloadable** Y
676+
**Environment** PGRST_JWT_SCHEMA_CLAIM_KEY
677+
**In-Database** pgrst.jwt_schema_claim_key
678+
=============== =================================
679+
680+
See :ref:`jwt_schema_extract` on how to specify key paths and usage examples.
681+
667682
.. _jwt-secret:
668683

669684
jwt-secret

src/PostgREST/ApiRequest.hs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import PostgREST.ApiRequest.Types (Action (..), DbAction (..),
3030
InvokeMethod (..),
3131
Mutation (..), Payload (..),
3232
RequestBody, Resource (..))
33+
import PostgREST.Auth.Types (AuthResult (..))
3334
import PostgREST.Config (AppConfig (..),
3435
OpenAPIMode (..))
3536
import PostgREST.Config.Database (TimezoneNames)
@@ -76,10 +77,10 @@ data ApiRequest = ApiRequest {
7677
}
7778

7879
-- | Examines HTTP request and translates it into user intent.
79-
userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest
80-
userApiRequest conf prefs req reqBody = do
80+
userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> AuthResult -> RequestBody -> Either ApiRequestError ApiRequest
81+
userApiRequest conf prefs req authResult reqBody = do
8182
resource <- getResource conf $ pathInfo req
82-
(schema, negotiatedByProfile) <- getSchema conf hdrs method
83+
(schema, negotiatedByProfile) <- getSchema conf hdrs method authResult
8384
act <- getAction resource schema method
8485
qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req
8586
(topLevelRange, ranges) <- getRanges method qPrms hdrs
@@ -151,14 +152,25 @@ getAction resource schema method =
151152
where
152153
qi = QualifiedIdentifier schema
153154

154-
155-
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
156-
getSchema AppConfig{configDbSchemas} hdrs method = do
157-
case profile of
158-
Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema p $ toList configDbSchemas
159-
| otherwise -> Right (p, True)
160-
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
155+
-- |
156+
-- We get schema in the following order:
157+
-- 1. Check JWT for a schema claim
158+
-- 2. If no schema claim, then check "Accept-Profile" and "Content-Profile" headers
159+
-- 3. If profile headers not sent, then default to first schema in "db-schemas"
160+
getSchema :: AppConfig -> RequestHeaders -> ByteString -> AuthResult -> Either ApiRequestError (Schema, Bool)
161+
getSchema AppConfig{configDbSchemas} hdrs method AuthResult{authSchema} = do
162+
case authSchema of
163+
Just s -> checkSchemaAcceptable (decodeUtf8 s) False
164+
Nothing ->
165+
case profile of
166+
Just p -> checkSchemaAcceptable p True
167+
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
161168
where
169+
checkSchemaAcceptable :: Text -> Bool -> Either ApiRequestError (Schema,Bool)
170+
checkSchemaAcceptable schema isNegotiated
171+
| schema `notElem` configDbSchemas = Left $ UnacceptableSchema schema $ toList configDbSchemas
172+
| otherwise = Right (schema, isNegotiated)
173+
162174
defaultSchema = NonEmptyList.head configDbSchemas
163175
profile = case method of
164176
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec

src/PostgREST/App.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe
177177
timezones = dbTimezones sCache
178178
prefs = ApiRequest.userPreferences conf req timezones
179179

180-
(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body
180+
(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req authResult body
181181
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache
182182

183183
let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest

src/PostgREST/Auth/Jwt.hs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ This module provides functions to deal with JWT parsing and validation (http://j
88
{-# LANGUAGE FlexibleContexts #-}
99
{-# LANGUAGE ImpredicativeTypes #-}
1010
{-# LANGUAGE LambdaCase #-}
11-
{-# LANGUAGE NamedFieldPuns #-}
1211
{-# LANGUAGE QuantifiedConstraints #-}
12+
{-# LANGUAGE RecordWildCards #-}
1313

1414
module PostgREST.Auth.Jwt
1515
( parseAndDecodeClaims
@@ -110,14 +110,16 @@ parseToken secret tkn = do
110110
jwtDecodeError _ = JwtDecodeErr UnreachableDecodeError
111111

112112
parseClaims :: (MonadError Error m, MonadIO m) => AppConfig -> UTCTime -> JSON.Object -> m AuthResult
113-
parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims = do
113+
parseClaims cfg@AppConfig{..} time mclaims = do
114114
validateClaims time (audMatchesCfg cfg) mclaims
115115
-- role defaults to anon if not specified in jwt
116116
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
117117
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
118+
let schema = unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtSchemaClaimKey
118119
pure AuthResult
119120
{ authClaims = mclaims
120121
, authRole = role
122+
, authSchema = schema
121123
}
122124
where
123125
unquoted :: JSON.Value -> BS.ByteString

src/PostgREST/Auth/Types.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import qualified Data.Aeson as JSON
66
import qualified Data.Aeson.KeyMap as KM
77
import qualified Data.ByteString as BS
88

9+
import Protolude
10+
911
-- |
1012
-- Parse and store result for JWT Claims. Can be accessed in
1113
-- db through GUCs (for RLS etc)
1214
data AuthResult = AuthResult
1315
{ authClaims :: KM.KeyMap JSON.Value
1416
, authRole :: BS.ByteString
17+
, authSchema :: Maybe BS.ByteString
1518
}

src/PostgREST/Config.hs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ import PostgREST.Config.Database (RoleIsolationLvl,
6161
RoleSettings)
6262
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
6363
JSPathExp (..), dumpJSPath,
64-
pRoleClaimKey)
64+
pRoleClaimKey,
65+
pSchemaClaimKey)
6566
import PostgREST.Config.Proxy (Proxy (..),
6667
isMalformedProxyUri, toURI)
6768
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
@@ -102,6 +103,7 @@ data AppConfig = AppConfig
102103
, configJWKS :: Maybe JwkSet
103104
, configJwtAudience :: Maybe Text
104105
, configJwtRoleClaimKey :: JSPath
106+
, configJwtSchemaClaimKey :: JSPath
105107
, configJwtSecret :: Maybe BS.ByteString
106108
, configJwtSecretIsBase64 :: Bool
107109
, configJwtCacheMaxEntries :: Int
@@ -187,6 +189,7 @@ toText conf =
187189
,("db-uri", q . configDbUri)
188190
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
189191
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
192+
,("jwt-schema-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtSchemaClaimKey)
190193
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
191194
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
192195
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
@@ -300,6 +303,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
300303
<*> pure Nothing
301304
<*> optStringOrURI "jwt-aud"
302305
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
306+
<*> parseSchemaClaimKey "jwt-schema-claim-key"
303307
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
304308
<*> (fromMaybe False <$> optWithAlias
305309
(optBool "jwt-secret-is-base64")
@@ -421,6 +425,12 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
421425
Nothing -> pure [JSPKey "role"]
422426
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
423427

428+
parseSchemaClaimKey :: C.Key -> C.Parser C.Config JSPath
429+
parseSchemaClaimKey k =
430+
optString k >>= \case
431+
Nothing -> pure [JSPKey "schema"]
432+
Just sck -> either (fail . show) pure $ pSchemaClaimKey sck
433+
424434
parseCORSAllowedOrigins k =
425435
optString k >>= \case
426436
Nothing -> pure Nothing
@@ -741,6 +751,9 @@ exampleConfigFile = S.unlines
741751
, ""
742752
, "## Jspath to the role claim key"
743753
, "jwt-role-claim-key = \".role\""
754+
, ""
755+
, "## Jspath to the schema claim key"
756+
, "jwt-schema-claim-key = \".schema\""
744757
, ""
745758
, "## Choose a secret, JSON Web Key (or set) to enable JWT auth"
746759
, "## (use \"@filename\" to load from separate file)"

0 commit comments

Comments
 (0)