feat: implement user permissions for SubsonicAPI and scrobbler plugins

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-03 18:16:16 -05:00
parent d660bbd900
commit 5f54944366
18 changed files with 458 additions and 186 deletions

View File

@ -220,6 +220,20 @@ Integrates with external scrobbling services. Export one or more of these functi
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes.
**Manifest permission:**
```json
{
"permissions": {
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}
```
**NowPlaying/Scrobble Input:**
```json
@ -647,16 +661,16 @@ Call Navidrome's Subsonic API internally (no network round-trip).
{
"permissions": {
"subsonicapi": {
"reason": "Access library data",
"allowedUsernames": ["user1", "user2"],
"allowAdmins": false
"reason": "Access library data"
},
"users": {
"reason": "Access user information for SubsonicAPI authorization"
}
}
}
```
- `allowedUsernames` Restrict which users the plugin can act as (empty = any user)
- `allowAdmins` Whether plugin can call API as admin users (default: false)
> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API.
**Host function:**
@ -1039,7 +1053,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.
3. **No Network Listeners** Plugins cannot bind ports
4. **Config Isolation** Plugins only receive their own config section
5. **Memory Limits** Controlled by the WebAssembly runtime
6. **SubsonicAPI Restrictions** Configurable user/admin access controls
6. **User-Scoped Authorization** Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features.
7. **Users Permission** Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed

View File

@ -9,8 +9,10 @@
"reason": "Schedule periodic checks for now playing status"
},
"subsonicapi": {
"reason": "Query the getNowPlaying API endpoint",
"allowAdmins": true
"reason": "Query the getNowPlaying API endpoint"
},
"users": {
"reason": "Access user information for SubsonicAPI authorization"
}
}
}

View File

@ -8,6 +8,9 @@
"http": {
"reason": "To send webhook notifications to configured URLs",
"allowedHosts": ["*"]
},
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}

View File

@ -8,7 +8,6 @@ import (
"net/http/httptest"
"net/url"
"path"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -27,19 +26,27 @@ const subsonicAPIVersion = "1.16.1"
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format).
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
ds model.DataStore
permissions *subsonicAPIPermissions
pluginID string
router SubsonicRouter
ds model.DataStore
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
userIDMap map[string]struct{}
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, permissions *SubsonicAPIPermission) host.SubsonicAPIService {
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
userIDMap := make(map[string]struct{})
for _, id := range allowedUserIDs {
userIDMap[id] = struct{}{}
}
return &subsonicAPIServiceImpl{
pluginID: pluginID,
router: router,
ds: ds,
permissions: parseSubsonicAPIPermissions(permissions),
pluginID: pluginID,
router: router,
ds: ds,
allowedUserIDs: allowedUserIDs,
allUsers: allUsers,
userIDMap: userIDMap,
}
}
@ -107,46 +114,29 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
if s.permissions == nil {
// If allUsers is true, allow any user
if s.allUsers {
return nil
}
if len(s.permissions.AllowedUsernames) > 0 {
if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok {
return fmt.Errorf("username %s is not allowed", username)
}
// Must have at least one allowed user ID configured
if len(s.allowedUserIDs) == 0 {
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
}
if !s.permissions.AllowAdmins {
usr, err := s.ds.User(ctx).FindByUsername(username)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("username %s not found", username)
}
return err
}
if usr.IsAdmin {
return fmt.Errorf("calling SubsonicAPI as admin user is not allowed")
// Look up the user by username to get their ID
usr, err := s.ds.User(ctx).FindByUsername(username)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("username %s not found", username)
}
return err
}
// Check if the user's ID is in the allowed list
if _, ok := s.userIDMap[usr.ID]; !ok {
return fmt.Errorf("user %s is not authorized for this plugin", username)
}
return nil
}
type subsonicAPIPermissions struct {
AllowedUsernames []string
AllowAdmins bool
usernameMap map[string]struct{}
}
func parseSubsonicAPIPermissions(data *SubsonicAPIPermission) *subsonicAPIPermissions {
if data == nil {
return &subsonicAPIPermissions{}
}
perms := &subsonicAPIPermissions{
AllowedUsernames: data.AllowedUsernames,
AllowAdmins: data.AllowAdmins,
usernameMap: make(map[string]struct{}),
}
for _, u := range data.AllowedUsernames {
perms.usernameMap[strings.ToLower(u)] = struct{}{}
}
return perms
}

