Files
romm/backend/handler/auth/permissions_map.py
zurdi a730173de6 feat: redesign permission system to support group-based granular permissions
- Introduced a new permission model with `PermissionGroup`, `UserPermissionOverride`, and `HiddenEntity` to manage access control.
- Added `DBPermissionsHandler` for handling permission-related database operations.
- Updated `User` model to include a foreign key to `PermissionGroup` and modified `oauth_scopes` to derive from the new permission model.
- Implemented tests to ensure the new permission model maintains parity with legacy access controls.
- Created documentation outlining the new permission system architecture and migration strategy.
2026-06-23 14:04:34 +00:00

123 lines
5.6 KiB
Python

"""Pure mapping between the granular permission model and legacy OAuth scopes.
Intentionally free of SQLAlchemy / FastAPI imports so it can be reused by both
the runtime ``oauth_scopes`` projection (added in a later PR) and the unit tests
that prove the migration backfill preserves every user's access.
The grant matrices below are the single source of truth for "what a legacy
viewer/editor could do". ``grants_to_scopes`` projects a resolved grant set back
onto the coarse ``Scope`` vocabulary. On day one the round-trip
role -> legacy grants -> projected scopes
must equal the user's pre-migration ``oauth_scopes`` exactly (verified by
``tests/handler/auth/test_permissions_parity.py``). The Alembic migration that
seeds the legacy groups inlines the SAME matrix as frozen literals; a test keeps
the two in lockstep.
"""
from __future__ import annotations
from collections.abc import Iterable
from handler.auth.constants import FULL_SCOPES, Scope
from models.permission import PermAction, PermEntity
# (entity, action, own_only)
Grant = tuple[PermEntity, PermAction, bool]
# Canonical scope ordering == the legacy READ -> WRITE -> EDIT -> FULL order
# (each is a prefix of FULL_SCOPES). Ordering projected scopes this way
# reproduces the exact list the old role-based `oauth_scopes` returned.
_SCOPE_ORDER: dict[Scope, int] = {s: i for i, s in enumerate(FULL_SCOPES)}
def order_scopes(scopes: Iterable[Scope]) -> list[Scope]:
return sorted(scopes, key=lambda s: _SCOPE_ORDER[s])
# Scopes granted unconditionally to any authenticated non-admin "user"
# (self-service): own profile + own per-rom data (play status, notes, ratings).
# These are never gated behind a group -- losing them would, e.g., stop a user
# saving their own game progress.
ALWAYS_ON_SCOPES: frozenset[Scope] = frozenset(
{
Scope.ME_READ,
Scope.ME_WRITE,
Scope.ROMS_USER_READ,
Scope.ROMS_USER_WRITE,
}
)
# (entity, action) -> coarse scopes it projects to. DELETE projects to nothing:
# there is no delete scope today (deletes gate on the matching *_WRITE scope),
# so delete granularity lives only in the new fine-grained layer.
_GRANT_SCOPES: dict[tuple[PermEntity, PermAction], frozenset[Scope]] = {
(PermEntity.ROMS, PermAction.READ): frozenset({Scope.ROMS_READ}),
(PermEntity.ROMS, PermAction.WRITE): frozenset({Scope.ROMS_WRITE}),
(PermEntity.PLATFORMS, PermAction.READ): frozenset({Scope.PLATFORMS_READ}),
(PermEntity.PLATFORMS, PermAction.WRITE): frozenset({Scope.PLATFORMS_WRITE}),
(PermEntity.FIRMWARE, PermAction.READ): frozenset({Scope.FIRMWARE_READ}),
(PermEntity.FIRMWARE, PermAction.WRITE): frozenset({Scope.FIRMWARE_WRITE}),
(PermEntity.COLLECTIONS, PermAction.READ): frozenset({Scope.COLLECTIONS_READ}),
(PermEntity.COLLECTIONS, PermAction.WRITE): frozenset({Scope.COLLECTIONS_WRITE}),
(PermEntity.ASSETS, PermAction.READ): frozenset({Scope.ASSETS_READ}),
(PermEntity.ASSETS, PermAction.WRITE): frozenset({Scope.ASSETS_WRITE}),
(PermEntity.DEVICES, PermAction.READ): frozenset({Scope.DEVICES_READ}),
(PermEntity.DEVICES, PermAction.WRITE): frozenset({Scope.DEVICES_WRITE}),
(PermEntity.USERS, PermAction.READ): frozenset({Scope.USERS_READ}),
(PermEntity.USERS, PermAction.WRITE): frozenset({Scope.USERS_WRITE}),
(PermEntity.TASKS, PermAction.WRITE): frozenset({Scope.TASKS_RUN}),
(PermEntity.LOGS, PermAction.READ): frozenset({Scope.LOGS_READ}),
}
# --- Legacy group matrices ----------------------------------------------------
# "Viewer (legacy)" == today's non-kiosk default user (WRITE_SCOPES): read the
# library; create/modify/delete only OWN collections/assets/devices.
LEGACY_VIEWER_GRANTS: tuple[Grant, ...] = (
(PermEntity.ROMS, PermAction.READ, False),
(PermEntity.PLATFORMS, PermAction.READ, False),
(PermEntity.FIRMWARE, PermAction.READ, False),
(PermEntity.COLLECTIONS, PermAction.READ, False),
(PermEntity.COLLECTIONS, PermAction.WRITE, True),
(PermEntity.COLLECTIONS, PermAction.DELETE, True),
(PermEntity.ASSETS, PermAction.READ, False),
(PermEntity.ASSETS, PermAction.WRITE, True),
(PermEntity.ASSETS, PermAction.DELETE, True),
(PermEntity.DEVICES, PermAction.READ, True),
(PermEntity.DEVICES, PermAction.WRITE, True),
(PermEntity.DEVICES, PermAction.DELETE, True),
)
# "Editor (legacy)" == EDIT_SCOPES: viewer + library-wide write AND delete of
# roms/platforms/firmware. Delete is True here because today's delete endpoints
# gate on *_WRITE -- editors can already delete, so preserving that is required
# to avoid silently revoking access on upgrade.
_LEGACY_EDITOR_EXTRA: tuple[Grant, ...] = (
(PermEntity.ROMS, PermAction.WRITE, False),
(PermEntity.ROMS, PermAction.DELETE, False),
(PermEntity.PLATFORMS, PermAction.WRITE, False),
(PermEntity.PLATFORMS, PermAction.DELETE, False),
(PermEntity.FIRMWARE, PermAction.WRITE, False),
(PermEntity.FIRMWARE, PermAction.DELETE, False),
)
LEGACY_EDITOR_GRANTS: tuple[Grant, ...] = LEGACY_VIEWER_GRANTS + _LEGACY_EDITOR_EXTRA
def grants_to_scopes(grants: Iterable[Grant], *, is_admin: bool = False) -> list[Scope]:
"""Project a resolved grant set onto the coarse legacy ``Scope`` vocabulary.
Admins always get the full set. For everyone else, the self-service
``ALWAYS_ON_SCOPES`` are unioned with the scopes implied by each grant.
Returned in canonical legacy READ -> WRITE -> EDIT -> FULL order.
"""
if is_admin:
return list(FULL_SCOPES)
out: set[Scope] = set(ALWAYS_ON_SCOPES)
for entity, action, _own_only in grants:
out |= _GRANT_SCOPES.get((entity, action), frozenset())
return order_scopes(out)