From 605792d7f8d6d3faf792ed2a56d5dd8f8ce8fc93 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 3 Jan 2026 22:21:11 -0500 Subject: [PATCH] feat: add GetAdmins method to retrieve admin users from the plugin Signed-off-by: Deluan --- plugins/host/users.go | 7 ++ plugins/host/users_gen.go | 30 +++++ plugins/host_users.go | 12 ++ plugins/host_users_test.go | 118 ++++++++++++++++++ plugins/pdk/go/host/nd_host_users.go | 41 ++++++ plugins/pdk/go/host/nd_host_users_stub.go | 15 +++ plugins/pdk/python/host/nd_host_users.py | 30 +++++ .../pdk/rust/nd-pdk-host/src/nd_host_users.rs | 32 +++++ plugins/testdata/test-users/main.go | 12 +- 9 files changed, 296 insertions(+), 1 deletion(-) diff --git a/plugins/host/users.go b/plugins/host/users.go index 42a969390..c05a0c797 100644 --- a/plugins/host/users.go +++ b/plugins/host/users.go @@ -25,4 +25,11 @@ type UsersService interface { // Returns a slice of users the plugin can access, or an empty slice if none configured. //nd:hostfunc GetUsers(ctx context.Context) ([]User, error) + + // GetAdmins returns only admin users the plugin has been granted access to. + // This is a convenience method that filters GetUsers results to include only admins. + // + // Returns a slice of admin users the plugin can access, or an empty slice if none. + //nd:hostfunc + GetAdmins(ctx context.Context) ([]User, error) } diff --git a/plugins/host/users_gen.go b/plugins/host/users_gen.go index 1f0255807..4e7210991 100644 --- a/plugins/host/users_gen.go +++ b/plugins/host/users_gen.go @@ -15,11 +15,18 @@ type UsersGetUsersResponse struct { Error string `json:"error,omitempty"` } +// UsersGetAdminsResponse is the response type for Users.GetAdmins. +type UsersGetAdminsResponse 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), + newUsersGetAdminsHostFunction(service), } } @@ -46,6 +53,29 @@ func newUsersGetUsersHostFunction(service UsersService) extism.HostFunction { ) } +func newUsersGetAdminsHostFunction(service UsersService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "users_getadmins", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + + // Call the service method + result, svcErr := service.GetAdmins(ctx) + if svcErr != nil { + usersWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := UsersGetAdminsResponse{ + 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) diff --git a/plugins/host_users.go b/plugins/host_users.go index bfdd980fc..a56c8f866 100644 --- a/plugins/host_users.go +++ b/plugins/host_users.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/utils/slice" ) type usersServiceImpl struct { @@ -49,4 +50,15 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { return result, nil } +func (s *usersServiceImpl) GetAdmins(ctx context.Context) ([]host.User, error) { + users, err := s.GetUsers(ctx) + if err != nil { + return nil, err + } + + return slice.Filter(users, func(u host.User) bool { + return u.IsAdmin + }), nil +} + var _ host.UsersService = (*usersServiceImpl)(nil) diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 0ca62fff5..2071a9320 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -141,6 +141,86 @@ var _ = Describe("UsersService", Ordered, func() { }) }) }) + + Describe("GetAdmins", func() { + var mockUserRepo *tests.MockedUserRepo + + BeforeEach(func() { + mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) + // Add test users - alice is admin, bob and charlie are not + _ = 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 only admin users", func() { + admins, err := service.GetAdmins(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(admins).To(HaveLen(1)) + Expect(admins[0].UserName).To(Equal("alice")) + Expect(admins[0].IsAdmin).To(BeTrue()) + }) + }) + + 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) + }) + + It("should return only admin users from allowed list", func() { + admins, err := service.GetAdmins(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(admins).To(HaveLen(1)) + Expect(admins[0].UserName).To(Equal("alice")) + }) + }) + + Context("with specific allowed users excluding admin", func() { + BeforeEach(func() { + // Only allow access to non-admin users + service = newUsersService(ds, []string{"user2", "user3"}, false) + }) + + It("should return empty when no admins in allowed list", func() { + admins, err := service.GetAdmins(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(admins).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.GetAdmins(ctx) + Expect(err).To(HaveOccurred()) + }) + }) + }) }) var _ = Describe("UsersService Integration", Ordered, func() { @@ -215,6 +295,16 @@ var _ = Describe("UsersService Integration", Ordered, func() { Expect(bob.IsAdmin).To(BeFalse()) }) }) + + Describe("GetAdmins Operations via Plugin", func() { + It("should get only admin users when allUsers is true", func() { + output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Users).To(HaveLen(1)) + Expect(output.Users[0].UserName).To(Equal("alice")) + Expect(output.Users[0].IsAdmin).To(BeTrue()) + }) + }) }) var _ = Describe("UsersService Integration with Specific Users", Ordered, func() { @@ -240,6 +330,34 @@ var _ = Describe("UsersService Integration with Specific Users", Ordered, func() Expect(userNames).To(ContainElements("alice", "charlie")) Expect(userNames).ToNot(ContainElement("bob")) }) + + It("should only return admin users from allowed list via GetAdmins", func() { + output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) + Expect(err).ToNot(HaveOccurred()) + // Only alice (user1) is admin, charlie (user3) is not + Expect(output.Users).To(HaveLen(1)) + Expect(output.Users[0].UserName).To(Equal("alice")) + Expect(output.Users[0].IsAdmin).To(BeTrue()) + }) + }) +}) + +var _ = Describe("UsersService Integration GetAdmins with No Admins", Ordered, func() { + var manager *Manager + + BeforeAll(func() { + var cleanup func() + // Only allow user2 (bob) and user3 (charlie), both non-admins + manager, cleanup = setupUsersIntegrationManager(false, `["user2", "user3"]`) + DeferCleanup(cleanup) + }) + + Describe("GetAdmins with no admin users in allowed list", func() { + It("should return empty when no admins in allowed list", func() { + output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Users).To(BeEmpty()) + }) }) }) diff --git a/plugins/pdk/go/host/nd_host_users.go b/plugins/pdk/go/host/nd_host_users.go index 504594487..21b6ad0ed 100644 --- a/plugins/pdk/go/host/nd_host_users.go +++ b/plugins/pdk/go/host/nd_host_users.go @@ -28,11 +28,21 @@ type User struct { //go:wasmimport extism:host/user users_getusers func users_getusers(uint64) uint64 +// users_getadmins is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user users_getadmins +func users_getadmins(uint64) uint64 + type usersGetUsersResponse struct { Result []User `json:"result,omitempty"` Error string `json:"error,omitempty"` } +type usersGetAdminsResponse 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. @@ -64,3 +74,34 @@ func UsersGetUsers() ([]User, error) { return response.Result, nil } + +// UsersGetAdmins calls the users_getadmins host function. +// GetAdmins returns only admin users the plugin has been granted access to. +// This is a convenience method that filters GetUsers results to include only admins. +// +// Returns a slice of admin users the plugin can access, or an empty slice if none. +func UsersGetAdmins() ([]User, error) { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := users_getadmins(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response usersGetAdminsResponse + 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 index ec35c1fd6..f76854894 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -43,3 +43,18 @@ func (m *mockUsersService) GetUsers() ([]User, error) { func UsersGetUsers() ([]User, error) { return UsersMock.GetUsers() } + +// GetAdmins is the mock method for UsersGetAdmins. +func (m *mockUsersService) GetAdmins() ([]User, error) { + args := m.Called() + return args.Get(0).([]User), args.Error(1) +} + +// UsersGetAdmins delegates to the mock instance. +// GetAdmins returns only admin users the plugin has been granted access to. +// This is a convenience method that filters GetUsers results to include only admins. +// +// Returns a slice of admin users the plugin can access, or an empty slice if none. +func UsersGetAdmins() ([]User, error) { + return UsersMock.GetAdmins() +} diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py index fd17cb9d5..a325156a7 100644 --- a/plugins/pdk/python/host/nd_host_users.py +++ b/plugins/pdk/python/host/nd_host_users.py @@ -25,6 +25,12 @@ def _users_getusers(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "users_getadmins") +def _users_getadmins(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. @@ -48,3 +54,27 @@ Returns a slice of users the plugin can access, or an empty slice if none config raise HostFunctionError(response["error"]) return response.get("result", None) + + +def users_get_admins() -> Any: + """GetAdmins returns only admin users the plugin has been granted access to. +This is a convenience method that filters GetUsers results to include only admins. + +Returns a slice of admin users the plugin can access, or an empty slice if none. + + 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_getadmins(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/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs index 73f5758cd..faa795bb9 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs @@ -25,9 +25,19 @@ struct UsersGetUsersResponse { error: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsersGetAdminsResponse { + #[serde(default)] + result: Vec, + #[serde(default)] + error: Option, +} + #[host_fn] extern "ExtismHost" { fn users_getusers(input: Json) -> Json; + fn users_getadmins(input: Json) -> Json; } /// GetUsers returns all users the plugin has been granted access to. @@ -52,3 +62,25 @@ pub fn get_users() -> Result, Error> { Ok(response.0.result) } + +/// GetAdmins returns only admin users the plugin has been granted access to. +/// This is a convenience method that filters GetUsers results to include only admins. +/// +/// Returns a slice of admin users the plugin can access, or an empty slice if none. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_admins() -> Result, Error> { + let response = unsafe { + users_getadmins(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/test-users/main.go b/plugins/testdata/test-users/main.go index 0eef953a1..08d07b360 100644 --- a/plugins/testdata/test-users/main.go +++ b/plugins/testdata/test-users/main.go @@ -10,7 +10,7 @@ import ( // TestUsersInput is the input for nd_test_users callback. type TestUsersInput struct { - Operation string `json:"operation"` // "get_users" + Operation string `json:"operation"` // "get_users", "get_admins" } // TestUsersOutput is the output from nd_test_users callback. @@ -41,6 +41,16 @@ func ndTestUsers() int32 { pdk.OutputJSON(TestUsersOutput{Users: users}) return 0 + case "get_admins": + admins, err := host.UsersGetAdmins() + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestUsersOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestUsersOutput{Users: admins}) + return 0 + default: errStr := "unknown operation: " + input.Operation pdk.OutputJSON(TestUsersOutput{Error: &errStr})