e70fe545cc
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>
81 lines
2.6 KiB
Python
81 lines
2.6 KiB
Python
"""Daily APScheduler cron that purges expired trash.
|
|
|
|
Mirrors version_pinning_scheduler.py: a single global BackgroundScheduler job
|
|
at 03:30 UTC bridges into the asyncio loop to run the async purge. Iterates
|
|
every user and applies that user's own `trash_retention_days` setting; 0
|
|
disables auto-purge for that user.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
|
|
from fabledassistant.services import trash as trash_svc
|
|
from fabledassistant.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_scheduler: BackgroundScheduler | None = None
|
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
|
|
def _run_purge_threadsafe() -> None:
|
|
"""APScheduler invokes this from a worker thread; bridge into the loop."""
|
|
if _loop is None:
|
|
logger.warning("trash scheduler: no loop registered")
|
|
return
|
|
|
|
async def _runner():
|
|
try:
|
|
from sqlalchemy import select
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.user import User
|
|
|
|
async with async_session() as session:
|
|
user_ids = (await session.execute(select(User.id))).scalars().all()
|
|
|
|
purged = 0
|
|
for uid in user_ids:
|
|
raw = await get_setting(uid, "trash_retention_days", "90")
|
|
try:
|
|
days = int(raw)
|
|
except (TypeError, ValueError):
|
|
days = 90
|
|
purged += await trash_svc.purge_expired(uid, days)
|
|
if purged:
|
|
logger.info("trash purge: removed %d expired row(s)", purged)
|
|
else:
|
|
logger.debug("trash purge: nothing expired")
|
|
except Exception:
|
|
logger.exception("trash purge run failed")
|
|
|
|
asyncio.run_coroutine_threadsafe(_runner(), _loop)
|
|
|
|
|
|
def start_trash_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
|
global _scheduler, _loop
|
|
if _scheduler is not None:
|
|
return
|
|
_loop = loop
|
|
_scheduler = BackgroundScheduler()
|
|
_scheduler.add_job(
|
|
_run_purge_threadsafe,
|
|
trigger=CronTrigger(hour=3, minute=30, timezone="UTC"),
|
|
id="trash_retention_purge",
|
|
replace_existing=True,
|
|
)
|
|
_scheduler.start()
|
|
logger.info("Trash retention scheduler started (daily 03:30 UTC)")
|
|
|
|
|
|
def stop_trash_scheduler() -> None:
|
|
global _scheduler
|
|
if _scheduler is not None:
|
|
_scheduler.shutdown(wait=False)
|
|
_scheduler = None
|
|
logger.info("Trash retention scheduler stopped")
|