test(fence): owner filters in any form, statement writes under any name, core commits by list

The owner-filter check missed an aliased() model, an import alias, .in_() and
filter_by; four live inline filters on aliased VideoState go through owned_by now.
Cross-unit writes are seen under any import name, through sa.update and via
__table__. Core modules may commit only where CORE_COMMITS names the reason;
trim_read and audit.gc flush now and their callers commit. The pin target, the
debug-session header and a link's target fetch through owned_row. The docs
claim what the fence holds, and say what is beyond it.
This commit is contained in:
2026-10-20 00:45:21 +02:00
parent e27656ee7e
commit 8206384c91
13 changed files with 215 additions and 90 deletions
+14 -7
View File
@@ -56,11 +56,16 @@ The domain is four units, each owning its tables (`models.py` in its package):
- **Whose a row is, `userscope` says** — in every module, core included: `owned_by(Model.user_id,
user)` for a filter, `owned_row(db, Model, id, user)` for a row by id (someone else's row is the
same answer as a missing one). A unit's `queries.own_x` returns None and its route answers
`not_found`; a core route calls `owned_or_404`.
`not_found`; a core route calls `owned_or_404`. A rule `owned_row` cannot say — a job its owner
shared, a built-in profile nobody owns, a folder read under `FOR SHARE` — is written out in the
unit's own code, beside the reason.
All of it is held by `tests/test_unit_fences.py`, with no exemptions: the unit map, each unit's
packages and edges (`UNIT_PACKAGES`, `READS`) and the jobs that commit (`UNIT_COMMITS`) are listed
there.
packages and edges (`UNIT_PACKAGES`, `READS`) and what may commit (`UNIT_COMMITS`, `CORE_COMMITS`)
are listed there. It reads the code's AST: an inline owner filter in any form (an alias, `.in_()`,
`filter_by`), a model built or a statement write (`insert`/`update`/`delete` under any name) across
units, and a commit off the lists all fail it. An ORM attribute write on another unit's loaded row
is beyond it — review's to catch.
## Transaction ownership
@@ -73,10 +78,12 @@ mutation it records, so an audit entry can never exist without its change (or vi
- The **operation owner** is the route handler, or a job. A unit's `service` flushes even when it
makes a whole transition (`service.cancel`): the handler commits it, together with whatever else the
request changed. What owns its OWN session commits it: `quota` and `jobruns`' run rows, and a unit's
jobs (the worker's `jobdb` writes, the syncs and ingests that commit per page or batch so a later
failure loses nothing already stored) — the fence's `UNIT_COMMITS` names each. Everything else
flushes.
request changed. What owns its OWN session commits it: `quota` and `jobruns`' run rows, the
scheduler's jobs, a unit's jobs (the worker's `jobdb` writes, the syncs and ingests that commit per
page or batch so a later failure loses nothing already stored), and a few core steps that must land
whatever their caller does next (a token's last-used stamp, a refreshed YouTube grant) — the fence's
`UNIT_COMMITS` and `CORE_COMMITS` name each with its reason, `test_route_conventions` a route's own
committing helpers. Everything else flushes.
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
attribute — see `service.decr_ref_count`.
+1 -2
View File
@@ -110,10 +110,9 @@ def record(
def gc(db: Session, retention_days: int) -> int:
"""Delete audit rows older than `retention_days`. Returns the number removed. A
retention_days <= 0 disables pruning (keep forever)."""
retention_days <= 0 disables pruning (keep forever). Flushes; the job commits."""
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
db.commit()
return result.rowcount or 0
+3 -7
View File
@@ -26,7 +26,7 @@ from uuid import uuid4
from sqlalchemy import and_, delete, func, not_, select, update
from sqlalchemy.orm import Session, defer
from app.userscope import owned_by
from app.userscope import owned_by, owned_row
from app import diagnostics
from app.models import DebugScreenshot, DebugSession, LogEvent, User
@@ -129,12 +129,8 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
"""The session a request names, if it is the caller's own and still live; else None."""
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
return None
session = db.get(DebugSession, value)
if (
session is None
or session.user_id != user.id
or not is_live(session, datetime.now(timezone.utc))
):
session = owned_row(db, DebugSession, value, user)
if session is None or not is_live(session, datetime.now(timezone.utc)):
return None
return session
+2 -2
View File
@@ -72,11 +72,11 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
# such pin target", which is the same answer this function gives for one that does not exist.
except ValueError:
return None
pl = db.get(Playlist, pid)
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
# the same reason `owned_or_404` 404s rather than 403s.
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
pl = owned_row(db, Playlist, pid, user)
return (col, pl.id) if pl is not None else None
def reorder(db: Session, user: User, ids: list[int]) -> None:
+3 -3
View File
@@ -93,7 +93,7 @@ def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tup
q = (
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
.join(Channel, Channel.id == Video.channel_id)
.join(st, and_(st.video_id == Video.id, st.user_id == user.id))
.join(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
.where(
st.position_seconds > 0,
st.status.notin_(("watched", "hidden")),
@@ -136,7 +136,7 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
row = db.execute(
select(*feed_columns(user, st))
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
.outerjoin(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
.where(Video.id == video_id)
).first()
return serialize(row) if row is not None else None
@@ -179,7 +179,7 @@ def playlists_summary(
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
.outerjoin(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
.where(PlaylistItem.playlist_id.in_(playlist_ids))
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
).all()
+3 -5
View File
@@ -296,13 +296,11 @@ def own_link(db: Session, user: User, link_id: int) -> DownloadLink | None:
if link is None:
return None
target = (
db.get(DownloadJob, link.job_id)
owned_row(db, DownloadJob, link.job_id, user)
if link.job_id is not None
else db.get(FileFolder, link.folder_id)
else owned_row(db, FileFolder, link.folder_id, user)
)
if target is None or target.user_id != user.id:
return None
return link
return link if target is not None else None
def links_to(
+1 -1
View File
@@ -212,7 +212,7 @@ def filtered_query(
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
state_required = show in ("watched", "hidden", "in_progress")
join_state = query.join if state_required else query.outerjoin
query = join_state(state, and_(state.video_id == Video.id, state.user_id == user.id))
query = join_state(state, and_(state.video_id == Video.id, owned_by(state.user_id, user)))
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
# abandoned). They're either deleted after a grace period or unhidden if they recover;
+1 -2
View File
@@ -49,7 +49,7 @@ def create_notification(
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
Idempotent and cheap; safe to call after marking things read."""
Idempotent and cheap; safe to call after marking things read. Flushes; the caller commits."""
ids = (
db.execute(
select(Notification.id)
@@ -64,5 +64,4 @@ def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
return 0
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
db.execute(delete(Notification).where(Notification.id.in_(ids)))
db.commit()
return len(ids)
+3 -2
View File
@@ -89,8 +89,9 @@ def owned_or_404(
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
A core route calls it directly. A unit's route cannot (it may not name a unit's model as a value):
it asks its unit's `queries.own_x`, which goes through the same `owned_row`, and answers
`not_found` itself."""
obj = owned_row(db, model, id_, user, owner_attr=owner_attr, lock=lock)
if obj is None:
raise not_found(what)
+2 -2
View File
@@ -78,8 +78,8 @@ def mark_read(
) -> dict:
n = owned_or_404(db, Notification, notification_id, user, what="notification")
n.read = True
db.commit()
trim_read(db, user.id)
db.commit()
return {"ok": True}
@@ -90,8 +90,8 @@ def mark_all_read(user: User = Depends(current_user), db: Session = Depends(get_
.where(owned_by(Notification.user_id, user), Notification.read.is_(False))
.values(read=True)
)
db.commit()
trim_read(db, user.id)
db.commit()
return {"ok": True}
+3 -2
View File
@@ -3,8 +3,9 @@
**Ownership.** A user-owned table says whose a row is in one column (`user_id`, or `to_user_id` for
something handed to them). Every read and write names that column through `owned_by` and fetches a
row by id through `owned_row`, never with an inline `Model.user_id == …`: the rule "a row owned by
someone else is answered like a missing one" then lives here, and a contract test
(`test_unit_fences.py`) holds every module to it, core included.
someone else is answered like a missing one" then lives here. A contract test
(`test_unit_fences.py`) holds every module, core included, to the filter's form; a rule this module
cannot say (own or shared, built-in or own) is written out where it applies, with its reason.
**Recipients.** "An active, non-suspended account" — the download share-by-email and the recipient
picker filter on it. Kept here so the two forms (SQL clauses for a query, a Python check for a loaded