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 显式报错,不再派生回落
This commit is contained in:
1 parent
8c6466cb00
commit
adef36538f
13 files changed
+1223
-199
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user