Bier.JWT.RoleClaim (bier v0.1.0)

Copy Markdown View Source

The jwt-role-claim-key JSON Path: where in the JWT claims the database role lives (default $.role).

PostgREST v16.0 replaced its bespoke leading-dot JSPath DSL with RFC 9535 JSON Path (PostgREST.Config.JSPath now delegates to the aeson-jsonpath package). The migration rules are:

  • every expression starts with the root identifier $.role becomes $.role, so the v14.12 spelling is now a parse error (conformance case 1711);
  • a member name containing anything but letters, digits and _ needs the bracket selector — .roles.write-role becomes $.roles["write-role"];
  • the DSL's string-comparison operators (^==, ==^, *==) are gone, replaced by the RFC 9535 search() function — .roles[?(@ ^== "pg_")] becomes $.roles[?search(@, "^pg_")].

Bier ships dependency-free, so this module hand-writes the subset of RFC 9535 that jwt-role-claim-key values actually use, mirroring aeson-jsonpath's parser and dumpQuery:

  • root $ followed by child segments;
  • the dotted member-name shorthand (.name);
  • bracketed name selectors, single- or double-quoted with the RFC's escape forms (["a-b"], ['a-b']);
  • bracketed integer index selectors, negative counting from the end ([0], [-1]);
  • bracketed filter selectors holding one comparison ([?(@ == "x")], also </<=/>/>=/!=) or one search() test ([?search(@, "^pg_")]), with the comparables being a literal or a singular query rooted at @ or $;
  • RFC 9535 whitespace (S) wherever the grammar allows it, including before each segment — $ .a and $.a [0] are legal queries.

Deliberately not modelled (they parse upstream but are rejected here, and no PostgREST fixture or documented role-claim value uses them):

ConstructExample
descendant segment$..role
wildcard selector$.roles[*], $.*
array slice$.roles[0:2]
multi-selector$.roles[0,1]
logical combinator$.roles[?@ == "a" || @ == "b"], &&, !

These are rejected with their own message — unsupported role-claim-key construct (<name>) in (<value>) — rather than the "failed to parse" message a malformed value gets (which conformance case 1711 pins byte for byte). A config PostgREST boots with can still make Bier unbootable (#99), but the operator learns the construct is unimplemented instead of being told their syntax is wrong.

dump/1 renders the canonical RFC 9535 text the way aeson-jsonpath's dumpQuery does (bracketed names and string literals in single quotes, filters unparenthesized unless the source had parentheses). The extra " -> \" and $ -> $$ escaping dumpJSPath applies for --dump-config is the config layer's job (Bier.CLI.Config), because the escaped text is not a re-parseable JSON Path.

Two deliberate divergences from dumpQuery

Upstream's dumpQuery is write-only: it wraps member names and string literals in single quotes with no escaping at all (DumpQuery.hs, Name txt -> "'" <> txt <> "'") and always writes a singular-query name segment dotted (NameSQSeg txt -> "." <> txt). Both emit text its own parser rejects — $["it's"] comes back out as $['it's'], $.a[?(@["x-y"] == "z")] as $.a[?(@.x-y == 'z')].

Bier cannot inherit that, because unlike upstream it re-reads its own dump: Bier.CLI.Config canonicalises jwt-role-claim-key through dump/1 and then hands that text to Bier.start_link/1, and conformance case 1726 pins the rule that a dumped config, written back to a file and re-dumped, is byte-identical. So dump/1:

  • escapes the enclosing quote, the backslash and the control characters inside a quoted name or string literal, using RFC 9535's escapable forms — plus \u0022 for ", which is legal bare inside a single-quoted string but would not survive dumpJSPath's " -> \" rewrite and the config reader's undo of it;
  • writes a singular-query name segment dotted only when the name is a bare dotted shorthand, and brackets it otherwise.

The two agree byte for byte on every value upstream dumps re-parseably, with one exception implied by the bullet above: a name or literal holding a bare ", which upstream leaves as-is ($['a"b']) and Bier escapes ($['a\u0022b']) so the config round-trip survives.

Extraction evaluates the query against the decoded claims and yields the first selected node when it is a non-empty JSON string — the same rule the default role claim always had.

A filter applied to an object selects its members in ascending member-name order. RFC 9535 leaves that order implementation-defined but requires each implementation to pick one; upstream inherits aeson's KeyMap traversal order, which Elixir has no equivalent of — and plain map iteration order is unspecified here and changes with map size, which would let identical claims resolve to different database roles across boots. Sorting by member name is the only order Bier can reproduce deterministically.

Summary

Functions

Render a parsed path as canonical RFC 9535 text, mirroring aeson-jsonpath's dumpQuery: bracketed names and string literals use single quotes, indexes render bare, and a filter renders as [?<expr>].

Evaluate path against the decoded claims. RFC 9535 queries produce a nodelist; PostgREST takes its first element and uses it only when it is a non-empty JSON string (missing, wrong type or empty yields nil).

Parse a jwt-role-claim-key JSON Path. Returns {:ok, path} or {:error, message}.

Types

comparable()

@type comparable() :: {:lit, term()} | {:query, :current | :root, [segment()]}

expr()

@type expr() ::
  {:paren, expr()}
  | {:comparison, comparable(), op(), comparable()}
  | {:search, comparable(), comparable()}

name_form()

@type name_form() :: :dot | :bracket

op()

@type op() :: :eq | :ne | :lt | :le | :gt | :ge

path()

@type path() :: [segment()]

segment()

@type segment() ::
  {:name, name_form(), String.t()} | {:index, integer()} | {:filter, expr()}

Functions

dump(path)

@spec dump(path()) :: String.t()

Render a parsed path as canonical RFC 9535 text, mirroring aeson-jsonpath's dumpQuery: bracketed names and string literals use single quotes, indexes render bare, and a filter renders as [?<expr>].

extract(claims, path)

@spec extract(map(), path()) :: String.t() | nil

Evaluate path against the decoded claims. RFC 9535 queries produce a nodelist; PostgREST takes its first element and uses it only when it is a non-empty JSON string (missing, wrong type or empty yields nil).

parse(input)

@spec parse(String.t()) :: {:ok, path()} | {:error, String.t()}

Parse a jwt-role-claim-key JSON Path. Returns {:ok, path} or {:error, message}.

A malformed value gets PostgREST's pinned message (case 1711). A value that is well-formed RFC 9535 but uses a construct outside Bier's subset gets a distinct message naming the construct — see the moduledoc and #99.