feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 显式报错,不再派生回落
This commit is contained in:
AskaEth committed 2026-10-04 12:07:51 +08:00
1 parent 8c6466cb00
commit adef36538f
13 files changed
+1223 -199

No files matched your search

+11
View File
@@ -29,6 +29,17 @@ func LoadOwnerProfile(path string) (*OwnerProfile, error) {
return p, nil
}
// Validate 用与 LoadOwnerProfile 完全相同的一套规则校验一份**内存中**的档案,
// 使管理面板的写路径能在落盘前就拒掉坏档案,而不是先写进磁盘、等下次启动才
// 报错。它只校验必填字段与半空的 accounts 项,不校验平台名是否已知
// (适配器可以后装)。
func (p *OwnerProfile) Validate() error {
if p == nil {
return fmt.Errorf("%w: empty profile", ErrRequiredField)
}
return p.validate()
}
// validate checks the owner profile for its required fields and for partial
// whitelist entries. An empty whitelist is allowed: on a fresh install nobody
// has been configured as owner, so the policy engine defaults to deny until the
+23
View File
@@ -68,6 +68,29 @@ func SaveOwnerProfile(path string, p *OwnerProfile) error {
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.
+66
View File
@@ -1,6 +1,7 @@
package user
import (
"errors"
"os"
"path/filepath"
"runtime"
@@ -156,3 +157,68 @@ func TestRegisterAccount_HotEffect(t *testing.T) {
t.Fatalf("accounts after duplicate = %d, want still 2", len(p.Accounts))
}
}
// TestOwnerProfile_Replace_HotEffect 钉住后台面板保存后的热替换语义:Replace 把
// 新档案装进**同一个指针**(渠道 bridge 解析 actor 用的就是它),因此新白名单
// 立即生效、被移除的账号立即失效。换指针的实现会在这里失败。
func TestOwnerProfile_Replace_HotEffect(t *testing.T) {
t.Parallel()
live := &OwnerProfile{ID: "owner", DisplayName: "旧名",
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "1"}}}
saved := &OwnerProfile{ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene",
IdentityFile: "configs/users/owner_identity.md", Notes: "主人。",
Accounts: []OwnerAccount{{Platform: "telegram", AccountID: "10086"}}}
live.Replace(saved)
if live.DisplayName != "叶酱" || live.DefaultPersona != "cyrene" || live.Notes != "主人。" {
t.Fatalf("live profile = %+v, want the saved values", live)
}
if !live.IsOwner("telegram", "10086") {
t.Error("newly whitelisted account must be recognized immediately")
}
if live.IsOwner("kook", "1") {
t.Error("removed account must stop being the owner — Replace replaces the whole list")
}
// accounts 是副本:随后改动 saved 不会影响活档案。
saved.Accounts[0].AccountID = "999"
if live.IsOwner("telegram", "999") || !live.IsOwner("telegram", "10086") {
t.Error("live profile must not alias the saved profile's account slice")
}
}
// TestOwnerProfile_Replace_NilSafe 断言 nil 入参是安全空操作(不 panic)。
func TestOwnerProfile_Replace_NilSafe(t *testing.T) {
t.Parallel()
live := &OwnerProfile{ID: "owner", DisplayName: "叶酱"}
live.Replace(nil)
if live.DisplayName != "叶酱" {
t.Fatalf("DisplayName = %q, want unchanged", live.DisplayName)
}
var nilProfile *OwnerProfile
nilProfile.Replace(live) // 必须不 panic
}
// TestOwnerProfile_Validate 断言写路径能在落盘前用与载入期同一套规则拒掉坏档案:
// 缺 id/displayName、accounts 项只填一半都报错,而空白名单是合法的(全新安装
// 就是没人被配成主人,策略引擎默认拒绝)。
func TestOwnerProfile_Validate(t *testing.T) {
t.Parallel()
if err := (&OwnerProfile{ID: "owner", DisplayName: "叶酱"}).Validate(); err != nil {
t.Fatalf("valid profile error: %v", err)
}
if err := (&OwnerProfile{ID: "owner"}).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("missing displayName err = %v, want ErrRequiredField", err)
}
if err := (&OwnerProfile{DisplayName: "叶酱"}).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("missing id err = %v, want ErrRequiredField", err)
}
half := &OwnerProfile{ID: "owner", DisplayName: "叶酱", Accounts: []OwnerAccount{{Platform: "kook"}}}
if err := half.Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("half-filled account err = %v, want ErrRequiredField", err)
}
if err := (*OwnerProfile)(nil).Validate(); !errors.Is(err, ErrRequiredField) {
t.Fatalf("nil profile err = %v, want ErrRequiredField", err)
}
}