diff --git a/backend/config/config_security_test.go b/backend/config/config_security_test.go index 2f7c198..5e5431f 100644 --- a/backend/config/config_security_test.go +++ b/backend/config/config_security_test.go @@ -43,6 +43,74 @@ func TestConfigCredentialsAreEncryptedAtRest(t *testing.T) { } } +func TestNotificationSystemConfigurationRequiresSuperAdmin(t *testing.T) { + for _, id := range []string{"telegram_push", "wechat_push"} { + t.Run(id, func(t *testing.T) { + original := PluginInstance{Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)} + current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: original}}} + for _, tc := range []struct { + name string + modify func(*PluginInstance) + }{ + {"enabled", func(p *PluginInstance) { p.Enabled = false }}, + {"settings", func(p *PluginInstance) { p.Settings = json.RawMessage(`{"recipient":"changed"}`) }}, + {"secret", func(p *PluginInstance) { p.Secret.Ciphertext = "changed" }}, + {"executable", func(p *PluginInstance) { p.Executable = "changed" }}, + {"capabilities", func(p *PluginInstance) { p.Permissions.Capabilities = []string{"network"} }}, + {"network", func(p *PluginInstance) { + p.Permissions.Network = []PluginNetworkTarget{{Scheme: "https", Host: "example.com", Port: 443}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + next := original + tc.modify(&next) + candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{id: next}}} + for _, role := range []string{"super_admin", "admin", "user"} { + err := AuthorizePluginChanges(AuditActor{UserID: "1", Role: role}, current, candidate) + if (err == nil) != (role == "super_admin") { + t.Fatalf("角色=%s,修改=%s,授权结果=%v", role, tc.name, err) + } + } + }) + } + for _, role := range []string{"super_admin", "admin", "user"} { + actor := AuditActor{UserID: "1", Role: role} + if err := AuthorizePluginChanges(actor, RuntimeConfig{}, current); (err == nil) != (role == "super_admin") { + t.Fatalf("角色=%s,首次配置授权结果=%v", role, err) + } + if err := AuthorizePluginChanges(actor, current, RuntimeConfig{}); (err == nil) != (role == "super_admin") { + t.Fatalf("角色=%s,移除配置授权结果=%v", role, err) + } + } + }) + } +} + +func TestAdminCanCarryUnchangedNotificationConfiguration(t *testing.T) { + for _, id := range []string{"telegram_push", "wechat_push"} { + current := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{ + id: {Enabled: true, Settings: json.RawMessage(`{"recipient":"original","enabled":true}`)}, + "spam_block": {Settings: json.RawMessage(`{"score":1}`)}, + }}} + candidate := RuntimeConfig{Plugins: PluginsSectionConfig{Plugins: map[string]PluginInstance{ + id: {Enabled: true, Settings: json.RawMessage(`{ "enabled": true, "recipient": "original" }`)}, + "spam_block": {Settings: json.RawMessage(`{"score":2}`)}, + }}} + if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err != nil { + t.Fatalf("修改垃圾邮件参数不应误拦截未变化的渠道 %s:%v", id, err) + } + if err := AuthorizePluginChanges(AuditActor{UserID: "3", Role: "user"}, current, candidate); err == nil { + t.Fatal("普通用户不应修改系统插件配置") + } + invalid := candidate.Plugins.Plugins[id] + invalid.Settings = append(invalid.Settings, []byte(" {}")...) + candidate.Plugins.Plugins[id] = invalid + if err := AuthorizePluginChanges(AuditActor{UserID: "2", Role: "admin"}, current, candidate); err == nil { + t.Fatal("尾随 JSON 不应视为未变化的系统配置") + } + } +} + func TestConfigPrefixCredentialRoundTrip(t *testing.T) { oldRoot := ROOT_PATH root := t.TempDir() diff --git a/backend/config/security.go b/backend/config/security.go index 069dd5d..b3ff3ab 100644 --- a/backend/config/security.go +++ b/backend/config/security.go @@ -182,7 +182,7 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C return nil } -// AuthorizePluginChanges 限制管理员只能修改已有插件参数,不允许安装、替换程序或提升权限。 +// AuthorizePluginChanges 限制管理员只能修改普通插件参数;公众号和机器人始终由超级管理员配置。 func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig) error { if actor.Role == string(access.RoleSuperAdmin) { return nil @@ -195,10 +195,35 @@ func AuthorizePluginChanges(actor AuditActor, current, candidate RuntimeConfig) if !ok || next.Executable != previous.Executable || !reflect.DeepEqual(next.Permissions, previous.Permissions) { return ErrAuthorizationRequired } + if id == "wechat_push" || id == "telegram_push" { + // 允许普通插件保存时原样携带共享配置,但不允许借整个分区绕过权限。 + if !sameNotificationPlugin(previous, next) { + return ErrAuthorizationRequired + } + } } return nil } +func sameNotificationPlugin(previous, next PluginInstance) bool { + var settings [2]any + for index, data := range []json.RawMessage{previous.Settings, next.Settings} { + if len(data) == 0 { + continue + } + if !json.Valid(data) { + return false + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.UseNumber() + if err := decoder.Decode(&settings[index]); err != nil { + return false + } + } + previous.Settings, next.Settings = nil, nil + return reflect.DeepEqual(previous, next) && reflect.DeepEqual(settings[0], settings[1]) +} + func DefaultConfigConfirmation(_ context.Context, _ AuditActor, confirmation StepUpConfirmation, operation ConfigOperation, _ Section) error { if operation != ConfigOperationRollback && !strings.EqualFold(confirmation.Method, "webauthn") && !strings.EqualFold(confirmation.Method, "recent_auth") { return nil diff --git a/backend/controllers/plugin_secret.go b/backend/controllers/plugin_secret.go index c95d80c..cfcc1f2 100644 --- a/backend/controllers/plugin_secret.go +++ b/backend/controllers/plugin_secret.go @@ -25,7 +25,8 @@ func PluginSecret(ctx *appcontext.Context, w http.ResponseWriter, req *http.Requ if req.Method == http.MethodPut { capability = access.CapabilityPluginWriteGlobal } - if ctx == nil || ctx.UserID <= 0 { + // 共享公众号和机器人密钥不属于分组管理员权限,首次配置和后续维护使用同一边界。 + if ctx == nil || ctx.UserID <= 0 || ctx.EffectiveRole() != access.RoleSuperAdmin { writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied}) return } diff --git a/backend/controllers/plugin_secret_test.go b/backend/controllers/plugin_secret_test.go index 5c99541..7916559 100644 --- a/backend/controllers/plugin_secret_test.go +++ b/backend/controllers/plugin_secret_test.go @@ -32,13 +32,16 @@ func TestPluginSecretAuthorization(t *testing.T) { {"anonymous", nil, "ACCESS_DENIED"}, {"no session user", &appcontext.Context{Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"}, {"ordinary user", &appcontext.Context{UserID: 3, Role: access.RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"}, - {"missing recent authentication", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecoveryCodeConfigured: true}, "RECENT_AUTH_REQUIRED"}, - {"missing recovery code", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true}, "RECOVERY_CODE_REQUIRED"}, + {"authenticated administrator", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"}, + {"missing recent authentication", &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin, RecoveryCodeConfigured: true}, "RECENT_AUTH_REQUIRED"}, + {"administrator without recovery code", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true}, "ACCESS_DENIED"}, } { t.Run(tc.name, func(t *testing.T) { - recorder := callPluginSecret(tc.ctx, http.MethodPut, "telegram_push", `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`) - if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), tc.code) { - t.Fatalf("status=%d response=%s", recorder.Code, recorder.Body.String()) + for _, id := range []string{"telegram_push", "wechat_push"} { + recorder := callPluginSecret(tc.ctx, http.MethodPut, id, `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), tc.code) { + t.Fatalf("渠道=%s,状态=%d,响应=%s", id, recorder.Code, recorder.Body.String()) + } } }) } @@ -46,10 +49,18 @@ func TestPluginSecretAuthorization(t *testing.T) { if err != nil || count != 0 { t.Fatalf("denied writes changed secrets: count=%d err=%v", count, err) } - for _, role := range []access.Role{access.RoleAdmin, access.RoleSuperAdmin} { - recorder := callPluginSecret(&appcontext.Context{UserID: 2, Role: role}, http.MethodGet, "telegram_push", "") - if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) { - t.Fatalf("read role=%s status=%d body=%s", role, recorder.Code, recorder.Body.String()) + for _, id := range []string{"telegram_push", "wechat_push"} { + for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} { + recorder := callPluginSecret(&appcontext.Context{UserID: 2, Role: role}, http.MethodGet, id, "") + if role != access.RoleSuperAdmin { + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "ACCESS_DENIED") { + t.Fatalf("角色=%s 不应读取系统渠道 %s 的密钥状态", role, id) + } + continue + } + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) { + t.Fatalf("超级管理员读取渠道=%s,状态=%d", id, recorder.Code) + } } } recorder := callPluginSecret(&appcontext.Context{UserID: 3, Role: access.RoleUser}, http.MethodGet, "telegram_push", "") @@ -63,7 +74,7 @@ func TestPluginSecretEncryptedLifecycleAndValidation(t *testing.T) { previous := hooks.PluginConfigService() hooks.SetPluginConfigService(service) t.Cleanup(func() { hooks.SetPluginConfigService(previous) }) - ctx := &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true} + ctx := &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true} for _, id := range []string{"telegram_push", "wechat_push"} { t.Run(id, func(t *testing.T) { for _, body := range []string{ diff --git a/backend/services/plugin/config_test.go b/backend/services/plugin/config_test.go index a992c79..39e8c2f 100644 --- a/backend/services/plugin/config_test.go +++ b/backend/services/plugin/config_test.go @@ -91,6 +91,36 @@ func TestConfigServiceAllowsEmptyTelegramChatIDToDisablePush(t *testing.T) { } } +func TestEveryRoleKeepsIndependentOwnTelegramSettings(t *testing.T) { + service, engine := newPluginConfigService(t) + var users []models.User + for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} { + user := insertPluginTestUser(t, engine, "own-"+string(role), role) + users = append(users, user) + status, err := service.GetOwnTelegramSettings(user.ID) + if err != nil || status.Settings.ChatID != "" || status.Version != 0 { + t.Fatalf("角色=%s,不应自动绑定系统接收身份:%v", role, err) + } + } + chatIDs := []string{"10101", "20202", "30303"} + for index, user := range users { + status, err := service.UpdateOwnTelegramSettings(user.ID, TelegramUserSettings{ChatID: chatIDs[index]}, 0) + if err != nil || status.Settings.ChatID != chatIDs[index] || status.Version != 1 { + t.Fatalf("角色=%s,本人通知设置保存失败:%v", user.EffectiveRole(), err) + } + } + if _, err := service.UpdateOwnTelegramSettings(users[0].ID, TelegramUserSettings{ChatID: "40404"}, 1); err != nil { + t.Fatalf("超级管理员不能修改本人的通知设置:%v", err) + } + chatIDs[0] = "40404" + for index, user := range users { + status, err := service.GetOwnTelegramSettings(user.ID) + if err != nil || status.Settings.ChatID != chatIDs[index] { + t.Fatalf("角色=%s,个人通知设置发生跨账户串用:%v", user.EffectiveRole(), err) + } + } +} + func TestConfigServiceRejectsTrailingTelegramSettingsJSON(t *testing.T) { service, engine := newPluginConfigService(t) user := insertPluginTestUser(t, engine, "trailing-json", access.RoleUser) diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 9c68941..7f40878 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -151,7 +151,10 @@ export function usePage(pageKey = 'base') { const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value) const hasConfigChanges = computed(() => editorText.value !== loadedText.value || (!pluginSecretPage.value && !!secretValue.value) || !!savedGeneration.value) - const canWrite = computed(() => globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global')) + const canWrite = computed(() => { + if (pluginSecretPage.value && globalStatus.role !== 'super_admin') return false + return globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global') + }) const canRollback = computed(() => globalStatus.hasCapability('config.rollback')) const canMigrate = computed(() => globalStatus.hasCapability('database.migrate')) const canRestart = computed(() => globalStatus.hasCapability('service.restart')) diff --git a/frontend/src/views/components/PluginSettings/PluginSettings.js b/frontend/src/views/components/PluginSettings/PluginSettings.js index 43b8b28..bace7b2 100644 --- a/frontend/src/views/components/PluginSettings/PluginSettings.js +++ b/frontend/src/views/components/PluginSettings/PluginSettings.js @@ -16,8 +16,10 @@ export function usePage() { // 全局配置不依赖正在运行的插件列表,未启用的插件也必须可以配置。 const sectionOptions = computed(() => store.hasCapability('plugin.read_global') ? [ { value: 'spam_block', label: '垃圾邮件拦截', kind: 'config' }, - { value: 'telegram_push', label: 'Telegram 推送', kind: 'config' }, - { value: 'wechat_push', label: '微信推送', kind: 'config' } + ...(store.role === 'super_admin' ? [ + { value: 'telegram_push', label: '系统 Telegram 推送', kind: 'config' }, + { value: 'wechat_push', label: '系统微信推送', kind: 'config' } + ] : []) ] : []) const accountOptions = computed(() => store.hasCapability('plugin.read') && store.hasCapability('plugin.write_own') ? [{ value: 'telegram-user', label: 'Telegram 通知', kind: 'account' }] diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 486df38..45001fd 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -55,10 +55,19 @@ function fixture(get, pageKey = 'base', options = {}) { get: path => path.endsWith('/pending') && !options.pendingGet ? Promise.resolve({ errorNo: 0, data: null }) : get(path), ...options.http }, - () => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback }) + () => ({ role: options.role || 'super_admin', hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback }) return { page, messages, successes, dispose: () => dispose() } } +for (const pageKey of ['telegram_push', 'wechat_push']) { + for (const role of ['super_admin', 'admin', 'user']) { + test(`${role} 的 ${pageKey} 系统配置写权限只对超级管理员开放`, () => { + const { page } = fixture(async () => ({ errorNo: 0, data: {} }), pageKey, { role }) + assert.equal(page.canWrite.value, role === 'super_admin') + }) + } +} + function deferred() { let resolve let reject diff --git a/test/frontend/plugin-navigation.test.mjs b/test/frontend/plugin-navigation.test.mjs index 920caae..d093cca 100644 --- a/test/frontend/plugin-navigation.test.mjs +++ b/test/frontend/plugin-navigation.test.mjs @@ -11,16 +11,44 @@ const createPage = compileFunction( ['computed', 'nextTick', 'onMounted', 'onBeforeUnmount', 'ref', 'http', 'useGlobalStatusStore'] ) -function fixture(capabilities = ['config.read', 'plugin.read_global', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] })) { +function fixture(capabilities = ['config.read', 'plugin.read_global', 'plugin.read', 'plugin.write_own'], get = async () => ({ errorNo: 0, data: [] }), role = 'super_admin') { const allowed = reactive(new Set(capabilities)) + const store = reactive({ role, hasCapability: value => allowed.has(value) }) const requests = [] let mount, dispose const page = createPage(computed, async () => {}, fn => { mount = fn }, fn => { dispose = fn }, ref, { get: async url => { requests.push(url); return get(url) } }, - () => ({ hasCapability: value => allowed.has(value) })) - return { page, allowed, requests, mount, dispose } + () => store) + return { page, allowed, requests, mount, dispose, store } } +for (const role of ['super_admin', 'admin', 'user']) { + test(`${role} 保留本人通知入口,只有超级管理员可进入系统公众号和机器人配置`, async () => { + const capabilities = role === 'user' + ? ['plugin.read', 'plugin.write_own'] + : ['plugin.read_global', 'plugin.read', 'plugin.write_own'] + const { page } = fixture(capabilities, undefined, role) + assert.deepEqual(page.sectionOptions.value.map(item => item.value), role === 'super_admin' + ? ['spam_block', 'telegram_push', 'wechat_push'] + : role === 'admin' ? ['spam_block'] : []) + assert.deepEqual(page.accountOptions.value.map(item => item.value), ['telegram-user']) + await page.selectSection('telegram-user') + assert.equal(page.activePage.value.kind, 'account') + await page.selectSection('wechat_push') + assert.equal(page.selectedSection.value, role === 'super_admin' ? 'wechat_push' : 'telegram-user') + }) +} + +test('超级管理员降级后隐藏系统通知配置但保留本人通知入口', async () => { + const { page, store } = fixture() + await page.selectSection('telegram_push') + store.role = 'admin' + assert.equal(page.activePage.value, undefined) + assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['spam_block']) + await page.selectSection('telegram-user') + assert.equal(page.activePage.value.kind, 'account') +}) + test('独立插件配置包含三个运行配置页,停用插件不依赖运行列表也能配置', async () => { const { page, mount } = fixture() assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])