Skip to content

feat: granular permission system (groups, overrides, per-entity hiding)#3605

Open
zurdi15 wants to merge 10 commits into
masterfrom
chore/permissions-system-rework
Open

feat: granular permission system (groups, overrides, per-entity hiding)#3605
zurdi15 wants to merge 10 commits into
masterfrom
chore/permissions-system-rework

Conversation

@zurdi15

@zurdi15 zurdi15 commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Replaces RomM's role + fixed-JWT-scopes authorization with a DB-backed, per-request model:

  • Two user kinds only: admin (bypasses everything) and user (granular).
  • Permission groups: a named read/write/delete matrix over entity types, with one configurable default group for new users.
  • Per-user overrides: tri-state grant/revoke on top of the group.
  • Opt-out visibility (denylist): an admin can hide specific platforms/games from a user or a whole group; hiding a platform cascades to its ROMs/firmware at query time.

User.oauth_scopes becomes a coarse projection of the new model, so the existing scope-based enforcement chain, client tokens, OAuth /token, kiosk, and the legacy v1 UI keep working. Upgrade is access-preserving: seeded "Viewer (legacy)" / "Editor (legacy)" groups reproduce each old role exactly (including delete, which previously gated on *_WRITE).

What's included

Backend

  • New models: permission_groups, permission_group_grants, user_permission_overrides, hidden_entities (single consolidated migration 0091_permission_system).
  • Resolver + coarse-scope projection (handler/auth/permissions.py), enforcement helpers (assert_can, require_permission, visibility filtering + 404-masking).
  • /permissions/me, admin CRUD under /api/permissions (catalog, groups, user assignment/overrides, hidden entities), permissions:changed socket broadcast, ActionKey exposed in OpenAPI.
  • Role narrowed to admin/user (migration 0092): VARCHAR-backed enum, Role.coerce for legacy strings, group-default fallback for group-less users, OIDC editor/viewer claims map to user.

Frontend

  • v2 admin UI: Permission Groups tab, group form (matrix + color + group-level hiding), merged user editor (admin toggle + group + overrides matrix + hidden platforms/games), colored access pills, shared permissionGroups Pinia store.
  • RCheckbox gains a generic multi-state mode (used by the permission matrices); RTable gains a subtle row entrance animation + per-column select-all.
  • Hidden platforms/games pickers (icon/cover, multi-select, list view).
  • v1 user management adapted to admin/user. Regenerated types; i18n added across all 18 locales.

Migration / deploy notes

  • Migrations auto-apply on backend startup. 0092 converts the native role enum to VARCHAR and normalizes ADMIN→admin, VIEWER/EDITOR→user (verified on MariaDB).
  • No user gains or loses access on upgrade (parity test enforces it).

Verification

  • Backend: full tests/endpoints + tests/handler suite green on MariaDB (incl. parity, resolver, OIDC, visibility, admin CRUD). Migration conversion probe-tested.
  • Frontend: vue-tsc typecheck, Vitest + Storybook play tests, Trunk lint, and i18n parity all pass; browser-verified in light + dark themes.

AI assistance disclosure

This PR was developed with substantial assistance from an AI agent (Claude Code / Claude Opus). The AI did the bulk of the implementation, test updates, and verification under human direction and review. Per CONTRIBUTING.md, disclosing this is required.

🤖 Generated with Claude Code

zurdi15 and others added 7 commits June 23, 2026 06:57
…issions

- 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.
…mission groups

- Added new permission handling in `backend/handler/auth/dependencies.py` to support fine-grained, DB-backed permission checks.
- Enhanced user role update logic in `backend/endpoints/user.py` to prevent demotion of the last admin.
- Introduced `hidden_platform_ids` and `hidden_rom_ids` parameters in various database handlers to manage visibility based on admin settings.
- Created new endpoints for managing permission groups, user memberships, and hidden entities in `backend/tests/endpoints/test_permissions_admin.py`.
- Added tests for permissions visibility and CRUD operations in `backend/tests/endpoints/test_permissions_visibility.py` and `backend/tests/endpoints/test_permissions_me.py`.
- Updated archive handling in `backend/utils/archives.py` to improve error logging and timeout management during extraction.
Collapse the Role enum from viewer/editor/admin to two kinds (admin/user);
non-admin access now comes entirely from permission groups + overrides.

Backend:
- Role -> StrEnum {USER, ADMIN}, VARCHAR-backed (native_enum=False); Role.coerce
  maps legacy/unknown strings (incl. in-flight invites) to USER.
- Resolver: group-less users fall back to the default group (dropped the
  role-based legacy fallback); kiosk caps all non-admins to read-only.
- OIDC editor/viewer claims both map to USER (env var names unchanged).
- Migration 0092 converts the native enum to VARCHAR and normalizes
  ADMIN->admin, VIEWER/EDITOR->user.
- Updated endpoints, conftest fixtures, role/oidc/parity/db-handler tests, tools.

Frontend:
- v2 + v1 user management now use admin/user (dropdowns, defaults, getRoleIcon).
- Regenerated types (Role = 'user' | 'admin'); added role-user i18n to all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 25, 2026 22:45
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Test Results (mariadb)

    1 files      1 suites   2m 39s ⏱️
