Files
Cyrene_v2/internal/user/save.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 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 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

115 lines
4.0 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
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)
}