Skip to content

Commit 7a9cf1f

Browse files
committed
fix: add project_id filter to SnowflakeRegistry UPDATE path
_apply_object had a missing project_id clause in the UPDATE branch's WHERE condition. In a shared Snowflake registry, this allowed a feast apply in one project to silently overwrite a same-named object belonging to a different project. The SELECT path already scoped correctly by project_id. The DELETE path (_delete_object) also scoped correctly. This commit brings the UPDATE path in line with both. Fixes #6208 Signed-off-by: Abhishek8108 <87538407+Abhishek8108@users.noreply.github.com>
1 parent beaa9a6 commit 7a9cf1f

File tree

2 files changed

+118
-1
lines changed

2 files changed

+118
-1
lines changed

sdk/python/feast/infra/registry/snowflake.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,8 @@ def _apply_object(
373373
{proto_field_name} = TO_BINARY({proto}),
374374
last_updated_timestamp = CURRENT_TIMESTAMP()
375375
WHERE
376-
{id_field_name.lower()} = '{name}'
376+
project_id = '{project}'
377+
AND {id_field_name.lower()} = '{name}'
377378
"""
378379
execute_snowflake_statement(conn, query)
379380

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2024 The Feast Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from unittest.mock import MagicMock, patch
16+
17+
import pandas as pd
18+
import pytest
19+
20+
from feast.entity import Entity
21+
from feast.infra.registry.snowflake import SnowflakeRegistry, SnowflakeRegistryConfig
22+
23+
24+
@pytest.fixture
25+
def registry():
26+
config = SnowflakeRegistryConfig(
27+
database="TEST_DB",
28+
schema="PUBLIC",
29+
account="test_account",
30+
user="test_user",
31+
password="test_password",
32+
)
33+
mock_conn = MagicMock()
34+
mock_conn.__enter__ = MagicMock(return_value=MagicMock())
35+
mock_conn.__exit__ = MagicMock(return_value=False)
36+
37+
# execute_snowflake_statement during __init__ creates tables; return empty cursor
38+
mock_cursor = MagicMock()
39+
mock_cursor.fetch_pandas_all.return_value = pd.DataFrame()
40+
41+
with patch(
42+
"feast.infra.registry.snowflake.GetSnowflakeConnection", return_value=mock_conn
43+
), patch(
44+
"feast.infra.registry.snowflake.execute_snowflake_statement",
45+
return_value=mock_cursor,
46+
):
47+
reg = SnowflakeRegistry(config, "test_project", None)
48+
return reg
49+
50+
51+
def test_apply_object_update_includes_project_id_in_where_clause(registry):
52+
"""
53+
Regression test for feast-dev/feast#6208.
54+
55+
_apply_object UPDATE path was missing project_id in the WHERE clause,
56+
allowing a same-named object in one project to silently overwrite the
57+
same-named object in a different project when a Snowflake registry is shared.
58+
59+
The SELECT path correctly scopes by project_id; the UPDATE must do the same.
60+
"""
61+
entity = Entity(name="driver", join_keys=["driver_id"])
62+
project = "project_b"
63+
64+
# Non-empty DataFrame tells _apply_object the row already exists → UPDATE path
65+
existing_row = pd.DataFrame({"project_id": [project]})
66+
67+
captured_queries = []
68+
69+
def capture_and_respond(conn, query):
70+
normalised = " ".join(query.split())
71+
captured_queries.append(normalised)
72+
cursor = MagicMock()
73+
cursor.fetch_pandas_all.return_value = (
74+
existing_row if "SELECT" in normalised else pd.DataFrame()
75+
)
76+
return cursor
77+
78+
mock_conn = MagicMock()
79+
mock_conn.__enter__ = MagicMock(return_value=MagicMock())
80+
mock_conn.__exit__ = MagicMock(return_value=False)
81+
82+
with patch(
83+
"feast.infra.registry.snowflake.GetSnowflakeConnection", return_value=mock_conn
84+
), patch(
85+
"feast.infra.registry.snowflake.execute_snowflake_statement",
86+
side_effect=capture_and_respond,
87+
), patch.object(
88+
registry, "_maybe_init_project_metadata"
89+
), patch.object(
90+
registry, "_initialize_project_if_not_exists"
91+
), patch.object(
92+
registry, "get_project", return_value=MagicMock()
93+
), patch.object(
94+
registry, "apply_project"
95+
), patch.object(
96+
registry, "_set_last_updated_metadata"
97+
):
98+
registry._apply_object(
99+
"ENTITIES",
100+
project,
101+
"ENTITY_NAME",
102+
entity,
103+
"ENTITY_PROTO",
104+
)
105+
106+
update_queries = [q for q in captured_queries if q.startswith("UPDATE")]
107+
assert len(update_queries) == 1, (
108+
f"Expected exactly 1 UPDATE query, got {len(update_queries)}: {update_queries}"
109+
)
110+
111+
update_query = update_queries[0]
112+
assert f"project_id = '{project}'" in update_query, (
113+
f"feast#6208: UPDATE WHERE clause is missing project_id filter — "
114+
f"cross-project overwrites are possible in shared Snowflake registries.\n"
115+
f"Query was: {update_query}"
116+
)

0 commit comments

Comments
 (0)