feat(plugins): 插件管理 - 区分系统与官方插件权限 (#329)

This commit is contained in:
MasterChiefCN
2026-08-27 11:22:00 +08:00
committed by GitHub
parent 23b4628c81
commit f111d57fc6
32 changed files with 1137 additions and 143 deletions

View File

@@ -72,7 +72,7 @@ func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {
}
}
func TestPluginsForUserHidesBundledPluginsOnlyForOrdinaryUsers(t *testing.T) {
func TestPluginsForUserKeepsOfficialApplicationsAndHidesManagedPlugins(t *testing.T) {
svc, _ := newFeatureAvailabilityTestService(t)
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
if _, err := svc.UpdateFeatureAvailability(admin, FeatureAvailability{
@@ -83,21 +83,22 @@ func TestPluginsForUserHidesBundledPluginsOnlyForOrdinaryUsers(t *testing.T) {
}
svc.pluginRuntime = &pluginRuntime{plugins: map[string]pluginRecord{
"bundled": {Source: "bundled", Metadata: protocol.Metadata{ID: "bundled", Name: "系统协议", Version: "1"}},
"uploaded": {Source: "uploaded", Metadata: protocol.Metadata{ID: "uploaded", Name: "用户协议", Version: "1"}},
"uploaded": {Source: "uploaded", Metadata: protocol.Metadata{ID: "uploaded", Name: "自定义协议", Version: "1"}},
"app": {Source: "bundled", Metadata: protocol.Metadata{ID: WorkflowPluginRunningHub, Name: "官方应用", Version: "1"}},
}}
visibleToUser, err := svc.PluginsForUser(&model.User{ID: "user-1", Role: model.UserRoleUser})
if err != nil {
t.Fatal(err)
}
if len(visibleToUser) != 1 || visibleToUser[0].Source != "uploaded" {
if len(visibleToUser) != 1 || visibleToUser[0].Manifest.ID != WorkflowPluginRunningHub {
t.Fatalf("PluginsForUser(user) = %#v", visibleToUser)
}
visibleToAdmin, err := svc.PluginsForUser(admin)
if err != nil {
t.Fatal(err)
}
if len(visibleToAdmin) != 2 {
if len(visibleToAdmin) != 3 {
t.Fatalf("PluginsForUser(admin) = %#v", visibleToAdmin)
}
}

View File

@@ -0,0 +1,303 @@
package service
import (
"fmt"
"strings"
"time"
"infinite-canvas/backend/internal/model"
)
const (
PluginEagleAssetConnector = "eagle-asset-connector"
PluginPromptOptimizer = "prompt-optimizer"
PluginPortraitClearance = "portrait-clearance"
PluginOriginOfficial = "official"
PluginOriginUploaded = "uploaded"
PluginKindProtocol = "protocol"
PluginKindApplication = "application"
PluginScopeSystem = "system"
PluginScopeUser = "user"
PluginConfigurationNone = "none"
PluginConfigurationSystem = "system"
PluginConfigurationUser = "user"
)
// PluginManagementView is computed by the host. Uploaded manifests cannot
// choose their own privilege or activation scope.
type PluginManagementView struct {
Origin string `json:"origin"`
Kind string `json:"kind"`
ActivationScope string `json:"activationScope"`
ConfigurationScope string `json:"configurationScope"`
}
type PluginStateView struct {
PluginID string `json:"pluginId"`
PlatformAvailable bool `json:"platformAvailable"`
UserEnabled bool `json:"userEnabled"`
UserConfigured bool `json:"userConfigured"`
EffectiveEnabled bool `json:"effectiveEnabled"`
CanToggle bool `json:"canToggle"`
CanConfigure bool `json:"canConfigure"`
BlockedReason string `json:"blockedReason,omitempty"`
}
type AdminPluginStateView struct {
PluginStateView
EnabledUserCount int64 `json:"enabledUserCount"`
}
var officialApplicationPolicies = map[string]PluginManagementView{
WorkflowPluginRunningHub: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser,
},
WorkflowPluginComfyUI: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser,
},
PluginEagleAssetConnector: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationUser,
},
PluginPromptOptimizer: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone,
},
PluginPortraitClearance: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone,
},
}
func pluginManagement(pluginID string, source string) PluginManagementView {
if strings.TrimSpace(source) == PluginOriginUploaded {
return PluginManagementView{
Origin: PluginOriginUploaded, Kind: PluginKindProtocol,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
}
}
if policy, ok := officialApplicationPolicies[strings.TrimSpace(pluginID)]; ok {
return policy
}
return PluginManagementView{
Origin: PluginOriginOfficial, Kind: PluginKindProtocol,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
}
}
func knownPluginIDs(items []PluginView) []string {
seen := make(map[string]struct{}, len(items)+len(officialApplicationPolicies))
ids := make([]string, 0, len(items)+len(officialApplicationPolicies))
for id := range officialApplicationPolicies {
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, item := range items {
if _, exists := seen[item.Manifest.ID]; exists {
continue
}
seen[item.Manifest.ID] = struct{}{}
ids = append(ids, item.Manifest.ID)
}
return ids
}
func runtimePluginByID(items []PluginView, pluginID string) (PluginView, bool) {
for _, item := range items {
if item.Manifest.ID == pluginID {
return item, true
}
}
return PluginView{}, false
}
func (s *Service) PluginStatesForUser(actor *model.User) (map[string]PluginStateView, error) {
items := s.Plugins()
result := make(map[string]PluginStateView, len(items)+len(officialApplicationPolicies))
for _, pluginID := range knownPluginIDs(items) {
state, err := s.pluginStateForUser(actor, pluginID, items)
if err != nil {
return nil, err
}
result[pluginID] = state
}
return result, nil
}
func (s *Service) pluginStateForUser(actor *model.User, pluginID string, items []PluginView) (PluginStateView, error) {
runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID)
source := "bundled"
if hasRuntime {
source = runtimePlugin.Source
} else if _, known := officialApplicationPolicies[pluginID]; !known {
return PluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID)
}
policy := pluginManagement(pluginID, source)
platformAvailable := policy.Kind == PluginKindApplication
if hasRuntime && (policy.ActivationScope == PluginScopeSystem || s.repo == nil) {
platformAvailable = runtimePlugin.Status == "enabled"
}
if s.repo != nil {
platformState, err := s.repo.PluginPlatformState(pluginID)
if err != nil {
return PluginStateView{}, fmt.Errorf("读取插件平台状态:%w", err)
}
if platformState != nil {
platformAvailable = platformState.Available
}
}
userEnabled := false
userConfigured := false
if policy.ActivationScope == PluginScopeUser && actor != nil {
if s.repo != nil {
userState, err := s.repo.UserPluginState(actor.ID, pluginID)
if err != nil {
return PluginStateView{}, fmt.Errorf("读取用户插件状态:%w", err)
}
if userState != nil {
userEnabled, userConfigured = userState.Enabled, true
}
}
// Preserve the old globally-enabled workflow behavior until each user
// explicitly saves a personal choice. Other official applications were
// already controlled by each user's local installation state.
if !userConfigured && hasRuntime && isLegacyWorkflowPlugin(pluginID) {
userEnabled = runtimePlugin.Status == "enabled"
}
}
effective := platformAvailable
if policy.ActivationScope == PluginScopeUser {
effective = platformAvailable && userEnabled
}
state := PluginStateView{
PluginID: pluginID, PlatformAvailable: platformAvailable,
UserEnabled: userEnabled, UserConfigured: userConfigured,
EffectiveEnabled: effective,
CanToggle: policy.ActivationScope == PluginScopeUser && platformAvailable,
CanConfigure: policy.ConfigurationScope == PluginConfigurationUser && platformAvailable,
}
if !platformAvailable {
state.BlockedReason = "管理员已停用该插件"
} else if policy.ActivationScope == PluginScopeSystem {
state.BlockedReason = "系统插件由管理员统一管理"
}
return state, nil
}
func isLegacyWorkflowPlugin(pluginID string) bool {
return pluginID == WorkflowPluginRunningHub || pluginID == WorkflowPluginComfyUI
}
func (s *Service) SetUserPluginEnabled(actor *model.User, pluginID string, enabled bool) (PluginStateView, error) {
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return PluginStateView{}, Forbidden("请先登录")
}
items := s.Plugins()
current, err := s.pluginStateForUser(actor, pluginID, items)
if err != nil {
return PluginStateView{}, err
}
runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID)
source := "bundled"
if hasRuntime {
source = runtimePlugin.Source
}
if pluginManagement(pluginID, source).ActivationScope != PluginScopeUser {
return PluginStateView{}, Forbidden("系统插件只能由管理员统一管理")
}
if !current.PlatformAvailable {
return PluginStateView{}, Forbidden("管理员已停用该插件")
}
if s.repo == nil {
return PluginStateView{}, fmt.Errorf("插件状态存储未初始化")
}
now := time.Now()
state := &model.UserPluginState{ID: newID(), UserID: actor.ID, PluginID: pluginID, Enabled: enabled, CreatedAt: now, UpdatedAt: now}
if err := s.repo.SaveUserPluginState(state); err != nil {
return PluginStateView{}, fmt.Errorf("保存用户插件状态:%w", err)
}
return s.pluginStateForUser(actor, pluginID, items)
}
func (s *Service) AdminPluginStates(actor *model.User) (map[string]AdminPluginStateView, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
states, err := s.PluginStatesForUser(actor)
if err != nil {
return nil, err
}
counts, err := s.repo.EnabledPluginUserCounts()
if err != nil {
return nil, fmt.Errorf("统计插件启用用户数:%w", err)
}
result := make(map[string]AdminPluginStateView, len(states))
for pluginID, state := range states {
result[pluginID] = AdminPluginStateView{PluginStateView: state, EnabledUserCount: counts[pluginID]}
}
return result, nil
}
func (s *Service) SetPluginPlatformAvailability(actor *model.User, pluginID string, available bool) (AdminPluginStateView, error) {
if err := s.RequireAdmin(actor); err != nil {
return AdminPluginStateView{}, err
}
items := s.Plugins()
runtimePlugin, hasRuntime := runtimePluginByID(items, pluginID)
source := "bundled"
if hasRuntime {
source = runtimePlugin.Source
} else if _, known := officialApplicationPolicies[pluginID]; !known {
return AdminPluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID)
}
policy := pluginManagement(pluginID, source)
previousRuntimeEnabled := false
runtimeChanged := false
if policy.ActivationScope == PluginScopeSystem {
if !hasRuntime {
return AdminPluginStateView{}, fmt.Errorf("插件 %q 缺少运行时", pluginID)
}
previousRuntimeEnabled = runtimePlugin.Status == "enabled"
if _, err := s.SetPluginEnabled(pluginID, available); err != nil {
return AdminPluginStateView{}, err
}
runtimeChanged = previousRuntimeEnabled != available
}
now := time.Now()
platformState := &model.PluginPlatformState{PluginID: pluginID, Available: available, UpdatedBy: actor.ID, CreatedAt: now, UpdatedAt: now}
if err := s.repo.SavePluginPlatformState(platformState); err != nil {
if runtimeChanged {
_, _ = s.SetPluginEnabled(pluginID, previousRuntimeEnabled)
}
return AdminPluginStateView{}, fmt.Errorf("保存插件平台状态:%w", err)
}
if err := s.appendAdminAudit(actor, "plugin.availability.update", "plugin", pluginID, "更新插件平台可用状态", map[string]any{"available": available, "kind": policy.Kind, "origin": policy.Origin}); err != nil {
return AdminPluginStateView{}, err
}
states, err := s.AdminPluginStates(actor)
if err != nil {
return AdminPluginStateView{}, err
}
return states[pluginID], nil
}
func (s *Service) RequirePluginForUser(userID string, pluginID string) error {
state, err := s.pluginStateForUser(&model.User{ID: strings.TrimSpace(userID)}, pluginID, s.Plugins())
if err != nil {
return err
}
if !state.EffectiveEnabled {
if state.BlockedReason != "" {
return Forbidden(state.BlockedReason)
}
return Forbidden("插件未启用")
}
return nil
}

