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 @@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. From versio
- Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193
- The name of an embedded table can no longer be used in filters if it has an alias by @laurenceisla in #4075
+ e.g. `?select=alias:table(*)&table.id=eq.1` is not possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead.
- Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984

## [14.13] - 2026-06-04

Expand Down
2 changes: 1 addition & 1 deletion cabal.project.freeze
Original file line number Diff line number Diff line change
@@ -1 +1 @@
index-state: hackage.haskell.org 2026-04-18T18:42:36Z
index-state: hackage.haskell.org 2026-06-03T09:50:41Z
2 changes: 0 additions & 2 deletions docs/postgrest.dict
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ CSV
durations
DDL
DOM
DSL
DevOps
Dramatiq
dockerize
Expand Down Expand Up @@ -76,7 +75,6 @@ isdistinct
JS
js
JSON
JSPath
JWK
JWT
jwt
Expand Down
36 changes: 13 additions & 23 deletions docs/references/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,11 @@ It's recommended to leave the JWT cache enabled as our load tests indicate ~20%
JWT Role 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.
JSON Path (`RFC 9535 <https://www.rfc-editor.org/rfc/rfc9535.html>`_) can be specified for 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.

The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expression grammar with extended string comparison operators. Supported operators are:
You can quickly try out JSON Path by visiting https://serdejsonpath.live/. If result has multiple values, first one gets selected.

- ``==`` selects the first array element that exactly matches the right operand
- ``!=`` selects the first array element that does not match the right operand
- ``^==`` selects the first array element that starts with the right operand
- ``==^`` 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 <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
Optionally, the result can be sliced by using the slice operator ``[a:b]`` after putting a pipe symbol like ``$.roles[0] | [1:]``. 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:

- ``[a:b]`` take slice from index ``a`` up to ``b``
- ``[a:]`` take slice from index ``a`` to end
Expand All @@ -250,30 +244,26 @@ 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]"
# Escape the dollar with another $ sign in the config file
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"
# non-alphanumerical characters can go inside single quotes
jwt-role-claim-key = "$$.'https://www.example.com/role'.key"

# {"postgrest":{"roles": ["other", "author"]}}
# `@` represents the current element in the array
# all the these match the string "author"
jwt-role-claim-key = ".postgrest.roles[?(@ == \"author\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ != \"other\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ ^== \"aut\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ ==^ \"hor\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ *== \"utho\")]"
# filter based on equality or regular expression
jwt-role-claim-key = "$$.postgrest.roles[?(@ == 'author')]"
jwt-role-claim-key = "$$.postgrest.roles[?@.search(@, '^au')]"

# {"postgrest":{"wlcg": ["/groupa", "/groupb/"]}}
# skip the "/" character using slice operator
jwt-role-claim-key = ".postgrest.wlcg[0][1:]"
jwt-role-claim-key = ".postgrest.wlcg[1][1:-1]"
jwt-role-claim-key = "$$.postgrest.wlcg[0] | [1:]"
jwt-role-claim-key = "$$.postgrest.wlcg[1] | [1:-1]"

.. 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>`_.
Note that in our implementation, only `search()` function from JSON Path is available for filtering.

JWT Security
------------
Expand Down
2 changes: 1 addition & 1 deletion docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ jwt-role-claim-key

=============== =================================
**Type** String
**Default** .role
**Default** $.role
**Reloadable** Y
**Environment** PGRST_JWT_ROLE_CLAIM_KEY
**In-Database** pgrst.jwt_role_claim_key
Expand Down
9 changes: 9 additions & 0 deletions nix/overlays/haskell-packages.nix
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ let
# Before upgrading fuzzyset to 0.3, check: https://github.com/PostgREST/postgrest/issues/3329
fuzzyset = prev.fuzzyset_0_2_4;

aeson-jsonpath =
prev.callHackageDirect
{
pkg = "aeson-jsonpath";
ver = "0.4.2.0";
sha256 = "sha256-K+3brf1zjSSjojtSCXFrip5rrP7AO/S4zndAxAnvEfc=";
}
{ };

http2 =
prev.callHackageDirect
{
Expand Down
1 change: 1 addition & 0 deletions postgrest.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ library
, HTTP >= 4000.3.7 && < 4000.5
, Ranged-sets >= 0.3 && < 0.6
, aeson >= 2.0.3 && < 2.3
, aeson-jsonpath >= 0.4.2 && < 0.5
, auto-update >= 0.1.4 && < 0.3
, base64-bytestring >= 1 && < 1.3
, bytestring >= 0.10.8 && < 0.13
Expand Down
6 changes: 3 additions & 3 deletions src/PostgREST/App.hs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import qualified Data.List as L
import Data.Streaming.Network (bindPortTCP)
import qualified Data.Text as T
import qualified Network.HTTP.Types as HTTP
import qualified Network.HTTP.Types.Header as HTTP (hVary)
import Network.HTTP.Types.Header (hVary)
import qualified Network.Socket as NS
import PostgREST.Unix (createAndBindDomainSocket)
import Protolude hiding (Handler)
Expand Down Expand Up @@ -223,10 +223,10 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
serverTimingHeaders timing = [serverTimingHeader timing | configServerTimingEnabled]

varyHeader :: HTTP.Header
varyHeader = (HTTP.hVary, "Accept, Prefer, Range")
varyHeader = (hVary, "Accept, Prefer, Range")

varyHeaderPresent :: [HTTP.Header] -> Bool
varyHeaderPresent = any (\(h, _v) -> h == HTTP.hVary)
varyHeaderPresent = any (\(h, _v) -> h == hVary)

withTiming :: (MonadError e m, MonadIO m) => AppConfig -> m a -> m (Maybe Double, a)
withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled
Expand Down
4 changes: 2 additions & 2 deletions src/PostgREST/Auth/Jwt.hs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)

import PostgREST.Auth.Types (AuthResult (..))
import PostgREST.Config (AppConfig (..), audMatchesCfg)
import PostgREST.Config.JSPath (walkJSPath)
import PostgREST.Config.JSPath (evaluateJSPath)
import PostgREST.Error (Error (..), JwtClaimsError (..),
JwtDecodeError (..), JwtError (..))

Expand Down Expand Up @@ -114,7 +114,7 @@ parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims
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
unquoted <$> evaluateJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
pure AuthResult
{ authClaims = mclaims
, authRole = role
Expand Down
13 changes: 6 additions & 7 deletions src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ module PostgREST.Config
( AppConfig (..)
, Environment
, JSPath
, JSPathExp(..)
, FilterExp(..)
, defaultRoleJSPathKey
, LogLevel(..)
, OpenAPIMode(..)
, Proxy(..)
Expand Down Expand Up @@ -63,9 +62,9 @@ import System.Posix.Types (FileMode)

import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
JSPathExp (..), dumpJSPath,
pRoleClaimKey)
import PostgREST.Config.JSPath (JSPath (..),
defaultRoleJSPathKey,
dumpJSPath, pRoleClaimKey)
import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Expand Down Expand Up @@ -189,7 +188,7 @@ toText conf =
,("db-tx-end", q . showTxEnd)
,("db-uri", q . configDbUri)
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
,("jwt-role-claim-key", q . dumpJSPath . configJwtRoleClaimKey)
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
Expand Down Expand Up @@ -419,7 +418,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
parseRoleClaimKey k al =
optWithAlias (optString k) (optString al) >>= \case
Nothing -> pure [JSPKey "role"]
Nothing -> pure defaultRoleJSPathKey -- $.role
Just rck -> either (fail . show) pure $ pRoleClaimKey rck

parseCORSAllowedOrigins k =
Expand Down
Loading