Skip to content

change: use RFC 9535 syntax for jwt-role-claim-key config#4984

Open
taimoorzaeem wants to merge 1 commit into
PostgREST:mainfrom
taimoorzaeem:break/aeson-jsonpath
Open

change: use RFC 9535 syntax for jwt-role-claim-key config#4984
taimoorzaeem wants to merge 1 commit into
PostgREST:mainfrom
taimoorzaeem:break/aeson-jsonpath

Conversation

@taimoorzaeem

@taimoorzaeem taimoorzaeem commented Jun 3, 2026

Copy link
Copy Markdown
Member

BREAKING CHANGE

Breaks the string comparison operators implemented in #3813. Those can
be replaced with regex searches using JSON Path search() function.

  • Implementation
  • Tests
  • Docs
  • Changelog entry

Closes #4914.

@taimoorzaeem taimoorzaeem marked this pull request as draft June 3, 2026 09:59
@taimoorzaeem taimoorzaeem added the breaking change A bug fix or enhancement that would cause a breaking change label Jun 3, 2026
@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from 0af8877 to 6746de2 Compare June 3, 2026 10:08
@taimoorzaeem taimoorzaeem changed the title break: use RFC 9535 syntax for jwt-role-claim-key config change: use RFC 9535 syntax for jwt-role-claim-key config Jun 3, 2026
@taimoorzaeem

Copy link
Copy Markdown
Member Author

Now that we are following a standard, I am not sure if we should keep #4603 because it complicates the neat design. Besides, its use case seems very limited to the issue in #4599.

@steve-chavez @wolfgangwalther @merl1n0 WDYT?

@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from 6746de2 to 6079929 Compare June 3, 2026 10:16
@steve-chavez

Copy link
Copy Markdown
Member

@taimoorzaeem One sec, so #4603 was never released so this is not really a breaking change right?

This gives us a chance to just have an amend commit, not change.

On top of it, slice operator can be used by adding the pipe symbol at the end like $.roles[0] | [1:].

Does it cover the same use case as #4599? If so, I agree with removing #4603.

Would like to see some docs to better understand the feature.

@taimoorzaeem

Copy link
Copy Markdown
Member Author

@taimoorzaeem One sec, so #4603 was never released so this is not really a breaking change right?

Yeah not because of that, it is breaking because we lose #3813, which was released in v13. It is replaced by the standard's $.roles[?@search(<key>, <regex>)].

Does it cover the same use case as #4599? If so, I agree with removing #4603.

It covers it but is not part of the standard and I implemented the slice operator downstream. I was talking about removing the string slice because to me it seems like a bad practice to further slice a selected role value in the JWT claims. Just wanted to know any opinion on it. I will write a detailed comment on this, if that is still not clear.

Would like to see some docs to better understand the feature.

Yeah, will do, that would make it clear.

@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from 6079929 to a871fcf Compare June 5, 2026 19:42
@taimoorzaeem

Copy link
Copy Markdown
Member Author

The new syntax for jwt-role-claim-key:

$.postgrest.roles[0] | [1:0]
# ^^^^^^^^^^^^^^^^^^
# The first part here is the standard JSON Path implemented in aeson-jsonpath library
$.postgrest.roles[0] | [1:0]
#                    ^^^^^^^
# The second part is the optional slice part which is not part of the standard. Technically,
# JSON Path is a selector so it can't modify a selected value. We implemented the string slicing
# part in #4603. So to keep it, I have designed it to optionally specify that after
# a pipe '|' symbol in this PR.

Now, I was talking a bit against keeping the slice operator in a previous comment because I think our goal is to follow the standard so we don't frequently change it and break things. That way, it is also easier to document and maintain it.

I am not sure about how useful string slicing feature is. The use case in #4599 seems very niche to me. 😕

@wolfgangwalther

Copy link
Copy Markdown
Member

Now, I was talking a bit against keeping the slice operator in a previous comment because I think our goal is to follow the standard so we don't frequently change it and break things. That way, it is also easier to document and maintain it.

I am not sure about how useful string slicing feature is. The use case in #4599 seems very niche to me. 😕

I agree on removing the slicing again.

@steve-chavez

Copy link
Copy Markdown
Member

$.postgrest.roles[0] | [1:0]

That looks good to me.

Now, I was talking a bit against keeping the slice operator in a previous comment because I think our goal is to follow the standard so we don't frequently change it and break things. That way, it is also easier to document and maintain it.

But the standard doesn't cover the use case in #4599 right? The workaround on #4594 (comment) pollutes the db 😿

