diff --git a/conf/configuration.go b/conf/configuration.go index e2aca6c13..be424278a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index 446bd50a0..e5e7f984c 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -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"` diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..ed44d139c 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -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) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index b0589fa12..adc216888 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -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") diff --git a/plugins/host_users.go b/plugins/host_users.go index a56c8f866..c67ba3bb6 100644 --- a/plugins/host_users.go +++ b/plugins/host_users.go @@ -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, diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 2071a9320..99e197190 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -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() { diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index ce125aa88..025ce249d 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -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) -} diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index e583ee4a5..1f6d3de97 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -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) diff --git a/plugins/manager.go b/plugins/manager.go index d8d4f28ef..c8521e6b0 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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 } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index b558da1be..5e8a1ff44 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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() diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 08c0073b6..3b2629731 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -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. diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 874c6603a..fbfd6ce32 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -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) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 05fc11757..1dad53d5f 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -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{ diff --git a/plugins/testdata/test-http-endpoint-native/go.mod b/plugins/testdata/test-http-endpoint-native/go.mod new file mode 100644 index 000000000..e6f6c1a68 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/go.mod @@ -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 diff --git a/plugins/testdata/test-http-endpoint-native/go.sum b/plugins/testdata/test-http-endpoint-native/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/go.sum @@ -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= diff --git a/plugins/testdata/test-http-endpoint-native/main.go b/plugins/testdata/test-http-endpoint-native/main.go new file mode 100644 index 000000000..6a4cf6869 --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/main.go @@ -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() {} diff --git a/plugins/testdata/test-http-endpoint-native/manifest.json b/plugins/testdata/test-http-endpoint-native/manifest.json new file mode 100644 index 000000000..364ac0cdf --- /dev/null +++ b/plugins/testdata/test-http-endpoint-native/manifest.json @@ -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" + } + } +} diff --git a/plugins/user_access.go b/plugins/user_access.go new file mode 100644 index 000000000..15e7fb354 --- /dev/null +++ b/plugins/user_access.go @@ -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 +} diff --git a/plugins/user_access_test.go b/plugins/user_access_test.go new file mode 100644 index 000000000..b1037e9b6 --- /dev/null +++ b/plugins/user_access_test.go @@ -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()) + }) + }) +})