navidrome/plugins/manifest_permissions_test.go
Kendall Garner 0cd15c1ddc
feat(prometheus): add metrics to Subsonic API and Plugins (#4266)
* Add prometheus metrics to subsonic and plugins

* address feedback, do not log error if operation is not supported

* add missing timestamp and client to stats

* remove .view from subsonic route

* directly inject DataStore in Prometheus, to avoid having to pass it in every call

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-06-27 22:13:57 -04:00

526 lines
18 KiB
Go

package plugins
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/plugins/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Helper function to create test plugins with typed permissions
func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPermissions) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Use the generated PluginManifest type directly - it handles JSON marshaling automatically
manifest := schema.PluginManifest{
Name: name,
Author: "Test Author",
Version: "1.0.0",
Description: "Test plugin for permissions",
Website: "https://test.navidrome.org/" + name,
Capabilities: []schema.PluginManifestCapabilitiesElem{
schema.PluginManifestCapabilitiesElemMetadataAgent,
},
Permissions: permissions,
}
// Marshal the typed manifest directly - gets all validation for free
manifestData, err := json.Marshal(manifest)
Expect(err).NotTo(HaveOccurred())
manifestPath := filepath.Join(pluginDir, "manifest.json")
Expect(os.WriteFile(manifestPath, manifestData, 0600)).To(Succeed())
// Create fake WASM file (since plugin discovery checks for it)
wasmPath := filepath.Join(pluginDir, "plugin.wasm")
Expect(os.WriteFile(wasmPath, []byte("fake wasm content"), 0600)).To(Succeed())
return pluginDir
}
var _ = Describe("Plugin Permissions", func() {
var (
mgr *Manager
tempDir string
ctx context.Context
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
mgr = createManager(nil, nil)
tempDir = GinkgoT().TempDir()
})
Describe("Permission Enforcement in createRuntime", func() {
It("should only load services specified in permissions", func() {
// Test with limited permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
runtimeFunc := mgr.createRuntime("test-plugin", permissions)
// Create runtime to test service availability
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// The runtime was created successfully with the specified permissions
Expect(runtime).NotTo(BeNil())
// Note: The actual verification of which specific host functions are available
// would require introspecting the WASM runtime, which is complex.
// The key test is that the runtime creation succeeds with valid permissions.
})
It("should create runtime with empty permissions", func() {
permissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("empty-permissions-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Should succeed but with no host services available
Expect(runtime).NotTo(BeNil())
})
It("should handle all available permissions", func() {
// Test with all possible permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
Websocket: &schema.PluginManifestPermissionsWebsocket{
Reason: "To handle real-time communication",
AllowedUrls: []string{"wss://api.example.com"},
AllowLocalNetwork: false,
},
Cache: &schema.PluginManifestPermissionsCache{
Reason: "To cache data and reduce API calls",
},
Artwork: &schema.PluginManifestPermissionsArtwork{
Reason: "To generate artwork URLs",
},
}
runtimeFunc := mgr.createRuntime("full-permissions-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
Expect(runtime).NotTo(BeNil())
})
})
Describe("Plugin Discovery with Permissions", func() {
BeforeEach(func() {
conf.Server.Plugins.Folder = tempDir
})
It("should discover plugin with valid permissions manifest", func() {
// Create plugin with http permission using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch metadata from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
},
}
createTestPlugin(tempDir, "valid-plugin", permissions)
// Scan for plugins
mgr.ScanPlugins()
// Verify plugin was discovered (even without valid WASM)
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("valid-plugin"))
})
It("should discover plugin with no permissions", func() {
// Create plugin with empty permissions using typed structs
permissions := schema.PluginManifestPermissions{}
createTestPlugin(tempDir, "no-perms-plugin", permissions)
mgr.ScanPlugins()
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("no-perms-plugin"))
})
It("should discover plugin with multiple permissions", func() {
// Create plugin with multiple permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch metadata from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read plugin configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic data updates",
},
}
createTestPlugin(tempDir, "multi-perms-plugin", permissions)
mgr.ScanPlugins()
pluginNames := mgr.PluginNames("MetadataAgent")
Expect(pluginNames).To(ContainElement("multi-perms-plugin"))
})
})
Describe("Existing Plugin Permissions", func() {
BeforeEach(func() {
// Use the testdata directory with updated plugins
conf.Server.Plugins.Folder = testDataDir
mgr.ScanPlugins()
})
It("should discover fake_scrobbler with empty permissions", func() {
scrobblerNames := mgr.PluginNames(CapabilityScrobbler)
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
})
It("should discover multi_plugin with scheduler permissions", func() {
agentNames := mgr.PluginNames(CapabilityMetadataAgent)
Expect(agentNames).To(ContainElement("multi_plugin"))
})
It("should discover all test plugins successfully", func() {
// All test plugins should be discovered with their updated permissions
testPlugins := []struct {
name string
capability string
}{
{"fake_album_agent", CapabilityMetadataAgent},
{"fake_artist_agent", CapabilityMetadataAgent},
{"fake_scrobbler", CapabilityScrobbler},
{"multi_plugin", CapabilityMetadataAgent},
{"fake_init_service", CapabilityLifecycleManagement},
}
for _, testPlugin := range testPlugins {
pluginNames := mgr.PluginNames(testPlugin.capability)
Expect(pluginNames).To(ContainElement(testPlugin.name), "Plugin %s should be discovered", testPlugin.name)
}
})
})
Describe("Permission Validation", func() {
It("should enforce permissions are required in manifest", func() {
// Create a manifest JSON string without the permissions field
manifestContent := `{
"name": "test-plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://test.navidrome.org/test-plugin",
"capabilities": ["MetadataAgent"]
}`
manifestPath := filepath.Join(tempDir, "manifest.json")
err := os.WriteFile(manifestPath, []byte(manifestContent), 0600)
Expect(err).NotTo(HaveOccurred())
_, err = LoadManifest(tempDir)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("field permissions in PluginManifest: required"))
})
It("should allow unknown permission keys", func() {
// Create manifest with both known and unknown permission types
pluginDir := filepath.Join(tempDir, "unknown-perms")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
manifestContent := `{
"name": "unknown-perms",
"author": "Test Author",
"version": "1.0.0",
"description": "Manifest with unknown permissions",
"website": "https://test.navidrome.org/unknown-perms",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch data from external APIs",
"allowedUrls": {
"*": ["*"]
}
},
"unknown": {
"customField": "customValue"
}
}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
// Test manifest loading directly - should succeed even with unknown permissions
loadedManifest, err := LoadManifest(pluginDir)
Expect(err).NotTo(HaveOccurred())
Expect(loadedManifest).NotTo(BeNil())
// With typed permissions, we check the specific fields
Expect(loadedManifest.Permissions.Http).NotTo(BeNil())
Expect(loadedManifest.Permissions.Http.Reason).To(Equal("To fetch data from external APIs"))
// The key point is that the manifest loads successfully despite unknown permissions
// The actual handling of AdditionalProperties depends on the JSON schema implementation
})
})
Describe("Runtime Pool with Permissions", func() {
It("should create separate runtimes for different permission sets", func() {
// Create two different permission sets using typed structs
permissions1 := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
permissions2 := schema.PluginManifestPermissions{
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
runtimeFunc1 := mgr.createRuntime("plugin1", permissions1)
runtimeFunc2 := mgr.createRuntime("plugin2", permissions2)
runtime1, err1 := runtimeFunc1(ctx)
Expect(err1).NotTo(HaveOccurred())
defer runtime1.Close(ctx)
runtime2, err2 := runtimeFunc2(ctx)
Expect(err2).NotTo(HaveOccurred())
defer runtime2.Close(ctx)
// Should be different runtime instances
Expect(runtime1).NotTo(BeIdenticalTo(runtime2))
})
})
Describe("Permission System Integration", func() {
It("should successfully validate manifests with permissions", func() {
// Create a valid manifest with permissions
pluginDir := filepath.Join(tempDir, "valid-manifest")
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
manifestContent := `{
"name": "valid-manifest",
"author": "Test Author",
"version": "1.0.0",
"description": "Valid manifest with permissions",
"website": "https://test.navidrome.org/valid-manifest",
"capabilities": ["MetadataAgent"],
"permissions": {
"http": {
"reason": "To fetch metadata from external APIs",
"allowedUrls": {
"*": ["*"]
}
},
"config": {
"reason": "To read plugin configuration settings"
}
}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed())
// Load the manifest - should succeed
manifest, err := LoadManifest(pluginDir)
Expect(err).NotTo(HaveOccurred())
Expect(manifest).NotTo(BeNil())
// With typed permissions, check the specific permission fields
Expect(manifest.Permissions.Http).NotTo(BeNil())
Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata from external APIs"))
Expect(manifest.Permissions.Config).NotTo(BeNil())
Expect(manifest.Permissions.Config.Reason).To(Equal("To read plugin configuration settings"))
})
It("should track which services are requested per plugin", func() {
// Test that different plugins can have different permission sets
permissions1 := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
permissions2 := schema.PluginManifestPermissions{
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration for scheduler",
},
}
permissions3 := schema.PluginManifestPermissions{} // Empty permissions
createTestPlugin(tempDir, "plugin-with-http", permissions1)
createTestPlugin(tempDir, "plugin-with-scheduler", permissions2)
createTestPlugin(tempDir, "plugin-with-none", permissions3)
conf.Server.Plugins.Folder = tempDir
mgr.ScanPlugins()
// All should be discovered
pluginNames := mgr.PluginNames(CapabilityMetadataAgent)
Expect(pluginNames).To(ContainElement("plugin-with-http"))
Expect(pluginNames).To(ContainElement("plugin-with-scheduler"))
Expect(pluginNames).To(ContainElement("plugin-with-none"))
})
})
Describe("Runtime Service Access Control", func() {
It("should successfully create runtime with permitted services", func() {
// Create runtime with HTTP permission using typed struct
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
runtimeFunc := mgr.createRuntime("http-only-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created successfully - host functions are loaded during runtime creation
Expect(runtime).NotTo(BeNil())
})
It("should successfully create runtime with multiple permitted services", func() {
// Create runtime with multiple permissions using typed structs
permissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
Scheduler: &schema.PluginManifestPermissionsScheduler{
Reason: "To schedule periodic tasks",
},
}
runtimeFunc := mgr.createRuntime("multi-service-plugin", permissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created successfully
Expect(runtime).NotTo(BeNil())
})
It("should create runtime with no services when no permissions granted", func() {
// Create runtime with empty permissions using typed struct
emptyPermissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("no-service-plugin", emptyPermissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should still be created, but with no host services
Expect(runtime).NotTo(BeNil())
})
It("should demonstrate secure-by-default behavior", func() {
// Test that default (empty permissions) provides no services
defaultPermissions := schema.PluginManifestPermissions{}
runtimeFunc := mgr.createRuntime("default-plugin", defaultPermissions)
runtime, err := runtimeFunc(ctx)
Expect(err).NotTo(HaveOccurred())
defer runtime.Close(ctx)
// Runtime should be created but with no host services
Expect(runtime).NotTo(BeNil())
})
It("should test permission enforcement by simulating unauthorized service access", func() {
// This test demonstrates that plugins would fail at runtime when trying to call
// host functions they don't have permission for, since those functions are simply
// not loaded into the WASM runtime environment.
// Create two different runtimes with different permissions using typed structs
httpOnlyPermissions := schema.PluginManifestPermissions{
Http: &schema.PluginManifestPermissionsHttp{
Reason: "To fetch data from external APIs",
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
"*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard},
},
AllowLocalNetwork: false,
},
}
configOnlyPermissions := schema.PluginManifestPermissions{
Config: &schema.PluginManifestPermissionsConfig{
Reason: "To read configuration settings",
},
}
httpRuntime, err := mgr.createRuntime("http-only", httpOnlyPermissions)(ctx)
Expect(err).NotTo(HaveOccurred())
defer httpRuntime.Close(ctx)
configRuntime, err := mgr.createRuntime("config-only", configOnlyPermissions)(ctx)
Expect(err).NotTo(HaveOccurred())
defer configRuntime.Close(ctx)
// Both runtimes should be created successfully, but they will have different
// sets of host functions available. A plugin trying to call unauthorized
// functions would get "function not found" errors during instantiation or execution.
Expect(httpRuntime).NotTo(BeNil())
Expect(configRuntime).NotTo(BeNil())
})
})
})