I don't think there is a problem if later on we break this config, it's after all server-side so the change is easy and in one place; unlike new functionality at the API level on which a breaking change then requires updating thousands of clients.

I am not sure about how useful string slicing feature is. The use case in #4599 seems very niche to me. 😕

IIRC it was based on another standard (ref) so maybe not that niche. If anything we should make more clear that WLCG is supported in the docs.

@taimoorzaeem

Copy link
Copy Markdown
Member Author

I don't think there is a problem if later on we break this config, it's after all server-side so the change is easy and in one place; unlike new functionality at the API level on which a breaking change then requires updating thousands of clients.

👍

If anything we should make more clear that WLCG is supported in the docs.

It is supported already if we go by the workaround on #4594 (comment).

The workaround [...] pollutes the db

IMO, the standard (ref) should be responsible for providing clean JWT profiles.

@wolfgangwalther

Copy link
Copy Markdown
Member

Just a thought: Let's not pretend to support that WLCG thing if all we're supporting is extracting that first top-level group. The example in #4594 (comment) has multiple groups, including subgroups - and we're not anywhere close to properly supporting that.

@steve-chavez

Copy link
Copy Markdown
Member

But the standard doesn't cover the use case in #4599 right? The workaround on #4594 (comment) pollutes the db

The above is not true as mentioned on #4594 (reply in thread), that spec defines the name to have a /.

I agree on removing the slicing again.

Agree now, no objection from my side.

Comment thread docs/references/auth.rst Outdated
Comment on lines +245 to +246
jwt-role-claim-key = "$$.postgrest.roles[?(@ == 'author')]"
jwt-role-claim-key = "$$.postgrest.roles[?@.search(@, '^au')]"

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.

When specifying this in the config file, it requires double $ chars to get parsed correctly. Works fine with single $ char in both db and env configs. Maybe something related to configurator-ng? Hm, will look into it.

@wolfgangwalther wolfgangwalther Jun 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

configurator-ng allows to reference environment variables, right? I think that happens via $, so this must be escaped, yes.

We even have a hint at this behavior in the docs for jwt-secret: https://docs.postgrest.org/en/v14/references/configuration.html#jwt-secret

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.

configurator-ng allows to reference environment variables, right?

Yeah, ref: https://github.com/PostgREST/configurator-pg#file-format.

We even have a hint at this behavior in the docs for jwt-secret: https://docs.postgrest.org/en/v14/references/configuration.html#jwt-secret

Cool, will document the same for this config as well.

@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch 2 times, most recently from efc2b04 to 6cfdc13 Compare June 12, 2026 20:15
@taimoorzaeem taimoorzaeem marked this pull request as ready for review June 12, 2026 20:15
Comment thread docs/references/auth.rst Outdated
- ``^==`` 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
You can quickly try out JSON Path by visiting https://serdejsonpath.live/. If result has multiple values, first one gets selected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
You can quickly try out JSON Path by visiting https://serdejsonpath.live/. If result has multiple values, first one gets selected.
You can quickly try out JSON Path by visiting https://serdejsonpath.live/. If the result has multiple values, the first one gets selected.

I'm confused by that second sentence - is it really related to the serdejsonpath.live website?

If not, it should probably be a separate paragraph. Maybe one of them as some kind of hint or so?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why should we promote that website? It's one more link that can break and postgres already provides jsonpath, if anything we should promote the latter.

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.

is it really related to the serdejsonpath.live website? If not, it should probably be a separate paragraph.

Yeah it is not related, will move it.

Why should we promote that website? It's one more link that can break and postgres already provides jsonpath, if anything we should promote the latter.

No specific reason to promote it, any website that can help users to quickly try it out could work here. postgres jsonpath isn't fully RFC 9535 compliant, for example: the following doesn't work when it should:

SELECT jsonb_path_query('{"names": ["alice", "bob"]}'::jsonb, '$.names[?search(@, "alice|bob")]');

Comment thread docs/references/auth.rst Outdated
Comment thread src/PostgREST/Config/JSPath.hs Outdated
escapeDoubleQuotes jsPathDump
where
-- When dumping, an extra $ is prefixed to escape the other $ char
jsPathDump = "$" <> JSP.dumpQuery query

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this is enough. $ can occur multiple times in such a query. I'm not sure whether the json path syntax allows multiple references to the root document... but I'm sure there is a way to put a $ into a string / regular identifier somehow or have it in a regex for search() or elsewhere?

@taimoorzaeem taimoorzaeem Jun 15, 2026

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.

