Skip to content

Commit 583ddf1

Browse files
harden: scope every Postgres read/delete by tenant_id (defense-in-depth)
Reads and deletes in postgres_client.py previously relied solely on Postgres RLS (WHERE tenant_id = current_setting('app.tenant_id')) for tenant scoping, with no app-level filter. That is safe only while the app connects as a non-owner, non-superuser role — one DATABASE_URL misconfiguration (connecting as the table owner or a BYPASSRLS role) would silently leak every tenant's reads. Add an explicit `AND tenant_id = %s` (bound to self.tenant_id) to query_prefix, get_point, get_history, list_collections, get_collection, node_count, both semantic_search tiers, query_entity, list_entities, and the delete/GC paths. Because _conn() sets app.tenant_id to the same self.tenant_id, the explicit filter is provably equivalent to the RLS predicate — verified against the production DB: identical result counts and top matches across all methods (query_prefix, get_point, get_history, semantic_search, node_count=16950, …). Isolation no longer depends on the DB role; RLS becomes a redundant second layer. Note: this is a correctness/security hardening, not a latency fix — warm query time is unchanged (the 400-700ms cloud read latency is network: a remote managed Postgres + Cloudflare, not the query plan). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d14771c commit 583ddf1

1 file changed

Lines changed: 27 additions & 26 deletions

File tree

