Skip to content

Commit 0af8877

Browse files
committed
break: use RFC 9535 syntax for jwt-role-claim-key config
BREAKING CHANGE On top of it, slice operator can be used by adding the pipe symbol at the end like `$.roles[0] | [1:]`. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent a149405 commit 0af8877

34 files changed

Lines changed: 117 additions & 386 deletions

nix/overlays/haskell-packages.nix

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ let
4949
# Before upgrading fuzzyset to 0.3, check: https://github.com/PostgREST/postgrest/issues/3329
5050
fuzzyset = prev.fuzzyset_0_2_4;
5151

52+
aeson-jsonpath =
53+
prev.callHackageDirect
54+
{
55+
pkg = "aeson-jsonpath";
56+
ver = "0.4.2.0";
57+
sha256 = "sha256-K+3brf1zjSSjojtSCXFrip5rrP7AO/S4zndAxAnvEfc=";
58+
}
59+
{ };
60+
5261
http2 =
5362
prev.callHackageDirect
5463
{

postgrest.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ library
103103
, HTTP >= 4000.3.7 && < 4000.5
104104
, Ranged-sets >= 0.3 && < 0.6
105105
, aeson >= 2.0.3 && < 2.3
106+
, aeson-jsonpath >= 0.4.2 && < 0.5
106107
, auto-update >= 0.1.4 && < 0.3
107108
, base64-bytestring >= 1 && < 1.3
108109
, bytestring >= 0.10.8 && < 0.13

src/PostgREST/Auth/Jwt.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
3131

3232
import PostgREST.Auth.Types (AuthResult (..))
3333
import PostgREST.Config (AppConfig (..), audMatchesCfg)
34-
import PostgREST.Config.JSPath (walkJSPath)
34+
import PostgREST.Config.JSPath (evaluateJSPath)
3535
import PostgREST.Error (Error (..), JwtClaimsError (..),
3636
JwtDecodeError (..), JwtError (..))
3737

@@ -114,7 +114,7 @@ parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims
114114
validateClaims time (audMatchesCfg cfg) mclaims
115115
-- role defaults to anon if not specified in jwt
116116
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
117-
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
117+
unquoted <$> evaluateJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
118118
pure AuthResult
119119
{ authClaims = mclaims
120120
, authRole = role

src/PostgREST/Config.hs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ module PostgREST.Config
1515
( AppConfig (..)
1616
, Environment
1717
, JSPath
18-
, JSPathExp(..)
19-
, FilterExp(..)
18+
, defaultRoleJSPathKey
2019
, LogLevel(..)
2120
, OpenAPIMode(..)
2221
, Proxy(..)
@@ -63,9 +62,9 @@ import System.Posix.Types (FileMode)
6362

6463
import PostgREST.Config.Database (RoleIsolationLvl,
6564
RoleSettings)
66-
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
67-
JSPathExp (..), dumpJSPath,
68-
pRoleClaimKey)
65+
import PostgREST.Config.JSPath (JSPath (..),
66+
defaultRoleJSPathKey,
67+
dumpJSPath, pRoleClaimKey)
6968
import PostgREST.Config.Proxy (Proxy (..),
7069
isMalformedProxyUri, toURI)
7170
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
@@ -189,7 +188,7 @@ toText conf =
189188
,("db-tx-end", q . showTxEnd)
190189
,("db-uri", q . configDbUri)
191190
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
192-
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
191+
,("jwt-role-claim-key", q . dumpJSPath . configJwtRoleClaimKey)
193192
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
194193
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
195194
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
@@ -419,7 +418,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
419418
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
420419
parseRoleClaimKey k al =
421420
optWithAlias (optString k) (optString al) >>= \case
422-
Nothing -> pure [JSPKey "role"]
421+
Nothing -> pure defaultRoleJSPathKey -- $.role
423422
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
424423

425424
parseCORSAllowedOrigins k =

src/PostgREST/Config/JSPath.hs

Lines changed: 56 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
{-# OPTIONS_GHC -Wno-unused-do-bind #-}
2-
{-# LANGUAGE LambdaCase #-}
2+
{-# LANGUAGE NamedFieldPuns #-}
33
module PostgREST.Config.JSPath
4-
( JSPath
5-
, JSPathExp(..)
6-
, FilterExp(..)
4+
( JSPath(..)
5+
, defaultRoleJSPathKey
76
, dumpJSPath
87
, pRoleClaimKey
9-
, walkJSPath
8+
, evaluateJSPath
109
) where
1110

1211
import qualified Data.Aeson as JSON
13-
import qualified Data.Aeson.Key as K
14-
import qualified Data.Aeson.KeyMap as KM
12+
import qualified Data.Aeson.JSONPath as JSP
13+
import qualified Data.Aeson.JSONPath.Parser as JSP
14+
import qualified Data.Aeson.JSONPath.Types as JSP
1515
import qualified Data.Text as T
1616
import qualified Data.Vector as V
1717
import qualified Text.ParserCombinators.Parsec as P
@@ -23,94 +23,68 @@ import Text.Read (read)
2323
import Protolude
2424

2525

26-
-- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")]
27-
type JSPath = [JSPathExp]
26+
-- | full jspath, e.g. "$.property[0].attr.detail[?(@ == "role1")] | [1:0]"
27+
data JSPath = JSPath
28+
{ jsPath :: JSP.Query
29+
, jsPathSlice :: Maybe (Maybe Int, Maybe Int)
30+
}
2831

29-
-- NOTE: We only accept one JSPFilter expr (at the end of input)
30-
-- | jspath expression
31-
data JSPathExp
32-
= JSPKey Text -- .property or ."property-dash"
33-
| JSPIdx Int -- [0]
34-
| JSPSlice (Maybe Int) (Maybe Int) -- [0:5] or [0:] or [:5] or [:]
35-
| JSPFilter FilterExp -- [?(@ == "match")]
32+
defaultRoleJSPathKey :: JSPath
33+
defaultRoleJSPathKey = JSPath (JSP.Query JSP.Root [JSP.QuerySegment JSP.Child $ JSP.Dotted "role"]) Nothing
3634

37-
data FilterExp
38-
= EqualsCond Text
39-
| NotEqualsCond Text
40-
| StartsWithCond Text
41-
| EndsWithCond Text
42-
| ContainsCond Text
43-
44-
dumpJSPath :: JSPathExp -> Text
45-
-- TODO: this needs to be quoted properly for special chars
46-
dumpJSPath (JSPKey k) = "." <> show k
47-
dumpJSPath (JSPIdx i) = "[" <> show i <> "]"
48-
dumpJSPath (JSPSlice s e) = "[" <> maybe "" show s <> ":" <> maybe "" show e <> "]"
49-
dumpJSPath (JSPFilter cond) = "[?(@" <> expr <> ")]"
50-
where
51-
expr =
52-
case cond of
53-
EqualsCond text -> " == " <> show text
54-
NotEqualsCond text -> " != " <> show text
55-
StartsWithCond text -> " ^== " <> show text
56-
EndsWithCond text -> " ==^ " <> show text
57-
ContainsCond text -> " *== " <> show text
35+
-- | Dump JSPath
36+
-- e.g. "$$.property[0].attr.detail[?(@ == "role1")] | [1:0]"
37+
dumpJSPath :: JSPath -> Text
38+
dumpJSPath JSPath{jsPath, jsPathSlice} =
39+
T.replace "\"" "\\\"" (jsPathDump <> jsPathSliceDump jsPathSlice)
40+
where
41+
jsPathDump = "$" <> JSP.dumpQuery jsPath
42+
jsPathSliceDump (Just (s, e)) = T.pack (" | " <> "[" <> maybe "" show s <> ":" <> maybe "" show e <> "]")
43+
jsPathSliceDump Nothing = mempty
5844

5945
-- | Evaluate JSPath on a JSON
60-
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
61-
walkJSPath x [] = x
62-
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
63-
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
64-
walkJSPath (Just (JSON.String str)) (JSPSlice start end:rest) =
65-
let
66-
len = T.length str
46+
evaluateJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
47+
evaluateJSPath Nothing _ = Nothing
48+
evaluateJSPath (Just json) JSPath{jsPath, jsPathSlice} =
49+
case jsPathSlice of
50+
Nothing -> role
51+
Just indices -> getSlice indices role
52+
where
53+
jsPathResult = JSP.queryQQ jsPath json
54+
role = case jsPathResult V.!? 0 of
55+
-- If the result is a string, return it. Otherwise return Nothing
56+
Just (JSON.String r) -> Just $ JSON.String r
57+
_ -> Nothing
58+
59+
getSlice :: (Maybe Int, Maybe Int) -> Maybe JSON.Value -> Maybe JSON.Value
60+
getSlice (start, end) (Just (JSON.String str)) = Just $ JSON.String slicedString
61+
where
62+
len = T.length str
6763

68-
norm :: Maybe Int -> Maybe Int -- Normalize negative indices to positive
69-
norm = fmap (\i -> max 0 $ min len $ if i < 0 then len + i else i)
64+
norm :: Maybe Int -> Maybe Int -- Normalize negative indices to positive
65+
norm = fmap (\i -> max 0 $ min len $ if i < 0 then len + i else i)
7066

71-
s = fromMaybe 0 $ norm start -- normalized start index
72-
e = fromMaybe len $ norm end -- normalized end index
73-
slicedString = if s >= e then T.empty else T.take (e-s) $ T.drop s str
74-
in
75-
walkJSPath (Just $ JSON.String slicedString) rest
67+
s = fromMaybe 0 $ norm start -- normalized start index
68+
e = fromMaybe len $ norm end -- normalized end index
69+
slicedString = if s >= e then T.empty else T.take (e-s) $ T.drop s str
7670

77-
walkJSPath (Just (JSON.Array ar)) (JSPFilter jspFilter:rest) = case jspFilter of
78-
EqualsCond txt -> walkJSPath (findFirstMatch (==) txt ar) rest
79-
NotEqualsCond txt -> walkJSPath (findFirstMatch (/=) txt ar) rest
80-
StartsWithCond txt -> walkJSPath (findFirstMatch T.isPrefixOf txt ar) rest
81-
EndsWithCond txt -> walkJSPath (findFirstMatch T.isSuffixOf txt ar) rest
82-
ContainsCond txt -> walkJSPath (findFirstMatch T.isInfixOf txt ar) rest
83-
where
84-
findFirstMatch matchWith pattern = find (\case
85-
JSON.String txt -> pattern `matchWith` txt
86-
_ -> False)
87-
walkJSPath _ _ = Nothing
71+
getSlice _ _ = Nothing
8872

8973
-- Used for the config value "role-claim-key"
9074
pRoleClaimKey :: Text -> Either Text JSPath
9175
pRoleClaimKey selStr =
9276
mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
9377

78+
-- | Parse RFC 9535 JSPath: $.roles[0]
9479
pJSPath :: P.Parser JSPath
95-
pJSPath = P.many1 pJSPathExp <* P.eof
96-
97-
pJSPathExp :: P.Parser JSPathExp
98-
pJSPathExp = P.try pJSPKey <|> P.try pJSPFilter <|> P.try pJSPIdx <|> pJSPSlice
99-
100-
pJSPKey :: P.Parser JSPathExp
101-
pJSPKey = do
102-
P.char '.'
103-
val <- toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue
104-
return (JSPKey val) <?> "pJSPKey: JSPath attribute key"
105-
106-
pJSPIdx :: P.Parser JSPathExp
107-
pJSPIdx = do
108-
P.char '['
109-
num <- read <$> P.many1 P.digit
110-
P.char ']'
111-
return (JSPIdx num) <?> "pJSPIdx: JSPath array index"
112-
113-
pJSPSlice :: P.Parser JSPathExp
80+
pJSPath = do
81+
jsPath' <- JSP.pRootQuery
82+
jsPathSlice' <- P.optionMaybe (P.try (P.spaces *> P.char '|' *> P.spaces *> pJSPSlice))
83+
P.eof
84+
return (JSPath jsPath' jsPathSlice') <?> "pJSPath: JSPath root"
85+
86+
-- | Parse slice operator: [1:]
87+
pJSPSlice :: P.Parser (Maybe Int, Maybe Int)
11488
pJSPSlice = do
11589
P.char '['
11690
startSign <- P.optionMaybe $ P.char '-'
@@ -121,30 +95,4 @@ pJSPSlice = do
12195
P.char ']'
12296
let start' = if isJust startSign then ((-1) *) <$> startIndex else startIndex
12397
end' = if isJust endSign then ((-1) *) <$> endIndex else endIndex
124-
return (JSPSlice start' end') <?> "pJSPSlice: JSPath string slice"
125-
126-
pJSPFilter :: P.Parser JSPathExp
127-
pJSPFilter = do
128-
P.try $ P.string "[?("
129-
condition <- pFilterConditionParser
130-
P.char ')'
131-
P.char ']'
132-
return (JSPFilter condition) <?> "pJSPFilter: JSPath filter exp"
133-
134-
pFilterConditionParser :: P.Parser FilterExp
135-
pFilterConditionParser = do
136-
P.char '@'
137-
P.spaces
138-
filt <- matchOperator
139-
P.spaces
140-
filt <$> pQuotedValue
141-
where
142-
matchOperator =
143-
P.try (P.string "==^" $> EndsWithCond)
144-
<|> P.try (P.string "==" $> EqualsCond)
145-
<|> P.try (P.string "!=" $> NotEqualsCond)
146-
<|> P.try (P.string "^==" $> StartsWithCond)
147-
<|> P.try (P.string "*==" $> ContainsCond)
148-
149-
pQuotedValue :: P.Parser Text
150-
pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
98+
return (start', end') <?> "pJSPSlice: JSPath string slice"

stack.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ nix:
99
- zlib
1010

1111
extra-deps:
12+
- aeson-jsonpath-0.4.2.0
1213
- configurator-pg-0.2.11
1314
- fuzzyset-0.2.4
1415
- hasql-notifications-0.2.4.0

test/io/configs/aliases.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ db-schema = "provided_through_alias"
22
db-pool-timeout = 5
33
max-rows = 1000
44
pre-request = "check_alias"
5-
role-claim-key = ".aliased"
5+
role-claim-key = "$$.aliased"
66
root-spec = "open_alias"
77
secret-is-base64 = true

test/io/configs/expected/aliases.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ db-tx-end = "commit"
2525
db-uri = "postgresql://"
2626
jwt-aud = ""
2727
jwt-cache-max-entries = 1000
28-
jwt-role-claim-key = ".\"aliased\""
28+
jwt-role-claim-key = "$$.aliased"
2929
jwt-secret = ""
3030
jwt-secret-is-base64 = true
3131
log-level = "error"

test/io/configs/expected/boolean-numeric.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ db-tx-end = "commit"
2525
db-uri = "postgresql://"
2626
jwt-aud = ""
2727
jwt-cache-max-entries = 1000
28-
jwt-role-claim-key = ".\"role\""
28+
jwt-role-claim-key = "$$.role"
2929
jwt-secret = ""
3030
jwt-secret-is-base64 = true
3131
log-level = "error"

test/io/configs/expected/boolean-string.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ db-tx-end = "commit"
2525
db-uri = "postgresql://"
2626
jwt-aud = ""
2727
jwt-cache-max-entries = 1000
28-
jwt-role-claim-key = ".\"role\""
28+
jwt-role-claim-key = "$$.role"
2929
jwt-secret = ""
3030
jwt-secret-is-base64 = true
3131
log-level = "error"

0 commit comments

Comments
 (0)