refactor(mcp): apply Part 1 review I1+I3+I4+I5

- Rename MCPToken -> Token (mcp.Token reads cleaner; zero external callers yet)
- IssueSystemToken: guard against nil ACPSessionID / UserID (early input validation)
- TokenService: inject now func for clock injection (parity with RateLimiter)
- Repository CHECK constraint tests: assert wrapped CodeInternal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:28:49 +08:00
parent 980c6fcf0e
commit a00f5a9efd
7 changed files with 106 additions and 65 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ type authCtxKey struct{}
var AuthContextKey = authCtxKey{} var AuthContextKey = authCtxKey{}
// AuthSession 是中间件解析后注入 ctx 的鉴权快照。 // AuthSession 是中间件解析后注入 ctx 的鉴权快照。
// 不直接暴露 *MCPToken(隐藏 hash 字段,避免下游误用)。 // 不直接暴露 *Token(隐藏 hash 字段,避免下游误用)。
type AuthSession struct { type AuthSession struct {
TokenID string // 用于审计 metadata 写入 TokenID string // 用于审计 metadata 写入
UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换) UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换)
+4 -4
View File
@@ -18,7 +18,7 @@ import (
func TestAuthMiddleware_BearerHeader(t *testing.T) { func TestAuthMiddleware_BearerHeader(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
svc := mcp.NewTokenService(repo, &fakeAudit{}) svc := mcp.NewTokenService(repo, &fakeAudit{}, nil)
res, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ res, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -49,7 +49,7 @@ func TestAuthMiddleware_BearerHeader(t *testing.T) {
func TestAuthMiddleware_QueryFallback(t *testing.T) { func TestAuthMiddleware_QueryFallback(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
svc := mcp.NewTokenService(repo, &fakeAudit{}) svc := mcp.NewTokenService(repo, &fakeAudit{}, nil)
res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -72,7 +72,7 @@ func TestAuthMiddleware_QueryFallback(t *testing.T) {
func TestAuthMiddleware_MissingToken(t *testing.T) { func TestAuthMiddleware_MissingToken(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
mw := mcp.NewAuthMiddleware(svc) mw := mcp.NewAuthMiddleware(svc)
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -87,7 +87,7 @@ func TestAuthMiddleware_MissingToken(t *testing.T) {
func TestAuthMiddleware_InvalidToken(t *testing.T) { func TestAuthMiddleware_InvalidToken(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
mw := mcp.NewAuthMiddleware(svc) mw := mcp.NewAuthMiddleware(svc)
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4 -4
View File
@@ -10,7 +10,7 @@
// - 工具/prompts/resources 的业务桥接(调 project / chat 现有 service) // - 工具/prompts/resources 的业务桥接(调 project / chat 现有 service)
// //
// 三类核心抽象: // 三类核心抽象:
// - MCPToken:DB 行记录,token plaintext 仅签发时返回一次 // - Token:DB 行记录,token plaintext 仅签发时返回一次
// - Scope:JSONB 形态的工具白名单 + project_ids 限定 // - Scope:JSONB 形态的工具白名单 + project_ids 限定
// - Issuer:'system' 或 'admin',决定生命期与是否绑定 ACP session // - Issuer:'system' 或 'admin',决定生命期与是否绑定 ACP session
package mcp package mcp
@@ -76,11 +76,11 @@ func (s *Scope) AllowsProject(id uuid.UUID) bool {
return false return false
} }
// MCPToken 是 mcp_tokens 表的 Go 表示。 // Token 是 mcp_tokens 表的 Go 表示。
// //
// TokenHash 是 SHA-256(plaintext);plaintext 仅在 IssueAdmin/IssueSystemToken // TokenHash 是 SHA-256(plaintext);plaintext 仅在 IssueAdmin/IssueSystemToken
// 的返回值里出现一次,DB 与本结构都不持有 plaintext。 // 的返回值里出现一次,DB 与本结构都不持有 plaintext。
type MCPToken struct { type Token struct {
ID uuid.UUID ID uuid.UUID
TokenHash []byte TokenHash []byte
UserID uuid.UUID UserID uuid.UUID
@@ -97,7 +97,7 @@ type MCPToken struct {
// IsActive 报告 token 当前是否可用(未撤销 + 未过期)。 // IsActive 报告 token 当前是否可用(未撤销 + 未过期)。
// now 显式注入便于测试;生产调用方传 time.Now()。 // now 显式注入便于测试;生产调用方传 time.Now()。
func (t *MCPToken) IsActive(now time.Time) bool { func (t *Token) IsActive(now time.Time) bool {
if t.RevokedAt != nil { if t.RevokedAt != nil {
return false return false
} }
+16 -16
View File
@@ -18,10 +18,10 @@ import (
// Repository 是 mcp 模块对 PG 的全部依赖。Service 单测通过手写 fakeRepo 满足。 // Repository 是 mcp 模块对 PG 的全部依赖。Service 单测通过手写 fakeRepo 满足。
type Repository interface { type Repository interface {
// CRUD // CRUD
Create(ctx context.Context, t *MCPToken) (*MCPToken, error) Create(ctx context.Context, t *Token) (*Token, error)
GetByHash(ctx context.Context, hash []byte) (*MCPToken, error) GetByHash(ctx context.Context, hash []byte) (*Token, error)
GetByID(ctx context.Context, id uuid.UUID) (*MCPToken, error) GetByID(ctx context.Context, id uuid.UUID) (*Token, error)
List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*MCPToken, error) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error)
// 状态变更 // 状态变更
UpdateLastUsed(ctx context.Context, id uuid.UUID) error UpdateLastUsed(ctx context.Context, id uuid.UUID) error
@@ -99,13 +99,13 @@ func scopeFromJSON(raw []byte) (Scope, error) {
return s, nil return s, nil
} }
// rowToMCPToken 把 sqlc 生成的 row 翻译为业务结构。 // rowToToken 把 sqlc 生成的 row 翻译为业务结构。
func rowToMCPToken(r mcpsqlc.McpToken) (*MCPToken, error) { func rowToToken(r mcpsqlc.McpToken) (*Token, error) {
scope, err := scopeFromJSON(r.Scope) scope, err := scopeFromJSON(r.Scope)
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "decode mcp_token.scope") return nil, errs.Wrap(err, errs.CodeInternal, "decode mcp_token.scope")
} }
return &MCPToken{ return &Token{
ID: fromPgUUID(r.ID), ID: fromPgUUID(r.ID),
TokenHash: r.TokenHash, TokenHash: r.TokenHash,
UserID: fromPgUUID(r.UserID), UserID: fromPgUUID(r.UserID),
@@ -131,7 +131,7 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error {
// ===== Create ===== // ===== Create =====
func (r *pgRepo) Create(ctx context.Context, t *MCPToken) (*MCPToken, error) { func (r *pgRepo) Create(ctx context.Context, t *Token) (*Token, error) {
scopeJSON, err := scopeToJSON(t.Scope) scopeJSON, err := scopeToJSON(t.Scope)
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "encode mcp_token.scope") return nil, errs.Wrap(err, errs.CodeInternal, "encode mcp_token.scope")
@@ -150,30 +150,30 @@ func (r *pgRepo) Create(ctx context.Context, t *MCPToken) (*MCPToken, error) {
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert mcp_token") return nil, errs.Wrap(err, errs.CodeInternal, "insert mcp_token")
} }
return rowToMCPToken(row) return rowToToken(row)
} }
// ===== Get ===== // ===== Get =====
func (r *pgRepo) GetByHash(ctx context.Context, hash []byte) (*MCPToken, error) { func (r *pgRepo) GetByHash(ctx context.Context, hash []byte) (*Token, error) {
row, err := r.q.GetMcpTokenByHash(ctx, hash) row, err := r.q.GetMcpTokenByHash(ctx, hash)
if err != nil { if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeMcpTokenInvalid, "mcp token not found") return nil, pgxNoRowsToErrNotFound(err, errs.CodeMcpTokenInvalid, "mcp token not found")
} }
return rowToMCPToken(row) return rowToToken(row)
} }
func (r *pgRepo) GetByID(ctx context.Context, id uuid.UUID) (*MCPToken, error) { func (r *pgRepo) GetByID(ctx context.Context, id uuid.UUID) (*Token, error) {
row, err := r.q.GetMcpTokenByID(ctx, toPgUUID(id)) row, err := r.q.GetMcpTokenByID(ctx, toPgUUID(id))
if err != nil { if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeMcpTokenNotFound, "mcp token not found") return nil, pgxNoRowsToErrNotFound(err, errs.CodeMcpTokenNotFound, "mcp token not found")
} }
return rowToMCPToken(row) return rowToToken(row)
} }
// ===== Stub methods (implemented in subsequent tasks) ===== // ===== Stub methods (implemented in subsequent tasks) =====
func (r *pgRepo) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*MCPToken, error) { func (r *pgRepo) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error) {
rows, err := r.q.ListMcpTokens(ctx, mcpsqlc.ListMcpTokensParams{ rows, err := r.q.ListMcpTokens(ctx, mcpsqlc.ListMcpTokensParams{
CallerUserID: toPgUUIDPtr(callerUserID), CallerUserID: toPgUUIDPtr(callerUserID),
ActiveOnly: activeOnly, ActiveOnly: activeOnly,
@@ -181,9 +181,9 @@ func (r *pgRepo) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly b
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list mcp_tokens") return nil, errs.Wrap(err, errs.CodeInternal, "list mcp_tokens")
} }
out := make([]*MCPToken, 0, len(rows)) out := make([]*Token, 0, len(rows))
for _, row := range rows { for _, row := range rows {
t, err := rowToMCPToken(row) t, err := rowToToken(row)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+16 -11
View File
@@ -62,7 +62,7 @@ func TestPgRepo_AdminToken_CRUD(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
expires := time.Now().Add(90 * 24 * time.Hour) expires := time.Now().Add(90 * 24 * time.Hour)
created, err := repo.Create(ctx, &mcp.MCPToken{ created, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), ID: uuid.New(),
TokenHash: hash, TokenHash: hash,
UserID: uid, UserID: uid,
@@ -133,12 +133,14 @@ func TestPgRepo_CheckConstraint_SystemRequiresSession(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// system token + 没 acp_session_id → CHECK 拒绝 // system token + 没 acp_session_id → CHECK 拒绝
_, err = repo.Create(ctx, &mcp.MCPToken{ _, err = repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: uid, ID: uuid.New(), TokenHash: hash, UserID: uid,
Name: "x", Issuer: mcp.IssuerSystem, CreatedBy: uid, Name: "x", Issuer: mcp.IssuerSystem, CreatedBy: uid,
}) })
require.Error(t, err) require.Error(t, err)
// PG 抛 23514 check_violation;包装在 errs.CodeInternal 里 ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInternal, ae.Code, "CHECK violation should wrap to CodeInternal")
} }
func TestPgRepo_CheckConstraint_AdminMustNotHaveSession(t *testing.T) { func TestPgRepo_CheckConstraint_AdminMustNotHaveSession(t *testing.T) {
@@ -152,12 +154,15 @@ func TestPgRepo_CheckConstraint_AdminMustNotHaveSession(t *testing.T) {
fakeSessionID := uuid.New() fakeSessionID := uuid.New()
// admin token + 有 acp_session_id → CHECK 拒绝 // admin token + 有 acp_session_id → CHECK 拒绝
_, err = repo.Create(ctx, &mcp.MCPToken{ _, err = repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: uid, ID: uuid.New(), TokenHash: hash, UserID: uid,
Name: "y", Issuer: mcp.IssuerAdmin, ACPSessionID: &fakeSessionID, Name: "y", Issuer: mcp.IssuerAdmin, ACPSessionID: &fakeSessionID,
CreatedBy: uid, CreatedBy: uid,
}) })
require.Error(t, err) require.Error(t, err)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInternal, ae.Code, "CHECK violation should wrap to CodeInternal")
} }
func TestPgRepo_List_FilterByCaller(t *testing.T) { func TestPgRepo_List_FilterByCaller(t *testing.T) {
@@ -171,7 +176,7 @@ func TestPgRepo_List_FilterByCaller(t *testing.T) {
mkAdmin := func(uid uuid.UUID, name string) { mkAdmin := func(uid uuid.UUID, name string) {
_, hash, _ := mcp.NewToken() _, hash, _ := mcp.NewToken()
_, err := repo.Create(ctx, &mcp.MCPToken{ _, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: uid, Name: name, ID: uuid.New(), TokenHash: hash, UserID: uid, Name: name,
Issuer: mcp.IssuerAdmin, CreatedBy: admin, Issuer: mcp.IssuerAdmin, CreatedBy: admin,
}) })
@@ -208,7 +213,7 @@ func TestPgRepo_List_ActiveOnly(t *testing.T) {
expPast := time.Now().Add(-1 * time.Hour) expPast := time.Now().Add(-1 * time.Hour)
mk := func(name string, exp *time.Time) uuid.UUID { mk := func(name string, exp *time.Time) uuid.UUID {
_, hash, _ := mcp.NewToken() _, hash, _ := mcp.NewToken()
tok, err := repo.Create(ctx, &mcp.MCPToken{ tok, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: user, Name: name, ID: uuid.New(), TokenHash: hash, UserID: user, Name: name,
Issuer: mcp.IssuerAdmin, ExpiresAt: exp, CreatedBy: admin, Issuer: mcp.IssuerAdmin, ExpiresAt: exp, CreatedBy: admin,
}) })
@@ -245,7 +250,7 @@ func TestPgRepo_PurgeExpired(t *testing.T) {
// 一个早已过期的 row(手工写 expires_at = 30d ago) // 一个早已过期的 row(手工写 expires_at = 30d ago)
_, hash, _ := mcp.NewToken() _, hash, _ := mcp.NewToken()
tk, err := repo.Create(ctx, &mcp.MCPToken{ tk, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: user, Name: "old", ID: uuid.New(), TokenHash: hash, UserID: user, Name: "old",
Issuer: mcp.IssuerAdmin, ExpiresAt: &old, CreatedBy: admin, Issuer: mcp.IssuerAdmin, ExpiresAt: &old, CreatedBy: admin,
}) })
@@ -253,7 +258,7 @@ func TestPgRepo_PurgeExpired(t *testing.T) {
// 一个新 token // 一个新 token
_, hash2, _ := mcp.NewToken() _, hash2, _ := mcp.NewToken()
_, err = repo.Create(ctx, &mcp.MCPToken{ _, err = repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash2, UserID: user, Name: "new", ID: uuid.New(), TokenHash: hash2, UserID: user, Name: "new",
Issuer: mcp.IssuerAdmin, ExpiresAt: &expFuture, CreatedBy: admin, Issuer: mcp.IssuerAdmin, ExpiresAt: &expFuture, CreatedBy: admin,
}) })
@@ -323,7 +328,7 @@ func TestPgRepo_RevokeBySession(t *testing.T) {
// 插一个 system token // 插一个 system token
_, hash, _ := mcp.NewToken() _, hash, _ := mcp.NewToken()
tk, err := repo.Create(ctx, &mcp.MCPToken{ tk, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: hash, UserID: uid, ID: uuid.New(), TokenHash: hash, UserID: uid,
Name: "system-x", Issuer: mcp.IssuerSystem, Name: "system-x", Issuer: mcp.IssuerSystem,
Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pid}}, Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pid}},
@@ -359,7 +364,7 @@ func TestPgRepo_ReapStaleSystemTokens(t *testing.T) {
// 一个 crashed session 的 system token // 一个 crashed session 的 system token
sidCrashed, pidA := mustInsertACPSetup(t, ctx, pool, uid, "crashed") sidCrashed, pidA := mustInsertACPSetup(t, ctx, pool, uid, "crashed")
_, h1, _ := mcp.NewToken() _, h1, _ := mcp.NewToken()
tk1, err := repo.Create(ctx, &mcp.MCPToken{ tk1, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: h1, UserID: uid, ID: uuid.New(), TokenHash: h1, UserID: uid,
Name: "crashed-tok", Issuer: mcp.IssuerSystem, Name: "crashed-tok", Issuer: mcp.IssuerSystem,
Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pidA}}, Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pidA}},
@@ -370,7 +375,7 @@ func TestPgRepo_ReapStaleSystemTokens(t *testing.T) {
// 一个 running session 的 system token(不该被 reap) // 一个 running session 的 system token(不该被 reap)
sidRunning, pidB := mustInsertACPSetup(t, ctx, pool, uid, "running") sidRunning, pidB := mustInsertACPSetup(t, ctx, pool, uid, "running")
_, h2, _ := mcp.NewToken() _, h2, _ := mcp.NewToken()
tk2, err := repo.Create(ctx, &mcp.MCPToken{ tk2, err := repo.Create(ctx, &mcp.Token{
ID: uuid.New(), TokenHash: h2, UserID: uid, ID: uuid.New(), TokenHash: h2, UserID: uid,
Name: "running-tok", Issuer: mcp.IssuerSystem, Name: "running-tok", Issuer: mcp.IssuerSystem,
Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pidB}}, Scope: mcp.Scope{ProjectIDs: []uuid.UUID{pidB}},
+21 -11
View File
@@ -20,7 +20,7 @@ import (
type TokenService interface { type TokenService interface {
IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error) IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error)
IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error)
Authenticate(ctx context.Context, plaintext string) (*MCPToken, error) Authenticate(ctx context.Context, plaintext string) (*Token, error)
Revoke(ctx context.Context, id, byUserID uuid.UUID) error Revoke(ctx context.Context, id, byUserID uuid.UUID) error
RevokeBySession(ctx context.Context, sessionID uuid.UUID) error RevokeBySession(ctx context.Context, sessionID uuid.UUID) error
} }
@@ -60,14 +60,18 @@ var defaultSystemTools = []string{
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase", "create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
} }
// NewTokenService 构造 service。 // NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
func NewTokenService(repo Repository, audit audit.Recorder) TokenService { func NewTokenService(repo Repository, audit audit.Recorder, now func() time.Time) TokenService {
return &tokenService{repo: repo, audit: audit} if now == nil {
now = time.Now
}
return &tokenService{repo: repo, audit: audit, now: now}
} }
type tokenService struct { type tokenService struct {
repo Repository repo Repository
audit audit.Recorder audit audit.Recorder
now func() time.Time
} }
// IssueAdmin 签发一个长期 admin token。Name + ExpiresAt 必填;scope 可空(= 全权)。 // IssueAdmin 签发一个长期 admin token。Name + ExpiresAt 必填;scope 可空(= 全权)。
@@ -79,7 +83,7 @@ func (s *tokenService) IssueAdmin(ctx context.Context, in IssueAdminInput) (Issu
if in.ExpiresAt.IsZero() { if in.ExpiresAt.IsZero() {
return IssueResult{}, errs.New(errs.CodeMcpTokenExpiresRequired, "expires_at is required for admin token") return IssueResult{}, errs.New(errs.CodeMcpTokenExpiresRequired, "expires_at is required for admin token")
} }
if !in.ExpiresAt.After(time.Now()) { if !in.ExpiresAt.After(s.now()) {
return IssueResult{}, errs.New(errs.CodeMcpTokenExpiresRequired, "expires_at must be in the future") return IssueResult{}, errs.New(errs.CodeMcpTokenExpiresRequired, "expires_at must be in the future")
} }
@@ -89,7 +93,7 @@ func (s *tokenService) IssueAdmin(ctx context.Context, in IssueAdminInput) (Issu
} }
id := uuid.New() id := uuid.New()
created, err := s.repo.Create(ctx, &MCPToken{ created, err := s.repo.Create(ctx, &Token{
ID: id, ID: id,
TokenHash: hash, TokenHash: hash,
UserID: in.UserID, UserID: in.UserID,
@@ -140,6 +144,12 @@ func (s *tokenService) IssueAdmin(ctx context.Context, in IssueAdminInput) (Issu
// 默认 ExpiresIn = 24h;默认 Tools 为内置 PM 工具白名单(spec §6.2)。 // 默认 ExpiresIn = 24h;默认 Tools 为内置 PM 工具白名单(spec §6.2)。
// 写一条 audit `mcp.token.create_system`。 // 写一条 audit `mcp.token.create_system`。
func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) { func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) {
if in.ACPSessionID == uuid.Nil {
return IssueResult{}, errs.New(errs.CodeInvalidInput, "acp_session_id is required for system token")
}
if in.UserID == uuid.Nil {
return IssueResult{}, errs.New(errs.CodeInvalidInput, "user_id is required for system token")
}
if in.ExpiresIn <= 0 { if in.ExpiresIn <= 0 {
in.ExpiresIn = 24 * time.Hour in.ExpiresIn = 24 * time.Hour
} }
@@ -148,7 +158,7 @@ func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemToken
// 用 copy 避免共享 defaultSystemTools 给调用方修改 // 用 copy 避免共享 defaultSystemTools 给调用方修改
tools = append([]string{}, defaultSystemTools...) tools = append([]string{}, defaultSystemTools...)
} }
expires := time.Now().Add(in.ExpiresIn) expires := s.now().Add(in.ExpiresIn)
plain, hash, err := NewToken() plain, hash, err := NewToken()
if err != nil { if err != nil {
@@ -156,7 +166,7 @@ func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemToken
} }
id := uuid.New() id := uuid.New()
created, err := s.repo.Create(ctx, &MCPToken{ created, err := s.repo.Create(ctx, &Token{
ID: id, ID: id,
TokenHash: hash, TokenHash: hash,
UserID: in.UserID, UserID: in.UserID,
@@ -200,8 +210,8 @@ func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemToken
// - revoked_at 非空 → CodeMcpTokenRevoked (401) // - revoked_at 非空 → CodeMcpTokenRevoked (401)
// - expires_at 已过 → CodeMcpTokenExpired (401) // - expires_at 已过 → CodeMcpTokenExpired (401)
// //
// 返回的 *MCPToken 不含 plaintext;调用方从中读 user_id + scope。 // 返回的 *Token 不含 plaintext;调用方从中读 user_id + scope。
func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*MCPToken, error) { func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*Token, error) {
if strings.TrimSpace(plaintext) == "" { if strings.TrimSpace(plaintext) == "" {
return nil, errs.New(errs.CodeMcpTokenInvalid, "missing token") return nil, errs.New(errs.CodeMcpTokenInvalid, "missing token")
} }
@@ -212,7 +222,7 @@ func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*MCP
if tok.RevokedAt != nil { if tok.RevokedAt != nil {
return nil, errs.New(errs.CodeMcpTokenRevoked, "token revoked") return nil, errs.New(errs.CodeMcpTokenRevoked, "token revoked")
} }
if tok.ExpiresAt != nil && !tok.ExpiresAt.After(time.Now()) { if tok.ExpiresAt != nil && !tok.ExpiresAt.After(s.now()) {
return nil, errs.New(errs.CodeMcpTokenExpired, "token expired") return nil, errs.New(errs.CodeMcpTokenExpired, "token expired")
} }
+44 -18
View File
@@ -20,18 +20,18 @@ import (
type fakeRepo struct { type fakeRepo struct {
mu sync.Mutex mu sync.Mutex
tokens map[uuid.UUID]*mcp.MCPToken tokens map[uuid.UUID]*mcp.Token
byHash map[string]uuid.UUID byHash map[string]uuid.UUID
} }
func newFakeRepo() *fakeRepo { func newFakeRepo() *fakeRepo {
return &fakeRepo{ return &fakeRepo{
tokens: map[uuid.UUID]*mcp.MCPToken{}, tokens: map[uuid.UUID]*mcp.Token{},
byHash: map[string]uuid.UUID{}, byHash: map[string]uuid.UUID{},
} }
} }
func (r *fakeRepo) Create(ctx context.Context, t *mcp.MCPToken) (*mcp.MCPToken, error) { func (r *fakeRepo) Create(ctx context.Context, t *mcp.Token) (*mcp.Token, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
c := *t c := *t
@@ -40,7 +40,7 @@ func (r *fakeRepo) Create(ctx context.Context, t *mcp.MCPToken) (*mcp.MCPToken,
r.byHash[string(c.TokenHash)] = c.ID r.byHash[string(c.TokenHash)] = c.ID
return &c, nil return &c, nil
} }
func (r *fakeRepo) GetByHash(ctx context.Context, h []byte) (*mcp.MCPToken, error) { func (r *fakeRepo) GetByHash(ctx context.Context, h []byte) (*mcp.Token, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
id, ok := r.byHash[string(h)] id, ok := r.byHash[string(h)]
@@ -50,7 +50,7 @@ func (r *fakeRepo) GetByHash(ctx context.Context, h []byte) (*mcp.MCPToken, erro
c := *r.tokens[id] c := *r.tokens[id]
return &c, nil return &c, nil
} }
func (r *fakeRepo) GetByID(ctx context.Context, id uuid.UUID) (*mcp.MCPToken, error) { func (r *fakeRepo) GetByID(ctx context.Context, id uuid.UUID) (*mcp.Token, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
t, ok := r.tokens[id] t, ok := r.tokens[id]
@@ -60,10 +60,10 @@ func (r *fakeRepo) GetByID(ctx context.Context, id uuid.UUID) (*mcp.MCPToken, er
c := *t c := *t
return &c, nil return &c, nil
} }
func (r *fakeRepo) List(ctx context.Context, caller *uuid.UUID, activeOnly bool) ([]*mcp.MCPToken, error) { func (r *fakeRepo) List(ctx context.Context, caller *uuid.UUID, activeOnly bool) ([]*mcp.Token, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
var out []*mcp.MCPToken var out []*mcp.Token
for _, t := range r.tokens { for _, t := range r.tokens {
if caller != nil && (t.UserID != *caller || t.Issuer != mcp.IssuerAdmin) { if caller != nil && (t.UserID != *caller || t.Issuer != mcp.IssuerAdmin) {
continue continue
@@ -151,7 +151,7 @@ func TestIssueAdmin_HappyPath(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
au := &fakeAudit{} au := &fakeAudit{}
svc := mcp.NewTokenService(repo, au) svc := mcp.NewTokenService(repo, au, nil)
uid := uuid.New() uid := uuid.New()
in := mcp.IssueAdminInput{ in := mcp.IssueAdminInput{
@@ -169,7 +169,7 @@ func TestIssueAdmin_HappyPath(t *testing.T) {
func TestIssueAdmin_NameRequired(t *testing.T) { func TestIssueAdmin_NameRequired(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ _, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -184,7 +184,7 @@ func TestIssueAdmin_NameRequired(t *testing.T) {
func TestIssueAdmin_ExpiresRequired(t *testing.T) { func TestIssueAdmin_ExpiresRequired(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ _, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -198,7 +198,7 @@ func TestIssueAdmin_ExpiresRequired(t *testing.T) {
func TestIssueAdmin_ExpiresMustBeFuture(t *testing.T) { func TestIssueAdmin_ExpiresMustBeFuture(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ _, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -215,7 +215,7 @@ func TestIssueSystemToken_Defaults(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
au := &fakeAudit{} au := &fakeAudit{}
svc := mcp.NewTokenService(repo, au) svc := mcp.NewTokenService(repo, au, nil)
uid := uuid.New() uid := uuid.New()
sid := uuid.New() sid := uuid.New()
@@ -243,7 +243,7 @@ func TestIssueSystemToken_Defaults(t *testing.T) {
func TestAuthenticate_HappyPath(t *testing.T) { func TestAuthenticate_HappyPath(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
svc := mcp.NewTokenService(repo, &fakeAudit{}) svc := mcp.NewTokenService(repo, &fakeAudit{}, nil)
res, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ res, err := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -260,7 +260,7 @@ func TestAuthenticate_HappyPath(t *testing.T) {
func TestAuthenticate_Empty(t *testing.T) { func TestAuthenticate_Empty(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.Authenticate(context.Background(), "") _, err := svc.Authenticate(context.Background(), "")
ae, ok := errs.As(err) ae, ok := errs.As(err)
@@ -271,7 +271,7 @@ func TestAuthenticate_Empty(t *testing.T) {
func TestAuthenticate_Revoked(t *testing.T) { func TestAuthenticate_Revoked(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
svc := mcp.NewTokenService(repo, &fakeAudit{}) svc := mcp.NewTokenService(repo, &fakeAudit{}, nil)
res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -290,7 +290,7 @@ func TestAuthenticate_Revoked(t *testing.T) {
func TestAuthenticate_Expired(t *testing.T) { func TestAuthenticate_Expired(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
svc := mcp.NewTokenService(repo, &fakeAudit{}) svc := mcp.NewTokenService(repo, &fakeAudit{}, nil)
res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{ res, _ := svc.IssueAdmin(context.Background(), mcp.IssueAdminInput{
UserID: uuid.New(), UserID: uuid.New(),
@@ -310,7 +310,7 @@ func TestAuthenticate_Expired(t *testing.T) {
func TestAuthenticate_TamperedToken(t *testing.T) { func TestAuthenticate_TamperedToken(t *testing.T) {
t.Parallel() t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}) svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.Authenticate(context.Background(), "mcpt_tampered_value") _, err := svc.Authenticate(context.Background(), "mcpt_tampered_value")
ae, ok := errs.As(err) ae, ok := errs.As(err)
@@ -322,7 +322,7 @@ func TestRevokeBySession_AuditPerToken(t *testing.T) {
t.Parallel() t.Parallel()
repo := newFakeRepo() repo := newFakeRepo()
au := &fakeAudit{} au := &fakeAudit{}
svc := mcp.NewTokenService(repo, au) svc := mcp.NewTokenService(repo, au, nil)
uid := uuid.New() uid := uuid.New()
sid := uuid.New() sid := uuid.New()
@@ -352,5 +352,31 @@ func TestRevokeBySession_AuditPerToken(t *testing.T) {
assert.Equal(t, 3, revokeCnt) assert.Equal(t, 3, revokeCnt)
} }
func TestIssueSystemToken_NilACPSessionID(t *testing.T) {
t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.IssueSystemToken(context.Background(), mcp.IssueSystemTokenInput{
UserID: uuid.New(),
ACPSessionID: uuid.Nil,
})
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}
func TestIssueSystemToken_NilUserID(t *testing.T) {
t.Parallel()
svc := mcp.NewTokenService(newFakeRepo(), &fakeAudit{}, nil)
_, err := svc.IssueSystemToken(context.Background(), mcp.IssueSystemTokenInput{
UserID: uuid.Nil,
ACPSessionID: uuid.New(),
})
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}
// 编译期保证:类型实现接口 // 编译期保证:类型实现接口
var _ = errors.New var _ = errors.New