feat: add GetAdmins method to retrieve admin users from the plugin

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-03 22:21:11 -05:00
parent ae5ecc6ac3
commit 605792d7f8
9 changed files with 296 additions and 1 deletions

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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())
})
})
})

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)

View File

@ -25,9 +25,19 @@ struct UsersGetUsersResponse {
error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UsersGetAdminsResponse {
#[serde(default)]
result: Vec<User>,
#[serde(default)]
error: Option<String>,
}
#[host_fn]
extern "ExtismHost" {
fn users_getusers(input: Json<serde_json::Value>) -> Json<UsersGetUsersResponse>;
fn users_getadmins(input: Json<serde_json::Value>) -> Json<UsersGetAdminsResponse>;
}
/// GetUsers returns all users the plugin has been granted access to.
@ -52,3 +62,25 @@ pub fn get_users() -> Result<Vec<User>, 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<Vec<User>, 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)
}

View File

@ -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})