Skip to content

Add claims-based permission system#2892

Draft
ztripez wants to merge 1 commit intomusic-assistant:devfrom
ztripez:feature/claims-based-permissions
Draft

Add claims-based permission system#2892
ztripez wants to merge 1 commit intomusic-assistant:devfrom
ztripez:feature/claims-based-permissions

Conversation

@ztripez
Copy link
Contributor

@ztripez ztripez commented Dec 26, 2025

Summary

Adds a claims-based permission system for Music Assistant. Permissions are integrated directly into the api_command() decorator via a required_permissions parameter — no separate decorator needed. The system works with existing roles today while enabling future support for external OIDC providers and provider-contributed custom claims.

Dependencies

Models PR: music-assistant/models#172 — adds permissions and claims fields to User dataclass. Until merged, this PR uses getattr/type: ignore[attr-defined] to safely access these attributes.

Changes

New Files

  • music_assistant/helpers/permissions.py: Permission system implementation

    • Permission enum with 17 resource:action scopes + wildcard (*)
    • get_permissions_for_role(): Maps UserRole → list of Permission
    • has_permission(): Checks if user has required permissions (wildcard-aware, role-fallback)
    • get_user_claim(): Helper for future provider-contributed claims
  • tests/core/test_permissions.py: 17 unit tests covering:

    • Permission enum values and resource:action format
    • Role-to-permission mapping (admin, user, guest)
    • Permission checking (wildcard, explicit, multi-permission, fallback to role)
    • JWT claim extraction

Modified Files

  • music_assistant/helpers/api.py: Added required_permissions parameter to api_command() decorator, APICommandHandler, and APICommandHandler.parse()
  • music_assistant/mass.py: Updated register_api_command() and _register_api_commands() to pass through required_permissions
  • music_assistant/helpers/jwt_auth.py: Added permissions claim to JWT payload
  • music_assistant/controllers/webserver/auth.py: Added _attach_permissions() method called from both JWT and legacy token auth paths; converted 9 endpoints
  • music_assistant/controllers/webserver/controller.py: HTTP dispatch checks required_permissions before required_role
  • music_assistant/controllers/webserver/websocket_client.py: WebSocket dispatch checks required_permissions before required_role
  • music_assistant/controllers/webserver/api_docs.py: API docs include required_permissions field
  • music_assistant/controllers/config.py: 9 endpoints converted from required_role to required_permissions
  • music_assistant/controllers/players/controller.py: 3 endpoints converted
  • music_assistant/controllers/media/base.py: 2 endpoints converted (update/remove)
  • music_assistant/controllers/media/genres.py: 8 endpoints converted
  • music_assistant/controllers/webserver/remote_access/__init__.py: 2 endpoints converted

Permission Scopes

Scope Description
* All permissions (admin wildcard)
library:read Browse/search the music library
library:write Add/update library items
library:delete Remove library items
players:read View player state
players:control Playback control (play/pause/skip)
players:configure Player settings, group creation
users:read View user list
users:manage Create/delete users, join codes
config:read View configuration
config:write Modify configuration
providers:read View provider list
providers:manage Add/remove/reload providers
metadata:read View metadata
metadata:refresh Trigger metadata refresh
streams:read View stream state
streams:control Control playback streams

Role → Permission Mapping

Role Permissions
Admin * (wildcard — grants everything)
User All *:read + players:control + streams:control
Guest Same as User (fallback)

Architecture

  1. api_command(required_permissions=[Permission.X]) stores permissions on the handler metadata
  2. JWT tokens include a "permissions" claim generated from the user's role
  3. auth.py attaches permissions and claims to the User object after authentication
  4. Both WebSocket and HTTP dispatch check handler.required_permissions first via has_permission(), falling back to handler.required_role for backward compatibility
  5. has_permission() checks the user's explicit permissions list; if empty/missing, generates permissions from the user's role as fallback

Addressing Reviewer Feedback

  • Integrated into api_command — no standalone @requires_permission decorator (marcelveldt)
  • Named "permission" not "scope" (marcelveldt)
  • Module-level imports only — no inline imports (MarvinSchenkel)
  • Unit tests added — 17 tests in tests/core/test_permissions.py (MarvinSchenkel)
  • Models PR openedmodels#172 adds permissions/claims to User (MarvinSchenkel)
  • Branch reset to current dev — clean single commit, no rebase noise

Testing

  • All 17 new permission tests pass
  • Full test suite: 737 passed (3 pre-existing failures in unrelated test_tags.py)
  • Pre-commit (ruff, mypy, etc.) all pass

@OzGav
Copy link
Contributor

OzGav commented Dec 27, 2025

@ztripez unfortunately test failure


@api_command("config/providers/save", required_role="admin")
@api_command("config/providers/save")
@requires_permission(Permission.PROVIDERS_MANAGE)
Copy link
Contributor

Choose a reason for hiding this comment

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

Tiny nitpick, and more-so just asking a question, but what grammatical form should we prefer for function names in this codebase? Do we want the imperative form require_permission instead of requires_permission or required_permission?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

cache invalidation and naming things are the two hardest problem in dev. 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

I've heard it as cache invalidation, naming things and off by one errors are the hardest two problems in dev.


@api_command("auth/user/enable", required_role="admin")
@api_command("auth/user/enable")
@requires_permission(Permission.USERS_MANAGE)
Copy link
Member

Choose a reason for hiding this comment

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

Do you want to guard each function with the "requires_permission" or only extend the api_command decorator with a permissions argument ?

Copy link
Member

Choose a reason for hiding this comment

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

Also, would scope be a better (more recognizable) name instead of permission ?
I like permission but in general scope seems to be more widely used

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, would scope be a better (more recognizable) name instead of permission ? I like permission but in general scope seems to be more widely used

yeah, scope is correct but permission is more telling. It's up to you i guess.

Copy link
Member

Choose a reason for hiding this comment

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

leave it with permission

@MarvinSchenkel
Copy link
Contributor

Two comments from my side while I am testing this:

  • Could you move inline imports to the top?
  • Since this is part of core functionality of MA, I would love to see some unit tests for this

@MarvinSchenkel MarvinSchenkel marked this pull request as draft January 26, 2026 10:15
:param required: Required permissions.
:return: True if user has all required permissions.
"""
user_permissions: list[str] | None = getattr(user, "_permissions", None)
Copy link
Contributor

Choose a reason for hiding this comment

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

Any specific reason that you did not add _permissions and _claims as attributes to User in the models package? This would prevent the need of getattr

Introduce a Permission enum and permission-checking helpers in
music_assistant/helpers/permissions.py with wildcard support and
role-based fallback. The api_command decorator now accepts a
required_permissions parameter, and both WebSocket and HTTP dispatch
enforce permissions before falling back to required_role.

All 23+ admin-only endpoints are converted from required_role='admin'
to fine-grained required_permissions (library, players, config, users,
providers, streams). JWT tokens include permissions in the payload,
and auth.py attaches permissions/claims on the User object.

Includes 17 unit tests covering the Permission enum, role mapping,
permission checks (wildcard, explicit, multi-permission, fallback),
and JWT claim extraction.
@ztripez ztripez force-pushed the feature/claims-based-permissions branch from 84645bf to 307cf61 Compare February 27, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants