Merge feature/auth-5c-demo (demo security audit + scheduled reset)

Audit: no authorization/isolation gaps. Add an admin-tunable scheduled demo-reset
job (reuses the manual reset; no-op if no demo account).
This commit is contained in:
npeter83 2026-06-19 15:25:14 +02:00
commit 8c38626dc7
6 changed files with 39 additions and 12 deletions

View file

@ -94,6 +94,8 @@ class Settings(BaseSettings):
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360 subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360 playlist_sync_minutes: int = 360
# How often the shared demo account is auto-reset to a clean baseline (communal sandbox).
demo_reset_minutes: int = 720
# --- Maintenance / validation job --- # --- Maintenance / validation job ---
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods # Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
# are in days because that's the granularity that matters; the rolling re-validation # are in days because that's the granularity that matters; the rolling re-validation

View file

@ -270,14 +270,10 @@ def _seed_demo_playlists(db: Session, demo: User) -> int:
return created return created
@router.post("/demo/reset") def reset_demo_state(db: Session, demo: User) -> int:
def reset_demo( """Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
_: User = Depends(admin_user), db: Session = Depends(get_db) back to a clean baseline and re-seed a few sample playlists. Returns the seeded count.
) -> dict: Shared by the admin's manual reset button and the scheduled demo reset (see scheduler)."""
"""Wipe the shared demo account's per-user state (watch/save/hide states, playlists,
preferences) back to a clean baseline and re-seed a few sample playlists. There's no
automatic reset this is the admin's manual 'clean up the sandbox' button."""
demo = get_or_create_demo_user(db)
db.execute(delete(VideoState).where(VideoState.user_id == demo.id)) db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE"). # Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
@ -285,4 +281,14 @@ def reset_demo(
db.commit() db.commit()
seeded = _seed_demo_playlists(db, demo) seeded = _seed_demo_playlists(db, demo)
db.commit() db.commit()
return {"reset": True, "playlists_seeded": seeded} return seeded
@router.post("/demo/reset")
def reset_demo(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}

View file

@ -56,6 +56,7 @@ JOB_INTERVALS: dict[str, int] = {
"subscriptions": settings.subscriptions_resync_minutes, "subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes, "playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes, "maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes,
} }
# Sane bounds for an admin-set interval (minutes). # Sane bounds for an admin-set interval (minutes).
@ -253,6 +254,20 @@ def _maintenance_job() -> None:
_job("maintenance", work) _job("maintenance", work)
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo
# account. Lazy import avoids a routes<->scheduler import cycle.
def work(db):
from app.models import User
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
from app.routes.admin import reset_demo_state
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). # shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = { JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -264,6 +279,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"subscriptions": _subscriptions_job, "subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job, "playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job, "maintenance": _maintenance_job,
"demo_reset": _demo_reset_job,
} }

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-Klassifizierung", "shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync", "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung" "maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -76,7 +76,8 @@
"shorts": "Shorts classification", "shorts": "Shorts classification",
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync", "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation" "maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-besorolás", "shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés" "maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",