View File

@ -82,10 +82,11 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
mockPluginRepo.Permitted = true
enabledPlugin := model.Plugin{
ID: "test-subsonicapi-plugin",
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
ID: "test-subsonicapi-plugin",
Path: pluginPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true, // Allow all users for test plugin
}
mockPluginRepo.SetData(model.Plugins{enabledPlugin})
@ -208,24 +209,20 @@ var _ = Describe("SubsonicAPIService", func() {
})
Describe("Permission Enforcement", func() {
Context("with AllowedUsernames restriction", func() {
Context("with specific user IDs allowed", func() {
It("blocks users not in the allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"alloweduser"},
AllowAdmins: true,
})
// allowedUserIDs contains "user2", but testuser is "user1"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not allowed"))
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("allows users in the allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"alloweduser"},
AllowAdmins: true,
})
// allowedUserIDs contains "user2" which is "alloweduser"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=alloweduser")
@ -233,48 +230,19 @@ var _ = Describe("SubsonicAPIService", func() {
Expect(response).To(ContainSubstring("ok"))
})
It("is case-insensitive for usernames", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowedUsernames: []string{"AllowedUser"},
AllowAdmins: true,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=alloweduser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with AllowAdmins=false", func() {
It("blocks admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: false,
})
It("blocks admin users when not in allowed list", func() {
// allowedUserIDs only contains "user1" (testuser), not "admin1"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=adminuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("admin user is not allowed"))
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("allows non-admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: false,
})
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with AllowAdmins=true", func() {
It("allows admin users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, &SubsonicAPIPermission{
AllowAdmins: true,
})
It("allows admin users when in allowed list", func() {
// allowedUserIDs contains "admin1"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
@ -283,21 +251,50 @@ var _ = Describe("SubsonicAPIService", func() {
})
})
Context("with no permissions set (nil)", func() {
It("allows all users", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
Context("with allUsers=true", func() {
It("allows all users regardless of allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
It("allows admin users when allUsers is true", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
Expect(err).ToNot(HaveOccurred())
Expect(response).To(ContainSubstring("ok"))
})
})
Context("with no users configured", func() {
It("returns error when no users are configured", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no users configured"))
})
It("returns error for empty user list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no users configured"))
})
})
})
Describe("URL Handling", func() {
It("returns error for missing username parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping")
@ -306,7 +303,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "://invalid")
@ -315,7 +312,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("extracts endpoint from path correctly", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil)
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
@ -328,7 +325,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("Router Availability", func() {
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil)
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")

View File

@ -259,10 +259,19 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return nil, false
}
// Create a new scrobbler adapter for this plugin
// Build user ID map for fast lookups
userIDMap := make(map[string]struct{})
for _, id := range plugin.allowedUserIDs {
userIDMap[id] = struct{}{}
}
// Create a new scrobbler adapter for this plugin with user authorization config
return &ScrobblerPlugin{
name: plugin.name,
plugin: plugin,
name: plugin.name,
plugin: plugin,
allowedUserIDs: plugin.allowedUserIDs,
allUsers: plugin.allUsers,
userIDMap: userIDMap,
}, true
}

View File

@ -50,8 +50,7 @@ var hostServices = []hostServiceEntry{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Subsonicapi
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, perm)
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterSubsonicAPIHostFunctions(service), nil
},
},
@ -330,15 +329,23 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
capabilities := detectCapabilities(instance)
instance.Close(m.ctx)
// Validate manifest against detected capabilities
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
compiled.Close(m.ctx)
return fmt.Errorf("manifest validation: %w", err)
}
m.mu.Lock()
m.plugins[p.ID] = &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
}
m.mu.Unlock()

View File

