feat: support string comparison for jwt-role-claim-key

This commit is contained in:
M. Taimoor Zaeem
2024-12-12 08:47:06 -05:00
committed by Steve Chavez
parent 2df167637d
commit af6b79d4d7
18 changed files with 371 additions and 30 deletions
+1
View File
@@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #2858, Performance improvements when calling RPCs via GET using indexes in more cases - @wolfgangwalther - #2858, Performance improvements when calling RPCs via GET using indexes in more cases - @wolfgangwalther
- #3560, Log resolved host in "Listening on ..." messages - @develop7 - #3560, Log resolved host in "Listening on ..." messages - @develop7
- #3727, Log maximum pool size - @steve-chavez - #3727, Log maximum pool size - @steve-chavez
- #1536, Add string comparison feature for jwt-role-claim-key - @taimoorzaeem
### Fixed ### Fixed
+6 -1
View File
@@ -27,10 +27,12 @@ CSV
durations durations
DDL DDL
DOM DOM
DSL
DevOps DevOps
dockerize dockerize
enum enum
Enums Enums
Entra
eq eq
ETH ETH
Ethereum Ethereum
@@ -68,9 +70,11 @@ isdistinct
JS JS
js js
JSON JSON
JSPath
JWK JWK
JWT JWT
jwt jwt
Keycloak
Kubernetes Kubernetes
localhost localhost
login login
@@ -94,10 +98,11 @@ npm
nxl nxl
nxr nxr
OAuth OAuth
ORM
Observability Observability
Okta
OpenAPI OpenAPI
openapi openapi
ORM
ov ov
parametrized parametrized
passphrase passphrase
+37
View File
@@ -161,6 +161,43 @@ JWT Claims Validation
PostgREST honors the :code:`exp` claim for token expiration, rejecting expired tokens. PostgREST honors the :code:`exp` claim for token expiration, rejecting expired tokens.
.. _jwt_role_claim_key_extract:
JWT Role Claim Key Extraction
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. 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:
- ``==`` 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
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]"
# {"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"
# {"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\")]"
JWT Security JWT Security
~~~~~~~~~~~~ ~~~~~~~~~~~~
+1 -11
View File
@@ -620,17 +620,7 @@ jwt-role-claim-key
*For backwards compatibility, this config parameter is also available without prefix as "role-claim-key".* *For backwards compatibility, this config parameter is also available without prefix as "role-claim-key".*
A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. This can be used to consume a JWT provided by a third party service like Auth0, Okta or Keycloak. Usage examples: See :ref:`jwt_role_claim_key_extract` on how to specify key paths and 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]"
# {"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"
.. _jwt-secret: .. _jwt-secret:
+15 -1
View File
@@ -27,6 +27,7 @@ import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Cache as C import qualified Data.Cache as C
import qualified Data.Scientific as Sci import qualified Data.Scientific as Sci
import qualified Data.Text as T
import qualified Data.Vault.Lazy as Vault import qualified Data.Vault.Lazy as Vault
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Jose.Jwk as JWT import qualified Jose.Jwk as JWT
@@ -46,7 +47,8 @@ import System.TimeIt (timeItT)
import PostgREST.AppState (AppState, AuthResult (..), getConfig, import PostgREST.AppState (AppState, AuthResult (..), getConfig,
getJwtCache, getTime) getJwtCache, getTime)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) import PostgREST.Config (AppConfig (..), FilterExp (..), JSPath,
JSPathExp (..))
import PostgREST.Error (Error (..)) import PostgREST.Error (Error (..))
import Protolude import Protolude
@@ -121,8 +123,20 @@ parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) = do
walkJSPath x [] = x walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest 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)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath (Just (JSON.Array ar)) [JSPFilter (EqualsCond txt)] = findFirstMatch (==) txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (NotEqualsCond txt)] = findFirstMatch (/=) txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (StartsWithCond txt)] = findFirstMatch T.isPrefixOf txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (EndsWithCond txt)] = findFirstMatch T.isSuffixOf txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (ContainsCond txt)] = findFirstMatch T.isInfixOf txt ar
walkJSPath _ _ = Nothing walkJSPath _ _ = Nothing
findFirstMatch matchWith pattern = foldr checkMatch Nothing
where
checkMatch (JSON.String txt) acc
| pattern `matchWith` txt = Just $ JSON.String txt
| otherwise = acc
checkMatch _ acc = acc
unquoted :: JSON.Value -> BS.ByteString unquoted :: JSON.Value -> BS.ByteString
unquoted (JSON.String t) = encodeUtf8 t unquoted (JSON.String t) = encodeUtf8 t
unquoted v = LBS.toStrict $ JSON.encode v unquoted v = LBS.toStrict $ JSON.encode v
+4 -2
View File
@@ -15,6 +15,7 @@ module PostgREST.Config
, Environment , Environment
, JSPath , JSPath
, JSPathExp(..) , JSPathExp(..)
, FilterExp(..)
, LogLevel(..) , LogLevel(..)
, OpenAPIMode(..) , OpenAPIMode(..)
, Proxy(..) , Proxy(..)
@@ -54,8 +55,9 @@ import System.Posix.Types (FileMode)
import PostgREST.Config.Database (RoleIsolationLvl, import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings) RoleSettings)
import PostgREST.Config.JSPath (JSPath, JSPathExp (..), import PostgREST.Config.JSPath (FilterExp (..), JSPath,
dumpJSPath, pRoleClaimKey) JSPathExp (..), dumpJSPath,
pRoleClaimKey)
import PostgREST.Config.Proxy (Proxy (..), import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI) isMalformedProxyUri, toURI)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi, import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
+65 -16
View File
@@ -1,6 +1,8 @@
{-# OPTIONS_GHC -Wno-unused-do-bind #-}
module PostgREST.Config.JSPath module PostgREST.Config.JSPath
( JSPath ( JSPath
, JSPathExp(..) , JSPathExp(..)
, FilterExp(..)
, dumpJSPath , dumpJSPath
, pRoleClaimKey , pRoleClaimKey
) where ) where
@@ -14,18 +16,37 @@ import Text.Read (read)
import Protolude import Protolude
-- | full jspath, e.g. .property[0].attr.detail -- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")]
type JSPath = [JSPathExp] type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash" -- NOTE: We only accept one JSPFilter expr (at the end of input)
-- | jspath expression
data JSPathExp data JSPathExp
= JSPKey Text = JSPKey Text -- .property or ."property-dash"
| JSPIdx Int | JSPIdx Int -- [0]
| JSPFilter FilterExp -- [?(@ == "match")]
data FilterExp
= EqualsCond Text
| NotEqualsCond Text
| StartsWithCond Text
| EndsWithCond Text
| ContainsCond Text
dumpJSPath :: JSPathExp -> Text dumpJSPath :: JSPathExp -> Text
-- TODO: this needs to be quoted properly for special chars -- TODO: this needs to be quoted properly for special chars
dumpJSPath (JSPKey k) = "." <> show k dumpJSPath (JSPKey k) = "." <> show k
dumpJSPath (JSPIdx i) = "[" <> show i <> "]" 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
-- Used for the config value "role-claim-key" -- Used for the config value "role-claim-key"
pRoleClaimKey :: Text -> Either Text JSPath pRoleClaimKey :: Text -> Either Text JSPath
@@ -33,19 +54,47 @@ pRoleClaimKey selStr =
mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr) mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
pJSPath :: P.Parser JSPath pJSPath :: P.Parser JSPath
pJSPath = toJSPath <$> (period *> pPath `P.sepBy` period <* P.eof) pJSPath = P.many1 pJSPathExp <* P.eof
pJSPathExp :: P.Parser JSPathExp
pJSPathExp = pJSPKey <|> pJSPFilter <|> pJSPIdx
pJSPKey :: P.Parser JSPathExp
pJSPKey = do
P.char '.'
val <- toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue
return (JSPKey val) <?> "pJSPKey: JSPath attribute key"
pJSPIdx :: P.Parser JSPathExp
pJSPIdx = do
P.char '['
num <- read <$> P.many1 P.digit
P.char ']'
return (JSPIdx num) <?> "pJSPIdx: JSPath array index"
pJSPFilter :: P.Parser JSPathExp
pJSPFilter = do
P.try $ P.string "[?("
condition <- pFilterConditionParser
P.char ')'
P.char ']'
P.eof -- this should be the last jspath expression
return (JSPFilter condition) <?> "pJSPFilter: JSPath filter exp"
pFilterConditionParser :: P.Parser FilterExp
pFilterConditionParser = do
P.char '@'
P.spaces
filt <- matchOperator
P.spaces
filt <$> pQuotedValue
where where
toJSPath :: [(Text, Maybe Int)] -> JSPath matchOperator =
toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx)) P.try (P.string "==^" $> EndsWithCond)
period = P.char '.' <?> "period (.)" <|> P.try (P.string "==" $> EqualsCond)
pPath :: P.Parser (Text, Maybe Int) <|> P.try (P.string "!=" $> NotEqualsCond)
pPath = (,) <$> pJSPKey <*> P.optionMaybe pJSPIdx <|> P.try (P.string "^==" $> StartsWithCond)
<|> P.try (P.string "*==" $> ContainsCond)
pJSPKey :: P.Parser Text
pJSPKey = toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
pJSPIdx :: P.Parser Int
pJSPIdx = P.char '[' *> (read <$> P.many1 P.digit) <* P.char ']' <?> "array index [0..n]"
pQuotedValue :: P.Parser Text pQuotedValue :: P.Parser Text
pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"') pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
@@ -0,0 +1,39 @@
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
db-channel-enabled = true
db-extra-search-path = "public"
db-hoisted-tx-settings = "statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation"
db-max-rows = ""
db-plan-enabled = false
db-pool = 10
db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800
db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = ""
db-prepared-statements = true
db-root-spec = ""
db-schemas = "public"
db-config = true
db-pre-config = ""
db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ == \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error"
openapi-mode = "follow-privileges"
openapi-security-active = false
openapi-server-proxy-uri = ""
server-cors-allowed-origins = ""
server-host = "!4"
server-port = 3000
server-trace-header = ""
server-timing-enabled = false
server-unix-socket = ""
server-unix-socket-mode = "660"
admin-server-host = "!4"
admin-server-port = ""
@@ -0,0 +1,39 @@
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
db-channel-enabled = true
db-extra-search-path = "public"
db-hoisted-tx-settings = "statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation"
db-max-rows = ""
db-plan-enabled = false
db-pool = 10
db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800
db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = ""
db-prepared-statements = true
db-root-spec = ""
db-schemas = "public"
db-config = true
db-pre-config = ""
db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ != \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error"
openapi-mode = "follow-privileges"
openapi-security-active = false
openapi-server-proxy-uri = ""
server-cors-allowed-origins = ""
server-host = "!4"
server-port = 3000
server-trace-header = ""
server-timing-enabled = false
server-unix-socket = ""
server-unix-socket-mode = "660"
admin-server-host = "!4"
admin-server-port = ""
@@ -0,0 +1,39 @@
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
db-channel-enabled = true
db-extra-search-path = "public"
db-hoisted-tx-settings = "statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation"
db-max-rows = ""
db-plan-enabled = false
db-pool = 10
db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800
db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = ""
db-prepared-statements = true
db-root-spec = ""
db-schemas = "public"
db-config = true
db-pre-config = ""
db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ^== \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error"
openapi-mode = "follow-privileges"
openapi-security-active = false
openapi-server-proxy-uri = ""
server-cors-allowed-origins = ""
server-host = "!4"
server-port = 3000
server-trace-header = ""
server-timing-enabled = false
server-unix-socket = ""
server-unix-socket-mode = "660"
admin-server-host = "!4"
admin-server-port = ""
@@ -0,0 +1,39 @@
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
db-channel-enabled = true
db-extra-search-path = "public"
db-hoisted-tx-settings = "statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation"
db-max-rows = ""
db-plan-enabled = false
db-pool = 10
db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800
db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = ""
db-prepared-statements = true
db-root-spec = ""
db-schemas = "public"
db-config = true
db-pre-config = ""
db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ==^ \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error"
openapi-mode = "follow-privileges"
openapi-security-active = false
openapi-server-proxy-uri = ""
server-cors-allowed-origins = ""
server-host = "!4"
server-port = 3000
server-trace-header = ""
server-timing-enabled = false
server-unix-socket = ""
server-unix-socket-mode = "660"
admin-server-host = "!4"
admin-server-port = ""
@@ -0,0 +1,39 @@
db-aggregates-enabled = false
db-anon-role = ""
db-channel = "pgrst"
db-channel-enabled = true
db-extra-search-path = "public"
db-hoisted-tx-settings = "statement_timeout,plan_filter.statement_cost_limit,default_transaction_isolation"
db-max-rows = ""
db-plan-enabled = false
db-pool = 10
db-pool-acquisition-timeout = 10
db-pool-max-lifetime = 1800
db-pool-max-idletime = 30
db-pool-automatic-recovery = true
db-pre-request = ""
db-prepared-statements = true
db-root-spec = ""
db-schemas = "public"
db-config = true
db-pre-config = ""
db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ *== \"role1\")]"
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-lifetime = 0
log-level = "error"
openapi-mode = "follow-privileges"
openapi-security-active = false
openapi-server-proxy-uri = ""
server-cors-allowed-origins = ""
server-host = "!4"
server-port = 3000
server-trace-header = ""
server-timing-enabled = false
server-unix-socket = ""
server-unix-socket-mode = "660"
admin-server-host = "!4"
admin-server-port = ""
@@ -0,0 +1 @@
jwt-role-claim-key = ".roles[?(@ == \"role1\")]"
@@ -0,0 +1 @@
jwt-role-claim-key = ".roles[?(@ != \"role1\")]"
@@ -0,0 +1 @@
jwt-role-claim-key = ".roles[?(@ ^== \"role1\")]"
@@ -0,0 +1 @@
jwt-role-claim-key = ".roles[?(@ ==^ \"role1\")]"
@@ -0,0 +1 @@
jwt-role-claim-key = ".roles[?(@ *== \"role1\")]"
+43
View File
@@ -153,6 +153,48 @@ roleclaims:
role: postgrest_test_author role: postgrest_test_author
other: true other: true
expected_status: 401 expected_status: 401
# https://github.com/PostgREST/postgrest/pull/3813
- key: '.realm_access.roles[?(@ == "postgrest_test_author")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ != "other")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ ^== "postgrest_te")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ ==^ "st_test_author")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ *== "_test_")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ == "string")]'
data:
realm_access:
roles:
- obj_key: obj_value
expected_status: 401 # fails because it compares an object with a string
invalidroleclaimkeys: invalidroleclaimkeys:
- 'role.other' - 'role.other'
@@ -160,6 +202,7 @@ invalidroleclaimkeys:
- '.my_role;;domain' - '.my_role;;domain'
- '.#$$%&$%/' - '.#$$%&$%/'
- '1234' - '1234'
- '.role[?(@ =)]'
invalidopenapimodes: invalidopenapimodes:
- 'follow-' - 'follow-'