Let the server decide whose memory a caller may read
A holder is now somebody who can prove it. Schema v11 stores a sha256 of a per-holder token beside the directory row, so a copied database cannot replay it and the plaintext is shown exactly once. The operator token mints and revokes those tokens; a holder token reads that holder's own memories and nothing else; naming somebody else without the operator token is refused with both ids in the message. The visibility rule was leaking before this: browse had none at all, and the search rules short-circuited to the whole library whenever no holder was named. The rule is now written out in five copies with one parameter carried four times, and a test compares the copies so they cannot drift — semgrep rejects an interpolated constant, so the copies are not a choice. Eyes carry three states on purpose. NULL means the whole library, which is what a process that owns the store is entitled to; a holder id means that holder; the empty string means nobody, which is what a tokenless HTTP caller is. Making NULL mean nobody instead would have forced every internal caller — CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting the rule that a local process reads as its own trusted principal. The capability card answers "can this store answer my kind of question" before anything is sent. It is derived from what the store holds, so it cannot promise a modality the store lacks, and it carries no memory text, so it is safe to read before trusting the store. HTTP, CLI and one MCP tool all serve it without a token.
This commit is contained in:
1 parent
209f2d0a1c
commit
83dfc14daa
29 files changed
+1335
-77
No files matched your search
@@ -11,10 +11,12 @@ from .retrieval import decode_cursor, encode_cursor
|
||||
from .schemas import AtomPage, Modality
|
||||
from .storage import ObjectStore
|
||||
|
||||
# The visibility rule is spelled out in four literal statements — two here, one in store_search, one in vector_index — because the SQL gate rejects a concatenated statement and a view cannot take a parameter; backend/tests/test_visibility_rule.py fails if the copies disagree.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Picture:
|
||||
"""The bytes behind an atom and how to label them when serving."""
|
||||
"""The stored bytes behind an atom and how to label them when serving."""
|
||||
|
||||
path: Path
|
||||
mime: str
|
||||
@@ -28,22 +30,48 @@ def browse(
|
||||
modality: Modality | None = None,
|
||||
source_id: str | None = None,
|
||||
holder: str | None = None,
|
||||
as_holder: str | None = None,
|
||||
) -> AtomPage:
|
||||
"""One page plus the total, so a list can say what it left out instead of looking endless."""
|
||||
"""One page plus the total, so a list can say what it left out instead of looking endless.
|
||||
|
||||
`holder` narrows the shelf to one holder's memories; `as_holder` is whose eyes the page is
|
||||
read through: a holder id, the empty string for a caller who is nobody (only what nobody owns
|
||||
and what is marked for everyone), or None to read the whole library as the operator does.
|
||||
"""
|
||||
offset = decode_cursor(cursor)
|
||||
wanted = (modality.value if modality else None, source_id, holder)
|
||||
filters = tuple(value for bound in wanted for value in (bound, bound))
|
||||
eyes = (as_holder, as_holder, as_holder, as_holder)
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
total = int(
|
||||
connection.execute(
|
||||
"SELECT COUNT(*) FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?)",
|
||||
filters,
|
||||
"SELECT COUNT(*) FROM v_atoms_current"
|
||||
" WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?)"
|
||||
" AND (? IS NULL"
|
||||
" OR holder_id = ''"
|
||||
" OR json_extract(visibility_json, '$.scope') = 'everyone'"
|
||||
" OR (? <> '' AND (holder_id = ?"
|
||||
" OR EXISTS ("
|
||||
" SELECT 1 FROM json_each(json_extract(visibility_json, '$.holders'))"
|
||||
" WHERE value = ?"
|
||||
" ))))",
|
||||
(*filters, *eyes),
|
||||
).fetchone()[0]
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?) ORDER BY ingested_at DESC, atom_id DESC LIMIT ? OFFSET ?",
|
||||
(*filters, limit + 1, offset),
|
||||
"SELECT * FROM v_atoms_current"
|
||||
" WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?)"
|
||||
" AND (? IS NULL"
|
||||
" OR holder_id = ''"
|
||||
" OR json_extract(visibility_json, '$.scope') = 'everyone'"
|
||||
" OR (? <> '' AND (holder_id = ?"
|
||||
" OR EXISTS ("
|
||||
" SELECT 1 FROM json_each(json_extract(visibility_json, '$.holders'))"
|
||||
" WHERE value = ?"
|
||||
" ))))"
|
||||
" ORDER BY ingested_at DESC, atom_id DESC LIMIT ? OFFSET ?",
|
||||
(*filters, *eyes, limit + 1, offset),
|
||||
).fetchall()
|
||||
atoms = [row_to_atom(row) for row in rows[:limit]]
|
||||
more = len(rows) > limit or offset + limit < total
|
||||
@@ -54,13 +82,22 @@ def browse(
|
||||
)
|
||||
|
||||
|
||||
def picture(settings: Settings, atom_id: str) -> Picture | None:
|
||||
def picture(settings: Settings, atom_id: str, as_holder: str | None = None) -> Picture | None:
|
||||
"""The stored bytes behind one atom; None means there is no picture to show, for any of the three reasons."""
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT image_ref, mime FROM v_atoms_current JOIN sources USING (source_id) WHERE atom_id = ?",
|
||||
(atom_id,),
|
||||
"SELECT image_ref, mime FROM v_atoms_current JOIN sources USING (source_id)"
|
||||
" WHERE atom_id = ?"
|
||||
" AND (? IS NULL"
|
||||
" OR holder_id = ''"
|
||||
" OR json_extract(visibility_json, '$.scope') = 'everyone'"
|
||||
" OR (? <> '' AND (holder_id = ?"
|
||||
" OR EXISTS ("
|
||||
" SELECT 1 FROM json_each(json_extract(visibility_json, '$.holders'))"
|
||||
" WHERE value = ?"
|
||||
" ))))",
|
||||
(atom_id, as_holder, as_holder, as_holder, as_holder),
|
||||
).fetchone()
|
||||
if row is None or not row["image_ref"]:
|
||||
return None
|
||||
|
||||
Reference in new issue
Block a user