1 846 tests 1 846 ✅ 0 💤 0 ❌
1 848 runs  1 848 ✅ 0 💤 0 ❌

Results for commit 7d45795.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Test Results (postgresql)

    1 files      1 suites   2m 33s ⏱️
1 846 tests 1 846 ✅ 0 💤 0 ❌
1 848 runs  1 848 ✅ 0 💤 0 ❌

Results for commit 7d45795.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the legacy role-based authorization model with a DB-backed, request-resolved permission system (groups + per-user overrides + per-entity hiding), while projecting effective permissions back into coarse OAuth scopes for backward compatibility across existing clients/UIs.

Changes:

  • Introduces backend models, resolver/projection, enforcement helpers, and endpoints for granular permissions (including hide/visibility filtering and socket-driven refresh).
  • Updates v2 admin UI to manage permission groups, per-user access (admin vs user + group + overrides), and hidden platforms/games; removes v2’s hardcoded role map.
  • Regenerates OpenAPI-derived frontend types and updates v1/v2 role selectors + translations to the narrowed admin/user roles.

Reviewed changes

Copilot reviewed 84 out of 106 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/src/views/Settings/UserProfile.vue Updates v1 role picker items to admin/user.
frontend/src/v2/views/Settings/UserProfile.vue Updates v2 role items/tone mapping for admin/user.
frontend/src/v2/views/Settings/Administration.vue Adds “Permission Groups” settings tab and dialogs.
frontend/src/v2/utils/groupColor.ts Adds group color palette + fallback resolver for UI pills/dots.
frontend/src/v2/lib/forms/RCheckbox/types.ts Introduces multi-state checkbox state typing.
frontend/src/v2/lib/forms/RCheckbox/RCheckbox.stories.ts Adds Storybook coverage for multi-state checkbox behavior.
frontend/src/v2/lib/forms/RCheckbox/index.ts Re-exports RCheckboxState type.
frontend/src/v2/lib/data/RTable/RTable.vue Adds one-time row entrance animation + stagger variable.
frontend/src/v2/composables/useCan/role-map.ts Removes legacy hardcoded role→actions mapping.
frontend/src/v2/composables/useCan/index.ts Hydrates permissions from /permissions/me + socket refresh; admin short-circuit.
frontend/src/v2/composables/useCan/actions.ts Switches ActionKey to OpenAPI-generated type; documents scope modeling.
frontend/src/v2/components/Settings/UsersSection.vue Displays access via admin/group pill; loads groups for labels/colors; adds access sorting.
frontend/src/v2/components/Settings/PermissionsMatrix.vue Adds tri-state group grants matrix with per-column select-all.
frontend/src/v2/components/Settings/PermissionGroupsSection.vue Adds admin table for permission groups with edit/delete actions.
frontend/src/v2/components/Settings/OverridesMatrix.vue Adds per-user override matrix (inherit/grant/grant-own/revoke) with select-all.
frontend/src/v2/components/Settings/InviteLinkDialog.vue Updates invite role options to admin/user.
frontend/src/v2/components/Settings/HiddenPlatformsPicker.vue Adds multi-select platform picker for hide lists.
frontend/src/v2/components/Settings/HiddenGamesPicker.vue Adds debounced ROM search + selectable hidden ROM list UI.
frontend/src/v2/components/Settings/CreateUserDialog.vue Replaces role select with admin toggle + initial group assignment.
frontend/src/utils/index.ts Adds icon mapping for user role while keeping legacy fallbacks.
frontend/src/types/emitter.d.ts Adds showGroupFormDialog event typing for v2 settings UI.
frontend/src/stores/permissions.ts Reworks permissions store to hydrate from /permissions/me with admin/hidden state.
frontend/src/stores/permissionGroups.ts Adds shared Pinia store for permission groups list/default.
frontend/src/services/api/permissions.ts Adds permissions API client (catalog, groups CRUD, user assignment, hiding, /me).
frontend/src/locales/zh_TW/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/zh_CN/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/tr_TR/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/ru_RU/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/ro_RO/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/pt_BR/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/pl_PL/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/ko_KR/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/ja_JP/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/it_IT/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/hu_HU/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/fr_FR/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/es_ES/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/en_US/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/en_GB/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/de_DE/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/cs_CZ/settings.json Adds new permission system UI strings (and role-user).
frontend/src/locales/bg_BG/settings.json Adds new permission system UI strings (and role-user).
frontend/src/components/Settings/Administration/Users/Dialog/InviteLink.vue Updates v1 invite role options to admin/user.
frontend/src/components/Settings/Administration/Users/Dialog/EditUser.vue Updates v1 edit-user role options to admin/user.
frontend/src/components/Settings/Administration/Users/Dialog/CreateUser.vue Defaults v1 create-user role to user and updates role options.
frontend/src/generated/models/UserSchema.ts Adds permission_group_id field to generated user schema.
frontend/src/generated/models/UserPermissionsUpdate.ts Adds generated schema for updating user permissions.
frontend/src/generated/models/UserPermissionsSchema.ts Adds generated schema for reading user permissions (admin view).
frontend/src/generated/models/Role.ts Narrows generated Role union to user/admin.
frontend/src/generated/models/PermissionsResponse.ts Adds generated /permissions/me response schema.
frontend/src/generated/models/PermissionScopeSchema.ts Adds generated permission scope schema.
frontend/src/generated/models/PermissionGroupUpdate.ts Adds generated schema for updating permission groups.
frontend/src/generated/models/PermissionGroupSchema.ts Adds generated schema for permission group details.
frontend/src/generated/models/PermissionGroupCreate.ts Adds generated schema for creating permission groups.
frontend/src/generated/models/PermissionCatalogSchema.ts Adds generated schema for permission vocabulary catalog.
frontend/src/generated/models/PermEntity.ts Adds generated granular permission entity vocabulary.
frontend/src/generated/models/PermAction.ts Adds generated granular permission action vocabulary.
frontend/src/generated/models/OverrideSchemaIO.ts Adds generated schema for per-user permission overrides.
frontend/src/generated/models/HiddenEntitySchema.ts Adds generated schema for hidden entities.
frontend/src/generated/models/HiddenEntityCreate.ts Adds generated schema for creating hidden entities.
frontend/src/generated/models/HiddenEntitiesSchema.ts Adds generated schema for hidden platforms/roms lists.
frontend/src/generated/models/GrantSchemaIO.ts Adds generated schema for group grant cells (IO).
frontend/src/generated/models/GrantSchema.ts Adds generated schema for effective grants in /permissions/me.
frontend/src/generated/models/Body_remove_hidden_entity_api_permissions_hidden_delete.ts Adds generated request body schema for hidden entity deletion.
frontend/src/generated/models/ActionKey.ts Adds generated UI-facing ActionKey enum from OpenAPI.
frontend/src/generated/index.ts Re-exports new generated types.
frontend/.storybook/preview.ts Seeds permissions store via /permissions/me-shaped payload for stories.
backend/utils/archives.py Adjusts 7z extraction timeout handling to avoid per-member log spam.
backend/tools/generate_test_data.py Updates seeded users to admin/user roles.
backend/tests/utils/test_archives.py Adds unit tests for new 7z streaming/timeout behavior.
backend/tests/handler/test_db_handler.py Updates user role expectations for narrowed roles.
backend/tests/handler/auth/test_permissions_resolver.py Adds resolver/projection tests for groups, overrides, hiding, kiosk, admin bypass.
backend/tests/handler/auth/test_permissions_parity.py Adds parity tests proving access preservation vs legacy roles and seeded migration rows.
backend/tests/handler/auth/test_permissions_dependencies.py Adds unit tests for new fine-grained enforcement helpers.
backend/tests/handler/auth/test_oidc.py Updates OIDC tests for collapsed user kind and admin mapping.
backend/tests/endpoints/test_permissions_visibility.py Adds end-to-end tests for hide/visibility and delete enforcement wiring.
backend/tests/endpoints/test_permissions_me.py Adds endpoint tests for /permissions/me action vocabulary + hidden IDs.
backend/tests/endpoints/test_permissions_admin.py Adds admin CRUD tests for permissions API and effective behavior.
backend/tests/endpoints/test_identity.py Updates identity endpoint tests for new roles.
backend/tests/endpoints/test_client_tokens.py Updates demotion test to demote adminuser.
backend/tests/conftest.py Updates fixtures to assign legacy editor/viewer access via groups under Role.USER.
backend/models/user.py Narrows Role, adds permission_group_id, projects oauth scopes from granular resolver.
backend/models/permission.py Introduces permission group, grants, overrides, and hidden entity models.
backend/main.py Registers new permissions router.
backend/handler/database/roms_handler.py Adds hidden-platform/rom filtering to ROM queries.
backend/handler/database/platforms_handler.py Adds hidden-platform filtering to platform queries.
backend/handler/database/firmware_handler.py Adds platform-based hide filtering to firmware queries.
backend/handler/database/init.py Exposes db_permission_handler.
backend/handler/auth/permissions.py Implements per-request permission resolution and scope projection.
backend/handler/auth/permissions_map.py Adds pure mapping from granular grants to legacy OAuth scopes + legacy matrices.
backend/handler/auth/dependencies.py Adds fine-grained request-cached enforcement helpers (assert_can, etc.).
backend/handler/auth/base_handler.py Collapses OIDC editor/viewer roles to user, preserves admin mapping.
backend/endpoints/user.py Coerces legacy role strings; prevents last-admin demotion; emits permissions-changed on role change.
backend/endpoints/roms/init.py Applies hidden filtering + 404 masking; enforces delete via fine-grained permission.
backend/endpoints/responses/permission.py Adds /permissions/me response schemas + ActionKey vocabulary and admin CRUD schemas.
backend/endpoints/responses/identity.py Adds permission_group_id to user response schema.
backend/endpoints/platform.py Applies hidden filtering + 404 masking; enforces delete via fine-grained permission.
backend/endpoints/firmware.py Applies hidden filtering + 404 masking; enforces delete via fine-grained permission.
backend/alembic/versions/0092_narrow_role_to_admin_user.py Migrates users.role to VARCHAR and normalizes values to admin/user.
Files not reviewed (21)
  • frontend/src/generated/index.ts: Generated file
  • frontend/src/generated/models/ActionKey.ts: Generated file
  • frontend/src/generated/models/Body_remove_hidden_entity_api_permissions_hidden_delete.ts: Generated file
  • frontend/src/generated/models/GrantSchema.ts: Generated file
  • frontend/src/generated/models/GrantSchemaIO.ts: Generated file
  • frontend/src/generated/models/HiddenEntitiesSchema.ts: Generated file
  • frontend/src/generated/models/HiddenEntityCreate.ts: Generated file
  • frontend/src/generated/models/HiddenEntitySchema.ts: Generated file
  • frontend/src/generated/models/OverrideSchemaIO.ts: Generated file
  • frontend/src/generated/models/PermAction.ts: Generated file
  • frontend/src/generated/models/PermEntity.ts: Generated file
  • frontend/src/generated/models/PermissionCatalogSchema.ts: Generated file
  • frontend/src/generated/models/PermissionGroupCreate.ts: Generated file
  • frontend/src/generated/models/PermissionGroupSchema.ts: Generated file
  • frontend/src/generated/models/PermissionGroupUpdate.ts: Generated file
  • frontend/src/generated/models/PermissionScopeSchema.ts: Generated file
  • frontend/src/generated/models/PermissionsResponse.ts: Generated file
  • frontend/src/generated/models/Role.ts: Generated file
  • frontend/src/generated/models/UserPermissionsSchema.ts: Generated file
  • frontend/src/generated/models/UserPermissionsUpdate.ts: Generated file
  • frontend/src/generated/models/UserSchema.ts: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/src/stores/permissionGroups.ts Outdated
