diff --git a/docs/references/configuration.rst b/docs/references/configuration.rst index 4237d55bcc..426bb8b4b5 100644 --- a/docs/references/configuration.rst +++ b/docs/references/configuration.rst @@ -628,7 +628,12 @@ jwt-role-claim-key # 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 }} + # {"postgrest":{"roles": ["other", "author"]}} + # accepts string comparison operators, supported operators are "==", "!=", "^==", "$==", "*==" + # see: https://github.com/dfilatov/jspath#comparison-operators + jwt-role-claim-key = ".postgrest.roles[?(@ == "author")]" + + # {"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" diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 46afa801c2..c8c3b720cb 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -27,6 +27,7 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.Cache as C import qualified Data.Scientific as Sci +import qualified Data.Text as T import qualified Data.Vault.Lazy as Vault import qualified Data.Vector as V import qualified Jose.Jwk as JWT @@ -46,7 +47,8 @@ import System.TimeIt (timeItT) import PostgREST.AppState (AppState, AuthResult (..), getConfig, getJwtCache, getTime) -import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..)) +import PostgREST.Config (AppConfig (..), FilterExp (..), JSPath, + JSPathExp (..)) import PostgREST.Error (Error (..)) import Protolude @@ -121,8 +123,20 @@ parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) = do 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 (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 + 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.String t) = encodeUtf8 t unquoted v = LBS.toStrict $ JSON.encode v diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 57276ef659..8f90f2b1f3 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -15,6 +15,7 @@ module PostgREST.Config , Environment , JSPath , JSPathExp(..) + , FilterExp(..) , LogLevel(..) , OpenAPIMode(..) , Proxy(..) @@ -54,8 +55,9 @@ import System.Posix.Types (FileMode) import PostgREST.Config.Database (RoleIsolationLvl, RoleSettings) -import PostgREST.Config.JSPath (JSPath, JSPathExp (..), - dumpJSPath, pRoleClaimKey) +import PostgREST.Config.JSPath (FilterExp (..), JSPath, + JSPathExp (..), dumpJSPath, + pRoleClaimKey) import PostgREST.Config.Proxy (Proxy (..), isMalformedProxyUri, toURI) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi, diff --git a/src/PostgREST/Config/JSPath.hs b/src/PostgREST/Config/JSPath.hs index 97405654f4..42cd065d0d 100644 --- a/src/PostgREST/Config/JSPath.hs +++ b/src/PostgREST/Config/JSPath.hs @@ -1,31 +1,50 @@ +{-# OPTIONS_GHC -Wno-unused-do-bind #-} module PostgREST.Config.JSPath ( JSPath , JSPathExp(..) + , FilterExp(..) , dumpJSPath , pRoleClaimKey ) where import qualified Text.ParserCombinators.Parsec as P -import Data.Either.Combinators (mapLeft) -import Text.ParserCombinators.Parsec (()) -import Text.Read (read) +import Data.Either.Combinators (mapLeft) +import Text.Read (read) import Protolude --- | full jspath, e.g. .property[0].attr.detail +-- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")] type JSPath = [JSPathExp] --- | jspath expression, e.g. .property, .property[0] or ."property-dash" +-- | jspath expression data JSPathExp - = JSPKey Text - | JSPIdx Int + = JSPKey Text -- .property or ."property-dash" + | JSPIdx Int -- [0] + | JSPFilter FilterExp -- [?(@ == "match")], [?(@ ^== "match-prefix")], etc + +data FilterExp + = EqualsCond Text + | NotEqualsCond Text + | StartsWithCond Text + | EndsWithCond Text + | ContainsCond Text dumpJSPath :: JSPathExp -> Text -- TODO: this needs to be quoted properly for special chars dumpJSPath (JSPKey k) = "." <> show k dumpJSPath (JSPIdx i) = "[" <> show i <> "]" +dumpJSPath (JSPFilter cond) = "[?(@" <> expr <> "]" + where + expr = + case cond of + EqualsCond text -> " == " <> text + NotEqualsCond text -> " != " <> text + StartsWithCond text -> " ^== " <> text + EndsWithCond text -> " $== " <> text + ContainsCond text -> " *== " <> text + -- Used for the config value "role-claim-key" pRoleClaimKey :: Text -> Either Text JSPath @@ -33,19 +52,47 @@ pRoleClaimKey selStr = mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr) pJSPath :: P.Parser JSPath -pJSPath = toJSPath <$> (period *> pPath `P.sepBy` period <* P.eof) - where - toJSPath :: [(Text, Maybe Int)] -> JSPath - toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx)) - period = P.char '.' "period (.)" - pPath :: P.Parser (Text, Maybe Int) - pPath = (,) <$> pJSPKey <*> P.optionMaybe pJSPIdx +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 + +pJSPIdx :: P.Parser JSPathExp +pJSPIdx = do + P.char '[' + num <- read <$> P.many1 P.digit + P.char ']' + return $ JSPIdx num -pJSPKey :: P.Parser Text -pJSPKey = toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue "attribute name [a..z0..9_$@])" +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 -pJSPIdx :: P.Parser Int -pJSPIdx = P.char '[' *> (read <$> P.many1 P.digit) <* P.char ']' "array index [0..n]" +pFilterConditionParser :: P.Parser FilterExp +pFilterConditionParser = do + P.char '@' + P.spaces + condOp <- P.choice $ map P.string ["==", "!=", "^==", "$==", "*=="] + P.spaces + value <- pQuotedValue + return $ case condOp of + "==" -> EqualsCond value + "!=" -> NotEqualsCond value + "^==" -> StartsWithCond value + "$==" -> EndsWithCond value + "*==" -> ContainsCond value + _ -> EqualsCond value -- Impossible case pQuotedValue :: P.Parser Text pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"') diff --git a/test/io/fixtures.yaml b/test/io/fixtures.yaml index 5c738d7c14..d644e70ea6 100644 --- a/test/io/fixtures.yaml +++ b/test/io/fixtures.yaml @@ -153,6 +153,41 @@ roleclaims: role: postgrest_test_author other: true expected_status: 401 + - 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 invalidroleclaimkeys: - 'role.other'