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

+132
View File
@@ -3,6 +3,8 @@ package auth
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
@@ -172,3 +174,133 @@ func TestClaimsFromContext_Missing(t *testing.T) {
t.Fatal("ClaimsFromContext on empty ctx should return ok=false")
}
}
// --- X-API-Key 分支(裁决 15.4.2 / 15.4.3)-----------------------------------
// discardLog 是中间件测试用的丢弃型 logger(不产出日志噪声)。
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// keyDoor 构建「Bearer + X-API-Key」双凭据门。
func keyDoor(keys KeyTable, required ...Role) func(http.Handler) http.Handler {
return WithAPIKey(keys, New(testConfig("")).Authorize(required...), discardLog(), required...)
}
// doAPIKey 发一个只带 `X-API-Key` 头的请求。
func doAPIKey(h http.Handler, key string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(apiKeyHeader, key)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestWithAPIKey_ValidKeyPassesOwnerGate 是 15.4.2 的核心断言:签发出去的 key
// 真的能用,并且拿到 owner 级(15.4.3),Sub 是脱敏 id 而不是密钥明文。
func TestWithAPIKey_ValidKeyPassesOwnerGate(t *testing.T) {
t.Parallel()
keys := newTestAPIKey()
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate error: %v", err)
}
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), key)
responseCode(t, rr, http.StatusOK)
if got := rr.Header().Get("X-Claims-Role"); got != string(RoleOwner) {
t.Errorf("X-Claims-Role = %q, want owner", got)
}
if got := rr.Header().Get("X-Claims-Sub"); got != apiKeySubPrefix+MaskKey(key) {
t.Errorf("X-Claims-Sub = %q, want masked id %q", got, apiKeySubPrefix+MaskKey(key))
}
if strings.Contains(rr.Header().Get("X-Claims-Sub"), key) {
t.Error("claims Sub leaked the key material")
}
}
// TestWithAPIKey_UnknownKeyRejected 断言未知 key 一律 401,且**即使同时带着一份
// 合法 Bearer 也不回落** —— fail-closed,坏 key 不能借道另一份凭据混过门。
func TestWithAPIKey_UnknownKeyRejected(t *testing.T) {
t.Parallel()
keys := newTestAPIKey()
door := keyDoor(keys, RoleOwner)
rr := doAPIKey(door(claimsHandler(t)), "not-a-key")
responseCode(t, rr, http.StatusUnauthorized)
assertErrorCode(t, rr, "unauthorized")
svc := New(testConfig(""))
tok, err := svc.Sign("u", RoleOwner, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(apiKeyHeader, "not-a-key")
req.Header.Set("Authorization", bearerHeader(tok))
rec := httptest.NewRecorder()
WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)(claimsHandler(t)).ServeHTTP(rec, req)
responseCode(t, rec, http.StatusUnauthorized)
}
// TestWithAPIKey_RevokedKeyRejected 断言吊销后的 key 立即失效(能发也能废)。
func TestWithAPIKey_RevokedKeyRejected(t *testing.T) {
t.Parallel()
keys := newTestAPIKey()
key, err := keys.Generate()
if err != nil {
t.Fatalf("Generate error: %v", err)
}
door := keyDoor(keys, RoleOwner)
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusOK)
keys.Revoke(key)
responseCode(t, doAPIKey(door(claimsHandler(t)), key), http.StatusUnauthorized)
}
// TestWithAPIKey_EmptyHeaderValueRejected 断言该头出现但值为空时按无效凭据拒绝,
// 而不是被当成「没带 key」悄悄回落到 Bearer。
func TestWithAPIKey_EmptyHeaderValueRejected(t *testing.T) {
t.Parallel()
keys := newTestAPIKey()
if _, err := keys.Generate(); err != nil {
t.Fatalf("Generate error: %v", err)
}
rr := doAPIKey(keyDoor(keys, RoleOwner)(claimsHandler(t)), "")
responseCode(t, rr, http.StatusUnauthorized)
}
// TestWithAPIKey_NilTableRejectsKeyHeader 断言 key 表未注入时带 key 的请求一律
// 拒绝(表不在 = 表为空),不带 key 的请求则照旧走 Bearer。
func TestWithAPIKey_NilTableRejectsKeyHeader(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
door := WithAPIKey(nil, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
responseCode(t, doAPIKey(door(claimsHandler(t)), "any-key"), http.StatusUnauthorized)
tok, err := svc.Sign("u", RoleOwner, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
rr := doAuth(door(claimsHandler(t)), tok)
responseCode(t, rr, http.StatusOK)
}
// TestWithAPIKey_BearerPathUnchanged 断言不带该头时行为与纯 Bearer 门逐字一致:
// 角色不足仍 403、缺凭据仍 401。
func TestWithAPIKey_BearerPathUnchanged(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
keys := newTestAPIKey()
door := WithAPIKey(keys, svc.Authorize(RoleOwner), discardLog(), RoleOwner)
admin, err := svc.Sign("u", RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
responseCode(t, doAuth(door(claimsHandler(t)), admin), http.StatusForbidden)
responseCode(t, doAuth(door(claimsHandler(t)), ""), http.StatusUnauthorized)
owner, err := svc.Sign("u", RoleOwner, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
responseCode(t, doAuth(door(claimsHandler(t)), owner), http.StatusOK)
}