Comment thread frontend/src/stores/permissions.ts Outdated
Comment thread frontend/src/v2/components/Settings/CreateUserDialog.vue Outdated
Comment thread frontend/src/v2/components/Settings/HiddenGamesPicker.vue Outdated
Comment thread backend/handler/auth/permissions.py Outdated
@zurdi15

zurdi15 commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Code review: granular permission system

Reviewed adversarially across four dimensions (auth core/resolver, migrations, endpoint enforcement + query visibility, frontend). The architecture is sound and the parity story (legacy groups reproducing old roles, projection feeding the coarse layer) holds up well. There is, however, one CI blocker and a cluster of visibility "fail-open" gaps that should be closed before this ships, since opt-out hiding is a core promise of the feature.

Items marked (verified) I reproduced or confirmed against the code/CI directly. Items marked (reported) come from the review pass and are worth a maintainer look but I did not individually reproduce.


Blocker (CI is red)

1. Backend pytest fails on MariaDB and PostgreSQL at tests/models/test_user.py::test_admin. (verified)
The admin scope projection returns sorted(FULL_SCOPES, key=lambda s: s.value) (alphabetical) at backend/handler/auth/permissions.py:156, while the non-admin path uses order_scopes(...) (canonical order) and the test asserts list-equality against the FULL_SCOPES constant order. Same 20 scopes, different order, so == fails. The scope set is correct, so this is cosmetic, but it blocks merge.

  • Fix: admin branch should return order_scopes(FULL_SCOPES) (consistent with the non-admin path), or return the constant directly; alternatively compare as sets in the test.
  • Process note: tests/models/ was outside the local verification sweep (tests/endpoints + tests/handler), which is why this slipped through. Worth running the full uv run pytest before declaring green.

