Files
LLeMbas/tests/test_sharing_grants.py
T
Homer 546f8a30d7 Boundaries that were supposed to hold
The security pass. Six findings, none reachable by visiting the site and
every one a boundary this codebase says it keeps.

A subagent is pinned to a list of read-only commands, in every mode,
unattended, with no card anybody could approve -- and `find *` was on it.
find writes files with -fprintf, runs programs with -exec and removes them
with -delete, and none of that needs a character the metacharacter guard
refuses. A page the model had just read could ask for a helper and get a
key into authorized_keys, from Plan mode, which promises to change
nothing. Refused in `subject()` rather than trimmed from the list: a
pattern cannot say "and no dangerous flags", and "this one looks
read-only" is exactly what put find there.

The loopback guard missed `0.0.0.0`, which is not is_loopback but does
connect to localhost -- so it answered a *decided* False and skipped the
DNS half too. The one spelling of "this machine" that walked past a guard
whose whole job is that sentence.

Twice in the update helper, which is the one place this deliberately
crosses a privilege boundary: root ran a script the service account owns,
and root sourced a file that account can replace. Either turns a
compromise of the web application into root. The first needed no
compromise at all -- a pull happens as the service user and root runs
whatever it fetched, so control of the branch was control of root. The
old test asserted that exact ExecStart line and had pinned it in place.

Push endpoints skipped check_url, the only outbound request that did. And
a chat could be filed in another account's folder, which hands over its
system prompt -- `_new_chat` resolved the folder, discarded it when it was
not the caller's, and stored the raw id anyway.

An existing helper install keeps the old wiring until install.sh is
re-run; update.sh now says so when it finds itself inside the checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:45:59 +02:00

324 lines
12 KiB
Python

