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
« 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".
4Every fact-returning surface (recall, query, cards, synthesize, intents, entity
5resolution, …) must restrict results to facts that are:
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).
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"""
22from __future__ import annotations
24from dataclasses import dataclass
25from typing import Any
27from .memory_garden_acl_gate import caller_visible_gardens, garden_acl_enforced
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"
34@dataclass(frozen=True)
35class ReadScope:
36 """A caller's resolved fact read scope (tenant + visible gardens)."""
38 tenant_id: str
39 enforce_gardens: bool
40 visible_gardens: frozenset[str]
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).
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
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)
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 )
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.
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