From 79b1ec36aa6a9f97e8411afb7fadf99dc4fe3b31 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 19:55:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20API=20Key=20=E8=90=BD=E7=9B=98?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E3=80=8C=E6=AF=8F=E6=AC=A1=E9=87=8D=E5=90=AF?= =?UTF-8?q?=20key=20=E5=85=A8=E5=A4=B1=E6=95=88=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缺陷:internal/auth/apikey.go 的 key 表是纯内存 map(注释自认 production should persist)⇒ 一次 restart 后所有 key 一律 401;实测挡住了大附件真机 e2e,也与「key 用于生产救火」的长凭据语义矛盾。 - 迁移 0016_api_keys:表 api_keys(id/name/key_hash/created_at/last_used_at/revoked_at)+ 唯一索引 idx_api_keys_key_hash;**只存 SHA-256 hash,无明文入口**。 - 纪律:Generate 先落库后改内存(失败 ⇒ 内存不变 + 返回错误);AttachKeyStore 启动时把库中未吊销 hash 载入内存(Load 失败仍接上 store ⇒ Generate 会响亮报错,而不是静默退回「不落盘」);Revoke/RevokeAll/RevokeByMask 先落库再移出内存(落库失败 ⇒ WARN + 内存不变,fail-closed:绝不让被吊销的 key 重启后复活)。 - 内存表也改为存 hash(不再存明文)⇒ 面板 id/掩码由 hash 派生:不再泄露明文首尾、且跨重启稳定(重启前记下的 id 重启后仍可吊销)。 - 契约零变化:HTTP 请求/响应形状、网关代码、既有内存用例断言全部未动(网关构造注入点只在组合根 wireAPIKeys)。 - 主会话独立验收:隔离 worktree(K1 的 7 文件)build/vet 绿、全树 59 包 0 FAIL、key 用例 24 PASS、双平台交叉编译绿;变异独立复现(跳过落库 ⇒ 重启存活用例红)。 - 取舍与遗留:吊销落库失败在 HTTP 层仍表现为 404/revoked:0(签名零改动的代价,日志可辨);last_used_at 列已建但恒 NULL(避免把读路径变写路径);旧内存 key 已被此前重启消灭,需重签一次后即长期有效。 --- cmd/cyrene/wire_frontend.go | 26 +- cmd/cyrene/wire_frontend_test.go | 71 +++ internal/auth/apikey.go | 267 ++++++++--- internal/auth/apikey_store.go | 142 ++++++ internal/auth/apikey_store_test.go | 430 ++++++++++++++++++ internal/db/migrations/0016_api_keys.down.sql | 8 + internal/db/migrations/0016_api_keys.up.sql | 30 ++ 7 files changed, 917 insertions(+), 57 deletions(-) create mode 100644 cmd/cyrene/wire_frontend_test.go create mode 100644 internal/auth/apikey_store.go create mode 100644 internal/auth/apikey_store_test.go create mode 100644 internal/db/migrations/0016_api_keys.down.sql create mode 100644 internal/db/migrations/0016_api_keys.up.sql diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 538da38..054615c 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -24,7 +24,7 @@ type capacityEmitter interface { // 返回真实数据。 func (a *app) wireFrontend() error { jwt := auth.New(a.cfg.Auth) - apiKeys := auth.NewAPIKey(a.cfg.Auth) + apiKeys := a.wireAPIKeys() ragStore := knowledge.New(knowledge.Deps{ Projects: a.projStore, Retriever: a.retriever, @@ -116,6 +116,30 @@ func (a *app) wireFrontend() error { return nil } +// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key +// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点 +// 长凭据的语义与「重启即作废」自相矛盾)。 +// +// 降级口径与上面的 refresh store 一致:构造或载入失败**不阻断启动**,key 仍可签发 +// 与校验,只是不跨重启存活;后果写进日志,不静默。注意 AttachKeyStore 在 Load 失败 +// 时仍会把 store 接上(见其注释)——那样之后的签发会响亮地失败,而不是又一次 +// 「面板报成功、重启全 401」。 +func (a *app) wireAPIKeys() *auth.APIKey { + keys := auth.NewAPIKey(a.cfg.Auth) + if a.db == nil { + return keys + } + store, err := auth.NewSQLiteKeyStore(a.db, a.log) + if err != nil { + a.log.Warn("api key store unavailable, issued keys will not survive restart", "error", err) + return keys + } + if err := keys.AttachKeyStore(context.Background(), store); err != nil { + a.log.Warn("api key store load failed, keys issued earlier may be rejected", "error", err) + } + return keys +} + // vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧 // 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。 // diff --git a/cmd/cyrene/wire_frontend_test.go b/cmd/cyrene/wire_frontend_test.go new file mode 100644 index 0000000..74d96ea --- /dev/null +++ b/cmd/cyrene/wire_frontend_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// 本文件锁定组合根接线(K1):wireFrontend 调用的 wireAPIKeys 必须真的把 SQLite +// 载体接到 key 管理器,否则签发的 key 又回到「重启即失效」。 +// +// 为什么在组合根测这件事:newApp 无法在单元测试里实例化(要开库、迁移、拉起全部 +// 子系统,见 wire_context_test.go 的说明),所以这里直接对 wireAPIKeys 这一步 +// 接线取证 —— 它与 newApp 里调用的是同一个函数。 + +// openMigratedTestDB 打开一个临时文件库并跑完全部迁移。用文件而非 :memory:, +// 因为「重启仍在」必须跨连接读同一份数据。 +func openMigratedTestDB(t *testing.T, path string) *sql.DB { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, path, testLog()) + if err != nil { + t.Fatalf("db.Open(%s): %v", path, err) + } + if err := db.MigrateUp(ctx, conn, testLog()); err != nil { + _ = conn.Close() + t.Fatalf("db.MigrateUp: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// TestWireAPIKeys_KeySurvivesRestart 断言组合根接的线真的落盘:签发 → 关连接 +// (进程退出)→ 用同一个库重新接线 → 同一把 key 仍能认证。 +func TestWireAPIKeys_KeySurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "cyrene.db") + + first := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()} + keys := first.wireAPIKeys() + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if err := first.db.Close(); err != nil { + t.Fatalf("close first connection: %v", err) + } + + second := &app{db: openMigratedTestDB(t, path), cfg: &config.Config{}, log: testLog()} + restarted := second.wireAPIKeys() + if !restarted.Verify(key) { + t.Fatal("重启后 key 无法认证:组合根没有把落盘载体接上") + } +} + +// TestWireAPIKeys_NilDBFallsBackToMemory 断言没有数据库句柄时(纯内存测试/ +// 构建路径)接线是空操作且不 panic:key 照旧可签发可校验,只是不跨重启。 +func TestWireAPIKeys_NilDBFallsBackToMemory(t *testing.T) { + a := &app{db: nil, cfg: &config.Config{}, log: testLog()} + keys := a.wireAPIKeys() // 必须不 panic + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !keys.Verify(key) { + t.Fatal("未接库路径的 key 无法认证") + } +} diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go index 818eda4..f3c873b 100644 --- a/internal/auth/apikey.go +++ b/internal/auth/apikey.go @@ -1,8 +1,11 @@ package auth import ( + "context" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "log/slog" "sync" @@ -11,21 +14,33 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// APIKey manages a bounded, in-memory constant table of API keys. 按裁决 -// 15.4.3(主人原话:「key 始终给超级管理员权限」),表里**不再绑定角色**: -// 任何已登记且未吊销的 key 鉴权后一律 owner 级 —— key 用于管理员调试,甚至 -// 生产环境救火,因此不做权限分级。Keys are generated with crypto/rand and -// stored by their string form. This suits the current single-binary milestone; -// production should persist keys (e.g. hashed) and support revocation. +// maskEllipsis 是脱敏标识中间的省略符;maskEdgeLen 是两端保留的十六进制位数。 +const ( + maskEllipsis = "••••" + maskEdgeLen = 4 +) + +// APIKey 管理一张有界、常量的 API key 表。按裁决 15.4.3(主人原话:「key 始终给 +// 超级管理员权限」),表里**不再绑定角色**:任何已登记且未吊销的 key 鉴权后一律 +// owner 级 —— key 用于管理员调试,甚至生产环境救火,因此不做权限分级。 +// +// 表里存的是 key 的 **SHA-256 哈希**(十六进制),不是明文:明文只在 Generate 的 +// 返回值里出现一次,既不驻留内存也不落库、不落日志。这样「内存副本」与「api_keys +// 表」的凭据口径完全一致(迁移 0016_api_keys),重启后把库里未吊销的哈希载回内存 +// 即可复原,无需也不可能还原明文。 +// +// store 为 nil 时是本包原有的纯内存行为(测试与未接库的构建路径),行为不变。 type APIKey struct { - mu sync.RWMutex - cfg config.AuthConfig - keys map[string]struct{} - log *slog.Logger + mu sync.RWMutex + cfg config.AuthConfig + keys map[string]struct{} + store KeyStore + log *slog.Logger } -// NewAPIKey returns an empty key manager. cfg is accepted for constructor -// parity and future persistence settings; keys are runtime-generated. +// NewAPIKey 返回一个空的 key 管理器。cfg 暂只为构造签名的一致性而接收(暂无 +// 影响 key 行为的字段);落盘载体由 AttachKeyStore 单独注入,因此未接库的调用方 +// (既有测试、纯内存用法)拿到的就是原样的内存表。 func NewAPIKey(cfg config.AuthConfig) *APIKey { return &APIKey{ cfg: cfg, @@ -34,10 +49,47 @@ func NewAPIKey(cfg config.AuthConfig) *APIKey { } } +// AttachKeyStore 把可选的落盘载体接到 key 管理器(迁移 0016_api_keys;K1): +// +// 1. 记下 store —— 此后 Generate/Revoke/RevokeAll 先落库、成功后才改内存; +// 2. 立即 LoadUnrevoked 把库里**未吊销**的哈希载入内存,于是重启后已签发的 +// key 仍能通过 Verify(这就是本修复要的行为)。 +// +// store 为 nil 是合法的空操作(保持纯内存,即既有行为)。Load 失败时返回错误, +// 但**store 依然被接上**:写路径随后会**响亮地**失败(Generate 返回错误),而不是 +// 悄悄退回「只在内存里成功」—— 那正是 K1 要消灭的静默失效(签发端点报 201、重启 +// 后全部 401)。此处与 capability/registry.AttachPolicyStore「Load 失败就不接线」 +// 刻意不同:那边默认值本身就是最严(fail-closed),这边不接线的默认是**不落盘**。 +func (k *APIKey) AttachKeyStore(ctx context.Context, store KeyStore) error { + if k == nil || store == nil { + return nil + } + k.mu.Lock() + k.store = store + k.mu.Unlock() + + hashes, err := store.LoadUnrevoked(ctx) + if err != nil { + return fmt.Errorf("auth: load api key store: %w", err) + } + k.mu.Lock() + defer k.mu.Unlock() + for _, hash := range hashes { + if hash != "" { + k.keys[hash] = struct{}{} + } + } + return nil +} + // Generate 铸造一个新 key 并登记进表,返回**仅此一次**可见的明文(绝不记录到 -// 日志)。role 是可选形参:传入时只校验它是不是已知角色名(未知即报 -// ErrInvalidRole),**不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无 -// 角色维度,任何有效 key 都是超管级(见 Lookup)。 +// 日志、绝不落库:库里只写它的哈希)。已接上 store 时**先落库后改内存**(照 +// capability/registry.SetPolicy 的既有纪律):落库失败 ⇒ 内存不变 + 返回错误, +// 绝不出现「面板说签发成功、重启却不认」的假成功。 +// +// role 是可选形参:传入时只校验它是不是已知角色名(未知即报 ErrInvalidRole), +// **不参与存储、不影响授权** —— 按裁决 15.4.3,key 表已无角色维度,任何有效 key +// 都是超管级(见 Lookup)。 func (k *APIKey) Generate(role ...Role) (string, error) { if len(role) > 0 && !role[0].Valid() { return "", fmt.Errorf("%w: %q", ErrInvalidRole, role[0]) @@ -47,39 +99,58 @@ func (k *APIKey) Generate(role ...Role) (string, error) { return "", fmt.Errorf("auth: generate api key: %w", err) } key := base64.RawURLEncoding.EncodeToString(raw) + hash := hashAPIKey(key) + if err := k.persistInsert(hash); err != nil { + return "", err + } k.mu.Lock() - k.keys[key] = struct{}{} + k.keys[hash] = struct{}{} k.mu.Unlock() return key, nil } -// Verify reports whether key is a known, unrevoked API key. +// Verify 报告 key 是否是一把已登记且未吊销的 API key。 func (k *APIKey) Verify(key string) bool { _, ok := k.Lookup(key) return ok } -// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记 -// 的 key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。 +// Lookup 报告 key 是否已登记,并返回其授权角色。按裁决 15.4.3,**任何**已登记的 +// key 都是超管(owner)级。key 不在表中时 ok 为 false,调用方必须据此拒绝。 +// +// 判定就是一次按哈希的 map 查表:未命中**不回查库**(启动时已载入全部有效哈希), +// 因此鉴权路径既不碰磁盘、也不做明文逐字节比较,天然 fail-closed(未知/已吊销一律 +// 不在表里)。被吊销的 key 因此绝不会回落到 Bearer(见 WithAPIKey)。 func (k *APIKey) Lookup(key string) (Role, bool) { k.mu.RLock() defer k.mu.RUnlock() - _, ok := k.keys[key] + _, ok := k.keys[hashAPIKey(key)] return RoleOwner, ok } -// Revoke removes key from the constant table. +// Revoke 吊销一把 key:**先落库标记 revoked_at,成功后才移出内存**。落库失败时 +// 内存保持不变(这把 key 在本进程内仍然有效)并记一条告警日志 —— 绝不出现「本进程 +// 说已吊销、重启后又活回来」的反向不一致。既有签名(无 ctx、无 error,网关的 404 +// 与计数出口依赖它)保持不变,因此这里用 context.Background() 并把失败降级为日志。 func (k *APIKey) Revoke(key string) { + hash := hashAPIKey(key) + if err := k.persistRevoke(hash); err != nil { + return + } k.mu.Lock() - defer k.mu.Unlock() - delete(k.keys, key) + delete(k.keys, hash) + k.mu.Unlock() } -// RevokeAll removes every currently registered API key and returns how many were -// removed. It is the administrative "revoke all" operation: after a call the -// table is empty, so no previously issued key can authenticate. +// RevokeAll 移除当前已登记的每一个 API key 并返回移除数量。它是管理端的「全部 +// 吊销」:调用之后表为空,此前签发的 key 一律无法认证。已接上 store 时先把库中 +// 所有未吊销行标记为已吊销(跨重启同样失效),成功后才清空内存;落库失败则内存 +// 不动、返回 0(「一个都没废掉」是诚实的回答,而不是清空内存后重启又复活)。 func (k *APIKey) RevokeAll() int { + if err := k.persistRevokeAll(); err != nil { + return 0 + } k.mu.Lock() defer k.mu.Unlock() n := len(k.keys) @@ -87,52 +158,136 @@ func (k *APIKey) RevokeAll() int { return n } -// RevokeByMask removes every key whose masked identifier (the same derived id -// List returns) matches mask. It returns true when at least one key was revoked. -// The mask is lossy, so it is matched by recomputing it for each key; the table -// is small so a linear scan is fine. An empty or nil-returning call is a no-op. +// RevokeByMask 移除脱敏标识(与 List 返回的 id 同一口径)等于 mask 的每一个 key, +// 有至少一把被移除时返回 true。掩码是有损的,因此按「为每条记录重算掩码」的方式 +// 匹配;表很小,线性扫描足够。空掩码是空操作。 +// +// 顺序仍是「先落库、后改内存」:只有落库成功的那些才移出内存,失败的留在表里并 +// 记告警(否则重启后它们会带着未标记的行复活)。SQL 在管理器锁之外执行,避免在 +// 持锁时做 IO。 func (k *APIKey) RevokeByMask(mask string) bool { if mask == "" { return false } + matches := k.hashesMatchingMask(mask) + if len(matches) == 0 { + return false + } + persisted := make([]string, 0, len(matches)) + for _, hash := range matches { + if err := k.persistRevoke(hash); err != nil { + continue + } + persisted = append(persisted, hash) + } + if len(persisted) == 0 { + return false + } k.mu.Lock() defer k.mu.Unlock() - revoked := false - for key := range k.keys { - if maskKey(key) == mask { - delete(k.keys, key) - revoked = true - } + for _, hash := range persisted { + delete(k.keys, hash) } - return revoked + return true } -// List returns a summary of every currently registered API key for the admin -// panel. Key material is never returned; each identifier is masked so the panel -// can tell keys apart without exposing the secret. The in-memory store keeps no -// creation time, so CreatedAt is nil for every entry. 每条的角色恒为 owner -// (裁决 15.4.3:key 不做角色分级)。 +// List 返回面板可见的每一条 key 摘要。绝不返回密钥材料:每条只给脱敏标识(哈希 +// 的首尾各 4 位十六进制),面板据此辨认与吊销。内存表不记创建时间,因此每条 +// CreatedAt 均为 nil(库里有 created_at 列,但目前不回读、不在本结构上暴露)。 +// 每条的角色恒为 owner(裁决 15.4.3:key 不做角色分级)。 func (k *APIKey) List() []UserSummary { k.mu.RLock() defer k.mu.RUnlock() out := make([]UserSummary, 0, len(k.keys)) - for key := range k.keys { - out = append(out, UserSummary{ID: maskKey(key), Username: "apikey", Role: RoleOwner}) + for hash := range k.keys { + out = append(out, UserSummary{ID: maskHash(hash), Username: "apikey", Role: RoleOwner}) } return out } -// MaskKey renders a key's leading and trailing groups so the panel can identify -// an entry without revealing the secret. Short keys are fully hidden. It is the -// exported form of maskKey so callers (e.g. the admin issue handler) can return -// the same masked id the List page shows. -func MaskKey(key string) string { return maskKey(key) } - -// maskKey renders a key's leading and trailing groups so the panel can identify -// an entry without revealing the secret. Short keys are fully hidden. -func maskKey(key string) string { - if len(key) <= 8 { - return "••••" +// hashesMatchingMask 返回当前表里脱敏标识等于 mask 的全部哈希(读锁内完成)。 +func (k *APIKey) hashesMatchingMask(mask string) []string { + k.mu.RLock() + defer k.mu.RUnlock() + out := make([]string, 0, 1) + for hash := range k.keys { + if maskHash(hash) == mask { + out = append(out, hash) + } } - return key[:4] + "••••" + key[len(key)-4:] + return out +} + +// persistInsert 把新 key 的哈希写进已接上的 store(未接上是空操作)。它刻意在 +// 管理器锁之外执行 SQL,调用方只在它成功后才改内存。 +func (k *APIKey) persistInsert(hash string) error { + store := k.storeSnapshot() + if store == nil { + return nil + } + if err := store.Insert(context.Background(), hash); err != nil { + return fmt.Errorf("auth: persist api key: %w", err) + } + return nil +} + +// persistRevoke 在库里把哈希对应的行标记为已吊销(未接 store 是空操作)。失败时 +// 记一条告警并返回错误,由调用方决定不动内存。 +func (k *APIKey) persistRevoke(hash string) error { + store := k.storeSnapshot() + if store == nil { + return nil + } + if err := store.Revoke(context.Background(), hash); err != nil { + k.log.Warn("api key revoke not persisted, key stays valid until restart", "err", err) + return fmt.Errorf("auth: revoke api key: %w", err) + } + return nil +} + +// persistRevokeAll 在库里标记全部未吊销行为已吊销(未接 store 是空操作)。 +func (k *APIKey) persistRevokeAll() error { + store := k.storeSnapshot() + if store == nil { + return nil + } + if _, err := store.RevokeAll(context.Background()); err != nil { + k.log.Warn("api key revoke-all not persisted, keys stay valid until restart", "err", err) + return fmt.Errorf("auth: revoke all api keys: %w", err) + } + return nil +} + +// storeSnapshot 在锁内读一次 store 指针,避免与 AttachKeyStore 的写入竞态。 +func (k *APIKey) storeSnapshot() KeyStore { + k.mu.RLock() + defer k.mu.RUnlock() + return k.store +} + +// MaskKey 返回一把 key 的脱敏标识:明文 → SHA-256 → 首尾各 4 位十六进制。它是 +// maskHash 的导出形态,供调用方(如管理端签发 handler 返回的 id、审计链的 Sub) +// 算出与 List 页面一致的标识。 +// +// 与修复前的旧口径(直接取明文首尾 4 位)相比,这个 id **不再泄露明文的任何一位**, +// 而且只由哈希决定 —— 同一把 key 在任何一次重启后都得到同一个 id,面板上记下的 id +// 跨重启仍然可吊销。 +func MaskKey(key string) string { return maskHash(hashAPIKey(key)) } + +// maskHash 把 64 位十六进制哈希渲染成脱敏标识(首尾各 maskEdgeLen 位)。它是 +// MaskKey / List / RevokeByMask 三者共用的 id 口径,改动它等于改动面板与审计链 +// 看到的标识,因此集中在这里。 +func maskHash(hash string) string { + if len(hash) <= maskEdgeLen*2 { + return maskEllipsis + } + return hash[:maskEdgeLen] + maskEllipsis + hash[len(hash)-maskEdgeLen:] +} + +// hashAPIKey 返回 key 的十六进制 SHA-256 —— 内存表与 api_keys 表都只认这一形式。 +// key 是 32 字节随机秘密(256 位熵),没有低熵输入可供爆破,因此无需加盐;查表由 +// map 与唯一索引完成,不做明文逐字节比较。 +func hashAPIKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) } diff --git a/internal/auth/apikey_store.go b/internal/auth/apikey_store.go new file mode 100644 index 0000000..513be2d --- /dev/null +++ b/internal/auth/apikey_store.go @@ -0,0 +1,142 @@ +package auth + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// KeyStore 持久化 API key 的哈希(迁移 0016_api_keys;K1)。 +// +// 它**只接受哈希**:本接口没有任何接受明文的入口,因此「明文落库」在这条路径上 +// 连写错都写不出来(明文只在 APIKey.Generate 的返回值里出现一次)。 +// *SQLiteKeyStore 是生产实现,测试可换成内存桩。实现必须对并发使用安全。 +type KeyStore interface { + // LoadUnrevoked 返回全部未吊销 key 的 hash。没有任何记录时返回空切片 + // (不是错误):那就是「还没签过 key」的诚实状态。 + LoadUnrevoked(ctx context.Context) ([]string, error) + // Insert 记录一把新 key 的 hash。hash 为空是非法调用,返回错误。 + Insert(ctx context.Context, keyHash string) error + // Revoke 把 hash 对应的行标记为已吊销(写 revoked_at)。未知或已吊销的 + // hash 是幂等的空操作,不返回错误。 + Revoke(ctx context.Context, keyHash string) error + // RevokeAll 把全部未吊销的行标记为已吊销,返回受影响的行数。 + RevokeAll(ctx context.Context) (int, error) +} + +// keyStoreTimeLayout 是落盘时间戳的格式(与迁移 0016 的 strftime 默认值同形: +// UTC RFC3339Nano)。 +const keyStoreTimeLayout = time.RFC3339Nano + +// API key 持久化的 SQL(全参数化,项目规范 §十)。表由迁移 0016_api_keys 建立。 +const ( + selectUnrevokedAPIKeysSQL = `SELECT key_hash FROM api_keys WHERE revoked_at IS NULL` + insertAPIKeySQL = `INSERT INTO api_keys (key_hash) VALUES (?)` + revokeAPIKeySQL = `UPDATE api_keys SET revoked_at = ? + WHERE key_hash = ? AND revoked_at IS NULL` + revokeAllAPIKeysSQL = `UPDATE api_keys SET revoked_at = ? WHERE revoked_at IS NULL` +) + +// SQLiteKeyStore 把 API key 的哈希落盘在 SQLite 的 api_keys 表上。它只写 hash: +// 明文既不经过本类型、也不进任何 SQL 参数。凭底层的 *sql.DB 对并发使用安全。 +type SQLiteKeyStore struct { + db *sql.DB + log *slog.Logger +} + +// NewSQLiteKeyStore 在 db 上构建 SQLite key 存储。db 为 nil 时返回 ErrNilDB。 +// +// 它刻意不自建 DDL:迁移 0016_api_keys 是这份 schema 的唯一权威路径(与 +// capability/registry.SQLitePolicyStore 同一口径),因此表缺失时读写会返回明确的 +// 错误(调用方据此让 Generate 响亮地失败),而不是悄悄降级成「只在内存里成功、 +// 重启全部失效」的假成功 —— 那正是本修复要消灭的缺陷。log 可为 nil。 +func NewSQLiteKeyStore(db *sql.DB, log *slog.Logger) (*SQLiteKeyStore, error) { + if db == nil { + return nil, fmt.Errorf("auth: api key store: %w", ErrNilDB) + } + if log == nil { + log = slog.Default() + } + return &SQLiteKeyStore{db: db, log: logger.WithSource(log, "auth/apikey")}, nil +} + +// LoadUnrevoked 返回 api_keys 里全部未吊销行的 key_hash。空表返回空切片。 +// 日志只记条数(哈希与明文都不打)。 +func (s *SQLiteKeyStore) LoadUnrevoked(ctx context.Context) ([]string, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("auth: api key store: not configured") + } + rows, err := s.db.QueryContext(ctx, selectUnrevokedAPIKeysSQL) + if err != nil { + return nil, fmt.Errorf("auth: load api key hashes: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]string, 0) + for rows.Next() { + var hash string + if err := rows.Scan(&hash); err != nil { + return nil, fmt.Errorf("auth: scan api key hash: %w", err) + } + out = append(out, hash) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("auth: iterate api key hashes: %w", err) + } + s.log.Debug("api keys loaded from store", "count", len(out)) + return out, nil +} + +// Insert 落盘一把新 key 的哈希。只有 hash 进 SQL,name/last_used_at 留 NULL +// (当前签发入口没有命名参数,最后一用时间不做同步写库)。 +func (s *SQLiteKeyStore) Insert(ctx context.Context, keyHash string) error { + if s == nil || s.db == nil { + return fmt.Errorf("auth: api key store: not configured") + } + if strings.TrimSpace(keyHash) == "" { + return fmt.Errorf("auth: insert api key: empty key hash") + } + if _, err := s.db.ExecContext(ctx, insertAPIKeySQL, keyHash); err != nil { + return fmt.Errorf("auth: insert api key: %w", err) + } + return nil +} + +// Revoke 把 hash 对应的未吊销行标记为已吊销。找不到行(未知或已吊销)不报错: +// 吊销是幂等操作,重复吊销同一把 key 应当成功。 +func (s *SQLiteKeyStore) Revoke(ctx context.Context, keyHash string) error { + if s == nil || s.db == nil { + return fmt.Errorf("auth: api key store: not configured") + } + if _, err := s.db.ExecContext(ctx, revokeAPIKeySQL, formatKeyStoreTime(time.Now()), keyHash); err != nil { + return fmt.Errorf("auth: revoke api key: %w", err) + } + return nil +} + +// RevokeAll 把全部未吊销行标记为已吊销并返回受影响行数。删除(而非标记)会丢掉 +// 「什么时候废掉的」这一审计信息,因此这里坚持标记。 +func (s *SQLiteKeyStore) RevokeAll(ctx context.Context) (int, error) { + if s == nil || s.db == nil { + return 0, fmt.Errorf("auth: api key store: not configured") + } + res, err := s.db.ExecContext(ctx, revokeAllAPIKeysSQL, formatKeyStoreTime(time.Now())) + if err != nil { + return 0, fmt.Errorf("auth: revoke all api keys: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("auth: revoke all api keys: %w", err) + } + return int(n), nil +} + +// formatKeyStoreTime 把 t 渲染成 UTC RFC3339Nano 以备落盘。 +func formatKeyStoreTime(t time.Time) string { + return t.UTC().Format(keyStoreTimeLayout) +} diff --git a/internal/auth/apikey_store_test.go b/internal/auth/apikey_store_test.go new file mode 100644 index 0000000..7c1fcce --- /dev/null +++ b/internal/auth/apikey_store_test.go @@ -0,0 +1,430 @@ +package auth + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// 本文件锁定 K1(API key 落盘):修「每次重启 key 全失效」。核心回归是 +// TestAPIKeyStore_SurvivesRestart —— 用同一个库丢掉实例再重建,key 仍然可认证。 +// 既有内存用例(apikey_test.go / rbac_test.go)断言一字不改,用来证明「未接库的 +// 路径行为不变」。 + +// errKeyStoreDown 是内存桩注入的落盘失败。 +var errKeyStoreDown = errors.New("api key store down") + +// openMigratedKeyDB 打开(文件或 :memory:)库并跑完全部迁移。跨重启用例要用文件, +// 因为「重启仍在」必须跨连接读同一份数据。 +func openMigratedKeyDB(t *testing.T, path string) *sql.DB { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, path, nil) + if err != nil { + t.Fatalf("db.Open(%s): %v", path, err) + } + if err := db.MigrateUp(ctx, conn, nil); err != nil { + _ = conn.Close() + t.Fatalf("db.MigrateUp: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// newStoredKeyManager 构建「已接上 SQLite 落盘载体」的 key 管理器(等价于组合根 +// wireAPIKeyStore 的那两步:构造 store → Attach)。 +func newStoredKeyManager(t *testing.T, conn *sql.DB) *APIKey { + t.Helper() + store, err := NewSQLiteKeyStore(conn, nil) + if err != nil { + t.Fatalf("NewSQLiteKeyStore: %v", err) + } + keys := NewAPIKey(config.AuthConfig{}) + if err := keys.AttachKeyStore(context.Background(), store); err != nil { + t.Fatalf("AttachKeyStore: %v", err) + } + return keys +} + +// apiKeysTableExists 报告 api_keys 表是否存在。 +func apiKeysTableExists(t *testing.T, ctx context.Context, conn *sql.DB) bool { + t.Helper() + var name string + err := conn.QueryRowContext(ctx, + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'`).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false + } + if err != nil { + t.Fatalf("query sqlite_master: %v", err) + } + return true +} + +// migrationStepsPastKey 计算「回滚到 id 之前」需要几步(按 schema_migrations 现算, +// 因此之后新增迁移不会把本测试带偏)。 +func migrationStepsPastKey(t *testing.T, ctx context.Context, conn *sql.DB, id string) int { + t.Helper() + rows, err := conn.QueryContext(ctx, `SELECT id FROM schema_migrations`) + if err != nil { + t.Fatalf("列出已应用迁移:%v", err) + } + defer func() { _ = rows.Close() }() + steps := 0 + for rows.Next() { + var got string + if err := rows.Scan(&got); err != nil { + t.Fatalf("读取迁移 id:%v", err) + } + if got >= id { + steps++ + } + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历迁移:%v", err) + } + if steps == 0 { + t.Fatalf("已应用迁移里没有 %s,本测试的前提不成立", id) + } + return steps +} + +// TestAPIKeyStore_SurvivesRestart 是 K1 的核心回归:签发 → 丢弃实例(关连接, +// 等价于进程退出)→ 用同一个库重新构造 → Verify 仍为 true,且 Lookup 给 owner、 +// List 里能看见同一条脱敏 id(面板跨重启仍能辨认与吊销它)。 +func TestAPIKeyStore_SurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "cyrene.db") + + first := openMigratedKeyDB(t, path) + keys := newStoredKeyManager(t, first) + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + id := MaskKey(key) + if err := first.Close(); err != nil { // 模拟进程退出 + t.Fatalf("close first connection: %v", err) + } + + second := openMigratedKeyDB(t, path) + restarted := newStoredKeyManager(t, second) + if !restarted.Verify(key) { + t.Fatal("重启后 Verify(key) = false:key 没有跨重启存活(未落盘或未载入)") + } + if role, ok := restarted.Lookup(key); !ok || role != RoleOwner { + t.Fatalf("重启后 Lookup = (%q,%v), want (owner,true)", role, ok) + } + found := false + for _, s := range restarted.List() { + if s.ID == id { + found = true + } + } + if !found { + t.Fatalf("重启后 List 里没有 id %q,面板将无法吊销这把 key", id) + } +} + +// TestAPIKeyStore_RevokedKeyStaysRevoked 锁定「吊销持久」:Revoke 后重建实例, +// 同一把 key 仍然无效(吊销不能只活在内存里)。 +func TestAPIKeyStore_RevokedKeyStaysRevoked(t *testing.T) { + path := filepath.Join(t.TempDir(), "cyrene.db") + + first := openMigratedKeyDB(t, path) + keys := newStoredKeyManager(t, first) + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + keys.Revoke(key) + if keys.Verify(key) { + t.Fatal("Revoke 后 Verify = true, want false") + } + if err := first.Close(); err != nil { + t.Fatalf("close first connection: %v", err) + } + + restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path)) + if restarted.Verify(key) { + t.Fatal("重启后被吊销的 key 复活了(吊销未落盘)") + } +} + +// TestAPIKeyStore_RevokeAllStaysRevoked 锁定「全部吊销」也跨重启生效,且库里 +// 保留行(标记 revoked_at,不删行)。 +func TestAPIKeyStore_RevokeAllStaysRevoked(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "cyrene.db") + + first := openMigratedKeyDB(t, path) + keys := newStoredKeyManager(t, first) + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if got := keys.RevokeAll(); got != 1 { + t.Fatalf("RevokeAll() = %d, want 1", got) + } + var rows, live int + if err := first.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil { + t.Fatalf("count api_keys: %v", err) + } + if err := first.QueryRowContext(ctx, + `SELECT COUNT(*) FROM api_keys WHERE revoked_at IS NULL`).Scan(&live); err != nil { + t.Fatalf("count live api_keys: %v", err) + } + if rows != 1 || live != 0 { + t.Fatalf("RevokeAll 后 rows=%d live=%d, want rows=1 live=0(标记而非删行)", rows, live) + } + if err := first.Close(); err != nil { + t.Fatalf("close first connection: %v", err) + } + + restarted := newStoredKeyManager(t, openMigratedKeyDB(t, path)) + if restarted.Verify(key) { + t.Fatal("重启后 RevokeAll 吊销的 key 复活了") + } +} + +// TestAPIKeyStore_StoresHashOnly 硬断言「库里只有哈希」:逐列取值,明文既不等于 +// 任何列、也不作为子串出现在任何列里;key_hash 必须等于独立算出的 SHA-256 hex。 +func TestAPIKeyStore_StoresHashOnly(t *testing.T) { + ctx := context.Background() + conn := openMigratedKeyDB(t, ":memory:") + keys := newStoredKeyManager(t, conn) + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + var id, name, keyHash, createdAt, lastUsed, revokedAt sql.NullString + if err := conn.QueryRowContext(ctx, + `SELECT id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys`). + Scan(&id, &name, &keyHash, &createdAt, &lastUsed, &revokedAt); err != nil { + t.Fatalf("select api_keys row: %v", err) + } + columns := map[string]sql.NullString{ + "id": id, "name": name, "key_hash": keyHash, + "created_at": createdAt, "last_used_at": lastUsed, "revoked_at": revokedAt, + } + for column, value := range columns { + if value.Valid && (value.String == key || strings.Contains(value.String, key)) { + t.Fatalf("列 %s 里出现了明文 key", column) + } + } + sum := sha256.Sum256([]byte(key)) + if want := hex.EncodeToString(sum[:]); keyHash.String != want { + t.Fatalf("key_hash = %q, want sha256 %q", keyHash.String, want) + } +} + +// TestAPIKeyStore_MigrationUpDownUp 锁定迁移 0016 可逆且幂等(临时库,不碰 data/)。 +func TestAPIKeyStore_MigrationUpDownUp(t *testing.T) { + ctx := context.Background() + conn := openMigratedKeyDB(t, filepath.Join(t.TempDir(), "cyrene.db")) + + if !apiKeysTableExists(t, ctx, conn) { + t.Fatal("api_keys 应在 MigrateUp 后存在") + } + steps := migrationStepsPastKey(t, ctx, conn, "0016_api_keys") + if err := db.MigrateDown(ctx, conn, steps, nil); err != nil { + t.Fatalf("MigrateDown(%d): %v", steps, err) + } + if apiKeysTableExists(t, ctx, conn) { + t.Fatal("api_keys 应在 MigrateDown 后消失") + } + if err := db.MigrateUp(ctx, conn, nil); err != nil { + t.Fatalf("MigrateUp(2nd): %v", err) + } + if !apiKeysTableExists(t, ctx, conn) { + t.Fatal("api_keys 应在第二次 MigrateUp 后重新出现") + } + var rows int + if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys`).Scan(&rows); err != nil { + t.Fatalf("count api_keys: %v", err) + } + if rows != 0 { + t.Fatalf("回滚再升级后应有 0 行,得到 %d 行(回滚未清空?)", rows) + } +} + +// TestAPIKey_GeneratePersistFailureKeepsMemory 锁定「先落库、后改内存」:落库失败 +// 时 Generate 返回错误,且这把 key **不进入**内存表(否则本进程内它仍能过鉴权、 +// 重启后又消失,等于一次假签发)。 +func TestAPIKey_GeneratePersistFailureKeepsMemory(t *testing.T) { + t.Parallel() + store := newMemoryKeyStore() + keys := NewAPIKey(config.AuthConfig{}) + if err := keys.AttachKeyStore(context.Background(), store); err != nil { + t.Fatalf("AttachKeyStore: %v", err) + } + store.failWrite = true + key, err := keys.Generate() + if err == nil { + t.Fatal("落库失败时 Generate = nil error, want error") + } + if key != "" { + t.Fatalf("落库失败时仍返回了明文 %q,调用方可能把它当签发成功", key) + } + if keys.Verify(key) { + t.Fatal("落库失败的 key 进入了内存表") + } + if got := len(keys.List()); got != 0 { + t.Fatalf("落库失败后内存表有 %d 条记录, want 0", got) + } +} + +// TestAPIKey_RevokePersistFailureKeepsKey 锁定吊销的「先落库、后改内存」:库标记 +// 失败时内存不动(本进程内仍有效),绝不出现「本进程说已吊销、重启又复活」的反向 +// 不一致。 +func TestAPIKey_RevokePersistFailureKeepsKey(t *testing.T) { + t.Parallel() + store := newMemoryKeyStore() + keys := NewAPIKey(config.AuthConfig{}) + if err := keys.AttachKeyStore(context.Background(), store); err != nil { + t.Fatalf("AttachKeyStore: %v", err) + } + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + store.failWrite = true + keys.Revoke(key) + if !keys.Verify(key) { + t.Fatal("落库失败时 Revoke 仍移出了内存,重启后这把 key 会复活") + } + if keys.RevokeByMask(MaskKey(key)) { + t.Fatal("落库失败时 RevokeByMask = true, want false") + } + if got := keys.RevokeAll(); got != 0 { + t.Fatalf("落库失败时 RevokeAll = %d, want 0", got) + } +} + +// TestAPIKey_AttachLoadFailureStillPersists 锁定 AttachKeyStore 在 Load 失败时仍 +// 接上 store(与 AttachPolicyStore 的「Load 失败就不接线」刻意不同):否则签发会 +// 静默退回内存态,正是 K1 要消灭的假成功。 +func TestAPIKey_AttachLoadFailureStillPersists(t *testing.T) { + t.Parallel() + store := newMemoryKeyStore() + store.failLoad = true + keys := NewAPIKey(config.AuthConfig{}) + if err := keys.AttachKeyStore(context.Background(), store); err == nil { + t.Fatal("Load 失败时 AttachKeyStore = nil error, want error") + } + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !keys.Verify(key) { + t.Fatal("Attach 后签发的 key 无法认证") + } + if len(store.live()) != 1 { + t.Fatal("Load 失败后签发的 key 没有落库(静默退回内存态)") + } +} + +// TestAPIKeyStore_MemoryHitNeverReQueries 锁定「内存命中即通过、未命中不回查库」: +// 直连库把行标为已吊销后,本进程仍然照内存表放行(下个进程才失效);未命中的 key +// 也不会去查库(LoadUnrevoked 只被 Attach 调用一次)。 +func TestAPIKeyStore_MemoryHitNeverReQueries(t *testing.T) { + t.Parallel() + store := newMemoryKeyStore() + keys := NewAPIKey(config.AuthConfig{}) + if err := keys.AttachKeyStore(context.Background(), store); err != nil { + t.Fatalf("AttachKeyStore: %v", err) + } + key, err := keys.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + store.hashes[hashAPIKey(key)] = true // 绕过管理器,直接标记库中已吊销 + if !keys.Verify(key) { + t.Fatal("内存命中时 Verify = false, want true(鉴权路径不应回查库)") + } + if keys.Verify("unknown-key") { + t.Fatal("未知 key 通过了校验") + } + if store.loadCalls != 1 { + t.Fatalf("LoadUnrevoked 被调用 %d 次, want 1(仅 Attach 时载入)", store.loadCalls) + } + // nil store 是合法的空操作:纯内存(既有行为)。 + plain := NewAPIKey(config.AuthConfig{}) + if err := plain.AttachKeyStore(context.Background(), nil); err != nil { + t.Fatalf("AttachKeyStore(nil) = %v, want nil", err) + } + plainKey, err := plain.Generate() + if err != nil || !plain.Verify(plainKey) { + t.Fatalf("纯内存路径失效:key=%q err=%v", plainKey, err) + } +} + +// memoryKeyStore 是 KeyStore 的内存桩:语义与 SQLite 实现一致(hashes 记录 +// hash → 是否已吊销),failLoad/failWrite 与 loadCalls 用于断言与故障注入。 +type memoryKeyStore struct { + hashes map[string]bool + failLoad bool + failWrite bool + loadCalls int +} + +func newMemoryKeyStore() *memoryKeyStore { + return &memoryKeyStore{hashes: make(map[string]bool)} +} + +// live 返回当前未吊销的哈希,供测试断言落库结果。 +func (m *memoryKeyStore) live() []string { + out := make([]string, 0, len(m.hashes)) + for hash, revoked := range m.hashes { + if !revoked { + out = append(out, hash) + } + } + return out +} + +func (m *memoryKeyStore) LoadUnrevoked(_ context.Context) ([]string, error) { + m.loadCalls++ + if m.failLoad { + return nil, errKeyStoreDown + } + return m.live(), nil +} + +func (m *memoryKeyStore) Insert(_ context.Context, keyHash string) error { + if m.failWrite { + return errKeyStoreDown + } + m.hashes[keyHash] = false + return nil +} + +func (m *memoryKeyStore) Revoke(_ context.Context, keyHash string) error { + if m.failWrite { + return errKeyStoreDown + } + m.hashes[keyHash] = true + return nil +} + +func (m *memoryKeyStore) RevokeAll(_ context.Context) (int, error) { + if m.failWrite { + return 0, errKeyStoreDown + } + n := 0 + for hash, revoked := range m.hashes { + if !revoked { + m.hashes[hash] = true + n++ + } + } + return n, nil +} diff --git a/internal/db/migrations/0016_api_keys.down.sql b/internal/db/migrations/0016_api_keys.down.sql new file mode 100644 index 0000000..e7184aa --- /dev/null +++ b/internal/db/migrations/0016_api_keys.down.sql @@ -0,0 +1,8 @@ +-- 0016_api_keys down: 回滚落盘的 key 表。 +-- +-- 回滚后的状态是「key 只活在内存、重启全失效」的旧形态(K1 修复前):已签发的 +-- key 随表一起丢弃,重启后一律 401,需要主人重新签发。丢弃是安全的 —— 运行时 +-- 内存副本与鉴权路径不读本表以外的任何东西,也不存在引用它的外键。 +DROP INDEX IF EXISTS idx_api_keys_key_hash; + +DROP TABLE IF EXISTS api_keys; diff --git a/internal/db/migrations/0016_api_keys.up.sql b/internal/db/migrations/0016_api_keys.up.sql new file mode 100644 index 0000000..5a999aa --- /dev/null +++ b/internal/db/migrations/0016_api_keys.up.sql @@ -0,0 +1,30 @@ +-- 0016_api_keys: API key 哈希落盘(K1 缺陷修复)。 +-- +-- 为什么必须落盘:此前 key 表只活在 internal/auth 的内存 map 里,管理面板 +-- POST /api/v1/admin/users/apikey 签出去的 key 一遇进程重启全部失效(X-API-Key +-- 一律 401)。而 design/11 §2 把 key 定位成「管理员调试、甚至生产环境救火」的 +-- 长凭据 —— 签了就必须真能用,不能重启即作废。本表是那份凭据的权威载体: +-- 启动时 auth.SQLiteKeyStore 载入全部 revoked_at IS NULL 的行到内存表,之后 +-- Generate/Revoke 先落库、成功后才改内存。 +-- +-- 口径: +-- * key_hash 是明文 key 的 **SHA-256 十六进制**,库里**只存哈希**;明文只在 +-- 签发响应里出现一次,绝不落库、绝不落日志(与 0008 refresh_tokens 的 +-- token_hash 同一纪律,且本包没有任何接受明文的写库入口)。 +-- * revoked_at 为 NULL 表示仍有效;吊销只打时间戳、不删行,留审计痕迹。 +-- * name 是可空备注;created_at / last_used_at 是运营时间戳。last_used_at +-- 当前**没有写入方**(最后一用时间不能靠「每次鉴权同步写库」拿,那样会把 +-- 鉴权路径变成写路径),有意留 NULL,等异步/批量方案落地再填。 +-- * 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。 +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + key_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + last_used_at TEXT, + revoked_at TEXT +); + +-- 唯一索引同时承担两件事:同一把 key 只能有一行;按 hash 查表/吊销有索引可走。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_key_hash + ON api_keys (key_hash);