High (security: hidden entities leak / privilege escalation)

2. Default-group hides fail open for users with no explicit group. (verified) backend/handler/auth/permissions.py:127-132
_resolve_grant_map correctly falls back to the default group's grants when user.permission_group_id is None, but _resolve_non_admin passes the raw user.permission_group_id (None) to get_hidden_entity_ids. So a platform/ROM hidden "from the default group" is not hidden from any user without an explicitly assigned group, which is the common case (add_user and set_user_group(..., None) both leave it NULL). Resolve the default group id before the hidden lookup, mirroring _resolve_grant_map.

3. /roms/by-hash and /roms/by-metadata-provider bypass the hidden-ROM mask. (verified) backend/endpoints/roms/__init__.py:855-915, 924-952
Every sibling read path (/{id}, /{id}/simple, content, download) 404-masks via can_see_rom, but these two do not. Any ROMS_READ user (the default) can dump a hidden ROM's full DetailedRomSchema by enumerating a small-integer provider id or supplying a known hash. Add the same is_authenticated and not can_see_rom(...) mask.

4. Granting the USERS entity to a non-admin enables admin creation (escalation footgun). (verified chain) permissions_map.py:69, endpoints/permissions.py:65, endpoints/user.py:60-66,101
The catalog exposes list(PermEntity) (including USERS/TASKS/LOGS) as grantable. (USERS, WRITE) projects to Scope.USERS_WRITE. add_user gates on that scope (not assert_admin) and does role=Role.coerce(role), accepting role="admin". So an admin who grants a user "Users: write" (intending user-list management) inadvertently lets them POST /users with role="admin" and mint a full admin. Pre-rework USERS_WRITE was admin-only. Consider excluding USERS/TASKS/LOGS from delegable entities, or requiring real admin to set role="admin" in add_user.


