Compare commits

...
3 Commits
Author SHA1 Message Date
Laurence Isla a34d37bb82 bump version to 13.0.4 2025-06-17 19:58:43 -05:00
Taimoor ZaeemandLaurence Isla 5b45113565 fix: jwt-aud config not failing when set to invalid URI (#4140)
The `jwt-aud` config was not validated when containing ':'
character according to RFC 3986. This fix validates it and
fails at startup if it is invalid.
2025-06-18 00:11:30 +00:00
Laurence Isla 733a896113 fix: regression that makes fts not work on domain types based on tsvector 2025-06-18 00:11:30 +00:00
12 changed files with 95 additions and 23 deletions
+7
View File
@@ -5,6 +5,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [13.0.4] - 2025-06-17
### Fixed
- Fix regression that makes full-text search not work on domain types based on `tsvector` by @laurenceisla in #4135
- Fix `jwt-aud` config not failing when set to an invalid URI by @taimoorzaeem in #4132
## [13.0.3] - 2025-06-16 ## [13.0.3] - 2025-06-16
### Fixed ### Fixed
+1 -1
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 13.0.3 version: 13.0.4
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for tables, views, and functions, supporting all HTTP methods that security for tables, views, and functions, supporting all HTTP methods that security
+16 -2
View File
@@ -48,7 +48,7 @@ import Data.List.NonEmpty (fromList, toList)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Scientific (floatingOrInteger) import Data.Scientific (floatingOrInteger)
import Jose.Jwk (Jwk, JwkSet) import Jose.Jwk (Jwk, JwkSet)
import Network.URI (escapeURIString, import Network.URI (escapeURIString, isURI,
isUnescapedInURIComponent) isUnescapedInURIComponent)
import Numeric (readOct, showOct) import Numeric (readOct, showOct)
import System.Environment (getEnvironment) import System.Environment (getEnvironment)
@@ -281,7 +281,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> (fromMaybe "postgresql://" <$> optString "db-uri") <*> (fromMaybe "postgresql://" <$> optString "db-uri")
<*> pure optPath <*> pure optPath
<*> pure Nothing <*> pure Nothing
<*> optString "jwt-aud" <*> optStringOrURI "jwt-aud"
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key" <*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
<*> (fmap encodeUtf8 <$> optString "jwt-secret") <*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optWithAlias <*> (fromMaybe False <$> optWithAlias
@@ -407,6 +407,20 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text) optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText
optStringOrURI :: C.Key -> C.Parser C.Config (Maybe Text)
optStringOrURI k = do
stringOrURI <- mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText
-- If the string contains ':' then it should
-- be a valid URI according to RFC 3986
case stringOrURI of
Just s -> if T.isInfixOf ":" s then validateURI s else return (Just s)
Nothing -> return Nothing
where
validateURI :: Text -> C.Parser C.Config (Maybe Text)
validateURI s = if isURI (T.unpack s)
then return $ Just s
else fail "jwt-aud should be a string or a valid URI"
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i) optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt
+8 -8
View File
@@ -278,7 +278,7 @@ data ResolverContext = ResolverContext
} }
resolveColumnField :: Column -> Maybe ToTsVector -> CoercibleField resolveColumnField :: Column -> Maybe ToTsVector -> CoercibleField
resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) Nothing (colDefault col) False resolveColumnField col toTsV = CoercibleField (colName col) mempty False toTsV (colNominalType col) (colType col) Nothing (colDefault col) False
resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField resolveTableFieldName :: Table -> FieldName -> Maybe ToTsVector -> CoercibleField
resolveTableFieldName table fieldName toTsV= resolveTableFieldName table fieldName toTsV=
@@ -291,12 +291,12 @@ resolveTypeOrUnknown ResolverContext{..} (fn, jp) toTsV =
case res of case res of
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr` -- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594 -- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False} cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False}
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False} cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False}
-- Do not apply to_tsvector to tsvector types -- Do not apply to_tsvector to tsvector types
cf@CoercibleField{cfIRType="tsvector"} -> cf{cfJsonPath=jp, cfToJson=True, cfToTsVector=Nothing} cf@CoercibleField{cfBaseType="tsvector"} -> cf{cfJsonPath=jp, cfToJson=True, cfToTsVector=Nothing}
-- other types will get converted `to_jsonb(col)->attr`, even unknown types -- other types will get converted `to_jsonb(col)->attr`, even unknown types
cf -> cf{cfJsonPath=jp, cfToJson=True} cf -> cf{cfJsonPath=jp, cfToJson=True}
where where
res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>= res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
Just . (\t -> resolveTableFieldName t fn toTsV) Just . (\t -> resolveTableFieldName t fn toTsV)
@@ -891,7 +891,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- where_ = [ -- where_ = [
-- CoercibleStmnt ( -- CoercibleStmnt (
-- CoercibleFilter { -- CoercibleFilter {
-- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, -- field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson=False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False},
-- opExpr = op -- opExpr = op
-- } -- }
-- ) -- )
@@ -907,7 +907,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter. -- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
-- --
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp []) -- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})] -- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfToTsVector = Nothing, cfIRType = "", cfBaseType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})]
-- --
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later. -- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
-- --
@@ -926,7 +926,7 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do
newNullFilters rPlans = \case newNullFilters rPlans = \case
(CoercibleExpr b lOp trees) -> (CoercibleExpr b lOp trees) ->
CoercibleExpr b lOp <$> (newNullFilters rPlans `traverse` trees) CoercibleExpr b lOp <$> (newNullFilters rPlans `traverse` trees)
flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _ _ _) opExpr)) -> flt@(CoercibleStmnt (CoercibleFilter CoercibleField{cfName=fld, cfJsonPath=[]} opExpr)) ->
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
case (foundRP, opExpr) of case (foundRP, opExpr) of
(Just ReadPlan{relAggAlias}, OpExpr b (Is IsNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias (Just ReadPlan{relAggAlias}, OpExpr b (Is IsNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias
+2 -1
View File
@@ -44,13 +44,14 @@ data CoercibleField = CoercibleField
, cfToJson :: Bool , cfToJson :: Bool
, cfToTsVector :: Maybe ToTsVector -- ^ If the field should be converted using to_tsvector(<language>, <field>) , cfToTsVector :: Maybe ToTsVector -- ^ If the field should be converted using to_tsvector(<language>, <field>)
, cfIRType :: Text -- ^ The native Postgres type of the field, the intermediate (IR) type before mapping. , cfIRType :: Text -- ^ The native Postgres type of the field, the intermediate (IR) type before mapping.
, cfBaseType :: Text -- ^ The base type of the field in case of domains, or just the type otherwise (without modifiers in case of pg_catalog types)
, cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType. , cfTransform :: Maybe TransformerProc -- ^ The optional mapping from irType -> targetType.
, cfDefault :: Maybe Text , cfDefault :: Maybe Text
, cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spreaded resources. , cfFullRow :: Bool -- ^ True if the field represents the whole selected row. Used in spread rels: instead of COUNT(*), it does a COUNT(<row>) in order to not mix with other spreaded resources.
} deriving (Eq, Show) } deriving (Eq, Show)
unknownField :: FieldName -> JsonPath -> CoercibleField unknownField :: FieldName -> JsonPath -> CoercibleField
unknownField name path = CoercibleField name path False Nothing "" Nothing Nothing False unknownField name path = CoercibleField name path False Nothing "" "" Nothing Nothing False
-- | Like an API request LogicTree, but with coercible field information. -- | Like an API request LogicTree, but with coercible field information.
data CoercibleLogicTree data CoercibleLogicTree
+1 -1
View File
@@ -182,7 +182,7 @@ callPlanToQuery (FunctionCall qi params arguments returnsScalar returnsSetOfScal
KeyParams [] -> "FROM " <> callIt mempty KeyParams [] -> "FROM " <> callIt mempty
KeyParams prms -> case arguments of KeyParams prms -> case arguments of
DirectArgs args -> "FROM " <> callIt (fmtArgs prms args) DirectArgs args -> "FROM " <> callIt (fmtArgs prms args)
JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) Nothing Nothing False) <$> prms) False True False <> ", " <> JsonArgs json -> fromJsonBodyF json ((\p -> CoercibleField (ppName p) mempty False Nothing (ppTypeMaxLength p) mempty Nothing Nothing False) <$> prms) False True False <> ", " <>
"LATERAL " <> callIt (fmtParams prms) "LATERAL " <> callIt (fmtParams prms)
callIt :: SQL.Snippet -> SQL.Snippet callIt :: SQL.Snippet -> SQL.Snippet
+5 -6
View File
@@ -41,12 +41,11 @@ cli:
use_defaultenv: true use_defaultenv: true
env: env:
PGRST_SERVER_UNIX_SOCKET_MODE: '778' PGRST_SERVER_UNIX_SOCKET_MODE: '778'
# TODO: Bug needs to be fixed - name: invalid jwt-aud
# - name: invalid jwt-aud expect: error
# expect: error use_defaultenv: true
# use_defaultenv: true env:
# env: PGRST_JWT_AUD: 'http://%%localhorst.invalid'
# PGRST_JWT_AUD: 'htp:/@@localhorst.invalid'
- name: invalid log-level - name: invalid log-level
expect: error expect: error
use_defaultenv: true use_defaultenv: true
+12
View File
@@ -278,3 +278,15 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
Dumper=yaml.SafeDumper if key == "dbTimezones" else ExtraNewLinesDumper, Dumper=yaml.SafeDumper if key == "dbTimezones" else ExtraNewLinesDumper,
) )
assert formatted == snapshot_yaml assert formatted == snapshot_yaml
def test_jwt_aud_config_set_to_invalid_uri(defaultenv):
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid URI"
env = {
**defaultenv,
"PGRST_JWT_AUD": "foo://%%$$^^.com",
}
with pytest.raises(PostgrestError):
dump = cli(["--dump-config"], env=env).split("\n")
assert "jwt-aud should be a string or a valid URI" in dump
+14
View File
@@ -294,6 +294,20 @@ spec = do
]|] ]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "works when the column type is a tsvector domain" $ do
get "tsearch_to_tsvector?select=text_search_domain&text_search_domain=fts(simple).of" `shouldRespondWith`
[json| [
{"text_search_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"}
]|]
{ matchHeaders = [matchContentTypeJson] }
it "works when the column type is a recursive tsvector domain" $ do
get "tsearch_to_tsvector?select=text_search_rec_domain&text_search_rec_domain=fts(simple).of" `shouldRespondWith`
[json| [
{"text_search_rec_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"}
]|]
{ matchHeaders = [matchContentTypeJson] }
context "text and json columns" $ do context "text and json columns" $ do
it "finds matches with to_tsquery" $ do it "finds matches with to_tsquery" $ do
get "/tsearch_to_tsvector?select=text_search&text_search=fts.impossible" `shouldRespondWith` get "/tsearch_to_tsvector?select=text_search&text_search=fts.impossible" `shouldRespondWith`
+16
View File
@@ -998,6 +998,22 @@ spec =
|] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "should work with filters that use the fts operator when the column type is a tsvector domain" $
get "/rpc/get_tsearch_to_tsvector?select=text_search_domain&text_search_domain=fts(simple).impossible" `shouldRespondWith`
[json|[
{"text_search_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"},
{"text_search_domain":"'amusant':5 'c':1 'de':6 'est':2 'faire':7 'impossible':9 'l':8 'peu':4 'un':3"}]
|]
{ matchHeaders = [matchContentTypeJson] }
it "should work with filters that use the fts operator when the column type is a recursive tsvector domain" $
get "/rpc/get_tsearch_to_tsvector?select=text_search_rec_domain&text_search_rec_domain=fts(simple).impossible" `shouldRespondWith`
[json|[
{"text_search_rec_domain":"'do':7 'fun':5 'impossible':9 'it':1 'kind':3 'of':4 's':2 'the':8 'to':6"},
{"text_search_rec_domain":"'amusant':5 'c':1 'de':6 'est':2 'faire':7 'impossible':9 'l':8 'peu':4 'un':3"}]
|]
{ matchHeaders = [matchContentTypeJson] }
it "should work with the phraseto_tsquery function" $ it "should work with the phraseto_tsquery function" $
get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith` get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
+4 -3
View File
@@ -954,15 +954,16 @@ INSERT INTO tsearch_to_tsvector(text_search) VALUES ('C''est un peu amusant de f
INSERT INTO tsearch_to_tsvector(text_search) VALUES ('Es ist eine Art Spaß, das Unmögliche zu machen'); INSERT INTO tsearch_to_tsvector(text_search) VALUES ('Es ist eine Art Spaß, das Unmögliche zu machen');
UPDATE tsearch_to_tsvector SET jsonb_search = jsonb_build_object('text_search', text_search); UPDATE tsearch_to_tsvector SET jsonb_search = jsonb_build_object('text_search', text_search);
UPDATE tsearch_to_tsvector SET text_search_domain = to_tsvector('simple', text_search);
UPDATE tsearch_to_tsvector SET text_search_rec_domain = to_tsvector('simple', text_search);
TRUNCATE TABLE artists CASCADE; TRUNCATE TABLE artists CASCADE;
INSERT INTO artists INSERT INTO artists
VALUES (1, 'duster'), (2, 'black country, new road'), (3, 'bjork'); VALUES (1, 'duster'), (2, 'black country, new road'), (3, 'bjork');
TRUNCATE TABLE albums CASCADE; TRUNCATE TABLE albums CASCADE;
INSERT INTO albums INSERT INTO albums
VALUES (1, 'stratosphere', 1), VALUES (1, 'stratosphere', 1),
(2, 'ants from up above',2), (2, 'ants from up above',2),
(3, 'vespertine',3), (3, 'vespertine',3),
(4, 'contemporary movement', 1); (4, 'contemporary movement', 1);
+9 -1
View File
@@ -3750,9 +3750,17 @@ create table surr_gen_default_upsert (
extra text extra text
); );
create domain tsvector_not_null as tsvector
constraint "tsvector is required" check (value is not null);
create domain tsvector_not_empty as tsvector_not_null
constraint "tsvector is required and not empty" check (value <> '');
create table tsearch_to_tsvector ( create table tsearch_to_tsvector (
text_search text, text_search text,
jsonb_search jsonb jsonb_search jsonb,
text_search_domain tsvector_not_null default '',
text_search_rec_domain tsvector_not_empty default '.'
); );
create function test.get_tsearch_to_tsvector() returns setof test.tsearch_to_tsvector AS $$ create function test.get_tsearch_to_tsvector() returns setof test.tsearch_to_tsvector AS $$