- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed) - 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体) - 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案 - 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
115 lines
4.0 KiB
Go
115 lines
4.0 KiB
Go
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
|
||
}
|
||
|
||
// Replace 把 src 的**落盘字段**(owner.yaml 的那些)原地装进 p,**保留 p 的指针
|
||
// 身份与互斥量**。
|
||
//
|
||
// 这是「后台面板改主人白名单立即生效」的实现方式:渠道 bridge 在启动时就捕获
|
||
// 了同一个 *OwnerProfile 指针并用它解析 actor(见 IsOwner),换指针则那些组件
|
||
// 仍看着旧档案。内存态来源(例如每渠道配置在热重载时带进来的层)不在这里的字段
|
||
// 之列,因此不会被一次面板保存抹掉。accounts 取副本,调用方之后改动 src 不会
|
||
// 影响活档案。它通过 mu 与 IsOwner/RegisterAccount 并发安全;src 为 nil 时是安全
|
||
// 的空操作。
|
||
func (p *OwnerProfile) Replace(src *OwnerProfile) {
|
||
if p == nil || src == nil {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.ID = src.ID
|
||
p.DisplayName = src.DisplayName
|
||
p.DefaultPersona = src.DefaultPersona
|
||
p.IdentityFile = src.IdentityFile
|
||
p.Notes = src.Notes
|
||
p.Accounts = append([]OwnerAccount(nil), src.Accounts...)
|
||
}
|
||
|
||
// 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)
|
||
}
|