Medium (secondary read paths not visibility-filtered; one parity change)

These all leak the existence/metadata/counts of hidden platforms or ROMs to a user they are hidden from (content downloads remain 404-masked, but ids, names, and counts leak). (all reported)

  • Library feeds (backend/endpoints/feeds.py, many lines) call get_platforms() / get_roms_scalar() with no hidden sets; under DISABLE_DOWNLOAD_ENDPOINT_AUTH the generated URLs are also usable.
  • Sibling ROMs (roms_handler.py:299-352 via DetailedRomSchema.sibling_roms) can include a hidden sibling.
  • /stats (endpoints/stats.py:11-34) totals include hidden entities and include_platform_stats=true leaks hidden platform names.
  • Collection rom_ids / rom_count (responses/collection.py, endpoints/collections.py) expose hidden ROM ids and counts.
  • search.py search_rom fetches a ROM by id with no can_see_rom check.

can_access ownership short-circuit defeats granted=False revokes on owned resources. backend/handler/auth/dependencies.py:46-48 (reported) Returns True for owner_id == user_id before consulting grants, so an admin's explicit revoke of (e.g.) own-delete is unenforceable. Since DELETE projects to no coarse scope, there is no @protected_route backstop.

OIDC editor claims and in-flight EDITOR invites now provision viewer-level access. base_handler.py:428-464, endpoints/user.py:204-210 (reported) New OIDC/invite editors get role=USER with no group, so they resolve to the default (Viewer) group, losing library-wide write. The migration preserves existing editors, so this is a deliberate-looking divergence for newly provisioned ones, but it breaks the "no access change" framing for that path. Confirm it is intended.


Low

  • delete_firmware (endpoints/firmware.py:298-303) and update_platform PUT (endpoints/platform.py:123-140) do not 404-mask hidden entities, unlike their read/delete siblings. (reported)
  • Hiding a platform/ROM does not invalidate the per-user unscoped sidecar cache, so hidden ids linger in total/rom_id_index until TTL. roms/__init__.py:106-124. (reported)
  • update_permission_group rename onto an existing name hits the unique constraint as a 500 (create checks first, update does not). endpoints/permissions.py:120-145. (reported)
  • No "last default group" guard: update_group(is_default=False) on the sole default leaves no default (fail-closed, but a footgun). (reported)
  • Kiosk read-only rests entirely on the coarse layer: resolve_permissions applies no kiosk cap, so a future mutating route gated only by assert_can (no coarse write scope) would be writable in kiosk mode. permissions.py:96-110. (reported)
  • Perf: oauth_scopes is now 2-3 DB queries per authenticated non-admin request, and the resolver re-resolves the grant map independently for fine-grained handlers (no per-request sharing). Admins short-circuit. models/user.py:133-140. (reported)
  • A11y: HiddenGamesPicker.vue:126 search rows are <li @click> with no keyboard handler. npx eslint reports 2 errors (keyboard/gamepad users cannot add games, violating v2 universal-input). Note: this does not fail CI (Trunk Check is green), but it should use a real button / role=button + @keydown. (verified)
  • Frontend dead code: EditUserDialog originalRole is set but never read; permissionsApi is an unused export in services/api/permissions.ts; stores/permissions.ts hidden state is write-only (no reader yet). (reported)
  • InviteLinkDialog.vue:109 renders role labels via charAt(0).toUpperCase() instead of the existing role-admin/role-user i18n keys, so they stay untranslated. (reported)
  • v2/utils/groupColor.ts hardcodes a hex palette (the one hex-literal spot in v2) and the swatch aria-label is the raw hex rather than a name. (reported)

Nits

  • Migration legacy-group grant matrices are hand-typed literals duplicating permissions_map.py rather than derived from it (kept in sync only by test_permissions_parity.py). (reported)
  • add_hidden_entity accepts non-cascading entity types (e.g. COLLECTIONS, FIRMWARE) that the resolver ignores, and does not validate entity_id exists (silent no-op). (reported)
  • get_admin_users() counts disabled admins in the last-admin guards; consider filtering enabled=True. (reported)
  • Em-dashes appear in several new comments (CLAUDE.md forbids them).

Investigated and refuted (so nobody chases these)

  • 0092 enum -> VARCHAR needs postgresql_using / breaks Postgres. Refuted. (verified) I reproduced the exact conversion on real Postgres 15: ALTER TABLE users ALTER COLUMN role TYPE varchar(20) (no USING) succeeds, and the normalization UPDATEs produce admin/user correctly. Both the dedicated migrate-postgres job and the Postgres pytest suite (which runs alembic upgrade head) are green. Not a blocker. (Minor: the native role enum type is left orphaned after conversion, which only matters for a downgrade round-trip, which is itself best-effort.)
  • HiddenGamesPicker eslint errors break CI lint. Refuted. Trunk (the actual CI/pre-commit lint gate) passes; it is a real a11y gap but not a CI failure (see Low above).

