Files
FabledGrimoire/tests/test_services_access_visibility.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

171 lines
6.5 KiB
Python

"""The two ACL predicates behind list queries.
`get_note_permission` answers "may I read THIS note?" one row at a time, which a
list query can't use. These express the same resolution as set membership. They
are pure SQL builders — group membership is a subquery rather than a fetched
list, so they need no session and callers' unit tests need not know they exist.
Two scopes, deliberately different:
readable_* — everything the ACL permits, for explicit acts (a typed search, a
fetch by id).
browsable_* — owner + project access only, for passive surfaces (browse lists,
facet counts, the process→skill manifest).
Assertions compile each clause to SQL and inspect its shape.
"""
import pytest
from grimoire.services.access import (
browsable_notes_clause,
notes_visibility_clause,
readable_notes_clause,
)
from tests.helpers import compiled_sql
_sql = compiled_sql
def _read(user_id: int = 7) -> str:
return _sql(readable_notes_clause(user_id))
def _browse(user_id: int = 7) -> str:
return _sql(browsable_notes_clause(user_id))
# --- read scope --------------------------------------------------------------
def test_read_scope_covers_ownership_and_every_share_path():
sql = _read()
assert "notes.user_id = 7" in sql # 1. ownership
assert "note_shares" in sql # 2/3. direct or group note share
assert "notes.project_id IN" in sql # 4. inherited from a shared project
assert "project_shares" in sql
def test_read_scope_resolves_group_membership_in_sql():
"""Group ids are a subquery, not a pre-fetched list — that's what keeps this
a pure function with no session of its own."""
sql = _read()
assert "group_memberships.user_id = 7" in sql
# Both the note-level and project-level share lookups consult it. Count FROM
# clauses rather than bare occurrences — each rendered subquery names the
# table in SELECT, FROM and WHERE — and compare loosely, since the assertion
# is about the arms existing, not about SQLAlchemy's formatting.
assert sql.count("FROM group_memberships") >= 2
assert _browse().count("FROM group_memberships") >= 1
def test_read_scope_is_never_the_whole_table():
"""Guard against the predicate degrading to always-true, which would expose
every user's notes to every other user."""
sql = _read().lower()
assert " true" not in sql
assert "1 = 1" not in sql
# --- browse scope: the trust boundary ---------------------------------------
def test_browse_scope_excludes_direct_note_shares():
"""The whole point of the narrower scope. If `note_shares` leaks in here, a
record someone shared one-to-one with the operator lands in their own browse
list, facet counts and skill manifest as though they had recorded it."""
assert "note_shares" not in _browse()
def test_browse_scope_keeps_ownership_and_project_access():
sql = _browse()
assert "notes.user_id = 7" in sql # your own records
assert "project_shares" in sql # a project shared with you
assert "projects" in sql # a project you own
def test_browse_scope_is_strictly_narrower_than_read_scope():
"""Browse must never surface something read scope wouldn't also allow, or a
list could show a record the caller cannot then open."""
browse, read = _browse(), _read()
assert "note_shares" in read and "note_shares" not in browse
for arm in ("notes.user_id = 7", "project_shares"):
assert arm in browse and arm in read
def test_browse_scope_is_never_the_whole_table():
sql = _browse().lower()
assert " true" not in sql
assert "1 = 1" not in sql
# --- the scope resolver ------------------------------------------------------
def test_own_scope_is_ownership_alone():
"""The near-duplicate gate depends on this: its verdict must not turn on
another person's records, or it would refuse a write and point the caller at
something they may not be able to edit."""
sql = _sql(notes_visibility_clause(7, "own"))
assert sql == "notes.user_id = 7"
def test_scope_resolver_maps_to_the_right_clauses():
assert _sql(notes_visibility_clause(7, "browse")) == _browse()
assert _sql(notes_visibility_clause(7, "read")) == _read()
def test_scope_defaults_to_the_narrowest():
"""A caller that forgets to choose must be wrong in the safe direction."""
assert _sql(notes_visibility_clause(7)) == _sql(notes_visibility_clause(7, "own"))
def test_unknown_scope_is_rejected_loudly():
"""Silently falling back would turn a typo into a data-exposure bug."""
with pytest.raises(ValueError):
notes_visibility_clause(7, "everything")
@pytest.mark.parametrize("clause_fn", [readable_notes_clause, browsable_notes_clause])
def test_clauses_are_pure_builders(clause_fn):
"""Synchronous and side-effect free — no coroutine, no session of their own.
This is the property that keeps them usable: when they opened their own
session, every unrelated service test had to know they existed and stub them,
and four test modules broke the moment a service started calling one."""
import inspect
assert not inspect.iscoroutinefunction(clause_fn)
assert _sql(clause_fn(7)) # builds without touching a database
# --- design systems ----------------------------------------------------------
#
# A design system is reachable two ways: you own it, or you can see a project
# that inherits from it. The gap between what those two grant is the invariant
# worth guarding — see get_design_system_permission.
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permission, readable, writable",
[
("owner", True, True),
# Project-derived. Being an EDITOR on a shared project must not confer
# the right to rewrite the family system that project inherits from —
# that would let one project's collaborator restyle every other project
# in the family.
("viewer", True, False),
(None, False, False),
],
)
async def test_reaching_a_design_system_via_a_project_reads_but_never_writes(
permission, readable, writable
):
from unittest.mock import AsyncMock, patch
from grimoire.services.access import (
can_read_design_system,
can_write_design_system,
)
with patch(
"grimoire.services.access.get_design_system_permission",
AsyncMock(return_value=permission),
):
assert await can_read_design_system(1, 3) is readable
assert await can_write_design_system(1, 3) is writable