fix(trash): owner-scope all trash ops — close cross-tenant IDOR/disclosure
Drift-audit Group 1 (authz/IDOR). Multi-user is live, so these were exploitable ACL bypasses: - trash.py: add _owner_clause() and apply it to _exists_alive, restore, purge, list_trash, and purge_expired. A batch_id is a bearer token; without an owner predicate a leaked/guessed id let one tenant read (list_trash), restore, or PERMANENTLY purge another's content. Topics and rules carried no owner check at all (_OWNER mapped them to None) — ownership now derives through the parent rulebook (or owning project, for project-scoped rules). - purge_expired is now per-user; trash_scheduler iterates every user and applies that user's own trash_retention_days window, instead of applying user 1's window to everyone (early data loss for other users). - rulebooks subscribe/unsubscribe_project now assert project ownership, matching the suppression endpoints. - topic/rule DELETE routes return 404 when nothing owned was removed. Regression test locks in that every model — including topics/rules — gets a real owner clause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,7 @@ async def test_purge_expired_skips_when_retention_zero():
|
||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.trash import purge_expired
|
||||
n = await purge_expired(0)
|
||||
n = await purge_expired(1, 0)
|
||||
assert n == 0
|
||||
assert not session.execute.called # never opens a delete
|
||||
|
||||
@@ -124,11 +124,34 @@ async def test_purge_expired_deletes_across_models_when_positive():
|
||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.trash import purge_expired
|
||||
n = await purge_expired(90)
|
||||
n = await purge_expired(1, 90)
|
||||
assert n == 7
|
||||
assert session.execute.await_count == 7
|
||||
|
||||
|
||||
def test_owner_clause_scopes_every_model():
|
||||
"""Regression: every trash op must owner-scope, including topics/rules
|
||||
which previously had NO owner check (IDOR across tenants)."""
|
||||
from fabledassistant.services.trash import _owner_clause, _MODEL_FOR
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
|
||||
|
||||
# Models with a direct owner column.
|
||||
assert "user_id" in str(_owner_clause(Note, 7))
|
||||
assert "owner_user_id" in str(_owner_clause(Rulebook, 7))
|
||||
|
||||
# Topics/rules carry no user_id — ownership is derived through the
|
||||
# parent rulebook (and, for rules, the owning project).
|
||||
topic_sql = str(_owner_clause(RulebookTopic, 7))
|
||||
assert "rulebooks" in topic_sql and "owner_user_id" in topic_sql
|
||||
rule_sql = str(_owner_clause(Rule, 7))
|
||||
assert "owner_user_id" in rule_sql and "projects" in rule_sql
|
||||
|
||||
# Every entity type resolves to a model that yields a non-empty clause.
|
||||
for model in set(_MODEL_FOR.values()):
|
||||
assert _owner_clause(model, 1) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_trash_groups_by_batch():
|
||||
session = _make_mock_session()
|
||||
|
||||
Reference in New Issue
Block a user