Files
Cyrene_v2/internal/user/owner.go
T
AskaEth af8591b78a 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 条/分钟
2026-10-04 12:07:40 +08:00

167 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package user owns the "主人" (owner) profile and account whitelist that the
// channel-layer policy engine uses for identity recognition. Per DevDocs/design/11
// §0, owner identification is structural — channel actor metadata matched against
// this whitelist — and is never driven by prompt text or an LLM judgment.
package user
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)
// against this list.
type OwnerAccount struct {
Platform string `yaml:"platform"`
AccountID string `yaml:"accountId"`
}
// OwnerProfile is the owner's identity record: a display name, the default
// persona bound to the owner, and the per-channel account whitelist. It is the
// source data for owner recognition; it is not injected into the persona's
// prompt as an authority.
//
// mu guards Accounts so a live profile can be hot-extended (RegisterAccount)
// while the channel bridge's ResolveActor reads it (IsOwner) concurrently. The
// mutex is unexported and carries no YAML tag, so it is invisible to encode and
// is not part of the persisted profile.
type OwnerProfile struct {
mu sync.RWMutex `yaml:"-"`
ID string `yaml:"id"`
DisplayName string `yaml:"displayName"`
DefaultPersona string `yaml:"defaultPersona"`
Accounts []OwnerAccount `yaml:"accounts"`
// IdentityFile is the path to the LLM-visible identity/relationship note
// (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 || accountID == "" {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
for _, a := range p.Accounts {
if a.Platform == platform && a.AccountID == accountID {
return true
}
}
for _, id := range p.adminIDs[platform] {
if id == accountID {
return true
}
}
return false
}
// RegisterAccount appends (platform, accountID) to the whitelist if it is not
// already present, leaving the profile otherwise untouched. It is the in-memory
// counterpart of RegisterOwnerAccount: the channel bridge holds the very same
// *OwnerProfile, so a scan-login confirmation can extend the whitelist without a
// restart. Duplicate (platform, accountID) pairs are a no-op, matching IsOwner.
func (p *OwnerProfile) RegisterAccount(platform, accountID string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, a := range p.Accounts {
if a.Platform == platform && a.AccountID == accountID {
return
}
}
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 {
if p == nil {
return nil
}
p.mu.RLock()
defer p.mu.RUnlock()
seen := make(map[string]struct{}, len(p.Accounts))
out := make([]string, 0, len(p.Accounts))
for _, a := range p.Accounts {
if _, ok := seen[a.Platform+":"+a.AccountID]; ok {
continue
}
seen[a.Platform+":"+a.AccountID] = struct{}{}
out = append(out, a.Platform+":"+a.AccountID)
}
return out
}