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,
@@ -0,0 +1,361 @@
package gateway
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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"
)
// 本文件钉住阶段 3-G3D 的四条裁决:主人档案真实读写(10.4.5)、API key 真能
// 用来鉴权(15.4.2)、key 一律超管(15.4.3)、identity.systemPrompt 必填
// (08.4.2)。涉及落盘的用例先把工作目录切到临时目录,因此它们**不碰仓库里的
// configs/**;(t.Chdir 与 t.Parallel 互斥,这些用例刻意不加 t.Parallel)。
const ownerProfileURL = "/api/v1/admin/owner-profile"
// liveOwnerRegistrar 是组合根 ownerRegistrar 的测试替身:除满足 OwnerRegistrar
// 外,还实现可选扩展 LiveOwnerProfile,使热替换路径可被端到端演练。
type liveOwnerRegistrar struct{ live *user.OwnerProfile }
func (r *liveOwnerRegistrar) RegisterChannelOwner(context.Context, string, string) error {
return nil
}
func (r *liveOwnerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.live }
// decodeOwnerProfile 从 {data} 信封解出一份主人档案。
func decodeOwnerProfile(t *testing.T, body []byte) ownerProfileResponse {
t.Helper()
var got ownerProfileResponse
if err := json.Unmarshal(decodeBody(t, body)["data"], &got); err != nil {
t.Fatalf("decode owner profile: %v (body=%s)", err, body)
}
return got
}
// doAPIKeyReq 发一个只带 `X-API-Key` 头的请求,模拟用 key 救火的管理员客户端。
func doAPIKeyReq(t *testing.T, method, url, key string) (*http.Response, []byte) {
t.Helper()
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if key != "" {
req.Header.Set("X-API-Key", key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
return resp, b
}
// TestOwnerProfile_GetPutRoundTrip 钉住 10.4.5:面板的「保存」走真实 API —— PUT
// 真的落盘(0600 原子写,内容可被重新载入),GET 读回同一份,不再有「只改本地
// state」的假成功。
func TestOwnerProfile_GetPutRoundTrip(t *testing.T) {
t.Chdir(t.TempDir())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 全新安装(还没人配过主人):200 + 空白档案,而不是 404,也不假装档已存在。
resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := decodeOwnerProfile(t, body); got.ID != "" || got.DisplayName != "" || len(got.Accounts) != 0 {
t.Fatalf("fresh profile = %+v, want an empty profile", got)
}
if _, err := os.Stat(ownerProfilePath); err == nil {
t.Fatal("a fresh install must not have a profile on disk yet")
}
want := ownerProfileResponse{
ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene",
IdentityFile: "configs/users/owner_identity.md", Notes: "主人。",
Accounts: []ownerAccountRequest{{Platform: " telegram ", AccountID: " 10086 "}},
}
resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL, want, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
saved := decodeOwnerProfile(t, body)
if saved.ID != "owner" || saved.DisplayName != "叶酱" || len(saved.Accounts) != 1 {
t.Fatalf("PUT data = %+v, want the saved profile", saved)
}
if saved.Accounts[0].Platform != "telegram" || saved.Accounts[0].AccountID != "10086" {
t.Fatalf("accounts = %+v, want whitespace-trimmed platform/accountId", saved.Accounts[0])
}
// 真保存的第一判据:磁盘上能重新载入出一模一样的档案(含白名单)。
onDisk, err := user.LoadOwnerProfile(ownerProfilePath)
if err != nil {
t.Fatalf("reload %s: %v", ownerProfilePath, err)
}
if onDisk.DisplayName != "叶酱" || !onDisk.IsOwner("telegram", "10086") {
t.Fatalf("disk profile = %+v, want the saved whitelist", onDisk)
}
info, err := os.Stat(ownerProfilePath)
if err != nil {
t.Fatalf("stat saved profile: %v", err)
}
if runtime.GOOS != "windows" {
// Windows 没有常规文件的 POSIX 权限位(Chmod 只切只读标志),因此
// 0600 只在类 Unix 平台可断言;save.go 仍然请求 0600。
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("saved profile mode = %o, want 0600", perm)
}
}
// 第二判据:GET 与 PUT 对同一份档案的读法一致(面板可信任任一方回填)。
resp, body = doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET after PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
reloaded := decodeOwnerProfile(t, body)
if reloaded.ID != saved.ID || reloaded.Notes != saved.Notes || len(reloaded.Accounts) != 1 {
t.Fatalf("GET after PUT = %+v, want %+v", reloaded, saved)
}
}
// TestOwnerProfile_PutRejectsInvalidProfile 断言坏档案被显式拒绝且**不落盘**:
// 校验失败绝不能留下半份档案。
func TestOwnerProfile_PutRejectsInvalidProfile(t *testing.T) {
t.Chdir(t.TempDir())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
cases := []struct {
name string
body ownerProfileResponse
}{
{"missing id", ownerProfileResponse{DisplayName: "叶酱"}},
{"missing displayName", ownerProfileResponse{ID: "owner"}},
{"half-filled account", ownerProfileResponse{
ID: "owner", DisplayName: "叶酱",
Accounts: []ownerAccountRequest{{Platform: "telegram"}},
}},
}
for _, tc := range cases {
resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, tc.body, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s: PUT status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, body)
}
wantErrorShape(t, body)
if _, err := os.Stat(ownerProfilePath); err == nil {
t.Fatalf("%s: a rejected profile must not be persisted", tc.name)
}
}
}
// TestOwnerProfile_OwnerOnly 断言这份档案只给 owner:它是身份红线(design/11
// §0),admin 也不能读、不能写。
func TestOwnerProfile_OwnerOnly(t *testing.T) {
t.Chdir(t.TempDir())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleViewer} {
tok := signAny(t, jwt, "acct", role)
resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s GET status = %d, want 403 (body=%s)", role, resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL,
ownerProfileResponse{ID: "owner", DisplayName: "x"}, tok)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s PUT status = %d, want 403 (body=%s)", role, resp.StatusCode, body)
}
wantErrorShape(t, body)
}
}
// TestOwnerProfile_PutHotSwapsLiveProfile 断言保存后活档案被**原地**替换:渠道
// bridge 持有的是同一个指针,因此新白名单立即生效、被移除的账号立即失效,无需
// 重启(design/11 §9「可编辑、热重载」)。
func TestOwnerProfile_PutHotSwapsLiveProfile(t *testing.T) {
t.Chdir(t.TempDir())
live := &user.OwnerProfile{
ID: "owner", DisplayName: "旧名",
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "1"}},
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: &liveOwnerRegistrar{live: live}})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, ownerProfileResponse{
ID: "owner", DisplayName: "叶酱",
Accounts: []ownerAccountRequest{{Platform: "telegram", AccountID: "10086"}},
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if live.DisplayName != "叶酱" {
t.Errorf("live DisplayName = %q, want 叶酱 (in-place swap)", live.DisplayName)
}
if !live.IsOwner("telegram", "10086") {
t.Error("newly whitelisted account must be live immediately")
}
if live.IsOwner("kook", "1") {
t.Error("removed account must stop being the owner")
}
}
// TestAdminApiKey_IssuedKeyReallyAuthenticates 是 15.4.2 的端到端断言:签发的
// key 真的能过 owner-only 门(不再是「可签发、无中间件接受」的死端),列表里恒为
// owner(15.4.3),吊销后立刻失效。
func TestAdminApiKey_IssuedKeyReallyAuthenticates(t *testing.T) {
t.Parallel()
keys := auth.NewAPIKey(config.AuthConfig{})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 签发不带任何 role:key 一律超管(15.4.3)。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/users/apikey", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("issue status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var issued struct {
ID string `json:"id"`
Key string `json:"key"`
Role string `json:"role"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &issued); err != nil {
t.Fatalf("decode issued key: %v", err)
}
if issued.Key == "" || issued.ID == "" {
t.Fatalf("issued = %+v, want id and one-time plaintext", issued)
}
if issued.Role != string(auth.RoleOwner) {
t.Fatalf("issued role = %q, want owner", issued.Role)
}
if issued.ID == issued.Key {
t.Error("the returned id must be the masked form, not the plaintext")
}
// 只带 X-API-Key(无 Bearer)访问 owner-only 读端点 —— 这就是「key 能用」。
resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin/users with X-API-Key status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// 管理列表里这条 key 恒为 owner(表已无角色维度)。
var listEnv struct {
Data []struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
} `json:"data"`
}
if err := json.Unmarshal(body, &listEnv); err != nil {
t.Fatalf("decode users list: %v", err)
}
found := false
for _, u := range listEnv.Data {
if u.ID == issued.ID {
found = true
if u.Username != "apikey" || u.Role != string(auth.RoleOwner) {
t.Fatalf("key entry = %+v, want apikey/owner", u)
}
}
}
if !found {
t.Fatalf("issued key %s missing from the users list", issued.ID)
}
// 吊销后同一把 key 立即 401:能发也能废。
resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/users/apikey/"+issued.ID, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("revoke status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("revoked key status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// TestAdminApiKey_BadKeyIsFailClosed 断言未知 key 一律 401,且**即使同时带着一份
// 合法 Bearer 也不回落** —— 坏 key 不能借道另一份凭据进门。
func TestAdminApiKey_BadKeyIsFailClosed(t *testing.T) {
t.Parallel()
keys := auth.NewAPIKey(config.AuthConfig{})
if _, err := keys.Generate(); err != nil {
t.Fatalf("generate: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
resp, body := doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", "not-a-key")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unknown key status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/users", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("X-API-Key", "not-a-key")
withBoth, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer withBoth.Body.Close()
if withBoth.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad key + valid bearer status = %d, want 401 (fail-closed)", withBoth.StatusCode)
}
}
// TestPersonaWrite_RequiresSystemPrompt 钉住 08.4.2:identity.systemPrompt 必填,
// 缺失/空白显式报 400(不回落、不派生),且被拒的请求不落盘;带上它则写出的
// YAML 能被载入期用同一套规则重新载入。
func TestPersonaWrite_RequiresSystemPrompt(t *testing.T) {
t.Chdir(t.TempDir())
reg := personareg.New()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
url := ts.URL + "/api/v1/admin/personas"
for _, req := range []map[string]any{
{"id": "xilian", "name": "昔涟", "role": "r"},
{"id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": " "},
} {
resp, body := doReq(t, http.MethodPost, url, req, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("POST %v status = %d, want 400 (body=%s)", req, resp.StatusCode, body)
}
wantErrorShape(t, body)
if _, ok := reg.Persona("xilian"); ok {
t.Fatal("a persona with no systemPrompt must not be registered")
}
}
resp, body := doReq(t, http.MethodPost, url, map[string]any{
"id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。",
}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("POST with systemPrompt status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml"))
if err != nil {
t.Fatalf("reload the saved persona (loader enforces the same rule): %v", err)
}
if reloaded.Identity.SystemPrompt != "你是昔涟。" {
t.Fatalf("systemPrompt = %q, want the written value", reloaded.Identity.SystemPrompt)
}
}
+42 -7
View File
@@ -22,10 +22,10 @@ func (s *Server) Handler() http.Handler {
// 已认证角色」:它指 viewer 角色存在的那个只读监控
// 表面(模块清单、运行时指标/trace、日志尾部
// 与通知 feed)—— 见 registerAdminRoutes。
anyAuth := s.auth.Authorize()
ownerAuth := s.auth.Authorize(auth.RoleOwner)
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
viewerAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
anyAuth := s.authorize()
ownerAuth := s.authorize(auth.RoleOwner)
writeAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin)
viewerAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
s.registerPublicRoutes(mux)
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
@@ -121,8 +121,7 @@ func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.H
// - ownerAuth(owner):带审计的策略写、每个获取凭据的
// 渠道端点(login-qr/login-status)以及每一个其他变更。
//
// 每个写 handler 都会持久化到真实后端存储,或用明确的
// "M10 / not-wired" 错误诚实作答,而不是编造成功。
// 管理写路由表见 registerAdminWriteRoutes。
func (s *Server) registerAdminRoutes(
mux *http.ServeMux,
ownerAuth func(http.Handler) http.Handler,
@@ -163,9 +162,19 @@ func (s *Server) registerAdminRoutes(
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
// 管理写:仅 owner,带审计。
s.registerAdminWriteRoutes(mux, ownerAuth)
}
// registerAdminWriteRoutes 挂载管理写路由:仅 owner,带审计。每个 handler 都会
// 持久化到真实后端存储,或用明确的 "M10 / not-wired" 错误诚实作答,而不是编造成功。
func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler) {
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
// 主人档案(裁决 10.4.5):白名单 + 显示名/关系备注的真实读写。
// **仅 owner** —— 这份档案决定谁能进 owner 车道(design/11 §0 的身份红线),
// 因此既不给 viewer 读、也不给 admin 写。
mux.Handle("GET /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminGetOwnerProfile)))
mux.Handle("PUT /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminPutOwnerProfile)))
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
@@ -212,6 +221,32 @@ func (s *Server) mountWebhooks(mux *http.ServeMux) {
}
}
// authorize 返回要求给定角色的凭据门。它由 Bearer JWT 门加上 `X-API-Key`
// 分支组成(裁决 15.4.2):key 表已注入时两种凭据都收,否则只认 Bearer。
// 按裁决 15.4.3,任何有效 key 一律 owner 级(owner 满足一切角色要求),
// 未知/已吊销 key 一律 401 且不回落 Bearer(fail-closed,见 auth.WithAPIKey)。
func (s *Server) authorize(required ...auth.Role) func(http.Handler) http.Handler {
bearer := s.auth.Authorize(required...)
keys := s.apiKeyTable()
if keys == nil {
return bearer
}
return auth.WithAPIKey(keys, bearer, s.log, required...)
}
// apiKeyTable 返回注入的 API key 表;未注入(或注入的实现不是 key 表)时为
// nil,此时鉴权只认 Bearer —— 「表不在」等同空表,绝不因此放行任何 key。
func (s *Server) apiKeyTable() auth.KeyTable {
if s.users == nil {
return nil
}
table, ok := s.users.(auth.KeyTable)
if !ok {
return nil
}
return table
}
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
// panic recover、CORS 与请求日志。