View File

@@ -0,0 +1,71 @@
package service
import (
"testing"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestApplicationPluginUsesUserStateUnderPlatformAvailability(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PluginPlatformState{}, &model.UserPluginState{}, &model.AdminAuditEvent{}); err != nil {
t.Fatal(err)
}
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
svc := &Service{repo: repository.New(db), pluginRuntime: center}
user := &model.User{ID: "user-1", Role: model.UserRoleUser}
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
states, err := svc.PluginStatesForUser(user)
if err != nil {
t.Fatal(err)
}
initial := states[WorkflowPluginRunningHub]
if !initial.PlatformAvailable || initial.UserEnabled || initial.EffectiveEnabled || !initial.CanToggle {
t.Fatalf("initial RunningHub state = %#v", initial)
}
enabled, err := svc.SetUserPluginEnabled(user, WorkflowPluginRunningHub, true)
if err != nil {
t.Fatal(err)
}
if !enabled.UserConfigured || !enabled.UserEnabled || !enabled.EffectiveEnabled {
t.Fatalf("enabled RunningHub state = %#v", enabled)
}
if _, err := svc.SetPluginPlatformAvailability(user, WorkflowPluginRunningHub, false); err == nil {
t.Fatal("ordinary user changed platform plugin availability")
}
disabled, err := svc.SetPluginPlatformAvailability(admin, WorkflowPluginRunningHub, false)
if err != nil {
t.Fatal(err)
}
if disabled.PlatformAvailable || disabled.EffectiveEnabled || disabled.EnabledUserCount != 1 {
t.Fatalf("platform-disabled RunningHub state = %#v", disabled)
}
if err := svc.RequireWorkflowPluginForUser(user.ID, "runninghub-workflow-image"); err == nil {
t.Fatal("platform-disabled workflow was accepted for a new user request")
}
if _, err := svc.SetPluginPlatformAvailability(admin, WorkflowPluginRunningHub, true); err != nil {
t.Fatal(err)
}
if err := svc.RequireWorkflowPluginForUser(user.ID, "runninghub-workflow-video"); err != nil {
t.Fatalf("restored user workflow rejected: %v", err)
}
}
func TestUploadedManifestCannotClaimUserActivationScope(t *testing.T) {
policy := pluginManagement(PluginPromptOptimizer, PluginOriginUploaded)
if policy.Origin != PluginOriginUploaded || policy.ActivationScope != PluginScopeSystem || policy.ConfigurationScope != PluginConfigurationSystem {
t.Fatalf("uploaded plugin policy = %#v", policy)
}
}

