fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
1 parent
7e7f53bb41
commit
af8591b78a
30 files changed
+1289
-144
No files matched your search
@@ -0,0 +1,96 @@
|
||||
package user
|
||||
|
||||
// 本文件覆盖裁决 06.4.2 的「每渠道账号配置的管理员 id 列表」白名单层:它是 channels.json
|
||||
// 的 adminIds[](design/08 §1.1)在内存里的落点,与 owner.yaml 的账号白名单取并集后共同
|
||||
// 决定 owner 车道,且**不写回 owner.yaml**。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOwnerProfileSetPlatformAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
|
||||
p.SetPlatformAdmins("qq-obv11", []string{"10001", " 10002 ", "10001", " "})
|
||||
|
||||
ids := p.PlatformAdmins("qq-obv11")
|
||||
if len(ids) != 2 || ids[0] != "10001" || ids[1] != "10002" {
|
||||
t.Fatalf("PlatformAdmins = %v, want [10001 10002](去空白 + 去重)", ids)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !p.IsOwner("qq-obv11", id) {
|
||||
t.Errorf("adminIds 命中的 %q 未判为主人", id)
|
||||
}
|
||||
}
|
||||
if p.IsOwner("qq-obv11", "10003") {
|
||||
t.Error("未声明的账号被判为主人")
|
||||
}
|
||||
if p.IsOwner("telegram", "10001") {
|
||||
t.Error("管理员 id 层跨平台串台了(应按平台隔离)")
|
||||
}
|
||||
if p.IsOwner("qq-obv11", "") {
|
||||
t.Error("空 accountId 不得命中白名单(fail-closed)")
|
||||
}
|
||||
|
||||
// 整体替换:重新声明后旧 id 立即失效,channels.json 热重载不会留下悬挂管理员。
|
||||
p.SetPlatformAdmins("qq-obv11", []string{"10002"})
|
||||
if p.IsOwner("qq-obv11", "10001") {
|
||||
t.Error("SetPlatformAdmins 未整体替换")
|
||||
}
|
||||
// 空列表(含只有空白的列表)清空该平台的这一层。
|
||||
p.SetPlatformAdmins("qq-obv11", []string{" "})
|
||||
if p.IsOwner("qq-obv11", "10002") || p.PlatformAdmins("qq-obv11") != nil {
|
||||
t.Error("空列表应清空该平台的管理员层")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerProfileAdminLayerConcurrentWithOwnerYaml(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 两层来源共存:owner.yaml 的账号白名单与 channels.json 的 adminIds 都能判主。
|
||||
p := &OwnerProfile{
|
||||
ID: "owner",
|
||||
DisplayName: "主人",
|
||||
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}},
|
||||
}
|
||||
p.SetPlatformAdmins("kook", []string{"555"})
|
||||
if !p.IsOwner("kook", "329284282") || !p.IsOwner("kook", "555") {
|
||||
t.Fatalf("两层白名单未取并集: yaml=%v adminIds=%v",
|
||||
p.IsOwner("kook", "329284282"), p.IsOwner("kook", "555"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerProfileAdminLayerNotPersisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "owner.yaml")
|
||||
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
|
||||
p.RegisterAccount("kook", "329284282")
|
||||
p.SetPlatformAdmins("qq-obv11", []string{"10001"})
|
||||
|
||||
if err := SaveOwnerProfile(path, p); err != nil {
|
||||
t.Fatalf("SaveOwnerProfile: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if strings.Contains(string(data), "10001") {
|
||||
t.Error("adminIds 层被写回了 owner.yaml(应为只读来源 channels.json)")
|
||||
}
|
||||
|
||||
reloaded, err := LoadOwnerProfile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOwnerProfile: %v", err)
|
||||
}
|
||||
if !reloaded.IsOwner("kook", "329284282") {
|
||||
t.Error("owner.yaml 的账号白名单应在重载后保留")
|
||||
}
|
||||
if reloaded.IsOwner("qq-obv11", "10001") {
|
||||
t.Error("adminIds 层不应来自 owner.yaml")
|
||||
}
|
||||
}
|
||||
+74
-2
@@ -4,7 +4,10 @@
|
||||
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
||||
package user
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
|
||||
// policy engine matches channel-layer actor metadata (platform + account id)
|
||||
@@ -34,14 +37,23 @@ type OwnerProfile struct {
|
||||
// (persona knowledge, NOT an access-control authority).
|
||||
IdentityFile string `yaml:"identityFile,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
|
||||
// adminIDs 是「每渠道账号配置的管理员 id 列表」层(裁决 06.4.2):channels.json
|
||||
// 里每个渠道账号可声明 adminIds[](design/08 §1.1、design/11 §0),加载/热重载时
|
||||
// 由 SetPlatformAdmins 写入。它与 Accounts 白名单(owner.yaml)取并集后共同决定
|
||||
// owner 车道,因此是内存态来源、不落盘——yaml:"-" 保证它不会被写回 owner.yaml。
|
||||
adminIDs map[string][]string `yaml:"-"`
|
||||
}
|
||||
|
||||
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
||||
// owner according to the whitelist. It is safe for concurrent use with
|
||||
// RegisterAccount (the bridge reads this on every inbound message while a
|
||||
// scan-login registration may extend the whitelist).
|
||||
//
|
||||
// 判主只看两处结构化来源(裁决 06.4.2):owner.yaml 的 Accounts 白名单与每渠道配置的
|
||||
// adminIds 层;空的 accountID 一律不算命中,避免白名单里的空占位把空发送者 id 判成主人。
|
||||
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||
if p == nil {
|
||||
if p == nil || accountID == "" {
|
||||
return false
|
||||
}
|
||||
p.mu.RLock()
|
||||
@@ -51,6 +63,11 @@ func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, id := range p.adminIDs[platform] {
|
||||
if id == accountID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -73,6 +90,61 @@ func (p *OwnerProfile) RegisterAccount(platform, accountID string) {
|
||||
p.Accounts = append(p.Accounts, OwnerAccount{Platform: platform, AccountID: accountID})
|
||||
}
|
||||
|
||||
// SetPlatformAdmins 用 ids 整体替换 platform 的每渠道管理员层(裁决 06.4.2)。整体
|
||||
// 替换(而不是追加)语义保证 channels.json 的一次热重载不会留下已被移除的管理员 id;
|
||||
// 空列表删除该平台条目。它只影响这一层,owner.yaml 的 Accounts 白名单不受影响。
|
||||
// 与 IsOwner/RegisterAccount 并发安全。
|
||||
func (p *OwnerProfile) SetPlatformAdmins(platform string, ids []string) {
|
||||
if p == nil || platform == "" {
|
||||
return
|
||||
}
|
||||
clean := normalizeAccountIDs(ids)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(clean) == 0 {
|
||||
delete(p.adminIDs, platform)
|
||||
return
|
||||
}
|
||||
if p.adminIDs == nil {
|
||||
p.adminIDs = make(map[string][]string, 1)
|
||||
}
|
||||
p.adminIDs[platform] = clean
|
||||
}
|
||||
|
||||
// PlatformAdmins 返回 platform 当前声明的管理员 id 层副本(诊断/展示用),未声明时返回
|
||||
// nil。与 SetPlatformAdmins 并发安全。
|
||||
func (p *OwnerProfile) PlatformAdmins(platform string) []string {
|
||||
if p == nil || platform == "" {
|
||||
return nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
ids := p.adminIDs[platform]
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), ids...)
|
||||
}
|
||||
|
||||
// normalizeAccountIDs 清理配置里声明的管理员 id:去空白、丢弃空串、按首次出现顺序
|
||||
// 去重。它保证 channels.json 里的重复/空白条目不会污染白名单。
|
||||
func normalizeAccountIDs(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountIdentifiers returns the distinct channel account ids owned by the
|
||||
// owner, for diagnostics / listing. Safe for concurrent use with RegisterAccount.
|
||||
func (p *OwnerProfile) AccountIdentifiers() []string {
|
||||
|
||||
Reference in new issue
Block a user