"""Sharing: what it grants, what it does not, and what it leaves behind.
Half of this file is about grants that outlive what they name. `Share` carries
no foreign key in either direction — `principal_id` points at a user *or* a
group and `resource_id` at one of four tables, neither of which SQLite can
express — so nothing cascades, and every delete has to say so explicitly.
`sharing.forget_principal` existed for exactly this and was called by nobody.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import (
ROLE_USER,
Group,
Note,
Report,
Share,
User,
)
from lembas.security import permissions
from lembas.services import reports as reports_service
from lembas.services import sharing
from lembas.services.library import notes as notes_service
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def reader(db) -> User:
person = User(
email="sam@shire.test", name="Sam", password_hash="x", role=ROLE_USER # noqa: S106
)
db.add(person)
db.commit()
return person
# --- Dangling grants ------------------------------------------------------------
def test_deleting_a_group_drops_the_shares_naming_it(db, client, registered, owner, reader):
"""It never did. A group id is a random hex string that nothing reissues
today and nothing promises not to reissue tomorrow."""
group = Group(name="team")
group.users.append(reader)
db.add(group)
db.commit()
note = notes_service.create(db, owner=owner, title="Secret", body="mellon")
sharing.set_grants(db, note, user_ids=[], group_ids=[group.id])
assert db.scalars(select(Share)).all()
client.post(f"/admin/groups/{group.id}/delete", follow_redirects=False)
db.expire_all()
assert db.scalars(select(Share)).all() == []
def test_deleting_an_account_drops_both_halves(db, client, registered, owner, reader):
"""Grants **to** them, and grants **of** their own work. The second is the
one nothing else could catch: their rows cascade, and the shares of those
rows have nothing to cascade from."""
theirs = notes_service.create(db, owner=reader, title="Theirs", body="x")
sharing.set_grants(db, theirs, user_ids=[owner.id], group_ids=[])
mine = notes_service.create(db, owner=owner, title="Mine", body="y")
sharing.set_grants(db, mine, user_ids=[reader.id], group_ids=[])
assert len(db.scalars(select(Share)).all()) == 2
client.post(f"/admin/users/{reader.id}/delete", follow_redirects=False)
db.expire_all()
assert db.scalars(select(Share)).all() == []
def test_deleting_a_report_drops_its_grants(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
reports_service.delete(db, report)
db.expire_all()
assert db.scalars(select(Share)).all() == []
# --- Reports ---------------------------------------------------------------------
def test_a_shared_report_is_visible_and_not_deletable(db, owner, reader):
"""Sharing grants reading. Being able to see a report is not being able to
delete it out from under the person who filed it."""
report = reports_service.create(db, owner=owner, title="Findings", body="x")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
assert reports_service.get(db, report.id, reader) is not None
assert reports_service.owned(db, report.id, reader) is None
assert reports_service.owned(db, report.id, owner) is not None
def test_an_unshared_report_stays_invisible(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
assert reports_service.get(db, report.id, reader) is None
assert list(db.scalars(reports_service.visible(reader))) == []
def test_a_shared_report_appears_in_the_feed_and_the_filter(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
theirs = reports_service.create(db, owner=reader, title="Mine", body="y")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
everything = {r.id for r in db.scalars(reports_service.visible(reader))}
only_shared = {
r.id for r in db.scalars(select(Report).where(sharing.only_shared(Report, reader)))
}
assert everything == {report.id, theirs.id}
assert only_shared == {report.id}
def test_reading_somebody_elses_report_does_not_clear_their_dot(db, client, registered, owner):
"""`unread` is the owner's notification. Somebody it was shared with opening
it would silence a dot meant for a person who has not seen it."""
other = User(
email="sam@shire.test", name="Sam", password_hash="x", role=ROLE_USER # noqa: S106
)
db.add(other)
db.commit()
report = reports_service.create(db, owner=other, title="Theirs", body="x")
report.unread = True
db.commit()
sharing.set_grants(db, report, user_ids=[owner.id], group_ids=[])
assert client.get(f"/reports/{report.id}").status_code == 200
db.refresh(report)
assert report.unread is True
# --- Shared with me --------------------------------------------------------------
def test_only_shared_excludes_your_own(db, owner, reader):
mine = notes_service.create(db, owner=reader, title="Mine", body="x")
theirs = notes_service.create(db, owner=owner, title="Theirs", body="y")
sharing.set_grants(db, theirs, user_ids=[reader.id], group_ids=[])
rows = {n.id for n in db.scalars(select(Note).where(sharing.only_shared(Note, reader)))}
assert rows == {theirs.id}
assert mine.id not in rows
def test_the_filter_is_a_url_you_can_keep(db, client, registered):
for path in ("/library/notes", "/library/skills", "/library/knowledge", "/reports"):
page = client.get(f"{path}?shared=1")
assert page.status_code == 200, path
assert "Shared with me" in page.text, path
# --- The panel -------------------------------------------------------------------
def test_a_grant_is_stored_the_moment_it_is_made(db, client, registered, owner, reader):
"""It used to ride along with the resource's save form, so ticking a box and
navigating away did nothing — silently."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
response = client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": reader.id, "on": "true"},
)
assert response.status_code == 200
assert sharing.can_read(db, note, reader) is True
# And the answer is the panel, showing what is now true.
assert "Shared with" in response.text
def test_a_grant_can_be_taken_back(db, client, registered, owner, reader):
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": reader.id, "on": "false"},
)
db.expire_all()
assert sharing.can_read(db, note, reader) is False
def test_the_panel_searches_rather_than_listing_everybody(db, client, registered, owner):
"""It rendered every group and every account on the instance, unpaginated,
on every detail page."""
for n in range(30):
db.add(
User(
email=f"p{n}@shire.test",
name=f"Person {n}",
password_hash="x", # noqa: S106
)
)
db.commit()
note = notes_service.create(db, owner=owner, title="Note", body="x")
everything = client.get(f"/api/library/share/note/{note.id}").text
assert everything.count('name="principal_id"') == 0 # values ride in hx-vals
assert "Person 29" not in everything
found = client.get(f"/api/library/share/note/{note.id}?q=Person+29").text
assert "Person 29" in found
def test_a_quote_in_the_search_does_not_break_the_panels_buttons(
db, client, registered, owner, reader
):
"""`hx-vals` carries the values, and it was built by pasting the search term
into a JSON string. Jinja escapes the quote for HTML, but the parser decodes
it again before htmx parses the JSON -- so a `"` ended the string, made the
attribute unparseable, and every checkbox in the panel silently stopped
submitting anything. `q` is the last key, so an injected one would also have
won a duplicate-key parse.
Built with `| tojson` over the whole object now, which escapes for JSON
first and lets Jinja escape that for HTML. An existing grant is what keeps a
row on screen whatever the search says.
"""
import html
import json
import re
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
hostile = '", "principal_id": "smuggled'
page = client.get(
f"/api/library/share/note/{note.id}", params={"q": hostile}
).text
values = re.findall(r"hx-vals='([^']*)'", page)
assert values, "the panel rendered no hx-vals at all"
for raw in values:
parsed = json.loads(html.unescape(raw))
assert parsed["principal_id"] != "smuggled"
assert parsed["q"] == hostile
def test_an_existing_grant_stays_listed_whatever_the_search_says(
db, client, registered, owner, reader
):
"""Otherwise the only way to remove a grant would be to search for the name
it was given to."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
page = client.get(f"/api/library/share/note/{note.id}?q=nobody-matches-this").text
assert "sam@shire.test" in page
def test_somebody_it_was_shared_with_cannot_share_it_on(db, client, registered, owner, reader):
"""What keeps "who can see this?" answerable by asking one person."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
other = TestClient(client.app)
other.post(
"/auth/register",
data={"name": "Merry", "email": "merry@shire.test", "password": "a-fine-second-breakfast"},
follow_redirects=False,
)
# Signed in as somebody else entirely: the panel is not theirs to open.
assert other.get(f"/api/library/share/note/{note.id}").status_code == 404
def test_a_grant_naming_nothing_is_refused(db, client, registered, owner):
"""A crafted id would write a grant that is invisible in the panel and
unremovable from it."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": "not-a-real-id", "on": "true"},
)
assert db.scalars(select(Share)).all() == []
def test_sharing_is_on_by_default(db, registered, owner):
"""It was off, which meant sharing shipped documented as done and
unreachable: the panel only renders for somebody who holds this."""
assert permissions.has(db, owner, "library.share") is True
assert permissions.DEFAULT_PERMISSIONS["library.share"] is True
# --- Read and write, split -------------------------------------------------------
def test_a_reader_can_search_notes_and_not_write_them(db, reader):
from lembas.db.models import Chat, Connection, Model
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
chat = Chat(user_id=reader.id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
settings_store.update(db, {"default_permissions": {"tools.notes.write": False}})
offered = {tool.name for tool in tools_service.resolve_tools(db, chat, reader).defs}
assert "notes_search" in offered
assert "notes_get" in offered
assert "notes_create" not in offered
assert "notes_edit" not in offered
assert "notes_delete" not in offered
def test_the_write_half_defaults_on(db, reader):
"""An instance that never looks behaves exactly as it did."""
for key in ("tools.notes.write", "tools.memory.write", "tools.skills.write"):
assert permissions.DEFAULT_PERMISSIONS[key] is True