Coverage for node / src / stigmem_node / fact_visibility.py: 100%

31 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Shared fact read-visibility boundary — the single definition of "which facts 

2may this caller read". 

3 

4Every fact-returning surface (recall, query, cards, synthesize, intents, entity 

5resolution, …) must restrict results to facts that are: 

6 

7 * in the caller's tenant (``f.tenant_id == caller.tenant_id``), and 

8 * in a garden the caller may see — using the PROJECTED garden 

9 ``COALESCE(fact_garden_membership.garden_id, facts.garden_id)`` — whenever 

10 the garden boundary is enforced (``garden_acl_enforced``; fail-closed once 

11 gardens-with-members exist). 

12 

13Resolve the caller's scope ONCE per request with :func:`caller_read_scope` 

14(a single batched membership query), then either splice the SQL fragment into a 

15``FROM facts f`` query (preferred — push the filter into the DB) or filter loaded 

16rows in-memory with :meth:`ReadScope.fact_visible`. A CI guard 

17(``scripts/check_fact_query_tenant_scope.py``) flags any ``FROM facts`` query in 

18the route layer that does not carry tenant scoping, so a new surface cannot 

19silently leak. 

20""" 

21 

22from __future__ import annotations 

23 

24from dataclasses import dataclass 

25from typing import Any 

26 

27from .memory_garden_acl_gate import caller_visible_gardens, garden_acl_enforced 

28 

29# SQL building block: callers that splice visible_facts_where() must also join 

30# fact_garden_membership so the projected-garden predicate resolves. 

31PROJECTED_GARDEN_JOIN = "LEFT JOIN fact_garden_membership fgm ON fgm.fact_id = f.id" 

32 

33 

34@dataclass(frozen=True) 

35class ReadScope: 

36 """A caller's resolved fact read scope (tenant + visible gardens).""" 

37 

38 tenant_id: str 

39 enforce_gardens: bool 

40 visible_gardens: frozenset[str] 

41 

42 def garden_allows(self, projected_garden_id: Any) -> bool: 

43 """Garden half of the visibility check, for surfaces that already enforce 

44 tenant scoping in SQL (recall ranker, graph edges, synthesize rows). 

45 

46 True when the garden boundary is not enforced, the fact is garden-less, 

47 or the caller is a member of the (projected) garden. ``projected_garden_id`` 

48 must be the COALESCE(fgm, facts) value, not raw ``facts.garden_id``. 

49 """ 

50 if self.enforce_gardens and projected_garden_id is not None: 

51 return projected_garden_id in self.visible_gardens 

52 return True 

53 

54 def fact_visible(self, *, tenant_id: Any, projected_garden_id: Any) -> bool: 

55 """True if a fact with this tenant + projected garden is readable.""" 

56 if tenant_id != self.tenant_id: 

57 return False 

58 return self.garden_allows(projected_garden_id) 

59 

60 

61def caller_read_scope(identity: Any) -> ReadScope: 

62 """Resolve the caller's fact read scope with ONE batched membership query.""" 

63 enforce = garden_acl_enforced() 

64 return ReadScope( 

65 tenant_id=getattr(identity, "tenant_id", "default") or "default", 

66 enforce_gardens=enforce, 

67 visible_gardens=caller_visible_gardens(identity) if enforce else frozenset(), 

68 ) 

69 

70 

71def visible_facts_where(scope: ReadScope, alias: str = "f") -> tuple[str, list[Any]]: 

72 """Return a ``(sql_fragment, params)`` enforcing the read scope in SQL. 

73 

74 The fragment assumes the query joins ``fact_garden_membership fgm`` (see 

75 :data:`PROJECTED_GARDEN_JOIN`). It always pins ``tenant_id`` and, when the 

76 garden boundary is enforced, restricts the projected garden to the caller's 

77 visible set (NULL garden always allowed). The IN-list is built only from 

78 ``?`` placeholders, so the SQL text stays free of caller input. 

79 """ 

80 params: list[Any] = [scope.tenant_id] 

81 fragment = f" AND {alias}.tenant_id = ?" 

82 if scope.enforce_gardens: 

83 if scope.visible_gardens: 

84 placeholders = ",".join("?" for _ in scope.visible_gardens) 

85 fragment += ( 

86 f" AND (COALESCE(fgm.garden_id, {alias}.garden_id) IS NULL" 

87 f" OR COALESCE(fgm.garden_id, {alias}.garden_id) IN ({placeholders}))" 

88 ) 

89 params.extend(sorted(scope.visible_gardens)) 

90 else: 

91 # Member of no garden → only garden-less facts are visible. 

92 fragment += f" AND COALESCE(fgm.garden_id, {alias}.garden_id) IS NULL" 

93 return fragment, params