From 08280eaea861184e82d3cec927aebd914382c94a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 3 Jan 2026 14:45:55 -0500 Subject: [PATCH] feat: add users permission management to plugin system Signed-off-by: Deluan --- .../20251227192712_create_plugin_table.sql | 2 + model/plugin.go | 2 + model/user.go | 1 + persistence/plugin_repository.go | 8 +- plugins/examples/Makefile | 11 +- plugins/host/users.go | 28 + plugins/host/users_gen.go | 72 +++ plugins/host_users.go | 52 ++ plugins/host_users_test.go | 485 ++++++++++++++++++ plugins/manager.go | 118 ++++- plugins/manager_loader.go | 61 ++- plugins/manifest-schema.json | 14 + plugins/manifest_gen.go | 9 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_config.go | 2 +- plugins/pdk/go/host/nd_host_config_stub.go | 2 +- plugins/pdk/go/host/nd_host_users.go | 66 +++ plugins/pdk/go/host/nd_host_users_stub.go | 45 ++ plugins/pdk/python/host/nd_host_config.py | 2 +- plugins/pdk/python/host/nd_host_users.py | 50 ++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../rust/nd-pdk-host/src/nd_host_config.rs | 2 +- .../pdk/rust/nd-pdk-host/src/nd_host_users.rs | 54 ++ plugins/testdata/Makefile | 5 +- plugins/testdata/test-users/go.mod | 16 + plugins/testdata/test-users/go.sum | 14 + plugins/testdata/test-users/main.go | 51 ++ plugins/testdata/test-users/manifest.json | 11 + server/nativeapi/native_api.go | 1 + server/nativeapi/plugin.go | 38 +- server/nativeapi/plugin_test.go | 123 +++++ tests/mock_plugin_manager.go | 22 +- tests/mock_user_repo.go | 11 + ui/src/i18n/en.json | 14 +- ui/src/plugin/ConfigCard.jsx | 20 +- ui/src/plugin/PluginList.jsx | 27 +- ui/src/plugin/PluginShow.jsx | 101 +++- ui/src/plugin/StatusCard.jsx | 11 +- ui/src/plugin/ToggleEnabledSwitch.jsx | 37 +- ui/src/plugin/UsersPermissionCard.jsx | 168 ++++++ 40 files changed, 1674 insertions(+), 91 deletions(-) create mode 100644 plugins/host/users.go create mode 100644 plugins/host/users_gen.go create mode 100644 plugins/host_users.go create mode 100644 plugins/host_users_test.go create mode 100644 plugins/pdk/go/host/nd_host_users.go create mode 100644 plugins/pdk/go/host/nd_host_users_stub.go create mode 100644 plugins/pdk/python/host/nd_host_users.py create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs create mode 100644 plugins/testdata/test-users/go.mod create mode 100644 plugins/testdata/test-users/go.sum create mode 100644 plugins/testdata/test-users/main.go create mode 100644 plugins/testdata/test-users/manifest.json create mode 100644 ui/src/plugin/UsersPermissionCard.jsx diff --git a/db/migrations/20251227192712_create_plugin_table.sql b/db/migrations/20251227192712_create_plugin_table.sql index 18a6bfa35..a25287fc8 100644 --- a/db/migrations/20251227192712_create_plugin_table.sql +++ b/db/migrations/20251227192712_create_plugin_table.sql @@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS plugin ( path TEXT NOT NULL, manifest TEXT NOT NULL, config TEXT, + users TEXT, + all_users INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 0, last_error TEXT, sha256 TEXT NOT NULL, diff --git a/model/plugin.go b/model/plugin.go index 44ade6109..063fb043f 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -7,6 +7,8 @@ type Plugin struct { Path string `structs:"path" json:"path"` Manifest string `structs:"manifest" json:"manifest"` Config string `structs:"config" json:"config,omitempty"` + Users string `structs:"users" json:"users,omitempty"` + AllUsers bool `structs:"all_users" json:"allUsers,omitempty"` Enabled bool `structs:"enabled" json:"enabled"` LastError string `structs:"last_error" json:"lastError,omitempty"` SHA256 string `structs:"sha256" json:"sha256"` diff --git a/model/user.go b/model/user.go index c590ba260..2127b635c 100644 --- a/model/user.go +++ b/model/user.go @@ -46,6 +46,7 @@ type UserRepository interface { CountAll(...QueryOptions) (int64, error) Delete(id string) error Get(id string) (*User, error) + GetAll(options ...QueryOptions) (Users, error) Put(*User) error UpdateLastLoginAt(id string) error UpdateLastAccessAt(id string) error diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 5c47969bf..53966a2e0 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -79,12 +79,14 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { // Upsert using INSERT ... ON CONFLICT for atomic operation _, err := r.db.NewQuery(` - INSERT INTO plugin (id, path, manifest, config, enabled, last_error, sha256, created_at, updated_at) - VALUES ({:id}, {:path}, {:manifest}, {:config}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) + INSERT INTO plugin (id, path, manifest, config, users, all_users, enabled, last_error, sha256, created_at, updated_at) + VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) ON CONFLICT(id) DO UPDATE SET path = excluded.path, manifest = excluded.manifest, config = excluded.config, + users = excluded.users, + all_users = excluded.all_users, enabled = excluded.enabled, last_error = excluded.last_error, sha256 = excluded.sha256, @@ -94,6 +96,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error { "path": plugin.Path, "manifest": plugin.Manifest, "config": plugin.Config, + "users": plugin.Users, + "all_users": plugin.AllUsers, "enabled": plugin.Enabled, "last_error": plugin.LastError, "sha256": plugin.SHA256, diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile index 983d59ddc..c8a62ab0c 100644 --- a/plugins/examples/Makefile +++ b/plugins/examples/Makefile @@ -12,6 +12,11 @@ RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml)) TINYGO := $(shell command -v tinygo 2> /dev/null) EXTISM_PY := $(shell command -v extism-py 2> /dev/null) +# PDK source files that trigger rebuild when changed (recursive) +PDK_GO_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) +PDK_PY_SOURCES := $(shell find ../pdk/python -name '*.py' 2>/dev/null) +PDK_RS_SOURCES := $(shell find ../pdk/rust -name '*.rs' 2>/dev/null) + # Allow building plugins without .ndp extension (e.g., make minimal instead of make minimal.ndp) .PHONY: $(PLUGINS) $(PYTHON_PLUGINS) $(RUST_PLUGINS) $(PLUGINS): %: %.ndp @@ -66,7 +71,7 @@ clean: # Use secondary expansion to properly track all Go source files .SECONDEXPANSION: -$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod +$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod $(PDK_GO_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else @@ -76,7 +81,7 @@ endif # Python plugin builds (generic rule for any folder with plugin/__init__.py) # Use secondary expansion to get all .py files in the plugin directory as dependencies .SECONDEXPANSION: -$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py) +$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py) $(PDK_PY_SOURCES) ifndef EXTISM_PY $(error extism-py is not installed. Install from https://github.com/extism/python-pdk) endif @@ -88,6 +93,6 @@ endif RUST_TARGET := wasm32-wasip1 RUSTUP_CARGO := $(shell rustup which cargo 2>/dev/null || echo cargo) RUSTUP_RUSTC := $(shell rustup which rustc 2>/dev/null) -$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) +$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) $(PDK_RS_SOURCES) cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target $(RUST_TARGET) cp $*/target/$(RUST_TARGET)/release/$(subst -,_,$*).wasm $@ diff --git a/plugins/host/users.go b/plugins/host/users.go new file mode 100644 index 000000000..42a969390 --- /dev/null +++ b/plugins/host/users.go @@ -0,0 +1,28 @@ +package host + +import "context" + +// User represents a Navidrome user with minimal information exposed to plugins. +// Sensitive fields like password, email, and internal IDs are intentionally excluded. +type User struct { + UserName string `json:"userName"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` +} + +// UsersService provides access to user information for plugins. +// +// This service allows plugins to query information about users that the plugin +// has been granted access to. Access is controlled by the administrator who +// configures which users each plugin can see. +// +//nd:hostservice name=Users permission=users +type UsersService interface { + // GetUsers returns all users the plugin has been granted access to. + // Only minimal user information (userName, name, isAdmin) is returned. + // Sensitive fields like password and email are never exposed. + // + // Returns a slice of users the plugin can access, or an empty slice if none configured. + //nd:hostfunc + GetUsers(ctx context.Context) ([]User, error) +} diff --git a/plugins/host/users_gen.go b/plugins/host/users_gen.go new file mode 100644 index 000000000..1f0255807 --- /dev/null +++ b/plugins/host/users_gen.go @@ -0,0 +1,72 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// UsersGetUsersResponse is the response type for Users.GetUsers. +type UsersGetUsersResponse struct { + Result []User `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterUsersHostFunctions registers Users service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction { + return []extism.HostFunction{ + newUsersGetUsersHostFunction(service), + } +} + +func newUsersGetUsersHostFunction(service UsersService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "users_getusers", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + + // Call the service method + result, svcErr := service.GetUsers(ctx) + if svcErr != nil { + usersWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := UsersGetUsersResponse{ + Result: result, + } + usersWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// usersWriteResponse writes a JSON response to plugin memory. +func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + usersWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// usersWriteError writes an error response to plugin memory. +func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_users.go b/plugins/host_users.go new file mode 100644 index 000000000..bfdd980fc --- /dev/null +++ b/plugins/host_users.go @@ -0,0 +1,52 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host" +) + +type usersServiceImpl struct { + ds model.DataStore + allowedUsers []string // User IDs this plugin can access + allUsers bool // If true, plugin can access all users +} + +func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService { + return &usersServiceImpl{ + ds: ds, + allowedUsers: allowedUsers, + allUsers: allUsers, + } +} + +func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { + users, err := s.ds.User(ctx).GetAll() + if err != nil { + 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] { + result = append(result, host.User{ + UserName: u.UserName, + Name: u.Name, + IsAdmin: u.IsAdmin, + }) + } + } + + return result, nil +} + +var _ host.UsersService = (*usersServiceImpl)(nil) diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go new file mode 100644 index 000000000..87b12c9db --- /dev/null +++ b/plugins/host_users_test.go @@ -0,0 +1,485 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("UsersService", Ordered, func() { + var ( + ctx context.Context + ds model.DataStore + service host.UsersService + ) + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + Describe("GetUsers", func() { + var mockUserRepo *tests.MockedUserRepo + + BeforeEach(func() { + mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) + // Add test users + _ = mockUserRepo.Put(&model.User{ + ID: "user1", + UserName: "alice", + Name: "Alice Admin", + IsAdmin: true, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user2", + UserName: "bob", + Name: "Bob User", + IsAdmin: false, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user3", + UserName: "charlie", + Name: "Charlie User", + IsAdmin: false, + }) + }) + + Context("with allUsers=true", func() { + BeforeEach(func() { + service = newUsersService(ds, nil, true) + }) + + It("should return all users", func() { + users, err := service.GetUsers(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(users).To(HaveLen(3)) + + // Verify that the correct fields are returned + userNames := make([]string, len(users)) + for i, u := range users { + userNames[i] = u.UserName + } + Expect(userNames).To(ContainElements("alice", "bob", "charlie")) + }) + + It("should return correct user properties", func() { + users, err := service.GetUsers(ctx) + Expect(err).ToNot(HaveOccurred()) + + // Find alice + var alice *host.User + for i := range users { + if users[i].UserName == "alice" { + alice = &users[i] + break + } + } + + Expect(alice).ToNot(BeNil()) + Expect(alice.UserName).To(Equal("alice")) + Expect(alice.Name).To(Equal("Alice Admin")) + Expect(alice.IsAdmin).To(BeTrue()) + }) + }) + + Context("with specific allowed users", func() { + BeforeEach(func() { + // Only allow access to user1 and user3 + service = newUsersService(ds, []string{"user1", "user3"}, false) + }) + + It("should return only allowed users", func() { + users, err := service.GetUsers(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(users).To(HaveLen(2)) + + userNames := make([]string, len(users)) + for i, u := range users { + userNames[i] = u.UserName + } + Expect(userNames).To(ContainElements("alice", "charlie")) + Expect(userNames).ToNot(ContainElement("bob")) + }) + }) + + Context("with empty allowed users and allUsers=false", func() { + BeforeEach(func() { + service = newUsersService(ds, []string{}, false) + }) + + It("should return no users", func() { + users, err := service.GetUsers(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(users).To(BeEmpty()) + }) + }) + + Context("when datastore returns error", func() { + BeforeEach(func() { + mockUserRepo.Error = model.ErrNotFound + service = newUsersService(ds, nil, true) + }) + + It("should propagate the error", func() { + _, err := service.GetUsers(ctx) + Expect(err).To(HaveOccurred()) + }) + }) + }) +}) + +var _ = Describe("UsersService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "users-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-users plugin + srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-users"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Setup mock DataStore with pre-enabled plugin and users + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-users", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, // Allow all users + }}) + + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.Put(&model.User{ + ID: "user1", + UserName: "alice", + Name: "Alice Admin", + IsAdmin: true, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user2", + UserName: "bob", + Name: "Bob User", + IsAdmin: false, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user3", + UserName: "charlie", + Name: "Charlie User", + IsAdmin: false, + }) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedUser: mockUserRepo, + } + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("should load plugin with users permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-users"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Users).ToNot(BeNil()) + }) + }) + + Describe("Users Operations via Plugin", func() { + type testUsersInput struct { + Operation string `json:"operation"` + } + type user struct { + UserName string `json:"userName"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` + } + type testUsersOutput struct { + Users []user `json:"users,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestUsers := func(ctx context.Context, input testUsersInput) (*testUsersOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-users"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_users", inputBytes) + if err != nil { + return nil, err + } + + var output testUsersOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + It("should get all users when allUsers is true", func() { + ctx := GinkgoT().Context() + + output, err := callTestUsers(ctx, testUsersInput{ + Operation: "get_users", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Users).To(HaveLen(3)) + + // Verify user names + userNames := make([]string, len(output.Users)) + for i, u := range output.Users { + userNames[i] = u.UserName + } + Expect(userNames).To(ContainElements("alice", "bob", "charlie")) + }) + + It("should return correct user properties", func() { + ctx := GinkgoT().Context() + + output, err := callTestUsers(ctx, testUsersInput{ + Operation: "get_users", + }) + Expect(err).ToNot(HaveOccurred()) + + // Find alice + var alice *user + for i := range output.Users { + if output.Users[i].UserName == "alice" { + alice = &output.Users[i] + break + } + } + + Expect(alice).ToNot(BeNil()) + Expect(alice.UserName).To(Equal("alice")) + Expect(alice.Name).To(Equal("Alice Admin")) + Expect(alice.IsAdmin).To(BeTrue()) + }) + + It("should return non-admin user correctly", func() { + ctx := GinkgoT().Context() + + output, err := callTestUsers(ctx, testUsersInput{ + Operation: "get_users", + }) + Expect(err).ToNot(HaveOccurred()) + + // Find bob + var bob *user + for i := range output.Users { + if output.Users[i].UserName == "bob" { + bob = &output.Users[i] + break + } + } + + Expect(bob).ToNot(BeNil()) + Expect(bob.UserName).To(Equal("bob")) + Expect(bob.Name).To(Equal("Bob User")) + Expect(bob.IsAdmin).To(BeFalse()) + }) + }) +}) + +var _ = Describe("UsersService Integration with Specific Users", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "users-specific-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the test-users plugin + srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-users"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Setup mock DataStore with specific allowed users (only user1 and user3) + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-users", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: false, + Users: `["user1", "user3"]`, // Only allow alice and charlie + }}) + + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.Put(&model.User{ + ID: "user1", + UserName: "alice", + Name: "Alice Admin", + IsAdmin: true, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user2", + UserName: "bob", + Name: "Bob User", + IsAdmin: false, + }) + _ = mockUserRepo.Put(&model.User{ + ID: "user3", + UserName: "charlie", + Name: "Charlie User", + IsAdmin: false, + }) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedUser: mockUserRepo, + } + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Users Operations with Specific Allowed Users", func() { + type testUsersInput struct { + Operation string `json:"operation"` + } + type user struct { + UserName string `json:"userName"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` + } + type testUsersOutput struct { + Users []user `json:"users,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestUsers := func(ctx context.Context, input testUsersInput) (*testUsersOutput, error) { + manager.mu.RLock() + p := manager.plugins["test-users"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_users", inputBytes) + if err != nil { + return nil, err + } + + var output testUsersOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + It("should only return allowed users", func() { + ctx := GinkgoT().Context() + + output, err := callTestUsers(ctx, testUsersInput{ + Operation: "get_users", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Users).To(HaveLen(2)) + + // Verify only alice and charlie are returned, not bob + userNames := make([]string, len(output.Users)) + for i, u := range output.Users { + userNames[i] = u.UserName + } + Expect(userNames).To(ContainElements("alice", "charlie")) + Expect(userNames).ToNot(ContainElement("bob")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index 203d40c3b..1c7f1496d 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "encoding/json" "fmt" "net/http" "os" @@ -305,8 +306,13 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { return nil // Already enabled } + // Check permission gates before enabling + if err := m.checkPermissionGates(plugin); err != nil { + return err + } + // Try to load the plugin - if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, plugin.Config); err != nil { + if err := m.loadPluginWithConfig(plugin); err != nil { // Store error and return plugin.LastError = err.Error() plugin.UpdatedAt = time.Now() @@ -394,7 +400,7 @@ func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) if err := m.unloadPlugin(id); err != nil { log.Debug(ctx, "Plugin was not loaded", "plugin", id) } - if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, configJSON); err != nil { + if err := m.loadPluginWithConfig(plugin); err != nil { plugin.LastError = err.Error() plugin.Enabled = false _ = repo.Put(plugin) @@ -407,6 +413,85 @@ func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) return nil } +// UpdatePluginUsers updates the users permission settings for a plugin. +// If the plugin is enabled, it will be reloaded with the new settings. +// If the plugin requires users permission and no users are configured (and allUsers is false), +// the plugin will be automatically disabled. +func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error { + if m.ds == nil { + return fmt.Errorf("datastore not configured") + } + + adminCtx := adminContext(ctx) + repo := m.ds.Plugin(adminCtx) + + plugin, err := repo.Get(id) + if err != nil { + return fmt.Errorf("getting plugin from DB: %w", err) + } + + wasEnabled := plugin.Enabled + + // Update users in DB + plugin.Users = usersJSON + plugin.AllUsers = allUsers + plugin.UpdatedAt = time.Now() + + // Check if plugin requires users permission and if the new settings are valid + shouldDisable := false + if wasEnabled { + manifest, err := readManifest(plugin.Path) + if err == nil && manifest.Permissions != nil && manifest.Permissions.Users != nil { + // Plugin requires users permission - check if it's still satisfied + if !allUsers { + if usersJSON == "" { + shouldDisable = true + } else { + var users []string + if err := json.Unmarshal([]byte(usersJSON), &users); err != nil || len(users) == 0 { + shouldDisable = true + } + } + } + } + } + + if shouldDisable { + // Disable the plugin since users permission is no longer satisfied + if err := m.unloadPlugin(id); err != nil { + log.Debug(ctx, "Plugin was not loaded", "plugin", id) + } + plugin.Enabled = false + if err := repo.Put(plugin); err != nil { + return fmt.Errorf("updating plugin users in DB: %w", err) + } + log.Info(ctx, "Disabled plugin due to users permission removal", "plugin", id) + m.sendPluginRefreshEvent(ctx, id) + return nil + } + + if err := repo.Put(plugin); err != nil { + return fmt.Errorf("updating plugin users in DB: %w", err) + } + + // Reload if enabled + if wasEnabled { + if err := m.unloadPlugin(id); err != nil { + log.Debug(ctx, "Plugin was not loaded", "plugin", id) + } + if err := m.loadPluginWithConfig(plugin); err != nil { + plugin.LastError = err.Error() + plugin.Enabled = false + _ = repo.Put(plugin) + return fmt.Errorf("reloading plugin with new users config: %w", err) + } + } + + log.Info(ctx, "Updated plugin users", "plugin", id) + m.sendPluginRefreshEvent(ctx, id) + return nil +} + // unloadPlugin removes a plugin from the manager and closes its resources. // Returns an error if the plugin is not found. func (m *Manager) unloadPlugin(name string) error { @@ -440,3 +525,32 @@ func (m *Manager) unloadPlugin(name string) error { log.Info(m.ctx, "Unloaded plugin", "plugin", name) return nil } + +// checkPermissionGates validates that all permission-based requirements are met +// before a plugin can be enabled. Returns an error if any gate condition fails. +func (m *Manager) checkPermissionGates(p *model.Plugin) error { + // Parse manifest to check permissions + manifest, err := readManifest(p.Path) + if err != nil { + return fmt.Errorf("reading manifest: %w", err) + } + + // Check users permission gate + if manifest.Permissions != nil && manifest.Permissions.Users != nil { + if !p.AllUsers && p.Users == "" { + return fmt.Errorf("users permission requires configuration: select users or enable 'all users' access") + } + // Also check that Users JSON array is not empty if AllUsers is false + if !p.AllUsers { + var users []string + if err := json.Unmarshal([]byte(p.Users), &users); err != nil { + return fmt.Errorf("invalid users configuration: %w", err) + } + if len(users) == 0 { + return fmt.Errorf("users permission requires configuration: select at least one user or enable 'all users' access") + } + } + } + + return nil +} diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 84043098e..8a0e48fee 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -9,6 +9,7 @@ import ( extism "github.com/extism/go-sdk" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" @@ -19,10 +20,12 @@ import ( // serviceContext provides dependencies needed by host service factories. type serviceContext struct { - pluginName string - manager *Manager - permissions *Permissions - config map[string]string + pluginName string + 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 } // hostServiceEntry defines a host service for table-driven registration. @@ -107,6 +110,14 @@ var hostServices = []hostServiceEntry{ return host.RegisterKVStoreHostFunctions(service), service }, }, + { + 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) + return host.RegisterUsersHostFunctions(service), nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. @@ -167,7 +178,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { } }() - if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, plugin.Config); err != nil { + if err := m.loadPluginWithConfig(&plugin); err != nil { // Store error in DB plugin.LastError = err.Error() plugin.Enabled = false @@ -203,8 +214,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { } // loadPluginWithConfig loads a plugin with configuration from DB. -// The ndpPath should point to an .ndp package file. -func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { +// The p.Path should point to an .ndp package file. +func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { if m.stopped.Load() { return fmt.Errorf("manager is stopped") } @@ -219,14 +230,22 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { // Parse config from JSON var pluginConfig map[string]string - if configJSON != "" { - if err := json.Unmarshal([]byte(configJSON), &pluginConfig); err != nil { + if p.Config != "" { + if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil { return fmt.Errorf("parsing plugin config: %w", err) } } + // Parse users from JSON + var allowedUsers []string + if p.Users != "" { + if err := json.Unmarshal([]byte(p.Users), &allowedUsers); err != nil { + return fmt.Errorf("parsing plugin users: %w", err) + } + } + // Open the .ndp package to get manifest and wasm bytes - pkg, err := openPackage(ndpPath) + pkg, err := openPackage(p.Path) if err != nil { return fmt.Errorf("opening package: %w", err) } @@ -264,10 +283,12 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { var closers []io.Closer svcCtx := &serviceContext{ - pluginName: name, - manager: m, - permissions: pkg.Manifest.Permissions, - config: pluginConfig, + pluginName: p.ID, + manager: m, + permissions: pkg.Manifest.Permissions, + config: pluginConfig, + allowedUsers: allowedUsers, + allUsers: p.AllUsers, } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { @@ -287,7 +308,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { // Enable experimental threads if requested in manifest if pkg.Manifest.HasExperimentalThreads() { runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(m.ctx, "Enabling experimental threads support", "plugin", name) + log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID) } extismConfig := extism.PluginConfig{ @@ -305,14 +326,14 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { compiled.Close(m.ctx) return fmt.Errorf("creating instance: %w", err) } - instance.SetLogger(extismLogger(name)) + instance.SetLogger(extismLogger(p.ID)) capabilities := detectCapabilities(instance) instance.Close(m.ctx) m.mu.Lock() - m.plugins[name] = &plugin{ - name: name, - path: ndpPath, + m.plugins[p.ID] = &plugin{ + name: p.ID, + path: p.Path, manifest: pkg.Manifest, compiled: compiled, capabilities: capabilities, @@ -322,7 +343,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error { m.mu.Unlock() // Call plugin init function - callPluginInit(m.ctx, m.plugins[name]) + callPluginInit(m.ctx, m.plugins[p.ID]) return nil } diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index ca5492fd2..7c7a5acb3 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -88,6 +88,9 @@ }, "kvstore": { "$ref": "#/$defs/KVStorePermission" + }, + "users": { + "$ref": "#/$defs/UsersPermission" } } }, @@ -224,6 +227,17 @@ "description": "Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB" } } + }, + "UsersPermission": { + "type": "object", + "description": "Users service permissions for accessing user information", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why users access is needed" + } + } } } } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index fcccdf81e..742ea9218 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -156,6 +156,9 @@ type Permissions struct { // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` + // Users corresponds to the JSON schema field "users". + Users *UsersPermission `json:"users,omitempty" yaml:"users,omitempty" mapstructure:"users,omitempty"` + // Websocket corresponds to the JSON schema field "websocket". Websocket *WebSocketPermission `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"` } @@ -202,6 +205,12 @@ type ThreadsFeature struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Users service permissions for accessing user information +type UsersPermission struct { + // Explanation for why users access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // WebSocket service permissions for establishing WebSocket connections type WebSocketPermission struct { // List of allowed host patterns for WebSocket connections (e.g., diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 7f318e0f1..82dc2c4aa 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -42,6 +42,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. + - Users: provides access to user information for plugins. - WebSocket: provides WebSocket communication capabilities for plugins. # Building Plugins diff --git a/plugins/pdk/go/host/nd_host_config.go b/plugins/pdk/go/host/nd_host_config.go index b4f258c62..1d913e626 100644 --- a/plugins/pdk/go/host/nd_host_config.go +++ b/plugins/pdk/go/host/nd_host_config.go @@ -126,7 +126,7 @@ func ConfigGetInt(key string) (int64, bool) { } // ConfigKeys calls the config_keys host function. -// List returns configuration keys matching the given prefix. +// Keys returns configuration keys matching the given prefix. // // Parameters: // - prefix: Key prefix to filter by. If empty, returns all keys. diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 6b47348cc..2b8485ce9 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -61,7 +61,7 @@ func (m *mockConfigService) Keys(prefix string) []string { } // ConfigKeys delegates to the mock instance. -// List returns configuration keys matching the given prefix. +// Keys returns configuration keys matching the given prefix. // // Parameters: // - prefix: Key prefix to filter by. If empty, returns all keys. diff --git a/plugins/pdk/go/host/nd_host_users.go b/plugins/pdk/go/host/nd_host_users.go new file mode 100644 index 000000000..504594487 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_users.go @@ -0,0 +1,66 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Users host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// User represents the User data structure. +// User represents a Navidrome user with minimal information exposed to plugins. +// Sensitive fields like password, email, and internal IDs are intentionally excluded. +type User struct { + UserName string `json:"userName"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` +} + +// users_getusers is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user users_getusers +func users_getusers(uint64) uint64 + +type usersGetUsersResponse struct { + Result []User `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// UsersGetUsers calls the users_getusers host function. +// GetUsers returns all users the plugin has been granted access to. +// Only minimal user information (userName, name, isAdmin) is returned. +// Sensitive fields like password and email are never exposed. +// +// Returns a slice of users the plugin can access, or an empty slice if none configured. +func UsersGetUsers() ([]User, error) { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := users_getusers(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response usersGetUsersResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go new file mode 100644 index 000000000..ec35c1fd6 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -0,0 +1,45 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import "github.com/stretchr/testify/mock" + +// User represents the User data structure. +// User represents a Navidrome user with minimal information exposed to plugins. +// Sensitive fields like password, email, and internal IDs are intentionally excluded. +type User struct { + UserName string `json:"userName"` + Name string `json:"name"` + IsAdmin bool `json:"isAdmin"` +} + +// mockUsersService is the mock implementation for testing. +type mockUsersService struct { + mock.Mock +} + +// UsersMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.UsersMock.On("MethodName", args...).Return(values...) +var UsersMock = &mockUsersService{} + +// GetUsers is the mock method for UsersGetUsers. +func (m *mockUsersService) GetUsers() ([]User, error) { + args := m.Called() + return args.Get(0).([]User), args.Error(1) +} + +// UsersGetUsers delegates to the mock instance. +// GetUsers returns all users the plugin has been granted access to. +// Only minimal user information (userName, name, isAdmin) is returned. +// Sensitive fields like password and email are never exposed. +// +// Returns a slice of users the plugin can access, or an empty slice if none configured. +func UsersGetUsers() ([]User, error) { + return UsersMock.GetUsers() +} diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py index ae015fc2a..1dab2fe0e 100644 --- a/plugins/pdk/python/host/nd_host_config.py +++ b/plugins/pdk/python/host/nd_host_config.py @@ -117,7 +117,7 @@ value cannot be parsed as an integer, exists will be false. def config_keys(prefix: str) -> Any: - """List returns configuration keys matching the given prefix. + """Keys returns configuration keys matching the given prefix. Parameters: - prefix: Key prefix to filter by. If empty, returns all keys. diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py new file mode 100644 index 000000000..fd17cb9d5 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_users.py @@ -0,0 +1,50 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the Users host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "users_getusers") +def _users_getusers(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def users_get_users() -> Any: + """GetUsers returns all users the plugin has been granted access to. +Only minimal user information (userName, name, isAdmin) is returned. +Sensitive fields like password and email are never exposed. + +Returns a slice of users the plugin can access, or an empty slice if none configured. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request_bytes = b"{}" + request_mem = extism.memory.alloc(request_bytes) + response_offset = _users_getusers(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", None) diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 243f32a2e..3dff68269 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -39,6 +39,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. +//! - [`users`] - provides access to user information for plugins. //! - [`websocket`] - provides WebSocket communication capabilities for plugins. #[doc(hidden)] @@ -90,6 +91,13 @@ pub mod subsonicapi { pub use super::nd_host_subsonicapi::*; } +#[doc(hidden)] +mod nd_host_users; +/// provides access to user information for plugins. +pub mod users { + pub use super::nd_host_users::*; +} + #[doc(hidden)] mod nd_host_websocket; /// provides WebSocket communication capabilities for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs index d2359255f..ebb4ffb8b 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs @@ -107,7 +107,7 @@ pub fn get_int(key: &str) -> Result<(i64, bool), Error> { Ok((response.0.value, response.0.exists)) } -/// List returns configuration keys matching the given prefix. +/// Keys returns configuration keys matching the given prefix. /// /// Parameters: /// - prefix: Key prefix to filter by. If empty, returns all keys. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs new file mode 100644 index 000000000..73f5758cd --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs @@ -0,0 +1,54 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Users host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// User represents a Navidrome user with minimal information exposed to plugins. +/// Sensitive fields like password, email, and internal IDs are intentionally excluded. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub user_name: String, + pub name: String, + pub is_admin: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsersGetUsersResponse { + #[serde(default)] + result: Vec, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn users_getusers(input: Json) -> Json; +} + +/// GetUsers returns all users the plugin has been granted access to. +/// Only minimal user information (userName, name, isAdmin) is returned. +/// Sensitive fields like password and email are never exposed. +/// +/// Returns a slice of users the plugin can access, or an empty slice if none configured. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_users() -> Result, Error> { + let response = unsafe { + users_getusers(Json(serde_json::json!({})))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index 7adddb65e..d53f2aaee 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -11,6 +11,9 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) +# PDK source files that trigger rebuild when changed (recursive) +PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) + # Build the .ndp package (zip containing manifest.json + plugin.wasm) %.ndp: %.wasm %/manifest.json @rm -f $@ @@ -20,7 +23,7 @@ clean: @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp # Build the wasm binary -%.wasm: %/*.go %/go.mod +%.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else diff --git a/plugins/testdata/test-users/go.mod b/plugins/testdata/test-users/go.mod new file mode 100644 index 000000000..973f29c9c --- /dev/null +++ b/plugins/testdata/test-users/go.mod @@ -0,0 +1,16 @@ +module test-users + +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-users/go.sum b/plugins/testdata/test-users/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-users/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-users/main.go b/plugins/testdata/test-users/main.go new file mode 100644 index 000000000..0eef953a1 --- /dev/null +++ b/plugins/testdata/test-users/main.go @@ -0,0 +1,51 @@ +// Test Users plugin for Navidrome plugin system integration tests. +// This plugin tests user metadata access via the Users host service. +// Build with: tinygo build -o ../test-users.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// TestUsersInput is the input for nd_test_users callback. +type TestUsersInput struct { + Operation string `json:"operation"` // "get_users" +} + +// TestUsersOutput is the output from nd_test_users callback. +type TestUsersOutput struct { + Users []host.User `json:"users,omitempty"` + Error *string `json:"error,omitempty"` +} + +// nd_test_users is the test callback that tests the users host functions. +// +//go:wasmexport nd_test_users +func ndTestUsers() int32 { + var input TestUsersInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestUsersOutput{Error: &errStr}) + return 0 + } + + switch input.Operation { + case "get_users": + users, err := host.UsersGetUsers() + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestUsersOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestUsersOutput{Users: users}) + return 0 + + default: + errStr := "unknown operation: " + input.Operation + pdk.OutputJSON(TestUsersOutput{Error: &errStr}) + return 0 + } +} + +func main() {} diff --git a/plugins/testdata/test-users/manifest.json b/plugins/testdata/test-users/manifest.json new file mode 100644 index 000000000..787260785 --- /dev/null +++ b/plugins/testdata/test-users/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Test Users Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test users plugin for integration testing", + "permissions": { + "users": { + "reason": "For testing user metadata access" + } + } +} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 9b4051d12..8377d12c2 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -26,6 +26,7 @@ type PluginManager interface { EnablePlugin(ctx context.Context, id string) error DisablePlugin(ctx context.Context, id string) error UpdatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error } type Router struct { diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index 66f2fddeb..e1c531842 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -43,8 +43,10 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler { // PluginUpdateRequest represents the fields that can be updated via the API type PluginUpdateRequest struct { - Enabled *bool `json:"enabled,omitempty"` - Config *string `json:"config,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config *string `json:"config,omitempty"` + Users *string `json:"users,omitempty"` + AllUsers *bool `json:"allUsers,omitempty"` } func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { @@ -89,6 +91,38 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { } } + // Handle users permission update (if provided) + if req.Users != nil || req.AllUsers != nil { + // Get current values if not provided in request + plugin, err := repo.Get(id) + if err != nil { + log.Error(ctx, "Error getting plugin for users update", "id", id, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + usersJSON := plugin.Users + allUsers := plugin.AllUsers + + if req.Users != nil { + // Validate users JSON if not empty + if *req.Users != "" && !isValidJSON(*req.Users) { + http.Error(w, "Invalid JSON in users field", http.StatusBadRequest) + return + } + usersJSON = *req.Users + } + if req.AllUsers != nil { + allUsers = *req.AllUsers + } + + if err := api.pluginManager.UpdatePluginUsers(ctx, id, usersJSON, allUsers); err != nil { + log.Error(ctx, "Error updating plugin users", "id", id, err) + http.Error(w, "Error updating plugin users: "+err.Error(), http.StatusInternalServerError) + return + } + } + // Handle enable/disable if req.Enabled != nil { if *req.Enabled { diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 5efc4c454..68da24e6c 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -246,6 +246,129 @@ var _ = Describe("Plugin API", func() { Expect(plugin.Config).To(Equal("")) }) + It("updates users field", func() { + // Configure mock to update the repo when UpdatePluginUsers is called + mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { + adminCtx := request.WithUser(ctx, adminUser) + p, _ := ds.Plugin(adminCtx).Get(id) + p.Users = usersJSON + p.AllUsers = allUsers + return ds.Plugin(adminCtx).Put(p) + } + + body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`) + req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var plugin model.Plugin + err := json.Unmarshal(w.Body.Bytes(), &plugin) + Expect(err).ToNot(HaveOccurred()) + Expect(plugin.Users).To(Equal(`["user1","user2"]`)) + Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) + Expect(mockManager.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["user1","user2"]`)) + }) + + It("updates allUsers field", func() { + // Configure mock to update the repo when UpdatePluginUsers is called + mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { + adminCtx := request.WithUser(ctx, adminUser) + p, _ := ds.Plugin(adminCtx).Get(id) + p.Users = usersJSON + p.AllUsers = allUsers + return ds.Plugin(adminCtx).Put(p) + } + + body := bytes.NewBufferString(`{"allUsers":true}`) + req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var plugin model.Plugin + err := json.Unmarshal(w.Body.Bytes(), &plugin) + Expect(err).ToNot(HaveOccurred()) + Expect(plugin.AllUsers).To(BeTrue()) + Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) + Expect(mockManager.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) + }) + + It("updates both users and allUsers fields together", func() { + // Configure mock to update the repo when UpdatePluginUsers is called + mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { + adminCtx := request.WithUser(ctx, adminUser) + p, _ := ds.Plugin(adminCtx).Get(id) + p.Users = usersJSON + p.AllUsers = allUsers + return ds.Plugin(adminCtx).Put(p) + } + + body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`) + req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var plugin model.Plugin + err := json.Unmarshal(w.Body.Bytes(), &plugin) + Expect(err).ToNot(HaveOccurred()) + Expect(plugin.Users).To(Equal(`["user1"]`)) + Expect(plugin.AllUsers).To(BeFalse()) + Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) + }) + + It("rejects invalid JSON in users field", func() { + body := bytes.NewBufferString(`{"users":"not valid json"}`) + req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("Invalid JSON")) + }) + + It("allows empty users", func() { + // Configure mock to update the repo when UpdatePluginUsers is called + mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { + adminCtx := request.WithUser(ctx, adminUser) + p, _ := ds.Plugin(adminCtx).Get(id) + p.Users = usersJSON + p.AllUsers = allUsers + return ds.Plugin(adminCtx).Put(p) + } + + body := bytes.NewBufferString(`{"users":""}`) + req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var plugin model.Plugin + err := json.Unmarshal(w.Body.Bytes(), &plugin) + Expect(err).ToNot(HaveOccurred()) + Expect(plugin.Users).To(Equal("")) + }) + It("returns 404 for non-existent plugin", func() { body := bytes.NewBufferString(`{"enabled":true}`) req := httptest.NewRequest("PUT", "/plugin/non-existent", body) diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go index 67ac8065d..553946556 100644 --- a/tests/mock_plugin_manager.go +++ b/tests/mock_plugin_manager.go @@ -5,7 +5,7 @@ import ( ) // MockPluginManager is a mock implementation of plugins.PluginManager for testing. -// It implements EnablePlugin, DisablePlugin, and UpdatePluginConfig methods. +// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, and UpdatePluginUsers methods. type MockPluginManager struct { // EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError. EnablePluginFn func(ctx context.Context, id string) error @@ -13,11 +13,14 @@ type MockPluginManager struct { DisablePluginFn func(ctx context.Context, id string) error // UpdatePluginConfigFn is called when UpdatePluginConfig is invoked. If nil, returns ConfigError. UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error + // UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError. + UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error // Default errors to return when Fn callbacks are not set EnableError error DisableError error ConfigError error + UsersError error // Track calls for assertions EnablePluginCalls []string @@ -26,6 +29,11 @@ type MockPluginManager struct { ID string ConfigJSON string } + UpdatePluginUsersCalls []struct { + ID string + UsersJSON string + AllUsers bool + } } func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error { @@ -54,3 +62,15 @@ func (m *MockPluginManager) UpdatePluginConfig(ctx context.Context, id, configJS } return m.ConfigError } + +func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error { + m.UpdatePluginUsersCalls = append(m.UpdatePluginUsersCalls, struct { + ID string + UsersJSON string + AllUsers bool + }{ID: id, UsersJSON: usersJSON, AllUsers: allUsers}) + if m.UpdatePluginUsersFn != nil { + return m.UpdatePluginUsersFn(ctx, id, usersJSON, allUsers) + } + return m.UsersError +} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 9f3dd672e..46f528ba5 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -70,6 +70,17 @@ func (u *MockedUserRepo) Get(id string) (*model.User, error) { return nil, model.ErrNotFound } +func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, error) { + if u.Error != nil { + return nil, u.Error + } + var users model.Users + for _, usr := range u.Data { + users = append(users, *usr) + } + return users, nil +} + func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index d581a07c5..03169dead 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -349,13 +349,16 @@ "updatedAt": "Updated", "createdAt": "Installed", "configKey": "Key", - "configValue": "Value" + "configValue": "Value", + "allUsers": "Allow all users", + "selectedUsers": "Selected users" }, "sections": { "status": "Status", "info": "Plugin Information", "configuration": "Configuration", - "manifest": "Manifest" + "manifest": "Manifest", + "usersPermission": "Users Permission" }, "status": { "enabled": "Enabled", @@ -365,6 +368,7 @@ "enable": "Enable", "disable": "Disable", "disabledDueToError": "Fix the error before enabling", + "disabledUsersRequired": "Select users before enabling", "addConfig": "Add Configuration" }, "notifications": { @@ -379,7 +383,11 @@ "messages": { "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "clickPermissions": "Click a permission for details", - "noConfig": "No configuration set" + "noConfig": "No configuration set", + "allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.", + "noUsers": "No users selected", + "permissionReason": "Reason", + "usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'." }, "placeholders": { "configKey": "key", diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx index 9d318be50..9a3b2ff5a 100644 --- a/ui/src/plugin/ConfigCard.jsx +++ b/ui/src/plugin/ConfigCard.jsx @@ -3,7 +3,6 @@ import { Card, CardContent, Typography, - Box, TextField as MuiTextField, Table, TableBody, @@ -13,18 +12,14 @@ import { TableRow, IconButton, Paper, - Button, } from '@material-ui/core' -import { MdSave, MdDelete } from 'react-icons/md' +import { MdDelete } from 'react-icons/md' export const ConfigCard = ({ configPairs, onConfigPairsChange, - isDirty, - loading, classes, translate, - onSave, }) => { const handleKeyChange = useCallback( (index, newKey) => { @@ -145,19 +140,6 @@ export const ConfigCard = ({ - - - - ) diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 20a05230f..bce3e30e1 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -23,10 +23,23 @@ const useStyles = makeStyles((theme) => ({ }, })) +const useManifest = () => { + const record = useRecordContext() + return useMemo(() => { + if (!record?.manifest) return null + try { + return JSON.parse(record.manifest) + } catch { + return null + } + }, [record?.manifest]) +} + const EnabledOrErrorField = () => { const record = useRecordContext() const translate = useTranslate() const classes = useStyles() + const manifest = useManifest() if (record.lastError) { return ( @@ -41,19 +54,7 @@ const EnabledOrErrorField = () => { ) } - return -} - -const useManifest = () => { - const record = useRecordContext() - return useMemo(() => { - if (!record?.manifest) return null - try { - return JSON.parse(record.manifest) - } catch { - return null - } - }, [record?.manifest]) + return } const ManifestField = ({ source }) => { diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx index 74fc675cb..273474897 100644 --- a/ui/src/plugin/PluginShow.jsx +++ b/ui/src/plugin/PluginShow.jsx @@ -10,7 +10,8 @@ import { Title as RaTitle, Loading, } from 'react-admin' -import { Box, useMediaQuery } from '@material-ui/core' +import { Box, useMediaQuery, Button } from '@material-ui/core' +import { MdSave } from 'react-icons/md' import Alert from '@material-ui/lab/Alert' import { Title, useResourceRefresh } from '../common' import { usePluginShowStyles } from './styles.js' @@ -19,6 +20,7 @@ import { StatusCard } from './StatusCard' import { InfoCard } from './InfoCard' import { ManifestSection } from './ManifestSection' import { ConfigCard } from './ConfigCard' +import { UsersPermissionCard } from './UsersPermissionCard' // Main show layout component const PluginShowLayout = () => { @@ -34,6 +36,12 @@ const PluginShowLayout = () => { const [isDirty, setIsDirty] = useState(false) const [lastRecordConfig, setLastRecordConfig] = useState(null) + // Users permission state + const [selectedUsers, setSelectedUsers] = useState([]) + const [allUsers, setAllUsers] = useState(false) + const [lastRecordUsers, setLastRecordUsers] = useState(null) + const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null) + // Convert JSON config to key-value pairs const jsonToPairs = useCallback((jsonString) => { if (!jsonString || jsonString.trim() === '') return [] @@ -70,15 +78,42 @@ const PluginShowLayout = () => { } }, [record, lastRecordConfig, isDirty, jsonToPairs]) - const handleConfigPairsChange = useCallback( - (newPairs) => { - setConfigPairs(newPairs) - const newJson = pairsToJson(newPairs) - const originalJson = record?.config || '' - setIsDirty(newJson !== originalJson) - }, - [record?.config, pairsToJson], - ) + // Initialize/update users permission state when record loads or changes + React.useEffect(() => { + if (record && !isDirty) { + const recordUsers = record.users || '' + const recordAllUsers = record.allUsers || false + + if ( + recordUsers !== lastRecordUsers || + recordAllUsers !== lastRecordAllUsers + ) { + try { + setSelectedUsers(recordUsers ? JSON.parse(recordUsers) : []) + } catch { + setSelectedUsers([]) + } + setAllUsers(recordAllUsers) + setLastRecordUsers(recordUsers) + setLastRecordAllUsers(recordAllUsers) + } + } + }, [record, lastRecordUsers, lastRecordAllUsers, isDirty]) + + const handleConfigPairsChange = useCallback((newPairs) => { + setConfigPairs(newPairs) + setIsDirty(true) + }, []) + + const handleSelectedUsersChange = useCallback((newSelectedUsers) => { + setSelectedUsers(newSelectedUsers) + setIsDirty(true) + }, []) + + const handleAllUsersChange = useCallback((newAllUsers) => { + setAllUsers(newAllUsers) + setIsDirty(true) + }, []) const [updatePlugin, { loading }] = useUpdate( 'plugin', @@ -91,6 +126,8 @@ const PluginShowLayout = () => { refresh() setIsDirty(false) setLastRecordConfig(null) // Reset to reinitialize from server + setLastRecordUsers(null) + setLastRecordAllUsers(null) notify('resources.plugin.notifications.updated', 'info') }, onFailure: (err) => { @@ -105,8 +142,17 @@ const PluginShowLayout = () => { const handleSaveConfig = useCallback(() => { if (!record) return const config = pairsToJson(configPairs) - updatePlugin('plugin', record.id, { config }, record) - }, [updatePlugin, record, configPairs, pairsToJson]) + const data = { config } + + // Include users data if users permission is present + const manifest = record.manifest ? JSON.parse(record.manifest) : null + if (manifest?.permissions?.users) { + data.users = JSON.stringify(selectedUsers) + data.allUsers = allUsers + } + + updatePlugin('plugin', record.id, data, record) + }, [updatePlugin, record, configPairs, pairsToJson, selectedUsers, allUsers]) // Parse manifest const { manifest, manifestJson } = useMemo(() => { @@ -148,7 +194,11 @@ const PluginShowLayout = () => { - + { + + + + + + ) diff --git a/ui/src/plugin/StatusCard.jsx b/ui/src/plugin/StatusCard.jsx index 9be1456c3..323a4ec10 100644 --- a/ui/src/plugin/StatusCard.jsx +++ b/ui/src/plugin/StatusCard.jsx @@ -1,16 +1,23 @@ import React from 'react' +import PropTypes from 'prop-types' import { Card, CardContent, Typography } from '@material-ui/core' import ToggleEnabledSwitch from './ToggleEnabledSwitch' -export const StatusCard = ({ classes, translate }) => { +export const StatusCard = ({ classes, translate, manifest }) => { return ( {translate('resources.plugin.sections.status')} - + ) } + +StatusCard.propTypes = { + classes: PropTypes.object.isRequired, + translate: PropTypes.func.isRequired, + manifest: PropTypes.object, +} diff --git a/ui/src/plugin/ToggleEnabledSwitch.jsx b/ui/src/plugin/ToggleEnabledSwitch.jsx index 942fb7338..46beaa081 100644 --- a/ui/src/plugin/ToggleEnabledSwitch.jsx +++ b/ui/src/plugin/ToggleEnabledSwitch.jsx @@ -10,6 +10,7 @@ import { import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' import { Tooltip, FormControlLabel } from '@material-ui/core' +import PropTypes from 'prop-types' const useStyles = makeStyles((theme) => ({ enabledSwitch: { @@ -30,8 +31,13 @@ const useStyles = makeStyles((theme) => ({ * @param {Object} props * @param {boolean} [props.showLabel=false] - Whether to show the enable/disable label * @param {string} [props.size='small'] - Switch size ('small' or 'medium') + * @param {Object} [props.manifest=null] - Parsed manifest object for permission checking */ -const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => { +const ToggleEnabledSwitch = ({ + showLabel = false, + size = 'small', + manifest = null, +}) => { const resource = useResourceContext() const record = useRecordContext() const notify = useNotify() @@ -73,12 +79,31 @@ const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => { ) const hasError = !!record?.lastError - const isDisabled = loading || hasError + + // Check if users permission is required but not configured + const usersPermissionRequired = useMemo(() => { + if (!manifest?.permissions?.users) return false + if (record?.allUsers) return false + // Check if users array is empty or not set + if (!record?.users) return true + try { + const users = JSON.parse(record.users) + return users.length === 0 + } catch { + return true + } + }, [manifest, record?.allUsers, record?.users]) + + const isDisabled = + loading || hasError || (usersPermissionRequired && !record?.enabled) const tooltipTitle = useMemo(() => { if (hasError) { return translate('resources.plugin.actions.disabledDueToError') } + if (usersPermissionRequired && !record?.enabled) { + return translate('resources.plugin.actions.disabledUsersRequired') + } if (!showLabel) { return translate( record?.enabled @@ -87,7 +112,7 @@ const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => { ) } return '' - }, [hasError, showLabel, record?.enabled, translate]) + }, [hasError, usersPermissionRequired, showLabel, record?.enabled, translate]) const switchElement = ( { ) } +ToggleEnabledSwitch.propTypes = { + showLabel: PropTypes.bool, + size: PropTypes.oneOf(['small', 'medium']), + manifest: PropTypes.object, +} + export default ToggleEnabledSwitch diff --git a/ui/src/plugin/UsersPermissionCard.jsx b/ui/src/plugin/UsersPermissionCard.jsx new file mode 100644 index 000000000..54a004ce8 --- /dev/null +++ b/ui/src/plugin/UsersPermissionCard.jsx @@ -0,0 +1,168 @@ +import React from 'react' +import { + Card, + CardContent, + Typography, + Box, + FormControlLabel, + Switch, + List, + ListItem, + ListItemIcon, + ListItemText, + Checkbox, +} from '@material-ui/core' +import CheckBoxIcon from '@material-ui/icons/CheckBox' +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' +import Alert from '@material-ui/lab/Alert' +import { useGetList, useTranslate } from 'react-admin' +import PropTypes from 'prop-types' + +export const UsersPermissionCard = ({ + manifest, + classes, + selectedUsers, + allUsers, + onSelectedUsersChange, + onAllUsersChange, +}) => { + const translate = useTranslate() + + // Fetch all users + const { data: usersData, loading: usersLoading } = useGetList('user', { + pagination: { page: 1, perPage: 1000 }, + sort: { field: 'userName', order: 'ASC' }, + }) + + const users = React.useMemo(() => { + return usersData ? Object.values(usersData) : [] + }, [usersData]) + + const handleToggleUser = React.useCallback( + (userId) => { + const newSelected = selectedUsers.includes(userId) + ? selectedUsers.filter((id) => id !== userId) + : [...selectedUsers, userId] + onSelectedUsersChange(newSelected) + }, + [selectedUsers, onSelectedUsersChange], + ) + + const handleAllUsersToggle = React.useCallback( + (event) => { + onAllUsersChange(event.target.checked) + }, + [onAllUsersChange], + ) + + // Get permission reason from manifest + const usersPermission = manifest?.permissions?.users + const reason = usersPermission?.reason + + // Check if permission is required but not configured + const isConfigurationRequired = + usersPermission && !allUsers && selectedUsers.length === 0 + + if (!usersPermission) { + return null + } + + return ( + + + + {translate('resources.plugin.sections.usersPermission')} + + + {reason && ( + + {translate('resources.plugin.messages.permissionReason')}: {reason} + + )} + + {isConfigurationRequired && ( + + + {translate('resources.plugin.messages.usersRequired')} + + + )} + + + + } + label={translate('resources.plugin.fields.allUsers')} + /> + + {translate('resources.plugin.messages.allUsersHelp')} + + + + {!allUsers && ( + + + {translate('resources.plugin.fields.selectedUsers')} + + {usersLoading ? ( + + {translate('ra.message.loading')} + + ) : users.length === 0 ? ( + + {translate('resources.plugin.messages.noUsers')} + + ) : ( + + {users.map((user) => ( + handleToggleUser(user.id)} + dense + > + + } + checkedIcon={} + checked={selectedUsers.includes(user.id)} + tabIndex={-1} + disableRipple + /> + + + + ))} + + )} + + )} + + + ) +} + +UsersPermissionCard.propTypes = { + manifest: PropTypes.object, + classes: PropTypes.object.isRequired, + selectedUsers: PropTypes.array.isRequired, + allUsers: PropTypes.bool.isRequired, + onSelectedUsersChange: PropTypes.func.isRequired, + onAllUsersChange: PropTypes.func.isRequired, +}