You've already forked agentic-coding-workflow
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:
@@ -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 转换)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user