feat: add library permission management to plugin system

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-03 20:05:32 -05:00
parent 801466445d
commit 06a2a4d89c
14 changed files with 610 additions and 78 deletions

View File

@ -2,11 +2,13 @@
CREATE TABLE IF NOT EXISTS plugin (
id TEXT PRIMARY KEY,
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,
manifest JSONB NOT NULL,
config JSONB,
users JSONB,
all_users BOOL NOT NULL DEFAULT false,
libraries JSONB,
all_libraries BOOL NOT NULL DEFAULT false,
enabled BOOL NOT NULL DEFAULT false,
last_error TEXT,
sha256 TEXT NOT NULL,
created_at DATETIME NOT NULL,

View File

@ -3,17 +3,19 @@ package model
import "time"
type Plugin struct {
ID string `structs:"id" json:"id"`
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"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id"`
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"`
Libraries string `structs:"libraries" json:"libraries,omitempty"`
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
type Plugins []Plugin

View File

@ -79,30 +79,34 @@ 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, 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})
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {: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,
libraries = excluded.libraries,
all_libraries = excluded.all_libraries,
enabled = excluded.enabled,
last_error = excluded.last_error,
sha256 = excluded.sha256,
updated_at = excluded.updated_at
`).Bind(dbx.Params{
"id": plugin.ID,
"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,
"created_at": time.Now(),
"updated_at": plugin.UpdatedAt,
"id": plugin.ID,
"path": plugin.Path,
"manifest": plugin.Manifest,
"config": plugin.Config,
"users": plugin.Users,
"all_users": plugin.AllUsers,
"libraries": plugin.Libraries,
"all_libraries": plugin.AllLibraries,
"enabled": plugin.Enabled,
"last_error": plugin.LastError,
"sha256": plugin.SHA256,
"created_at": time.Now(),
"updated_at": plugin.UpdatedAt,
}).Execute()
return err
}

View File

@ -11,17 +11,32 @@ import (
type libraryServiceImpl struct {
ds model.DataStore
hasFilesystemPerm bool
allowedLibraryIDs []int
allLibraries bool
libraryIDMap map[int]struct{}
}
func newLibraryService(ds model.DataStore, perm *LibraryPermission) host.LibraryService {
func newLibraryService(ds model.DataStore, perm *LibraryPermission, allowedLibraryIDs []int, allLibraries bool) host.LibraryService {
hasFS := perm != nil && perm.Filesystem
libraryIDMap := make(map[int]struct{})
for _, id := range allowedLibraryIDs {
libraryIDMap[id] = struct{}{}
}
return &libraryServiceImpl{
ds: ds,
hasFilesystemPerm: hasFS,
allowedLibraryIDs: allowedLibraryIDs,
allLibraries: allLibraries,
libraryIDMap: libraryIDMap,
}
}
func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Library, error) {
// Check if the library is accessible
if !s.isLibraryAccessible(int(id)) {
return nil, fmt.Errorf("library not accessible: library ID %d is not in the allowed list", id)
}
lib, err := s.ds.Library(ctx).Get(int(id))
if err != nil {
return nil, fmt.Errorf("library not found: %w", err)
@ -30,15 +45,27 @@ func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Li
return s.convertLibrary(lib), nil
}
// isLibraryAccessible checks if a library ID is accessible to this plugin.
func (s *libraryServiceImpl) isLibraryAccessible(id int) bool {
if s.allLibraries {
return true
}
_, ok := s.libraryIDMap[id]
return ok
}
func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, fmt.Errorf("failed to get libraries: %w", err)
}
result := make([]host.Library, len(libs))
for i, lib := range libs {
result[i] = *s.convertLibrary(&lib)
// Filter libraries based on allowed list
var result []host.Library
for _, lib := range libs {
if s.isLibraryAccessible(lib.ID) {
result = append(result, *s.convertLibrary(&lib))
}
}
return result, nil

View File

@ -36,7 +36,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetLibrary", func() {
It("should return library metadata without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 1,
@ -68,7 +68,7 @@ var _ = Describe("LibraryService", Ordered, func() {
It("should return library metadata with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
lib := &model.Library{
ID: 2,
@ -94,7 +94,7 @@ var _ = Describe("LibraryService", Ordered, func() {
It("should return error for non-existent library", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason}).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl)
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{})
@ -108,7 +108,7 @@ var _ = Describe("LibraryService", Ordered, func() {
Describe("GetAllLibraries", func() {
It("should return all libraries without filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -131,7 +131,7 @@ var _ = Describe("LibraryService", Ordered, func() {
It("should return all libraries with filesystem permission", func() {
reason := "test"
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
@ -151,6 +151,103 @@ var _ = Describe("LibraryService", Ordered, func() {
})
})
Describe("Library Access Filtering", func() {
It("should only return libraries in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
{ID: 3, Name: "Classical", Path: "/music/classical", TotalSongs: 75},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
results, err := service.GetAllLibraries(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal(int32(2)))
Expect(results[0].Name).To(Equal("Jazz"))
})
It("should return error when getting a library not in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
// Requesting library 1 which is not in the allowed list
_, err := service.GetLibrary(ctx, 1)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not accessible"))
})
It("should allow access to a library in the allowed list", func() {
reason := "test"
// Only allow library ID 2
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
result, err := service.GetLibrary(ctx, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result.ID).To(Equal(int32(2)))
Expect(result.Name).To(Equal("Jazz"))
})
It("should return empty list when no libraries are allowed and allLibraries is false", func() {
reason := "test"
// No libraries allowed
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
results, err := service.GetAllLibraries(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(0))
})
It("should return all libraries when allLibraries is true regardless of allowed list", func() {
reason := "test"
// allLibraries=true should ignore the allowed list
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl)
libs := model.Libraries{
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
}
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(libs)
results, err := service.GetAllLibraries(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
})
})
Describe("Plugin Integration", func() {
var (
manager *Manager
@ -269,10 +366,11 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-library",
Path: destPath,
SHA256: hashHex,
Enabled: true,
ID: "test-library",
Path: destPath,
SHA256: hashHex,
Enabled: true,
AllLibraries: true, // Grant access to all libraries for testing
}})
mockLibraryRepo := &tests.MockLibraryRepo{}

View File

@ -399,6 +399,17 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a
})
}
// UpdatePluginLibraries updates the libraries permission settings for a plugin.
// If the plugin is enabled, it will be reloaded with the new settings.
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
// the plugin will be automatically disabled.
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
p.Libraries = librariesJSON
p.AllLibraries = allLibraries
})
}
// updatePluginSettings is a common implementation for updating plugin settings.
// The updateFn is called to apply the specific field updates to the plugin.
// If the plugin is enabled, it will be reloaded. If users permission is required
@ -422,19 +433,25 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn
updateFn(plugin)
plugin.UpdatedAt = time.Now()
// Check if plugin requires users permission and if it's still satisfied
// Check if plugin requires permission and if it's still satisfied
shouldDisable := false
disableReason := ""
if wasEnabled {
manifest, err := readManifest(plugin.Path)
if err == nil && manifest.Permissions != nil && manifest.Permissions.Users != nil {
if !hasValidUsersConfig(plugin.Users, plugin.AllUsers) {
if err == nil && manifest.Permissions != nil {
if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) {
shouldDisable = true
disableReason = "users permission removal"
}
if manifest.Permissions.Library != nil && !hasValidLibrariesConfig(plugin.Libraries, plugin.AllLibraries) {
shouldDisable = true
disableReason = "library permission removal"
}
}
}
if shouldDisable {
// Disable the plugin since users permission is no longer satisfied
// Disable the plugin since permission is no longer satisfied
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
@ -442,7 +459,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin in DB: %w", err)
}
log.Info(ctx, "Disabled plugin due to users permission removal", "plugin", id)
log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
@ -519,6 +536,13 @@ func (m *Manager) checkPermissionGates(p *model.Plugin) error {
}
}
// Check library permission gate
if manifest.Permissions != nil && manifest.Permissions.Library != nil {
if !hasValidLibrariesConfig(p.Libraries, p.AllLibraries) {
return fmt.Errorf("library permission requires configuration: select libraries or enable 'all libraries' access")
}
}
return nil
}
@ -537,3 +561,19 @@ func hasValidUsersConfig(usersJSON string, allUsers bool) bool {
}
return len(users) > 0
}
// hasValidLibrariesConfig checks if a plugin has valid libraries configuration.
// Returns true if allLibraries is true, or if librariesJSON contains at least one library.
func hasValidLibrariesConfig(librariesJSON string, allLibraries bool) bool {
if allLibraries {
return true
}
if librariesJSON == "" {
return false
}
var libraries []int
if err := json.Unmarshal([]byte(librariesJSON), &libraries); err != nil {
return false
}
return len(libraries) > 0
}

View File

@ -20,12 +20,14 @@ import (
// serviceContext provides dependencies needed by host service factories.
type serviceContext struct {
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
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
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
}
// hostServiceEntry defines a host service for table-driven registration.
@ -92,7 +94,7 @@ var hostServices = []hostServiceEntry{
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Library
service := newLibraryService(ctx.manager.ds, perm)
service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries)
return host.RegisterLibraryHostFunctions(service), nil
},
},
@ -243,6 +245,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
}
// Parse libraries from JSON
var allowedLibraries []int
if p.Libraries != "" {
if err := json.Unmarshal([]byte(p.Libraries), &allowedLibraries); err != nil {
return fmt.Errorf("parsing plugin libraries: %w", err)
}
}
// Open the .ndp package to get manifest and wasm bytes
pkg, err := openPackage(p.Path)
if err != nil {
@ -270,9 +280,20 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
// Build a set of allowed library IDs for fast lookup
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries))
for _, id := range allowedLibraries {
allowedLibrarySet[id] = struct{}{}
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
// Only mount if allLibraries is true or library is in the allowed list
if p.AllLibraries {
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
} else if _, ok := allowedLibrarySet[lib.ID]; ok {
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
}
}
pluginManifest.AllowedPaths = allowedPaths
}
@ -282,12 +303,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
var closers []io.Closer
svcCtx := &serviceContext{
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
allowedLibraries: allowedLibraries,
allLibraries: p.AllLibraries,
}
for _, entry := range hostServices {
if entry.hasPermission(pkg.Manifest.Permissions) {

View File

@ -27,6 +27,7 @@ type PluginManager interface {
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
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error
}
type Router struct {

View File

@ -43,10 +43,12 @@ 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"`
Users *string `json:"users,omitempty"`
AllUsers *bool `json:"allUsers,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
Users *string `json:"users,omitempty"`
AllUsers *bool `json:"allUsers,omitempty"`
Libraries *string `json:"libraries,omitempty"`
AllLibraries *bool `json:"allLibraries,omitempty"`
}
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
@ -93,6 +95,14 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
}
}
// Handle libraries permission update (if provided)
if req.Libraries != nil || req.AllLibraries != nil {
if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil {
log.Error(ctx, "Error updating plugin libraries", err)
return
}
}
// Handle enable/disable
if req.Enabled != nil {
if *req.Enabled {
@ -195,3 +205,36 @@ func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.Pl
}
return nil
}
// validateAndUpdateLibraries validates the libraries JSON and updates the plugin.
// Returns an error if validation or update fails (error response already written).
func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error {
// Get current values if not provided in request
plugin, err := repo.Get(id)
if err != nil {
log.Error(ctx, "Error getting plugin for libraries update", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return err
}
librariesJSON := plugin.Libraries
allLibraries := plugin.AllLibraries
if req.Libraries != nil {
if *req.Libraries != "" && !isValidJSON(*req.Libraries) {
http.Error(w, "Invalid JSON in libraries field", http.StatusBadRequest)
return errors.New("invalid JSON")
}
librariesJSON = *req.Libraries
}
if req.AllLibraries != nil {
allLibraries = *req.AllLibraries
}
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries); err != nil {
log.Error(ctx, "Error updating plugin libraries", "id", id, err)
http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError)
return err
}
return nil
}

View File

@ -5,7 +5,7 @@ import (
)
// MockPluginManager is a mock implementation of plugins.PluginManager for testing.
// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, and UpdatePluginUsers methods.
// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, UpdatePluginUsers and UpdatePluginLibraries methods.
type MockPluginManager struct {
// EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError.
EnablePluginFn func(ctx context.Context, id string) error
@ -15,12 +15,15 @@ type MockPluginManager struct {
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
// UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError.
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error
// Default errors to return when Fn callbacks are not set
EnableError error
DisableError error
ConfigError error
UsersError error
EnableError error
DisableError error
ConfigError error
UsersError error
LibrariesError error
// Track calls for assertions
EnablePluginCalls []string
@ -34,6 +37,11 @@ type MockPluginManager struct {
UsersJSON string
AllUsers bool
}
UpdatePluginLibrariesCalls []struct {
ID string
LibrariesJSON string
AllLibraries bool
}
}
func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error {
@ -74,3 +82,15 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON
}
return m.UsersError
}
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct {
ID string
LibrariesJSON string
AllLibraries bool
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries})
if m.UpdatePluginLibrariesFn != nil {
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries)
}
return m.LibrariesError
}

View File

@ -351,14 +351,17 @@
"configKey": "Key",
"configValue": "Value",
"allUsers": "Allow all users",
"selectedUsers": "Selected users"
"selectedUsers": "Selected users",
"allLibraries": "Allow all libraries",
"selectedLibraries": "Selected libraries"
},
"sections": {
"status": "Status",
"info": "Plugin Information",
"configuration": "Configuration",
"manifest": "Manifest",
"usersPermission": "Users Permission"
"usersPermission": "Users Permission",
"libraryPermission": "Library Permission"
},
"status": {
"enabled": "Enabled",
@ -369,6 +372,7 @@
"disable": "Disable",
"disabledDueToError": "Fix the error before enabling",
"disabledUsersRequired": "Select users before enabling",
"disabledLibrariesRequired": "Select libraries before enabling",
"addConfig": "Add Configuration"
},
"notifications": {
@ -387,7 +391,10 @@
"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'."
"usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'.",
"allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.",
"noLibraries": "No libraries selected",
"librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'."
},
"placeholders": {
"configKey": "key",

View File

@ -0,0 +1,171 @@
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 LibraryPermissionCard = ({
manifest,
classes,
selectedLibraries,
allLibraries,
onSelectedLibrariesChange,
onAllLibrariesChange,
}) => {
const translate = useTranslate()
// Fetch all libraries
const { data: librariesData, loading: librariesLoading } = useGetList(
'library',
{
pagination: { page: 1, perPage: 1000 },
sort: { field: 'name', order: 'ASC' },
},
)
const libraries = React.useMemo(() => {
return librariesData ? Object.values(librariesData) : []
}, [librariesData])
const handleToggleLibrary = React.useCallback(
(libraryId) => {
const newSelected = selectedLibraries.includes(libraryId)
? selectedLibraries.filter((id) => id !== libraryId)
: [...selectedLibraries, libraryId]
onSelectedLibrariesChange(newSelected)
},
[selectedLibraries, onSelectedLibrariesChange],
)
const handleAllLibrariesToggle = React.useCallback(
(event) => {
onAllLibrariesChange(event.target.checked)
},
[onAllLibrariesChange],
)
// Get permission reason from manifest
const libraryPermission = manifest?.permissions?.library
const reason = libraryPermission?.reason
// Check if permission is required but not configured
const isConfigurationRequired =
libraryPermission && !allLibraries && selectedLibraries.length === 0
if (!libraryPermission) {
return null
}
return (
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.libraryPermission')}
</Typography>
{reason && (
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('resources.plugin.messages.permissionReason')}: {reason}
</Typography>
)}
{isConfigurationRequired && (
<Box mb={2}>
<Alert severity="warning">
{translate('resources.plugin.messages.librariesRequired')}
</Alert>
</Box>
)}
<Box mb={2}>
<FormControlLabel
control={
<Switch
checked={allLibraries}
onChange={handleAllLibrariesToggle}
color="primary"
/>
}
label={translate('resources.plugin.fields.allLibraries')}
/>
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.allLibrariesHelp')}
</Typography>
</Box>
{!allLibraries && (
<Box className={classes.usersList}>
<Typography variant="subtitle2" gutterBottom>
{translate('resources.plugin.fields.selectedLibraries')}
</Typography>
{librariesLoading ? (
<Typography variant="body2" color="textSecondary">
{translate('ra.message.loading')}
</Typography>
) : libraries.length === 0 ? (
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.noLibraries')}
</Typography>
) : (
<List
dense
style={{
maxHeight: 200,
overflow: 'auto',
border: '1px solid rgba(0, 0, 0, 0.12)',
borderRadius: 4,
}}
>
{libraries.map((library) => (
<ListItem
key={library.id}
button
onClick={() => handleToggleLibrary(library.id)}
dense
>
<ListItemIcon>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selectedLibraries.includes(library.id)}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText
primary={library.name}
secondary={library.path}
/>
</ListItem>
))}
</List>
)}
</Box>
)}
</CardContent>
</Card>
)
}
LibraryPermissionCard.propTypes = {
manifest: PropTypes.object,
classes: PropTypes.object.isRequired,
selectedLibraries: PropTypes.array.isRequired,
allLibraries: PropTypes.bool.isRequired,
onSelectedLibrariesChange: PropTypes.func.isRequired,
onAllLibrariesChange: PropTypes.func.isRequired,
}

View File

@ -21,6 +21,7 @@ import { InfoCard } from './InfoCard'
import { ManifestSection } from './ManifestSection'
import { ConfigCard } from './ConfigCard'
import { UsersPermissionCard } from './UsersPermissionCard'
import { LibraryPermissionCard } from './LibraryPermissionCard'
// Main show layout component
const PluginShowLayout = () => {
@ -42,6 +43,12 @@ const PluginShowLayout = () => {
const [lastRecordUsers, setLastRecordUsers] = useState(null)
const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null)
// Libraries permission state
const [selectedLibraries, setSelectedLibraries] = useState([])
const [allLibraries, setAllLibraries] = useState(false)
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
// Convert JSON config to key-value pairs
const jsonToPairs = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return []
@ -100,6 +107,30 @@ const PluginShowLayout = () => {
}
}, [record, lastRecordUsers, lastRecordAllUsers, isDirty])
// Initialize/update libraries permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordLibraries = record.libraries || ''
const recordAllLibraries = record.allLibraries || false
if (
recordLibraries !== lastRecordLibraries ||
recordAllLibraries !== lastRecordAllLibraries
) {
try {
setSelectedLibraries(
recordLibraries ? JSON.parse(recordLibraries) : [],
)
} catch {
setSelectedLibraries([])
}
setAllLibraries(recordAllLibraries)
setLastRecordLibraries(recordLibraries)
setLastRecordAllLibraries(recordAllLibraries)
}
}
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
const handleConfigPairsChange = useCallback((newPairs) => {
setConfigPairs(newPairs)
setIsDirty(true)
@ -115,6 +146,16 @@ const PluginShowLayout = () => {
setIsDirty(true)
}, [])
const handleSelectedLibrariesChange = useCallback((newSelectedLibraries) => {
setSelectedLibraries(newSelectedLibraries)
setIsDirty(true)
}, [])
const handleAllLibrariesChange = useCallback((newAllLibraries) => {
setAllLibraries(newAllLibraries)
setIsDirty(true)
}, [])
const [updatePlugin, { loading }] = useUpdate(
'plugin',
record?.id,
@ -128,6 +169,8 @@ const PluginShowLayout = () => {
setLastRecordConfig(null) // Reset to reinitialize from server
setLastRecordUsers(null)
setLastRecordAllUsers(null)
setLastRecordLibraries(null)
setLastRecordAllLibraries(null)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
@ -151,8 +194,23 @@ const PluginShowLayout = () => {
data.allUsers = allUsers
}
// Include libraries data if library permission is present
if (manifest?.permissions?.library) {
data.libraries = JSON.stringify(selectedLibraries)
data.allLibraries = allLibraries
}
updatePlugin('plugin', record.id, data, record)
}, [updatePlugin, record, configPairs, pairsToJson, selectedUsers, allUsers])
}, [
updatePlugin,
record,
configPairs,
pairsToJson,
selectedUsers,
allUsers,
selectedLibraries,
allLibraries,
])
// Parse manifest
const { manifest, manifestJson } = useMemo(() => {
@ -230,6 +288,15 @@ const PluginShowLayout = () => {
onAllUsersChange={handleAllUsersChange}
/>
<LibraryPermissionCard
manifest={manifest}
classes={classes}
selectedLibraries={selectedLibraries}
allLibraries={allLibraries}
onSelectedLibrariesChange={handleSelectedLibrariesChange}
onAllLibrariesChange={handleAllLibrariesChange}
/>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"

View File

@ -95,8 +95,24 @@ const ToggleEnabledSwitch = ({
}
}, [manifest, record?.allUsers, record?.users])
// Check if library permission is required but not configured
const libraryPermissionRequired = useMemo(() => {
if (!manifest?.permissions?.library) return false
if (record?.allLibraries) return false
// Check if libraries array is empty or not set
if (!record?.libraries) return true
try {
const libraries = JSON.parse(record.libraries)
return libraries.length === 0
} catch {
return true
}
}, [manifest, record?.allLibraries, record?.libraries])
const permissionRequired =
usersPermissionRequired || libraryPermissionRequired
const isDisabled =
loading || hasError || (usersPermissionRequired && !record?.enabled)
loading || hasError || (permissionRequired && !record?.enabled)
const tooltipTitle = useMemo(() => {
if (hasError) {
@ -105,6 +121,9 @@ const ToggleEnabledSwitch = ({
if (usersPermissionRequired && !record?.enabled) {
return translate('resources.plugin.actions.disabledUsersRequired')
}
if (libraryPermissionRequired && !record?.enabled) {
return translate('resources.plugin.actions.disabledLibrariesRequired')
}
if (!showLabel) {
return translate(
record?.enabled
@ -113,7 +132,14 @@ const ToggleEnabledSwitch = ({
)
}
return ''
}, [hasError, usersPermissionRequired, showLabel, record?.enabled, translate])
}, [
hasError,
usersPermissionRequired,
libraryPermissionRequired,
showLabel,
record?.enabled,
translate,
])
const switchElement = (
<Switch
@ -127,11 +153,12 @@ const ToggleEnabledSwitch = ({
)
if (showLabel) {
const showTooltip = hasError || (permissionRequired && !record?.enabled)
return (
<Tooltip
title={tooltipTitle}
disableHoverListener={!hasError}
disableFocusListener={!hasError}
disableHoverListener={!showTooltip}
disableFocusListener={!showTooltip}
>
<FormControlLabel
control={switchElement}