View File

@@ -23,15 +23,16 @@ const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
// plugin center. Protocol-specific runtime data is nested under Protocol so
// the public plugin contract can grow without adding another center API.
type PluginView struct {
Manifest PluginManifestView `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName"`
Package string `json:"package"`
SHA256 string `json:"sha256"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Manifest PluginManifestView `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName"`
Package string `json:"package"`
SHA256 string `json:"sha256"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Management PluginManagementView `json:"management"`
}
type PluginManifestView struct {

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
@@ -168,7 +169,11 @@ func (s *Service) Plugins() []PluginView {
if s.pluginRuntime == nil {
return []PluginView{}
}
return s.pluginRuntime.list()
items := s.pluginRuntime.list()
for index := range items {
items[index].Management = pluginManagement(items[index].Manifest.ID, items[index].Source)
}
return items
}
// PluginsForUser keeps the plugin center response aligned with the public
@@ -188,7 +193,7 @@ func (s *Service) PluginsForUser(actor *model.User) ([]PluginView, error) {
}
filtered := make([]PluginView, 0, len(items))
for _, item := range items {
if item.Source != "bundled" {
if item.Management.ActivationScope == PluginScopeUser {
filtered = append(filtered, item)
}
}
@@ -202,6 +207,33 @@ func (s *Service) InstallPlugin(data []byte, fileName string) (PluginView, error
return s.pluginRuntime.install(data, fileName)
}
func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName string) (PluginView, error) {
if err := s.RequireAdmin(actor); err != nil {
return PluginView{}, err
}
parsed, err := protocol.ParsePluginPackage(data)
if err != nil {
return PluginView{}, err
}
if _, reserved := officialApplicationPolicies[parsed.Manifest.Metadata.ID]; reserved {
return PluginView{}, fmt.Errorf("插件 ID %q 由官方应用保留", parsed.Manifest.Metadata.ID)
}
plugin, err := s.InstallPlugin(data, fileName)
if err != nil {
return PluginView{}, err
}
plugin.Management = pluginManagement(plugin.Manifest.ID, plugin.Source)
now := time.Now()
state := &model.PluginPlatformState{PluginID: plugin.Manifest.ID, Available: plugin.Status == "enabled", UpdatedBy: actor.ID, CreatedAt: now, UpdatedAt: now}
if err := s.repo.SavePluginPlatformState(state); err != nil {
return PluginView{}, fmt.Errorf("保存插件平台状态:%w", err)
}
if err := s.appendAdminAudit(actor, "plugin.install", "plugin", plugin.Manifest.ID, "安装自定义插件", map[string]any{"fileName": fileName, "sha256": plugin.SHA256}); err != nil {
return PluginView{}, err
}
return plugin, nil
}
func (s *Service) PluginPackage(id string) ([]byte, string, error) {
if s.pluginRuntime == nil {
return nil, "", fmt.Errorf("插件运行时未初始化")
@@ -235,3 +267,19 @@ func (s *Service) UninstallPlugin(id string) error {
}
return s.pluginRuntime.uninstall(id)
}
func (s *Service) UninstallPluginForAdmin(actor *model.User, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
if err := s.UninstallPlugin(id); err != nil {
return err
}
if err := s.repo.DeleteUserPluginStates(id); err != nil {
return fmt.Errorf("清理用户插件状态:%w", err)
}
if err := s.repo.DeletePluginPlatformState(id); err != nil {
return fmt.Errorf("清理插件平台状态:%w", err)
}
return s.appendAdminAudit(actor, "plugin.uninstall", "plugin", id, "卸载自定义插件", nil)
}

View File

@@ -32,7 +32,7 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
frontendEnabled := false
if workflowProviderTask {
config, _ := normalizedInput["config"].(map[string]any)
if err := s.RequireWorkflowPluginForInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil {
if err := s.RequireWorkflowPluginForUser(userID, strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil {
return nil, err
}
} else {

View File

@@ -3,6 +3,7 @@ package service
import (
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
)
@@ -66,14 +67,34 @@ func workflowPluginIDForInterface(value string) (string, bool) {
func (s *Service) WorkflowPluginStatuses() map[string]string {
statuses := make(map[string]string, 2)
for _, plugin := range s.Plugins() {
if plugin.Manifest.ID == WorkflowPluginRunningHub || plugin.Manifest.ID == WorkflowPluginComfyUI {
statuses[plugin.Manifest.ID] = plugin.Status
for _, pluginID := range []string{WorkflowPluginRunningHub, WorkflowPluginComfyUI} {
state, err := s.pluginStateForUser(nil, pluginID, s.Plugins())
if err == nil && state.PlatformAvailable {
statuses[pluginID] = "enabled"
} else {
statuses[pluginID] = "disabled"
}
}
return statuses
}
func (s *Service) WorkflowPluginStatusesForUser(userID string) (map[string]string, error) {
statuses := make(map[string]string, 2)
actor := &model.User{ID: strings.TrimSpace(userID)}
for _, pluginID := range []string{WorkflowPluginRunningHub, WorkflowPluginComfyUI} {
state, err := s.pluginStateForUser(actor, pluginID, s.Plugins())
if err != nil {
return nil, err
}
if state.EffectiveEnabled {
statuses[pluginID] = "enabled"
} else {
statuses[pluginID] = "disabled"
}
}
return statuses, nil
}
func (s *Service) RequireWorkflowPluginForInterface(interfaceType string) error {
pluginID, ok := workflowPluginIDForInterface(normalizeWorkflowInterfaceType(interfaceType))
if !ok {
@@ -89,6 +110,20 @@ func (s *Service) RequireWorkflowPluginForInterface(interfaceType string) error
return nil
}
func (s *Service) RequireWorkflowPluginForUser(userID string, interfaceType string) error {
pluginID, ok := workflowPluginIDForInterface(normalizeWorkflowInterfaceType(interfaceType))
if !ok {
return Forbidden("未知工作流插件")
}
if err := s.RequirePluginForUser(userID, pluginID); err != nil {
if pluginID == WorkflowPluginRunningHub {
return Forbidden("RunningHub 工作流插件未启用")
}
return Forbidden("ComfyUI Bridge 工作流插件未启用")
}
return nil
}
func normalizeWorkflowInterfaceType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
return value