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

@@ -2,6 +2,7 @@ package gateway
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -14,6 +15,7 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格
@@ -66,6 +68,14 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
return
}
// identity.systemPrompt 必填(裁决 08.4.2):这里不做回落、不派生默认值 ——
// 否则一条没有身份的「人格」会被静默降级成拼凑出的说明串。载入期
// (loader.Load)执行同一规则,因此写进来的档案必然也能被重新载入。
if strings.TrimSpace(req.SystemPrompt) == "" {
s.auditClaims(r, "persona.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
return
}
reg, ok := s.personas.(*personareg.Registry)
if !ok {
s.auditClaims(r, "persona.create", "error")
@@ -117,6 +127,13 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
return
}
// identity.systemPrompt 必填(裁决 08.4.2),与创建路径同一把门:缺失/空白
// 显式报错,不回落、不静默保留旧值(「没改」要由客户端显式传回原值)。
if strings.TrimSpace(req.SystemPrompt) == "" {
s.auditClaims(r, "persona.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "systemPrompt is required (identity.systemPrompt must not be empty)"))
return
}
req.ID = id
reg, ok := s.personas.(*personareg.Registry)
if !ok {
@@ -174,40 +191,32 @@ func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
})
}
// apiKeyIssueRequest 是 POST /api/v1/admin/users/apikey 的请求体。
type apiKeyIssueRequest struct {
Role string `json:"role"`
}
// adminIssueApiKey 铸造一个绑定到某角色的真实 API key,并返回一次
// 明文(唯一可见的一次)。响应中的 id 是管理列表所展示的
// 脱敏形式,因此之后可用它吊销该 key。
// adminIssueApiKey 铸造一个真实 API key 并返回一次明文(唯一可见的一次)。
// 响应中的 id 是管理列表所展示的脱敏形式,之后可用它吊销该 key。
//
// 按裁决 15.4.3,key **不做角色分级**:本端点不再读请求体里的 role(也不
// 接受 role 语义),表里也没有角色维度 —— 任何有效 key 鉴权后都是超级管理员
// (owner)。响应仍回传实际授予的角色(恒为 owner),让调用方一眼看到
// 「签出去的就是超管 key」,而不是以为自己在签一把只读 key。
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
var req apiKeyIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
role, err := auth.ParseRole(req.Role)
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
return
}
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
key, err := keys.Generate(role)
key, err := keys.Generate()
if err != nil {
s.auditClaims(r, "apikey.issue", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
return
}
s.auditClaims(r, "apikey.issue", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
writeJSON(w, http.StatusCreated, map[string]any{
"id": auth.MaskKey(key),
"key": key,
"role": string(auth.RoleOwner),
})
}
// adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个
@@ -229,13 +238,133 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
// personaFromRequest 从 upsert 请求构建 loader.Persona,在省略时
// 填入派生的 system prompt/characterRef,使最小创建也能通过校验。
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
systemPrompt := strings.TrimSpace(req.SystemPrompt)
if systemPrompt == "" {
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
// --- 主人档案(裁决 10.4.5)--------------------------------------------------
//
// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据
// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写)
// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。
// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML
// 一一对应,面板可原样往返回填。
type ownerProfileResponse struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
DefaultPersona string `json:"defaultPersona,omitempty"`
IdentityFile string `json:"identityFile,omitempty"`
Notes string `json:"notes,omitempty"`
Accounts []ownerAccountRequest `json:"accounts"`
}
// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。
type ownerAccountRequest struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
}
// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。
func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse {
out := ownerProfileResponse{
ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona,
IdentityFile: p.IdentityFile, Notes: p.Notes,
Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)),
}
for _, a := range p.Accounts {
out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID})
}
return out
}
// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白
// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。
func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile {
p := &user.OwnerProfile{
ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName),
DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile),
Notes: strings.TrimSpace(r.Notes),
Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)),
}
for _, a := range r.Accounts {
p.Accounts = append(p.Accounts, user.OwnerAccount{
Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID),
})
}
return p
}
// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过
// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。
func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) {
p, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{}))
return
}
// 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案:
// 那会让「白名单是空的」和「档案读不出来」看起来一模一样。
s.log.Error("owner profile read", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err))
return
}
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使
// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、
// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。
func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) {
var req ownerProfileResponse
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
p := req.toOwnerProfile()
if err := p.Validate(); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err))
return
}
if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil {
s.auditClaims(r, "owner.profile.update", "error")
s.log.Error("owner profile persist", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile"))
return
}
s.hotSwapOwnerProfile(p)
s.auditClaims(r, "owner.profile.update", "ok")
writeJSON(w, http.StatusOK, ownerProfileFrom(p))
}
// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其
// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来
// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。
type liveOwnerProfile interface {
// LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。
LiveOwnerProfile() *user.OwnerProfile
}
// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条
// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。
func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) {
holder, ok := s.ownerRegistrar.(liveOwnerProfile)
if !ok {
s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply")
return
}
live := holder.LiveOwnerProfile()
if live == nil {
s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply")
return
}
live.Replace(p)
s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts))
}
// personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时
// 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则
// **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进
// 磁盘的人格必然带着真实身份,也必然能被 loader.Load 重新载入。
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
characterRef := strings.TrimSpace(req.CharacterRef)
if characterRef == "" {
characterRef = req.ID
@@ -244,7 +373,7 @@ func personaFromRequest(req personaUpsertRequest) *loader.Persona {
ID: req.ID,
Name: req.Name,
CharacterRef: characterRef,
Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt},
Identity: loader.Identity{Role: req.Role, SystemPrompt: strings.TrimSpace(req.SystemPrompt)},
Personality: req.Personality,
SpeechStyle: req.SpeechStyle,
Catchphrase: req.Catchphrase,