隔离系统通知配置权限并保留各角色个人设置
Some checks are pending
Security Checks / security (push) Waiting to run

个人扫码绑定实现前检查点;补充权限与账户隔离回归用例,业务实机验收尚未执行。
This commit is contained in:
smail 2026-09-28 20:57:56 +08:00
parent 39ac7d2d2c
commit 2a532b2224
9 changed files with 196 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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