Solid / verified OK

  • Resolver precedence (admin bypass -> per-user override -> group grant -> default group) is correct; granted=False overrides pop() correctly; group-less users fall back to the default group; missing default group is guarded.
  • Projection parity is exact: legacy Viewer/Editor grants are set-equal to WRITE_SCOPES/EDIT_SCOPES, including the DELETE grants that the old *_WRITE scopes implied, so no one loses delete on upgrade and no one gains admin scope.
  • Migration chain is a single head (0090 -> 0091 -> 0092), revision ids are all <= 32 chars, exactly one is_default group is seeded, backfill ordering is correct (groups before assignment), and SQLite/MariaDB/Postgres upgrade paths all run.
  • Every /api/permissions admin route is assert_admin-gated (a non-admin with USERS_* scope still cannot read the catalog or mutate groups/overrides/hides); update_user has no mass-assignment of permission_group_id/is_admin; self-demote and last-admin (primary paths) are guarded.
  • get_roms list totals and filter sidebars are correctly hidden-filtered (counts match the filtered query).
  • Frontend: vue-tsc typecheck and i18n parity (all 17 non-default locales) pass; role-map.ts deleted cleanly with no lingering refs; RCheckbox/RTable primitive purity intact; the override (inherit/grant/grant_own/revoke) and group (none/full/own) matrices round-trip and emit well-formed payloads; v1 changes are minimal and additive.

Nice work overall. The blocker is a one-line fix; the High visibility gaps are the main thing to close before merge given hiding is a headline feature.

Review assisted by Claude Code (Claude Opus): four parallel adversarial review agents plus manual verification of the blocker, both High visibility findings, the escalation chain, and the Postgres-migration refutation.

zurdi15 and others added 2 commits June 26, 2026 12:37
Resolves the CI blocker and a cluster of opt-out visibility "fail-open"
gaps surfaced in review of the granular permission system.

Security / correctness:
- admin oauth_scopes projection keeps canonical FULL_SCOPES order
  (order_scopes) instead of sorting alphabetically, fixing the red
  test_user.py::test_admin on MariaDB + Postgres.
- default-group hides no longer fail open: the resolver resolves the
  effective (own-or-default) group before the hidden-entity lookup.
- /roms/by-hash and /roms/by-metadata-provider now 404-mask hidden roms.
- USERS-entity grant no longer enables admin creation: add_user and
  invite-link require a real admin to mint admin accounts.

Visibility leaks closed on secondary read paths:
- feeds, sibling roms (list query + single-rom schemas), /stats counts
  and per-platform breakdowns, collection rom_ids/rom_count, search_rom.

Hardening / cleanups:
- firmware/platform PUT 404-mask hidden entities; group rename conflict
  returns 400 not 500; guard against removing the last default group;
  kiosk read-only enforced at the fine layer; add_hidden_entity rejects
  non-cascading entity types.

Frontend:
- permissionGroups.ensureLoaded coalesces concurrent callers on one
  in-flight request; permissions.setGrants resets isAdmin/hidden;
  CreateUserDialog no longer orphans a user when group assignment fails;
  HiddenGamesPicker search rows are native buttons (keyboard/gamepad);
  invite-role labels and group swatch aria-label use i18n; drop dead code
  (originalRole, unused permissionsApi export).