@ -12,13 +12,15 @@ import (
// plugin represents a loaded plugin
type plugin struct {
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
name string // Plugin name (from filename)
path string // Path to the wasm file
manifest *Manifest
compiled *extism.CompiledPlugin
capabilities []Capability // Auto-detected capabilities based on exported functions
closers []io.Closer // Cleanup functions to call on unload
metrics PluginMetricsRecorder
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
}
// instance creates a new plugin instance for the given context.

View File

@ -147,24 +147,12 @@
},
"SubsonicAPIPermission": {
"type": "object",
"description": "SubsonicAPI service permissions",
"description": "SubsonicAPI service permissions. Requires 'users' permission to be declared.",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why SubsonicAPI access is needed"
},
"allowedUsernames": {
"type": "array",
"description": "List of usernames the plugin can pass as u. Any user if empty",
"items": {
"type": "string"
}
},
"allowAdmins": {
"type": "boolean",
"description": "If false, reject calls where the u is an admin",
"default": false
}
}
},

View File

@ -1,7 +1,50 @@
package plugins
import (
"encoding/json"
"fmt"
)
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json
// ParseManifest unmarshals manifest JSON and performs cross-field validation.
// This is the single entry point for manifest parsing after reading from a file.
func ParseManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing manifest JSON: %w", err)
}
if err := m.Validate(); err != nil {
return nil, fmt.Errorf("validating manifest: %w", err)
}
return &m, nil
}
// Validate performs cross-field validation that cannot be expressed in JSON Schema.
// This validates rules like "SubsonicAPI permission requires users permission".
func (m *Manifest) Validate() error {
// SubsonicAPI permission requires users permission
if m.Permissions != nil && m.Permissions.Subsonicapi != nil {
if m.Permissions.Users == nil {
return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared")
}
}
return nil
}
// ValidateWithCapabilities validates the manifest against detected capabilities.
// This must be called after WASM capability detection since Scrobbler capability
// is detected from exported functions, not manifest declarations.
func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
// Scrobbler capability requires users permission
if hasCapability(capabilities, CapabilityScrobbler) {
if m.Permissions == nil || m.Permissions.Users == nil {
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
}
}
return nil
}
// AllowedHosts returns a list of allowed hosts for HTTP requests.
// Returns the hosts directly from the manifest's permissions.
func (m *Manifest) AllowedHosts() []string {

View File

@ -169,36 +169,12 @@ type SchedulerPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// SubsonicAPI service permissions
// SubsonicAPI service permissions. Requires 'users' permission to be declared.
type SubsonicAPIPermission struct {
// If false, reject calls where the u is an admin
AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"`
// List of usernames the plugin can pass as u. Any user if empty
AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"`
// Explanation for why SubsonicAPI access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *SubsonicAPIPermission) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
type Plain SubsonicAPIPermission
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
if v, ok := raw["allowAdmins"]; !ok || v == nil {
plain.AllowAdmins = false
}
*j = SubsonicAPIPermission(plain)
return nil
}
// Enable experimental WebAssembly threads support
type ThreadsFeature struct {
// Explanation for why threads support is needed

View File

@ -216,4 +216,180 @@ var _ = Describe("Manifest", func() {
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
})
Describe("ParseManifest", func() {
It("parses a valid manifest with users permission", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"permissions": {
"subsonicapi": {},
"users": {}
}
}`)
m, err := ParseManifest(data)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Test Plugin"))
Expect(m.Permissions.Subsonicapi).ToNot(BeNil())
Expect(m.Permissions.Users).ToNot(BeNil())
})
It("returns error for invalid JSON", func() {
data := []byte(`{invalid}`)
_, err := ParseManifest(data)
Expect(err).To(HaveOccurred())
})
It("returns error when subsonicapi is requested without users permission", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"permissions": {
"subsonicapi": {}
}
}`)
_, err := ParseManifest(data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
Expect(err.Error()).To(ContainSubstring("users"))
})
})
Describe("Validate", func() {
It("validates manifest with subsonicapi and users permissions", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Subsonicapi: &SubsonicAPIPermission{},
Users: &UsersPermission{},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("returns error when subsonicapi without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Subsonicapi: &SubsonicAPIPermission{},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
})
It("validates manifest without subsonicapi", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Http: &HTTPPermission{},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest without any permissions", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("ValidateWithCapabilities", func() {
It("validates scrobbler capability with users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Users: &UsersPermission{},
},
}
err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler})
Expect(err).ToNot(HaveOccurred())
})
It("returns error when scrobbler capability without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("scrobbler"))
Expect(err.Error()).To(ContainSubstring("users"))
})
It("validates non-scrobbler capability without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent})
Expect(err).ToNot(HaveOccurred())
})
It("validates multiple capabilities including scrobbler", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Users: &UsersPermission{},
},
}
err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent, CapabilityScrobbler})
Expect(err).ToNot(HaveOccurred())
})
It("validates with nil capabilities", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, nil)
Expect(err).ToNot(HaveOccurred())
})
It("validates with empty capabilities", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{})
Expect(err).ToNot(HaveOccurred())
})
})
})