synrix/postgres_client.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ def list_collections(self) -> List[str]:
237237
cur = conn.cursor()
238238
cur.execute(
239239
"SELECT DISTINCT split_part(name, ':', 1) FROM nodes "
240-
"WHERE valid_until = 0 LIMIT 100"
240+
"WHERE tenant_id = %s AND valid_until = 0 LIMIT 100",
241+
(self.tenant_id,)
241242
)
242243
return [r[0] for r in cur.fetchall()]
243244
finally:
@@ -248,8 +249,8 @@ def get_collection(self, name: str) -> Dict[str, Any]:
248249
try:
249250
cur = conn.cursor()
250251
cur.execute(
251-
"SELECT COUNT(*) FROM nodes WHERE name LIKE %s AND valid_until = 0",
252-
(f"{name}:%",)
252+
"SELECT COUNT(*) FROM nodes WHERE tenant_id = %s AND name LIKE %s AND valid_until = 0",
253+
(self.tenant_id, f"{name}:%")
253254
)
254255
count = cur.fetchone()[0]
255256
return {"name": name, "count": count}
@@ -264,7 +265,7 @@ def delete_collection(self, name: str) -> bool:
264265
conn = self._conn()
265266
try:
266267
cur = conn.cursor()
267-
cur.execute("DELETE FROM nodes WHERE name LIKE %s", (f"{name}:%",))
268+
cur.execute("DELETE FROM nodes WHERE tenant_id = %s AND name LIKE %s", (self.tenant_id, f"{name}:%"))
268269
conn.commit()
269270
return True
270271
finally:
@@ -396,9 +397,9 @@ def query_prefix(self, prefix: str, collection: str = None, limit: int = 100) ->
396397
cur = conn.cursor()
397398
cur.execute(
398399
"SELECT id, name, data, metadata, valid_from, valid_until "
399-
"FROM nodes WHERE name LIKE %s AND valid_until = 0 "
400+
"FROM nodes WHERE tenant_id = %s AND name LIKE %s AND valid_until = 0 "
400401
"ORDER BY valid_from DESC LIMIT %s",
401-
(f"{prefix}%", limit)
402+
(self.tenant_id, f"{prefix}%", limit)
402403
)
403404
results = []
404405
for row in cur.fetchall():
@@ -426,11 +427,11 @@ def get_point(self, collection: str, point_id: Union[int, str]) -> Dict[str, Any
426427
try:
427428
cur = conn.cursor()
428429
if isinstance(point_id, int):
429-
cur.execute("SELECT id, name, data, metadata FROM nodes WHERE id = %s", (point_id,))
430+
cur.execute("SELECT id, name, data, metadata FROM nodes WHERE tenant_id = %s AND id = %s", (self.tenant_id, point_id))
430431
else:
431432
cur.execute(
432-
"SELECT id, name, data, metadata FROM nodes WHERE name = %s AND valid_until = 0",
433-
(str(point_id),)
433+
"SELECT id, name, data, metadata FROM nodes WHERE tenant_id = %s AND name = %s AND valid_until = 0",
434+
(self.tenant_id, str(point_id))
434435
)
435436
row = cur.fetchone()
436437
if not row:
@@ -562,16 +563,16 @@ def semantic_search(self, query_embedding, collection: str = None, limit: int =
562563
FROM fact_embeddings fe
563564
LEFT JOIN nodes n ON n.tenant_id = fe.tenant_id
564565
AND n.name = fe.node_name AND n.valid_until = 0
565-
WHERE fe.embedding IS NOT NULL
566+
WHERE fe.embedding IS NOT NULL AND fe.tenant_id = %s
566567
{prefix_filter}
567568
ORDER BY fe.embedding <=> %s::vector
568569
LIMIT %s
569570
"""
570571
# Build params in correct SQL order: score_vec, [prefix], order_vec, limit
571572
if name_prefix:
572-
params = [emb_str, name_prefix + "%", emb_str, limit * 2]
573+
params = [emb_str, self.tenant_id, name_prefix + "%", emb_str, limit * 2]
573574
else:
574-
params = [emb_str, emb_str, limit * 2]
575+
params = [emb_str, self.tenant_id, emb_str, limit * 2]
575576
cur.execute(sql, params)
576577

577578
for row in cur.fetchall():
@@ -606,16 +607,16 @@ def semantic_search(self, query_embedding, collection: str = None, limit: int =
606607
sql2 = f"""
607608
SELECT id, name, data, 1 - (embedding <=> %s::vector) AS score
608609
FROM nodes
609-
WHERE embedding IS NOT NULL AND valid_until = 0
610+
WHERE embedding IS NOT NULL AND valid_until = 0 AND tenant_id = %s
610611
{prefix_cond}
611612
ORDER BY embedding <=> %s::vector
612613
LIMIT %s
613614
"""
614615
# Build params in correct SQL order: score_vec, [prefix], order_vec, limit
615616
if name_prefix:
616-
params2 = [emb_str, name_prefix + "%", emb_str, remaining * 2]
617+
params2 = [emb_str, self.tenant_id, name_prefix + "%", emb_str, remaining * 2]
617618
else:
618-
params2 = [emb_str, emb_str, remaining * 2]
619+
params2 = [emb_str, self.tenant_id, emb_str, remaining * 2]
619620
cur.execute(sql2, params2)
620621

621622
for row in cur.fetchall():
@@ -657,8 +658,8 @@ def get_history(self, name: str, collection: str = None) -> List[Dict[str, Any]]
657658
cur = conn.cursor()
658659
cur.execute(
659660
"SELECT id, name, data, valid_from, valid_until "
660-
"FROM nodes WHERE name = %s ORDER BY valid_from ASC",
661-
(name,)
661+
"FROM nodes WHERE tenant_id = %s AND name = %s ORDER BY valid_from ASC",
662+
(self.tenant_id, name)
662663
)
663664
results = []
664665
for row in cur.fetchall():
@@ -715,8 +716,8 @@ def query_entity(self, name: str, collection: str = None) -> Optional[Dict[str,
715716
cur = conn.cursor()
716717
cur.execute(
717718
"SELECT id, name, entity_type, mention_count, first_seen, last_seen "
718-
"FROM entities WHERE name = %s LIMIT 1",
719-
(name,)
719+
"FROM entities WHERE tenant_id = %s AND name = %s LIMIT 1",
720+
(self.tenant_id, name)
720721
)
721722
row = cur.fetchone()
722723
if not row:
@@ -733,8 +734,8 @@ def list_entities(self, collection: str = None, entity_type: str = None,
733734
conn = self._conn()
734735
try:
735736
cur = conn.cursor()
736-
sql = "SELECT id, name, entity_type, mention_count FROM entities WHERE 1=1"
737-
params = []
737+
sql = "SELECT id, name, entity_type, mention_count FROM entities WHERE tenant_id = %s"
738+
params = [self.tenant_id]
738739
if entity_type:
739740
sql += " AND entity_type = %s"
740741
params.append(entity_type)
@@ -818,9 +819,9 @@ def delete_by_prefix_before(self, prefix: str, cutoff_timestamp: float,
818819
if only_superseded else ""
819820
)
820821
cur.execute(
821-
"DELETE FROM nodes WHERE name LIKE %s AND valid_from < %s"
822+
"DELETE FROM nodes WHERE tenant_id = %s AND name LIKE %s AND valid_from < %s"
822823
+ superseded_clause,
823-
(f"{prefix}%", cutoff_timestamp)
824+
(self.tenant_id, f"{prefix}%", cutoff_timestamp)
824825
)
825826
count = cur.rowcount
826827
conn.commit()
@@ -845,11 +846,11 @@ def node_count(self, collection: str = None) -> int:
845846
cur = conn.cursor()
846847
if collection:
847848
cur.execute(
848-
"SELECT COUNT(*) FROM nodes WHERE name LIKE %s AND valid_until = 0",
849-
(f"{collection}:%",)
849+
"SELECT COUNT(*) FROM nodes WHERE tenant_id = %s AND name LIKE %s AND valid_until = 0",
850+
(self.tenant_id, f"{collection}:%")
850851
)
851852
else:
852-
cur.execute("SELECT COUNT(*) FROM nodes WHERE valid_until = 0")
853+
cur.execute("SELECT COUNT(*) FROM nodes WHERE tenant_id = %s AND valid_until = 0", (self.tenant_id,))
853854
return cur.fetchone()[0]
854855
finally:
855856
self._release(conn)

0 commit comments

Comments
 (0)