AI assistance: changes authored with Claude Code (Claude Opus), driven by
the Copilot review and a multi-agent adversarial review, then verified
(backend pytest, frontend typecheck/vitest, i18n parity, trunk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
19704 14401 73% 0% 🟢

New Files

File Coverage Status
backend/endpoints/permissions.py 76% 🟢
backend/endpoints/responses/permission.py 100% 🟢
backend/handler/auth/dependencies.py 80% 🟢
backend/handler/auth/permissions.py 99% 🟢
backend/handler/auth/permissions_map.py 100% 🟢
backend/handler/database/permissions_handler.py 80% 🟢
backend/models/permission.py 100% 🟢
TOTAL 91% 🟢

Modified Files

File Coverage Status
backend/endpoints/activity.py 41% 🟢
backend/endpoints/collections.py 59% 🟢
backend/endpoints/feeds.py 18% 🟢
backend/endpoints/firmware.py 27% 🟢
backend/endpoints/platform.py 81% 🟢
backend/endpoints/responses/identity.py 97% 🟢
backend/endpoints/responses/rom.py 94% 🟢
backend/endpoints/roms/_init_.py 72% 🟢
backend/endpoints/roms/patch.py 48% 🟢
backend/endpoints/search.py 29% 🟢
backend/endpoints/stats.py 42% 🟢
backend/endpoints/user.py 61% 🟢
backend/handler/auth/base_handler.py 71% 🟢
backend/handler/database/_init_.py 100% 🟢
backend/handler/database/firmware_handler.py 55% 🟢
backend/handler/database/platforms_handler.py 91% 🟢
backend/handler/database/roms_handler.py 67% 🟢
backend/handler/database/stats_handler.py 51% 🟢
backend/main.py 92% 🟢
backend/models/user.py 88% 🟢
backend/utils/archives.py 60% 🟢
TOTAL 64% 🟢

updated for commit: 7d45795 by action🐍

@gantoine gantoine self-requested a review June 26, 2026 17:21

@gantoine gantoine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the authorization core in depth — resolver, scope projection, enforcement helpers, per-entity visibility/404-masking, the admin CRUD, and the migrations. (I left the v2 admin UI mostly aside; backend is the source of truth.) This is a well-architected rework and the core is sound; the issues are visibility-coverage gaps on a few endpoints plus one Postgres migration concern.

Verified correct

  • Resolver is fail-closed (handler/auth/permissions.py): no group / no default group / unknown role / missing overrides all resolve to an empty grant set → every allows() returns False. Admin bypass is exact (user.role == Role.ADMIN) and a normal user never constructs is_admin=True.
  • Override precedence is right: explicit revoke pops the group grant, explicit grant adds. (One sharp edge: a per-user grant override silently replaces the group's own_only because override identity is (entity, action) without own_only; both directions are admin-initiated and the narrowing direction fails closed, so not exploitable — just a granularity limitation worth a comment.)
  • Scope projection is a strict subset (permissions_map.py): DELETE projects to no coarse scope, so delete is double-gated (coarse *_WRITE necessary + authoritative assert_can(DELETE)). The legacy parity matrices match the 0091 seed grants exactly.
  • Permission-admin CRUD is admin-only: every mutating route in endpoints/permissions.py calls assert_admin(request) in its body, so the coarse [Scope.USERS_WRITE] decorator can't be abused by a non-admin who's merely been granted USERS write. assert_admin correctly 403s unless perms.is_admin.
  • Read-path masking is broad and consistent on the endpoints this PR touched: list (get_roms threads hidden_* into the query; sidecars/counts/rom_id_index derive from the filtered query; sidecar cache key is per-user), single-entity GETs/HEAD/content (roms, platform, firmware), download_roms, all feeds.py variants, collections (incl. fixing rom_count), search, and stats counts via _exclude_hidden. Delete paths combine assert_can(DELETE) + can_see_*. Kiosk mode locks non-admins to read-only at both the scope and fine layers.

Should fix — visibility-coverage gaps

These mutation endpoints fetch by id and 404 only on non-existence, with no can_see_* mask, unlike their siblings (delete_roms, update_platform) — so a hidden entity is editable and its existence is confirmed:

  • update_rom (PUT /roms/{id}, endpoints/roms/__init__.py:1316-1319) — medium: an editor can edit metadata/cover of a ROM hidden from them. Mirror delete_roms: if not rom or not perms.can_see_rom(rom.id, rom.platform_id): raise RomNotFoundInDatabaseException(id).
  • update_rom_user (PUT /roms/{id}/props, :1828-1831) — low/medium: writes the caller's own RomUser row + confirms existence of a hidden ROM.
  • add_firmware (endpoints/firmware.py:48-52) — low: upload firmware to a hidden platform (platform-hide should cascade to firmware).

Should fix — gaps in pre-existing endpoints the new model doesn't cover

These aren't in this PR's diff (so no inline comment), but the hidden-entity feature is incomplete without them — flagging because they defeat the feature's purpose:

  • patch_rom (endpoints/roms/patch.py, gated only [Scope.ROMS_READ]) — high: looks up a RomFile by id and streams the patched ROM bytes back with no can_see_rom check. Any user can download the content of a hidden ROM by file id. This is the only path that leaks actual file bytes; worth resolving in this PR since it's the feature's headline guarantee. Fix: resolve rom_file.rom and 404 unless can_see_rom(rom.id, rom.platform_id) (and the same for the patch file).
  • get_all_activity / get_rom_activity (endpoints/activity.py) — medium: return rom_name/rom_cover_path/platform_* for live sessions across all users with no hidden filter, leaking hidden ROM/platform metadata.

Migration — 0092 is not Postgres-safe

alembic/versions/0092_narrow_role_to_admin_user.py converts role from a native Enum to VARCHAR with no postgresql_using and never drops the orphaned PG role type. The repo's own migrations show both required patterns (0010_igdb_id_integerr.py uses postgresql_using="...::text"; 0026/0033/0075 do ENUM(name=...).drop(connection)). Consequences on Postgres: the upgrade likely fails the enum→varchar cast without postgresql_using="role::text" (and migrations auto-apply on startup, so that would block boot), and downgrade()'s sa.Enum(name="role") recreation collides with the lingering type ("type role already exists"). The PR notes verification on MariaDB only — please test 0092 up+down on a real Postgres instance and mirror the existing precedent. (Chain itself is a single linear head; only the dialect handling is the issue. Minor: two files share the 0091_ filename prefix — harmless but confusing.)

Net: the design and the bulk of enforcement are solid. The can_see_* masking just needs to be applied uniformly across every entity read/write path (the three in-diff endpoints above, plus patch_rom/activity), and 0092 needs Postgres dialect handling, before this is safe to merge.


Generated by Claude Code

…ation

Visibility-coverage gaps (404-mask hidden entities, mirroring the existing
delete/read paths):
- update_rom (PUT /roms/{id}) and update_rom_user (PUT /roms/{id}/props)
- add_firmware: platform-hide now cascades to firmware uploads
- patch_rom: resolve the parent rom of both the base and patch files and
  404 when hidden, so a hidden rom's bytes can no longer be streamed back
- activity feeds (get_all_activity / get_rom_activity): drop sessions whose
  rom is hidden from the caller

Migration: make the role enum -> varchar narrowing Postgres-safe. The cast
now uses postgresql_using, the orphaned native role type is dropped on
upgrade, and downgrade recreates it explicitly (create_type=False) before
re-typing the column. Verified up/down/up on Postgres 16 and MariaDB.

Also collapses the two permission migrations into a single 0092 and notes
the override own_only replacement granularity limit in the resolver.

AI assistance: implemented with Claude Code (review-fix pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zurdi15

zurdi15 commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

@gantoine thanks for the thorough review. All points addressed in 7d4579540.

Visibility-coverage gaps (in-diff)

  • update_rom (PUT /roms/{id}) and update_rom_user (PUT /roms/{id}/props) now 404-mask hidden roms, mirroring delete_roms.
  • add_firmware now 404-masks uploads to a hidden platform (platform-hide cascades to firmware).

Pre-existing endpoints

  • patch_rom (high) — resolves the parent rom of both the base file and the patch file and 404s unless can_see_rom, before any filesystem access. The hidden-rom byte leak is closed.
  • get_all_activity / get_rom_activity — drop sessions whose rom is hidden from the caller (via get_hidden_rom_ids_among, so the platform cascade is honored too).

Migration Postgres-safety

The role enum -> VARCHAR narrowing is now Postgres-safe:

  • cast uses postgresql_using="role::text";
  • the orphaned native role enum type is dropped after the alter (checkfirst=True);
  • downgrade() recreates the type explicitly with create_type=False before re-typing the column, casting back with postgresql_using="role::role".

Verified locally on a real Postgres 16 with an up -> down -> up cycle: column ends as varchar with no orphaned type, downgrade restores the enum, and re-upgrade has no "type already exists" collision. The PR's migrate-postgres and pytest (postgresql) CI jobs are green.

The two migrations are also collapsed into a single 0092_permission_system (so the duplicate 0091_ filename prefix is gone), and I added a comment in the resolver documenting the override own_only-replacement granularity limit you flagged.

Added tests for the newly-masked update_rom / update_rom_user / patch_rom paths in test_permissions_visibility.py.

AI assistance: these fixes were implemented with Claude Code.

- **Backend**: `pytest` on resolver (precedence, ownership, own_only), projection-parity, migration up/down + backfill assertions, visibility filtering (list + count + 404-mask), last-admin guard, OIDC/invite mapping, kiosk read-only.
- **The parity test is the linchpin**: for a DB seeded with admin/editor/viewer users, assert each user's projected `oauth_scopes` equals their pre-migration scopes exactly — this proves the upgrade changes nobody's access.
- **Frontend** (CLAUDE.md §VII): `npm run typecheck`, `npm run lint`, `npm run test`, `npm run generate` after backend API changes. Browser-test `uiVersion=v2`: a `user` with restricted group sees gated controls hidden (`v-if`), a hidden platform absent from their gallery; admin sees everything; live update when an admin changes their permissions (socket). Both themes, four modalities.
- **Manual upgrade test**: run the migration against a populated dev DB; confirm existing editors/viewers retain identical capabilities (including delete) and visibility.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't store these typically

Comment thread backend/models/user.py
# Derived from the granular permission model (groups + overrides),
# projected onto the legacy coarse scopes. Resolved per access via its
# own session; admins short-circuit without touching the DB.
from handler.auth.permissions import compute_oauth_scopes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be imported at the top of the file?

entity: Mapped[PermEntity] = mapped_column(_str_enum(PermEntity, 20))
action: Mapped[PermAction] = mapped_column(_str_enum(PermAction, 10))
granted: Mapped[bool] = mapped_column(Boolean)
own_only: Mapped[bool] = mapped_column(Boolean, default=False)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why an override instead of directly editing the user's matrix?

if not request.user.is_authenticated:
return siblings

from handler.auth.dependencies import get_permissions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to top of file?

if request.user.is_authenticated and not get_permissions(request).can_see_rom(
rom.id, rom.platform_id
):
raise RomNotFoundInDatabaseException(id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is repeated a lot, should probably be a decorator

visible = set(s.rom_ids) - hidden
if len(visible) != len(s.rom_ids):
s.rom_ids = visible
s.rom_count = len(visible)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dunno how i feel about this pattern, but let's merge and get the AI to refactor later if needed!

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Firmware with ID {id} not found",
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same decorator idea here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants