refactor(plugins): streamline user access management in plugin services

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-02-13 12:46:36 -05:00
parent 9f7b6870ac
commit b0f91715b9
19 changed files with 369 additions and 174 deletions

View File

@ -239,11 +239,13 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
Enabled bool
Folder string
CacheSize string
AutoReload bool
LogLevel string
EndpointRequestLimit int
EndpointRequestWindow time.Duration
}
type extAuthOptions struct {
@ -671,6 +673,8 @@ func setViperDefaults() {
viper.SetDefault("plugins.enabled", true)
viper.SetDefault("plugins.cachesize", "200MB")
viper.SetDefault("plugins.autoreload", false)
viper.SetDefault("plugins.endpointrequestlimit", 60)
viper.SetDefault("plugins.endpointrequestwindow", time.Minute)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)

View File

@ -17,6 +17,7 @@ type HTTPHandleRequest struct {
Method string `json:"method"`
// Path is the request path relative to the plugin's base URL.
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
Path string `json:"path"`
// Query is the raw query string without the leading '?'.
Query string `json:"query,omitempty"`

View File

@ -26,27 +26,19 @@ 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), and optionally 'f' (format).
type subsonicAPIServiceImpl struct {
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{}
pluginName string
router SubsonicRouter
ds model.DataStore
userAccess UserAccess
}
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
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{}{}
}
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
return &subsonicAPIServiceImpl{
pluginID: pluginID,
router: router,
ds: ds,
allowedUserIDs: allowedUserIDs,
allUsers: allUsers,
userIDMap: userIDMap,
pluginName: pluginName,
router: router,
ds: ds,
userAccess: userAccess,
}
}
@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
}
if err := s.checkPermissions(ctx, username); err != nil {
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
return nil, err
}
// Add required Subsonic API parameters
query.Set("c", s.pluginID) // Client name (plugin ID)
query.Set("c", s.pluginName) // Client name (plugin ID)
query.Set("v", subsonicAPIVersion) // API version
if setJSON {
query.Set("f", "json") // Response format
@ -135,14 +127,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
// If allUsers is true, allow any user
if s.allUsers {
if s.userAccess.allUsers {
return nil
}
// 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)
// Must have at least one allowed user configured
if !s.userAccess.HasConfiguredUsers() {
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
}
// Look up the user by username to get their ID
@ -155,7 +146,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
}
// Check if the user's ID is in the allowed list
if _, ok := s.userIDMap[usr.ID]; !ok {
if !s.userAccess.IsAllowed(usr.ID) {
return fmt.Errorf("user %s is not authorized for this plugin", username)
}

View File

@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with specific user IDs allowed", func() {
It("blocks users not in the allowed list", func() {
// allowedUserIDs contains "user2", but testuser is "user1"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
It("allows users in the allowed list", func() {
// allowedUserIDs contains "user2" which is "alloweduser"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=alloweduser")
@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() {
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)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=adminuser")
@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
It("allows admin users when in allowed list", func() {
// allowedUserIDs contains "admin1"
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"}))
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with allUsers=true", func() {
It("allows all users regardless of allowed list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=testuser")
@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("allows admin users when allUsers is true", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
response, err := service.Call(ctx, "/ping?u=adminuser")
@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
Context("with no users configured", func() {
It("returns error when no users are configured", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error for empty user list", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{}))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")
@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("URL Handling", func() {
It("returns error for missing username parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping")
@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "://invalid")
@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("extracts endpoint from path correctly", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("CallRaw", func() {
It("returns binary data and content-type", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("does not set f=json parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("enforces permission checks", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error when username is missing", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt")
@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() {
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "://invalid")
@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
Describe("Router Availability", func() {
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
ctx := GinkgoT().Context()
_, err := service.Call(ctx, "/ping?u=testuser")

View File

@ -9,16 +9,14 @@ import (
)
type usersServiceImpl struct {
ds model.DataStore
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
ds model.DataStore
userAccess UserAccess
}
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
return &usersServiceImpl{
ds: ds,
allowedUsers: allowedUsers,
allUsers: allUsers,
ds: ds,
userAccess: userAccess,
}
}
@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
return nil, err
}
// Build allowed users map for efficient lookup
allowedMap := make(map[string]bool, len(s.allowedUsers))
for _, id := range s.allowedUsers {
allowedMap[id] = true
}
var result []host.User
for _, u := range users {
// If allUsers is true, include all users
// Otherwise, only include users in the allowed list
if s.allUsers || allowedMap[u.ID] {
if s.userAccess.IsAllowed(u.ID) {
result = append(result, host.User{
UserName: u.UserName,
Name: u.Name,

View File

@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should return all users", func() {
@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users", func() {
BeforeEach(func() {
// Only allow access to user1 and user3
service = newUsersService(ds, []string{"user1", "user3"}, false)
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"}))
})
It("should return only allowed users", func() {
@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with empty allowed users and allUsers=false", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{}, false)
service = newUsersService(ds, NewUserAccess(false, []string{}))
})
It("should return no users", func() {
@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should propagate the error", func() {
@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should return only admin users", func() {
@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users including admin", func() {
BeforeEach(func() {
// Allow access to user1 (admin) and user2 (non-admin)
service = newUsersService(ds, []string{"user1", "user2"}, false)
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"}))
})
It("should return only admin users from allowed list", func() {
@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("with specific allowed users excluding admin", func() {
BeforeEach(func() {
// Only allow access to non-admin users
service = newUsersService(ds, []string{"user2", "user3"}, false)
service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"}))
})
It("should return empty when no admins in allowed list", func() {
@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
service = newUsersService(ds, NewUserAccess(true, nil))
})
It("should propagate the error", func() {

View File

@ -3,9 +3,10 @@ package plugins
import (
"io"
"net/http"
"slices"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -28,6 +29,12 @@ type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.
// at runtime. Plugin lookup happens per-request under RLock.
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
r := chi.NewRouter()
// Apply rate limiting if configured
if conf.Server.Plugins.EndpointRequestLimit > 0 {
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
}
h := &endpointHandler{
manager: manager,
ds: ds,
@ -104,7 +111,7 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !p.allUsers && !isUserAllowed(p.allowedUserIDs, user.ID) {
if !p.userAccess.IsAllowed(user.ID) {
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
http.Error(w, "Forbidden", http.StatusForbidden)
return
@ -120,8 +127,9 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
}
// Build the plugin request
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
relPath := "/" + chi.URLParam(r, "*")
if relPath == "/" {
if relPath == "/" || relPath == "" {
relPath = ""
}
@ -177,8 +185,3 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
}
}
}
// isUserAllowed checks if the given user ID is in the allowed list.
func isUserAllowed(allowedIDs []string, userID string) bool {
return slices.Contains(allowedIDs, userID)
}

View File

@ -72,8 +72,8 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy both test plugins
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} {
// Copy all test plugins
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
destPath := filepath.Join(tmpDir, pluginName+PackageExtension)
data, err := os.ReadFile(srcPath)
@ -109,7 +109,7 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
// Build enabled plugins list
var enabledPlugins model.Plugins
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public"} {
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
data, err := os.ReadFile(pluginPath)
Expect(err).ToNot(HaveOccurred())
@ -162,6 +162,18 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
})
It("loads the native auth endpoint plugin", func() {
manager.mu.RLock()
p := manager.plugins["test-http-endpoint-native"]
manager.mu.RUnlock()
Expect(p).ToNot(BeNil())
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
})
It("loads the public endpoint plugin", func() {
manager.mu.RLock()
p := manager.plugins["test-http-endpoint-public"]
@ -239,6 +251,55 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
})
})
Describe("Native Auth Endpoints", func() {
It("returns hello response with valid native auth", func() {
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
req.Header.Set("X-Test-User", "testuser")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
})
It("returns echo response with user details", func() {
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
req.Header.Set("X-Test-User", "adminuser")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
Expect(err).ToNot(HaveOccurred())
Expect(resp["method"]).To(Equal("POST"))
Expect(resp["path"]).To(Equal("/echo"))
Expect(resp["body"]).To(Equal("native body"))
Expect(resp["hasUser"]).To(BeTrue())
Expect(resp["username"]).To(Equal("adminuser"))
})
It("returns 401 without auth header", func() {
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("returns 401 with invalid auth header", func() {
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
req.Header.Set("X-Test-User", "nonexistent")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("Public Endpoints (auth: none)", func() {
It("returns webhook response without auth", func() {
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)

View File

@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
return nil, false
}
// 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
// Create a new scrobbler adapter for this plugin
return &ScrobblerPlugin{
name: plugin.name,
plugin: plugin,
allowedUserIDs: plugin.allowedUserIDs,
allUsers: plugin.allUsers,
userIDMap: userIDMap,
name: plugin.name,
plugin: plugin,
}, true
}

View File

@ -24,10 +24,9 @@ type serviceContext struct {
manager *Manager
permissions *Permissions
config map[string]string
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
userAccess UserAccess // User authorization for this plugin
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
}
// hostServiceEntry defines a host service for table-driven registration.
@ -52,7 +51,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) {
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess)
return host.RegisterSubsonicAPIHostFunctions(service), nil
},
},
@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
service := newUsersService(ctx.manager.ds, ctx.userAccess)
return host.RegisterUsersHostFunctions(service), nil
},
},
@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
var hostFunctions []extism.HostFunction
var closers []io.Closer
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
svcCtx := &serviceContext{
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
userAccess: userAccess,
allowedLibraries: allowedLibraries,
allLibraries: p.AllLibraries,
}
@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
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,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
userAccess: userAccess,
}
m.mu.Unlock()

View File

@ -12,15 +12,14 @@ 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
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
allUsers bool // If true, plugin can access all users
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
userAccess UserAccess // User authorization for this plugin
}
// instance creates a new plugin instance for the given context.

View File

@ -33,11 +33,8 @@ 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
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
name string
plugin *plugin
}
// IsAuthorized checks if the user is authorized with this scrobbler.
@ -45,7 +42,7 @@ type ScrobblerPlugin struct {
// 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) {
if !s.plugin.userAccess.IsAllowed(userId) {
return false
}
@ -63,18 +60,6 @@ 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,41 +71,6 @@ 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

@ -0,0 +1,16 @@
module test-http-endpoint-native
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,61 @@
// Test plugin for native auth (JWT) HTTP endpoint integration tests.
// Build with: tinygo build -o ../test-http-endpoint-native.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"encoding/json"
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
)
func init() {
httpendpoint.Register(&testNativeEndpoint{})
}
type testNativeEndpoint struct{}
func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
switch req.Path {
case "/hello":
return httpendpoint.HTTPHandleResponse{
Status: 200,
Headers: map[string][]string{
"Content-Type": {"text/plain"},
},
Body: "Hello from native auth plugin!",
}, nil
case "/echo":
// Echo back the request as JSON
data, _ := json.Marshal(map[string]any{
"method": req.Method,
"path": req.Path,
"query": req.Query,
"body": req.Body,
"hasUser": req.User != nil,
"username": userName(req.User),
})
return httpendpoint.HTTPHandleResponse{
Status: 200,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: string(data),
}, nil
default:
return httpendpoint.HTTPHandleResponse{
Status: 404,
Body: "Not found: " + req.Path,
}, nil
}
}
func userName(u *httpendpoint.HTTPUser) string {
if u == nil {
return ""
}
return u.Username
}
func main() {}

View File

@ -0,0 +1,16 @@
{
"name": "Test HTTP Endpoint Native Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "Test plugin for native (JWT) HTTP endpoint integration testing",
"permissions": {
"endpoints": {
"auth": "native",
"paths": ["/hello", "/echo"],
"reason": "Testing native auth HTTP endpoint handling"
},
"users": {
"reason": "Authenticated endpoints require user access"
}
}
}

35
plugins/user_access.go Normal file
View File

@ -0,0 +1,35 @@
package plugins
// UserAccess encapsulates user authorization for a plugin,
// determining which users are allowed to interact with it.
type UserAccess struct {
allUsers bool
userIDMap map[string]struct{}
}
// NewUserAccess creates a UserAccess from the plugin's configuration.
// If allUsers is true, all users are allowed regardless of the list.
func NewUserAccess(allUsers bool, userIDs []string) UserAccess {
userIDMap := make(map[string]struct{}, len(userIDs))
for _, id := range userIDs {
userIDMap[id] = struct{}{}
}
return UserAccess{
allUsers: allUsers,
userIDMap: userIDMap,
}
}
// IsAllowed checks if the given user ID is permitted.
func (ua UserAccess) IsAllowed(userID string) bool {
if ua.allUsers {
return true
}
_, ok := ua.userIDMap[userID]
return ok
}
// HasConfiguredUsers reports whether any specific user IDs have been configured.
func (ua UserAccess) HasConfiguredUsers() bool {
return ua.allUsers || len(ua.userIDMap) > 0
}

View File

@ -0,0 +1,64 @@
//go:build !windows
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("UserAccess", func() {
Describe("IsAllowed", func() {
It("returns true when allUsers is true", func() {
ua := NewUserAccess(true, nil)
Expect(ua.IsAllowed("any-user")).To(BeTrue())
})
It("returns true when allUsers is true even with an explicit list", func() {
ua := NewUserAccess(true, []string{"user-1"})
Expect(ua.IsAllowed("other-user")).To(BeTrue())
})
It("returns false when userIDs is empty", func() {
ua := NewUserAccess(false, []string{})
Expect(ua.IsAllowed("user-1")).To(BeFalse())
})
It("returns false when userIDs is nil", func() {
ua := NewUserAccess(false, nil)
Expect(ua.IsAllowed("user-1")).To(BeFalse())
})
It("returns true when user is in the list", func() {
ua := NewUserAccess(false, []string{"user-1", "user-2"})
Expect(ua.IsAllowed("user-1")).To(BeTrue())
})
It("returns false when user is not in the list", func() {
ua := NewUserAccess(false, []string{"user-1", "user-2"})
Expect(ua.IsAllowed("user-3")).To(BeFalse())
})
})
Describe("HasConfiguredUsers", func() {
It("returns true when allUsers is true", func() {
ua := NewUserAccess(true, nil)
Expect(ua.HasConfiguredUsers()).To(BeTrue())
})
It("returns true when specific users are configured", func() {
ua := NewUserAccess(false, []string{"user-1"})
Expect(ua.HasConfiguredUsers()).To(BeTrue())
})
It("returns false when no users are configured", func() {
ua := NewUserAccess(false, nil)
Expect(ua.HasConfiguredUsers()).To(BeFalse())
})
It("returns false when user list is empty", func() {
ua := NewUserAccess(false, []string{})
Expect(ua.HasConfiguredUsers()).To(BeFalse())
})
})
})