Great catch! Yes, JSON Path syntax allows referencing the root path again. We should just escape the $ char all over.

@taimoorzaeem taimoorzaeem marked this pull request as draft June 15, 2026 08:35
@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from 6cfdc13 to e5b61ca Compare June 15, 2026 08:36
@taimoorzaeem taimoorzaeem marked this pull request as ready for review June 15, 2026 14:30
Comment thread src/PostgREST/Config/JSPath.hs Outdated
Comment on lines +46 to +48
dumpJSPath (JSPIdx i) = "[" <> show i <> "]"
dumpJSPath (JSPFilter cond) = "[?(@" <> expr <> ")]"
where
expr =
case cond of
EqualsCond text -> " == " <> show text
NotEqualsCond text -> " != " <> show text
StartsWithCond text -> " ^== " <> show text
EndsWithCond text -> " ==^ " <> show text
ContainsCond text -> " *== " <> show text
-- | Dump JSPath
-- e.g. "$.property[0].attr.detail[?(@ == "role1")]"
dumpJSPath :: JSPath -> Text
dumpJSPath (JSPath query) = (escapeDollarChar . escapeDoubleQuotes) jsPathDump
where
jsPathDump = JSP.dumpQuery query
escapeDoubleQuotes = T.replace "\"" "\\\""
-- When dumping, $ must be escaped
escapeDollarChar = T.replace "$" "$$"

-- | Evaluate JSPath on a JSON
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath (Just (JSON.Array ar)) [JSPFilter jspFilter] = case jspFilter of
EqualsCond txt -> findFirstMatch (==) txt ar
NotEqualsCond txt -> findFirstMatch (/=) txt ar
StartsWithCond txt -> findFirstMatch T.isPrefixOf txt ar
EndsWithCond txt -> findFirstMatch T.isSuffixOf txt ar
ContainsCond txt -> findFirstMatch T.isInfixOf txt ar
where
findFirstMatch matchWith pattern = find (\case
JSON.String txt -> pattern `matchWith` txt
_ -> False)
walkJSPath _ _ = Nothing
evaluateJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
evaluateJSPath Nothing _ = Nothing
evaluateJSPath (Just json) (JSPath query) = role

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Q: Are we covering in loadtests any performance improvements or regressions when using the JSPath? If not, could we add a new loadtest?

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.

Looks like for loadtest we only cover the default scenario of $.role:

def generate_jwt(
now: int,
exp_inc: Optional[int],
rsa_private_key: Optional[jwt.algorithms.RSAAlgorithm],
) -> str:
"""Generate an HS256 or RS256 JWT"""
payload = {
"sub": f"user_{random.getrandbits(32)}",
"iat": now,
"role": "postgrest_test_author",
}

But yeah that's not good enough. We should add a loadtest I think. Will look into it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's make sure we're not adding yet another entirely separate loadtest for this.

It should be enough to just change some/all of the JWT tests to use at least some, possibly all, tokens with a more complex jwt-role-claim-key. It's entirely OK to test multiple things at once.

@taimoorzaeem taimoorzaeem marked this pull request as draft June 16, 2026 07:38
Comment thread docs/references/auth.rst Outdated
Comment thread docs/references/auth.rst Outdated
Comment thread docs/references/auth.rst Outdated
.. 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>`_.
- If the result of JSON Path query contains multiple values, the first one gets selected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
- If the result of JSON Path query contains multiple values, the first one gets selected.
- If the result of the JSON Path query contains multiple values, the first one gets selected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe even better: "If the JSON Path query returns multiple values, ..."

Comment thread docs/references/auth.rst Outdated
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>`_.
- If the result of JSON Path query contains multiple values, the first one gets selected.
- Only when using the :ref:`file_config`, all ``$`` characters in the value must be escaped with an additional ``$`` char. For :ref:`env_variables_config` and :ref:`in_db_config`, only use a single ``$`` char.
- In our implementation, only the `search()` function from JSON Path is available for filtering.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
- In our implementation, only the `search()` function from JSON Path is available for filtering.
- Currently, `search()` is the only JSON Path filter implemented.

Can we maybe link "JSON Path filter" to an authoritative source on what other filters there should be?

@taimoorzaeem taimoorzaeem Jun 17, 2026

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.

Comment thread CHANGELOG.md Outdated
Comment on lines +36 to +37
- Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984
+ Removes the string comparison operators implemented in #3813 in favor of using JSON Path `search()` function

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we need a vastly expanded changelog entry. "migration guide" style, with examples what people where using previously and should be using now etc.

WDYT?

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.

Yes, agreed. It would be much easier for users.

