feat(owner): 扫码绑定确认时自动把主人id登记进owner.yaml白名单(热生效)
This commit is contained in:
1 parent
0aa387f83c
commit
5a7ee9d772
7 files changed
+487
-65
No files matched your search
+42
-2
@@ -4,6 +4,8 @@
|
||||
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
||||
package user
|
||||
|
||||
import "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.
|
||||
@@ -16,7 +18,14 @@ type OwnerAccount struct {
|
||||
// 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"`
|
||||
@@ -28,8 +37,15 @@ type OwnerProfile struct {
|
||||
}
|
||||
|
||||
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
||||
// owner according to the whitelist.
|
||||
// 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).
|
||||
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
for _, a := range p.Accounts {
|
||||
if a.Platform == platform && a.AccountID == accountID {
|
||||
return true
|
||||
@@ -38,9 +54,33 @@ func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||
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})
|
||||
}
|
||||
|
||||
// AccountIdentifiers returns the distinct channel account ids owned by the
|
||||
// owner, for diagnostics / listing.
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SaveOwnerProfile writes p to path as YAML, atomically (temp file + rename) with
|
||||
// mode 0600 and lenient parent-directory creation, matching the runtime config
|
||||
// convention (SaveChannels). The whole struct is marshaled, so curated fields
|
||||
// (notes, identityFile, displayName, ...) survive a round-trip. YAML comments in
|
||||
// the existing file are not part of the struct and are dropped by the encode,
|
||||
// consistent with the project's other config writers.
|
||||
func SaveOwnerProfile(path string, p *OwnerProfile) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("user: save owner profile: empty profile")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("user: save owner profile: empty path")
|
||||
}
|
||||
data, err := yaml.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user: encode %s: %w", path, err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("user: create %s: %w", dir, err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".owner-*.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user: temp file in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
closed := false
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if !closed {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
// CreateTemp opens with 0600; chmod explicitly so the on-disk file never
|
||||
// carries a broader mode even if the umask changes.
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
return fmt.Errorf("user: chmod %s: %w", tmpName, err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("user: write %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("user: sync %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("user: close %s: %w", tmpName, err)
|
||||
}
|
||||
closed = true
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("user: rename to %s: %w", path, err)
|
||||
}
|
||||
// Best-effort dir fsync so the rename is durable across a crash.
|
||||
if d, derr := os.Open(dir); derr == nil {
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterOwnerAccount loads the owner whitelist at path, appends (platform,
|
||||
// accountID) when it is not already present (idempotent), and writes the updated
|
||||
// profile back. It returns an error when the profile cannot be loaded or saved.
|
||||
// The on-disk replace is atomic, so a concurrent reader never sees a partial
|
||||
// file. Hot-reload of a running owner profile is the caller's concern (see
|
||||
// cmd/cyrene's ownerRegistrar), which applies RegisterAccount to the live profile
|
||||
// the channel bridge already holds.
|
||||
func RegisterOwnerAccount(path, platform, accountID string) error {
|
||||
if strings.TrimSpace(platform) == "" {
|
||||
return fmt.Errorf("user: register owner account: empty platform")
|
||||
}
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return fmt.Errorf("user: register owner account: empty account id")
|
||||
}
|
||||
p, err := LoadOwnerProfile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user: load %s: %w", path, err)
|
||||
}
|
||||
p.RegisterAccount(platform, accountID)
|
||||
return SaveOwnerProfile(path, p)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSaveOwnerProfile_RoundTrip verifies a profile loaded from disk survives a
|
||||
// Save → Load cycle without dropping curated fields (notes, identityFile, ...) or
|
||||
// the account whitelist. It also exercises the atomic-write + parent-dir-create
|
||||
// path (writing under a not-yet-existing subdirectory).
|
||||
func TestSaveOwnerProfile_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src := writeProfile(t, `id: owner
|
||||
displayName: 叶酱
|
||||
defaultPersona: cyrene
|
||||
identityFile: configs/users/owner_identity.md
|
||||
accounts:
|
||||
- platform: kook
|
||||
accountId: "329284282"
|
||||
notes: "主人。陪伴对象。"
|
||||
`)
|
||||
orig, err := LoadOwnerProfile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("load source: %v", err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "nested", "deep", "owner.yaml")
|
||||
if err := SaveOwnerProfile(dst, orig); err != nil {
|
||||
t.Fatalf("SaveOwnerProfile: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("stat saved profile: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("saved file mode = %o, want 0600", perm)
|
||||
}
|
||||
|
||||
got, err := LoadOwnerProfile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("load saved: %v", err)
|
||||
}
|
||||
if got.ID != "owner" || got.DisplayName != "叶酱" || got.DefaultPersona != "cyrene" {
|
||||
t.Fatalf("scalar fields = %+v, want owner/叶酱/cyrene", got)
|
||||
}
|
||||
if got.IdentityFile != "configs/users/owner_identity.md" {
|
||||
t.Errorf("identityFile = %q, want preserved", got.IdentityFile)
|
||||
}
|
||||
if got.Notes != "主人。陪伴对象。" {
|
||||
t.Errorf("notes = %q, want preserved", got.Notes)
|
||||
}
|
||||
if !got.IsOwner("kook", "329284282") {
|
||||
t.Error("saved profile should still recognize the kook owner account")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOwnerProfile_NilProfile errors instead of panicking.
|
||||
func TestSaveOwnerProfile_NilProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := SaveOwnerProfile(filepath.Join(t.TempDir(), "o.yaml"), nil); err == nil {
|
||||
t.Fatal("expected an error for a nil profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterOwnerAccount_AppendAndDedupe verifies that a new account is appended
|
||||
// to the whitelist and recognized, a duplicate registration is a no-op, and an
|
||||
// already-present account is never duplicated.
|
||||
func TestRegisterOwnerAccount_AppendAndDedupe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeProfile(t, `id: owner
|
||||
displayName: 叶酱
|
||||
defaultPersona: cyrene
|
||||
accounts:
|
||||
- platform: kook
|
||||
accountId: "329284282"
|
||||
notes: "主人"
|
||||
`)
|
||||
|
||||
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
|
||||
t.Fatalf("register new account: %v", err)
|
||||
}
|
||||
p, err := LoadOwnerProfile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if !p.IsOwner("weixin-ilink", "wx1") {
|
||||
t.Error("newly registered (weixin-ilink, wx1) must be recognized as owner")
|
||||
}
|
||||
if !p.IsOwner("kook", "329284282") {
|
||||
t.Error("existing (kook, 329284282) must still be recognized")
|
||||
}
|
||||
if got := len(p.Accounts); got != 2 {
|
||||
t.Fatalf("accounts = %d, want 2 (kook + weixin-ilink)", got)
|
||||
}
|
||||
|
||||
// Duplicate registration is a no-op.
|
||||
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
|
||||
t.Fatalf("re-register same account: %v", err)
|
||||
}
|
||||
p, _ = LoadOwnerProfile(path)
|
||||
if got := len(p.Accounts); got != 2 {
|
||||
t.Fatalf("accounts after duplicate = %d, want still 2", got)
|
||||
}
|
||||
|
||||
// Re-registering an existing kook account is also a no-op.
|
||||
if err := RegisterOwnerAccount(path, "kook", "329284282"); err != nil {
|
||||
t.Fatalf("re-register existing kook: %v", err)
|
||||
}
|
||||
p, _ = LoadOwnerProfile(path)
|
||||
if got := len(p.Accounts); got != 2 {
|
||||
t.Fatalf("accounts after kook dedupe = %d, want still 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterOwnerAccount_ValidatesInput rejects empty platform / account id.
|
||||
func TestRegisterOwnerAccount_ValidatesInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeProfile(t, "id: owner\ndisplayName: 叶酱\naccounts: []\n")
|
||||
if err := RegisterOwnerAccount(path, "", "wx1"); err == nil {
|
||||
t.Error("empty platform must error")
|
||||
}
|
||||
if err := RegisterOwnerAccount(path, "weixin-ilink", ""); err == nil {
|
||||
t.Error("empty account id must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterAccount_HotEffect verifies the in-memory mutation the channel bridge
|
||||
// relies on: after RegisterAccount on a live profile, IsOwner sees it immediately
|
||||
// and a duplicate is a no-op. This is the hot-reload path (no restart).
|
||||
func TestRegisterAccount_HotEffect(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &OwnerProfile{ID: "owner", DisplayName: "叶酱",
|
||||
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}}}
|
||||
|
||||
p.RegisterAccount("weixin-ilink", "wx1")
|
||||
if !p.IsOwner("weixin-ilink", "wx1") {
|
||||
t.Error("hot-registered (weixin-ilink, wx1) must be recognized immediately")
|
||||
}
|
||||
if len(p.Accounts) != 2 {
|
||||
t.Fatalf("accounts = %d, want 2", len(p.Accounts))
|
||||
}
|
||||
|
||||
p.RegisterAccount("weixin-ilink", "wx1") // duplicate
|
||||
if len(p.Accounts) != 2 {
|
||||
t.Fatalf("accounts after duplicate = %d, want still 2", len(p.Accounts))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user