package mcp import ( "context" "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // TokenService 是 MCP token 的业务编排层。三类入口: // - IssueAdmin:admin 后台手动签发,必须给 expires_at // - IssueSystemToken:ACP supervisor 起 session 时调,绑定 acp_session_id // - Authenticate:HTTP 中间件调,验 plaintext + 返回 user_id + scope // // 撤销与列表通过 Repository 直接调即可,不在 service 上加薄壳。 type TokenService interface { IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) Authenticate(ctx context.Context, plaintext string) (*MCPToken, error) Revoke(ctx context.Context, id, byUserID uuid.UUID) error RevokeBySession(ctx context.Context, sessionID uuid.UUID) error } // IssueAdminInput 是 admin 签发时的入参。UserID 缺省 = 当前 admin 自己(由调用方填)。 // Name 必填;ExpiresAt 必填(spec §10 决策 #14:防长期泄露)。 type IssueAdminInput struct { UserID uuid.UUID Name string Scope Scope ExpiresAt time.Time CreatedBy uuid.UUID // 当前 admin 用户 ID } // IssueSystemTokenInput 是 ACP session 启动时的入参。 type IssueSystemTokenInput struct { UserID uuid.UUID ACPSessionID uuid.UUID ProjectIDs []uuid.UUID Tools []string // 缺省 nil = 系统默认(不含 chat) ExpiresIn time.Duration // 默认 24h } // IssueResult 是签发返回。Plaintext 仅此一次返回;调用方须立即转交给客户端。 type IssueResult struct { ID uuid.UUID Plaintext string ExpiresAt *time.Time } // defaultSystemTools 是 system token 的默认工具白名单(spec §6.2)。 // 不含 list_recent_messages:agent 不该看用户私人对话历史。 var defaultSystemTools = []string{ "list_projects", "list_workspaces", "list_requirements", "list_issues", "get_issue", "get_requirement", "create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue", "create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase", } // NewTokenService 构造 service。 func NewTokenService(repo Repository, audit audit.Recorder) TokenService { return &tokenService{repo: repo, audit: audit} } type tokenService struct { repo Repository audit audit.Recorder } // IssueAdmin 签发一个长期 admin token。Name + ExpiresAt 必填;scope 可空(= 全权)。 // 写一条 audit `mcp.token.create`。 func (s *tokenService) IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error) { if strings.TrimSpace(in.Name) == "" { return IssueResult{}, errs.New(errs.CodeMcpTokenNameRequired, "name is required") } if in.ExpiresAt.IsZero() { return IssueResult{}, errs.New(errs.CodeMcpTokenExpiresRequired, "expires_at is required for admin token") } plain, hash, err := NewToken() if err != nil { return IssueResult{}, errs.Wrap(err, errs.CodeInternal, "generate token plaintext") } id := uuid.New() created, err := s.repo.Create(ctx, &MCPToken{ ID: id, TokenHash: hash, UserID: in.UserID, Name: in.Name, Issuer: IssuerAdmin, Scope: in.Scope, ExpiresAt: &in.ExpiresAt, CreatedBy: in.CreatedBy, }) if err != nil { return IssueResult{}, err } _ = s.audit.Record(ctx, audit.Entry{ UserID: &in.CreatedBy, Action: "mcp.token.create", TargetType: "mcp_token", TargetID: created.ID.String(), Metadata: map[string]any{ "token_id": created.ID.String(), "user_id": in.UserID.String(), "name": in.Name, "scope": in.Scope, "expires_at": in.ExpiresAt, }, }) return IssueResult{ ID: created.ID, Plaintext: plain, ExpiresAt: created.ExpiresAt, }, nil } // IssueSystemToken 在 ACP supervisor 起 session 时调用。 // // 重要约束:调用必须在已经创建 acp_sessions row 的事务**之后或同一事务内**进行 —— // 因为 mcp_tokens.acp_session_id 是 NOT NULL CHECK + FK,session row 不存在则 // FK 校验失败。spec §6.1 明确建议同事务提交。本 service 不直接管事务边界(由 // ACP SessionService 在 Create 流程内编排),但调用顺序由调用方保证。 // // 默认 ExpiresIn = 24h;默认 Tools 为内置 PM 工具白名单(spec §6.2)。 // 写一条 audit `mcp.token.create_system`。 func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) { if in.ExpiresIn <= 0 { in.ExpiresIn = 24 * time.Hour } tools := in.Tools if tools == nil { // 用 copy 避免共享 defaultSystemTools 给调用方修改 tools = append([]string{}, defaultSystemTools...) } expires := time.Now().Add(in.ExpiresIn) plain, hash, err := NewToken() if err != nil { return IssueResult{}, errs.Wrap(err, errs.CodeInternal, "generate system token") } id := uuid.New() created, err := s.repo.Create(ctx, &MCPToken{ ID: id, TokenHash: hash, UserID: in.UserID, Name: "acp:" + in.ACPSessionID.String(), Issuer: IssuerSystem, Scope: Scope{Tools: tools, ProjectIDs: in.ProjectIDs}, ACPSessionID: &in.ACPSessionID, ExpiresAt: &expires, CreatedBy: in.UserID, // system 签发时 created_by 即 owner(无 admin 操作者) }) if err != nil { return IssueResult{}, err } _ = s.audit.Record(ctx, audit.Entry{ UserID: &in.UserID, Action: "mcp.token.create_system", TargetType: "mcp_token", TargetID: created.ID.String(), Metadata: map[string]any{ "token_id": created.ID.String(), "acp_session_id": in.ACPSessionID.String(), "user_id": in.UserID.String(), }, }) return IssueResult{ ID: created.ID, Plaintext: plain, ExpiresAt: created.ExpiresAt, }, nil } // Authenticate 校验 plaintext token: // 1. 计算 hash 反查 row // 2. 检查 revoked_at IS NULL // 3. 检查 expires_at IS NULL OR > now() // 4. 异步触发 last_used_at 更新(不阻塞调用方;使用独立后台 ctx) // // 失败映射: // - row 不存在 → CodeMcpTokenInvalid (401) // - revoked_at 非空 → CodeMcpTokenRevoked (401) // - expires_at 已过 → CodeMcpTokenExpired (401) // // 返回的 *MCPToken 不含 plaintext;调用方从中读 user_id + scope。 func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*MCPToken, error) { if strings.TrimSpace(plaintext) == "" { return nil, errs.New(errs.CodeMcpTokenInvalid, "missing token") } tok, err := s.repo.GetByHash(ctx, HashToken(plaintext)) if err != nil { return nil, err // 已在 repo 层映射为 CodeMcpTokenInvalid } if tok.RevokedAt != nil { return nil, errs.New(errs.CodeMcpTokenRevoked, "token revoked") } if tok.ExpiresAt != nil && !tok.ExpiresAt.After(time.Now()) { return nil, errs.New(errs.CodeMcpTokenExpired, "token expired") } // 异步更新 last_used_at,不阻塞调用方;使用独立 ctx 防 request 取消 go func(id uuid.UUID) { bg, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.repo.UpdateLastUsed(bg, id) }(tok.ID) return tok, nil } func (s *tokenService) Revoke(ctx context.Context, id, byUserID uuid.UUID) error { panic("not implemented: Revoke") } func (s *tokenService) RevokeBySession(ctx context.Context, sessionID uuid.UUID) error { panic("not implemented: RevokeBySession") }