@taimoorzaeem

Copy link
Copy Markdown
Member Author

I have run loadtest with:

$ postgrest-loadtest -k jwt-hs

Array Index

Performance loss is little.

# payload = {
#   ... ,
#   "postgrest" : { "roles" : ["other", "postgrest_test_author"] }
# }

# .postgrest.roles[1] (main)

Requests      [total, rate, throughput]         200001, 6272.43, 6272.40
Duration      [total, attack, wait]             31.886s, 31.886s, 420ns
Latencies     [min, mean, 50, 90, 95, 99, max]  420ns, 158.564µs, 143.674µs, 245.273µs, 281.492µs, 344.152µs, 3.764ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

# $.postgrest.roles[1] (HEAD)

Requests      [total, rate, throughput]         200001, 5864.22, 5864.19
Duration      [total, attack, wait]             34.105s, 34.105s, 551ns
Latencies     [min, mean, 50, 90, 95, 99, max]  551ns, 169.711µs, 157.525µs, 273.823µs, 294.209µs, 360.819µs, 20.214ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

Regex search

Performance loss is significant because regex search is expensive as compared to a prefix check.

# payload = {
#   ... ,
#   "postgrest" : { "roles" : ["other", "postgrest_test_author"] }
# }

# .postgrest.roles[?(@ ^== "postgrest_")] (main)

Requests      [total, rate, throughput]         200001, 6096.12, 6096.09
Duration      [total, attack, wait]             32.808s, 32.808s, 401ns
Latencies     [min, mean, 50, 90, 95, 99, max]  401ns, 163.184µs, 155.913µs, 250.21µs, 285.162µs, 349.578µs, 3.733ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

# $.postgrest.roles[?search(@, '^postgrest_')] (HEAD)

Requests      [total, rate, throughput]         200001, 3356.67, 3356.65
Duration      [total, attack, wait]             59.583s, 59.583s, 912ns
Latencies     [min, mean, 50, 90, 95, 99, max]  912ns, 296.792µs, 273.828µs, 432.092µs, 467.252µs, 556.505µs, 3.84ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

@wolfgangwalther

Copy link
Copy Markdown
Member

Array Index

Performance loss is little.

I think we should investigate this, because it affects everyone.

I didn't look at the code again, but are we making sure that we're only parsing or string config value for the jspath once? Where else could we lose that performance?

Regex search

Performance loss is significant because regex search is expensive as compared to a prefix check.

I wouldn't care about this. We're just comparing apples and oranges here, so not really a meaningful number. Also very much a special case anyway.

@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from e5b61ca to d9e8170 Compare June 22, 2026 12:35
@taimoorzaeem

taimoorzaeem commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

I didn't look at the code again, but are we making sure that we're only parsing or string config value for the jspath once? Where else could we lose that performance?

Yes, only once. I found the culprit, I was doing an extra case check which was impacting performance.

Updated loadtest result:

Array Index

Slight improvement now:

# payload = {
#   ... ,
#   "postgrest" : { "roles" : ["other", "postgrest_test_author"] }
# }

# .postgrest.roles[1]

Requests      [total, rate, throughput]         200001, 6126.28, 6126.25
Duration      [total, attack, wait]             32.646s, 32.646s, 501ns
Latencies     [min, mean, 50, 90, 95, 99, max]  501ns, 162.392µs, 154.355µs, 268.83µs, 289.308µs, 354.268µs, 18.913ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

# $.postgrest.roles[1]

Requests      [total, rate, throughput]         200001, 6281.94, 6281.91
Duration      [total, attack, wait]             31.837s, 31.837s, 501ns
Latencies     [min, mean, 50, 90, 95, 99, max]  501ns, 158.278µs, 146.285µs, 256.881µs, 288.409µs, 354.087µs, 3.472ms
Bytes In      [total, mean]                     0, 0.00
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           100.00%
Status Codes  [code:count]                      0:1  200:200000

@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch 2 times, most recently from b57d5f4 to f3ea248 Compare June 22, 2026 17:27
BREAKING CHANGE

Breaks the string comparison operators implemented in PostgREST#3813. Those can
be replaced with regex searches using JSON Path `search()` function.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
@taimoorzaeem taimoorzaeem force-pushed the break/aeson-jsonpath branch from f3ea248 to 90c3bee Compare June 22, 2026 17:27
@taimoorzaeem taimoorzaeem marked this pull request as ready for review June 22, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change A bug fix or enhancement that would cause a breaking change

Development

Successfully merging this pull request may close these issues.

Support JsonPath union operator

3 participants