View File

@ -2,7 +2,6 @@ package plugins
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
@ -62,13 +61,13 @@ func openPackage(ndpPath string) (*ndpPackage, error) {
}
// Parse and validate manifest
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &ndpPackage{
Manifest: &manifest,
Manifest: manifest,
WasmBytes: wasmBytes,
}, nil
}
@ -90,12 +89,12 @@ func readManifest(ndpPath string) (*Manifest, error) {
return nil, fmt.Errorf("reading manifest: %w", err)
}
var manifest Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
manifest, err := ParseManifest(manifestBytes)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &manifest, nil
return manifest, nil
}
}

View File

@ -97,11 +97,12 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
}
enabledPlugins = append(enabledPlugins, model.Plugin{
ID: pluginName,
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: configJSON,
ID: pluginName,
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: configJSON,
AllUsers: true, // Allow all users by default in tests
})
}

View File

@ -33,12 +33,23 @@ func init() {
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
// the scrobbler.Scrobbler interface for scrobbling to external services.
type ScrobblerPlugin struct {
name string
plugin *plugin
name string
plugin *plugin
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
userIDMap map[string]struct{} // Cached map for fast lookups
}
// IsAuthorized checks if the user is authorized with this scrobbler
// IsAuthorized checks if the user is authorized with this scrobbler.
// First checks if the user is allowed to use this plugin (server-side),
// then delegates to the plugin for service-specific authorization.
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
// First check server-side authorization based on plugin configuration
if !s.isUserAllowed(userId) {
return false
}
// Then delegate to the plugin for service-specific authorization
username := getUsernameFromContext(ctx)
input := capabilities.IsAuthorizedRequest{
Username: username,
@ -52,6 +63,18 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
return result
}
// isUserAllowed checks if the given user ID is allowed to use this plugin.
func (s *ScrobblerPlugin) isUserAllowed(userId string) bool {
if s.allUsers {
return true
}
if len(s.allowedUserIDs) == 0 {
return false
}
_, ok := s.userIDMap[userId]
return ok
}
// NowPlaying sends a now playing notification to the scrobbler
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
username := getUsernameFromContext(ctx)

View File

@ -71,6 +71,41 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
})
})
Describe("isUserAllowed", func() {
It("returns true when allUsers is true", func() {
sp := &ScrobblerPlugin{allUsers: true}
Expect(sp.isUserAllowed("any-user")).To(BeTrue())
})
It("returns false when allowedUserIDs is empty and allUsers is false", func() {
sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}}
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
})
It("returns false when allowedUserIDs is nil and allUsers is false", func() {
sp := &ScrobblerPlugin{allUsers: false}
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
})
It("returns true when user is in allowedUserIDs", func() {
sp := &ScrobblerPlugin{
allUsers: false,
allowedUserIDs: []string{"user-1", "user-2"},
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
}
Expect(sp.isUserAllowed("user-1")).To(BeTrue())
})
It("returns false when user is not in allowedUserIDs", func() {
sp := &ScrobblerPlugin{
allUsers: false,
allowedUserIDs: []string{"user-1", "user-2"},
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
}
Expect(sp.isUserAllowed("user-3")).To(BeFalse())
})
})
Describe("NowPlaying", func() {
It("successfully calls the plugin", func() {
track := &model.MediaFile{

View File

@ -2,5 +2,10 @@
"name": "Test Scrobbler",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test scrobbler plugin for integration testing"
"description": "A test scrobbler plugin for integration testing",
"permissions": {
"users": {
"reason": "Receive scrobble events for users assigned to this plugin"
}
}
}

View File

@ -5,8 +5,10 @@
"description": "Test plugin for SubsonicAPI host function",
"permissions": {
"subsonicapi": {
"reason": "Testing SubsonicAPI access",
"allowAdmins": true
"reason": "Testing SubsonicAPI access"
},
"users": {
"reason": "Access user information for SubsonicAPI authorization"
}
}
}