Files
romm/backend/handler/streaming/access.py
T
1f6b4d11ca feat(permissions): gate emulator streaming and in-browser play behind group permissions (#5185)
* feat(permissions): gate emulator streaming behind a group permission

Add a streaming permission entity. Read shows streaming and allows joining
a session; write allows starting one. Users without read get a disabled
streaming config, so every streaming surface hides.

Migration 0152 grants read and write to every existing group, so nobody
loses access on upgrade. Groups created afterwards start without it.
Owner-scoped session controls (save, exit, release) stay ungated so a
revoked user can still leave a running game without losing saves.

* fix(streaming): pr-ready code-review fixes for the streaming grant

Own-only read no longer joins other users' sessions, the config refetches on permissions:changed, the store skips the joinable request when streaming is off, and tests clean up the groups they create.

* refactor(streaming): pr-ready simplify pass on the streaming grant

Collapse the streaming permission helpers onto assert_can/can_access, return an empty joinable list instead of 403, skip the permission resolve when streaming is off, and add a stream.start action key so the UI hides Stream (but keeps Join) for users without the write grant.

* docs(streaming): pr-ready review-polish pass on the streaming grant

Trim the 0152 migration docstring to a single summary line, matching 0150 and 0151.

* docs(streaming): pr-ready humanizer pass on the streaming grant

Plainer wording for the stream.start action key comment and the Permission Groups paragraph, which now says an own-only read shows streaming without the join.

* feat(permissions): gate in-browser emulation behind a group permission

Adds an emulation entity next to streaming. Read plays in the browser
(EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG) and joins netplay rooms,
write hosts them. Migration 0152 now backfills both entities onto every
existing group.

* fix(streaming): address Greptile review on PR 5185

- Pin the claimed container in the stream player, so a permission refresh
  that turns streaming off mid-game keeps save, load and disc swap until
  the session ends.
- Empty the Join list when a refreshed config turns streaming off, and
  discard any joinable request still in flight.
- Update the installPermissionsHydration doc to name every layout that
  calls it.

* fix(migrations): renumber play permissions migration to 0153 after master's 0152

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(permissions): drop redundant PermEntity comments

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(streaming): drop the live config refresh on permission changes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 14:47:11 -04:00

340 lines
12 KiB
Python

"""Who may see and drive a streaming session, and which container holds it."""
from typing import Any
from fastapi import HTTPException, Request
from handler.auth.dependencies import (
assert_can,
can_access,
get_permissions,
get_rom_visibility_filter,
)
from handler.database import db_platform_handler, db_rom_handler
from handler.streaming.config import (
ResolvedContainer,
containers_by_key,
containers_for_platform,
entry_for_platform,
key_for_name,
)
from handler.streaming.session_store import (
get_live_session,
session_is_desktop,
session_platform_matches,
)
from logger.logger import log
from models.permission import PermAction, PermEntity
from models.rom import Rom
from models.user import Role
def session_owner_id(session: dict[str, Any], request: Request) -> int:
"""Whose library a session's states and saves belong to.
An admin may drive a session they do not own, and what it writes is still
the player's, so the acting user only stands in for a record that names
nobody.
"""
user_id = session.get("user_id")
return user_id if isinstance(user_id, int) else request.user.id
def assert_session_owner(session: dict[str, Any], request: Request) -> None:
"""Only the user who claimed a session (or an admin) may control it."""
if session.get("user_id") == request.user.id:
return
if request.user.role == Role.ADMIN:
return
raise HTTPException(status_code=403, detail="Session is claimed by another user")
def _allows_streaming(request: Request, *actions: PermAction) -> bool:
# A session is always the caller's own, so an "own items only" grant counts.
perms = get_permissions(request)
return all(perms.allows(PermEntity.STREAMING, a, owned=True) for a in actions)
def can_see_streaming(request: Request) -> bool:
"""Whether the caller may see streaming and play their own sessions."""
return _allows_streaming(request, PermAction.READ)
def can_join_streaming(request: Request) -> bool:
"""Whether the caller may join someone else's session."""
return can_access(get_permissions(request), PermEntity.STREAMING, PermAction.READ)
def assert_can_join_streaming(request: Request) -> None:
"""Raise 403 unless the caller may join someone else's session."""
assert_can(get_permissions(request), PermEntity.STREAMING, PermAction.READ)
def assert_can_start_streaming(request: Request) -> None:
"""Raise 403 unless the caller may start a session, which takes a container."""
if not _allows_streaming(request, PermAction.READ, PermAction.WRITE):
raise HTTPException(status_code=403, detail="Insufficient permissions")
def rom_is_visible(request: Request, rom: Rom | None) -> bool:
"""Can the caller see this ROM?
Sessions outlive nothing but the cache, so a rom_id that no longer
resolves is treated as visible: there is no hidden ROM left to protect.
"""
return rom is None or get_rom_visibility_filter(request).allows(rom)
def session_rom(session: dict[str, Any]) -> Rom | None:
"""The ROM a session is running, None for a desktop or a deleted entry."""
rom_id = session.get("rom_id")
return db_rom_handler.get_rom_simple(rom_id) if rom_id is not None else None
def session_rom_is_visible(request: Request, session: dict[str, Any]) -> bool:
"""Can the caller see the ROM a session is running?"""
return rom_is_visible(request, session_rom(session))
def joinable_session_rom(
request: Request, session: dict[str, Any]
) -> tuple[bool, Rom | None]:
"""Whether a live session is one this caller may ask to join (its host opted
into multiplayer, it is somebody else's, its ROM is not hidden), with that ROM."""
if not session.get("multiplayer") or session.get("user_id") == request.user.id:
return False, None
rom = session_rom(session)
return rom_is_visible(request, rom), rom
def assert_session_rom_visible(
request: Request, session: dict[str, Any], *, not_found_detail: str
) -> None:
"""Raise 404 when the session's ROM is hidden from the caller."""
if not session_rom_is_visible(request, session):
raise HTTPException(status_code=404, detail=not_found_detail)
def visible_rom_name(request: Request, session: dict[str, Any]) -> str | None:
"""The name of the ROM a session is running, blanked when the caller cannot
see that ROM. "Busy" is safe to report to anyone; what is running is not."""
if not session or not session_rom_is_visible(request, session):
return None
name = session.get("rom_name")
return str(name) if name else None
def platform_is_visible(request: Request, platform_slug: str) -> bool:
"""Can the caller see this platform?
A container may name a slug the library has never scanned, which has no
platform row and so nothing to hide.
"""
if not platform_slug or not request.user.is_authenticated:
return True
platform = db_platform_handler.get_platform_by_slug(platform_slug)
if platform is None:
return True
return get_permissions(request).can_see_platform(platform.id)
def session_in_scope(
session: dict[str, Any],
platform: str,
include_desktop: bool,
claimed_at: str | None = None,
) -> bool:
"""Whether a stored session, or the notice it left, is the one a route asked
about: same platform, a desktop only if named, the given claim."""
if not include_desktop and session_is_desktop(session):
return False
if claimed_at is not None and session.get("claimed_at") != claimed_at:
return False
return session_platform_matches(session, platform)
async def find_session_for_user(
candidates: list[ResolvedContainer],
user_id: int,
*,
platform: str,
include_desktop: bool = False,
claimed_at: str | None = None,
) -> tuple[ResolvedContainer, str, dict[str, Any]] | None:
"""The candidate holding this user's session, as (container, key, session).
With a pool the platform no longer identifies the container, the session does.
Args:
platform: only sessions claimed for it match.
include_desktop: whether a desktop matches, only when its container is named.
claimed_at: only the one claim the caller was given matches.
"""
for candidate in candidates:
session_key = candidate.key
session = await get_live_session(session_key)
if session is None:
continue
if not session_in_scope(session, platform, include_desktop, claimed_at):
continue
if session.get("user_id") == user_id:
return candidate, session_key, session
return None
def named_container(platform: str, container_key: str) -> ResolvedContainer:
"""A container serving a platform, found by key so one in a later pool stays
reachable. Raises 404 when the key names no such container."""
candidate = entry_for_platform(containers_by_key().get(container_key, []), platform)
if candidate is None:
raise HTTPException(
status_code=404,
detail=f"No streaming container '{container_key}' for platform '{platform}'",
)
return candidate
async def resolve_named_container(
platform: str, container_key: str
) -> tuple[ResolvedContainer, str, dict[str, Any] | None]:
"""A named container plus whatever session it holds.
Returns (container, session_key, session), the session being None when the
container is free or draining.
"""
return (
named_container(platform, container_key),
container_key,
await get_live_session(container_key),
)
async def resolve_owned_session(
platform: str, request: Request
) -> tuple[ResolvedContainer, str, dict[str, Any]]:
"""Find the caller's session for this platform in its first pool.
Returns (container, session_key, session). Raises 404 when the platform has
no configured container or nothing is active, 403 when every active session
belongs to someone else, 409 when an admin's fallback is ambiguous.
"""
candidates = containers_for_platform(platform)
if not candidates:
raise HTTPException(
status_code=404,
detail=f"No streaming container configured for platform '{platform}'",
)
others: list[tuple[ResolvedContainer, str, dict[str, Any]]] = []
for candidate in candidates:
session_key = candidate.key
session = await get_live_session(session_key)
if session is None:
continue
# These routes act on a game; a desktop is reached by naming it.
if not session_in_scope(session, platform, include_desktop=False):
continue
if session.get("user_id") == request.user.id:
return candidate, session_key, session
others.append((candidate, session_key, session))
if not others:
raise HTTPException(
status_code=404, detail=f"No active session for platform '{platform}'"
)
# An admin may control a session they do not own, but the scan found none of
# theirs, so fall back to the platform's active session. A pool can hold
# several and the path does not say which, so the caller has to name one.
if request.user.role != Role.ADMIN:
raise HTTPException(
status_code=403, detail="Session is claimed by another user"
)
if len(others) > 1:
raise HTTPException(
status_code=409,
detail=(
f"{len(others)} sessions are active on platform '{platform}', "
"name a container instead"
),
)
return others[0]
async def resolve_claim(
platform: str,
request: Request,
container_key: str | None,
claimed_at: str | None,
*,
game_only: bool,
) -> tuple[ResolvedContainer, str, dict[str, Any]] | None:
"""The session a route acting on a claim reaches, or None when that claim is gone.
Args:
container_key: the container the caller named, if any.
claimed_at: the stamp of the one claim the caller was given.
game_only: only a game of this platform matches a named container, while
a release ends whatever it holds.
"""
if container_key is not None:
container, session_key, session = await resolve_named_container(
platform, container_key
)
if session is None:
return None
if game_only and not session_in_scope(session, platform, include_desktop=False):
return None
else:
try:
container, session_key, session = await resolve_owned_session(
platform, request
)
except HTTPException as exc:
# Nothing of the caller's is active, so a stamped claim is gone and a
# repeated call from the same tab finds nothing: both are a no-op.
if exc.status_code != 404 and claimed_at is None:
raise
return None
# A tab whose claim was replaced (a takeover, or Play pressed again elsewhere)
# must not act on the claim that took its place.
if claimed_at is not None and session.get("claimed_at") != claimed_at:
log.info("%s ignored for a replaced claim", request.url.path)
return None
# After the stamp check, so another player's takeover reads as a gone claim.
if container_key is not None:
assert_session_owner(session, request)
return container, session_key, session
async def require_claim(
platform: str,
request: Request,
container_key: str | None,
claimed_at: str | None,
) -> tuple[ResolvedContainer, str, dict[str, Any]]:
"""The game a control route drives, raising 404 once the caller's claim is gone."""
target = await resolve_claim(
platform, request, container_key, claimed_at, game_only=True
)
if target is None:
raise HTTPException(
status_code=404, detail=f"No active session for platform '{platform}'"
)
return target
def container_by_name(name: str) -> tuple[ResolvedContainer, str]:
"""A configured container named by its name or key, plus the platform to
file its sessions under.
The session routes are platform-keyed, so a container serving several gets
the first, which `container_for_session` resolves back to this entry.
Raises 404 when the name matches no container.
"""
key = key_for_name(name) if name else None
entries = containers_by_key().get(key) if key else None
if not entries:
raise HTTPException(status_code=404, detail=f"No streaming container '{name}'")
return entries[0], entries[0].platform