diff --git a/internal/app/app.go b/internal/app/app.go index 1e35312..f000419 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -272,6 +272,10 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { } mcpServer := mcp.NewMCPServer(mcpDeps) mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter) + mcp.MountAdmin(r, mcp.AdminHandlerDeps{ + Tokens: mcpTokenSvc, + Users: userSvc, + }) // ===== ACP module wiring (Part 2: full SessionService + Supervisor) ===== acpRepo := acp.NewPostgresRepository(pool) diff --git a/internal/mcp/handler.go b/internal/mcp/handler.go index 9815421..defd2ca 100644 --- a/internal/mcp/handler.go +++ b/internal/mcp/handler.go @@ -1,10 +1,20 @@ package mcp import ( + "context" + "encoding/json" "net/http" + "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" + "github.com/yan1h/agent-coding-workflow/internal/user" ) // MountTransports 在 chi router 上挂载 MCP transport 端点: @@ -56,3 +66,186 @@ func injectTokenIDMiddleware() func(http.Handler) http.Handler { }) } } + +// ===== Admin Token CRUD HTTP Handler ===== + +// AdminHandlerDeps 装配 admin token CRUD HTTP handler 所需依赖。 +type AdminHandlerDeps struct { + Tokens TokenService + Users user.Service +} + +// MountAdmin 把 admin token CRUD 挂到 /api/v1/mcp/tokens。 +func MountAdmin(r chi.Router, deps AdminHandlerDeps) { + r.Route("/api/v1/mcp/tokens", func(r chi.Router) { + r.Post("/", postToken(deps)) + r.Get("/", listTokens(deps)) + r.Delete("/{id}", deleteToken(deps)) + }) +} + +type postTokenReq struct { + UserID string `json:"user_id,omitempty"` + Name string `json:"name"` + Scope Scope `json:"scope"` + ExpiresAt time.Time `json:"expires_at"` +} +type postTokenResp struct { + ID string `json:"id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +func postToken(deps AdminHandlerDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + caller, isAdmin, err := resolveSessionUser(r.Context(), deps.Users, r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + if !isAdmin { + httpx.WriteError(w, rid, errs.New(errs.CodeForbidden, "admin only")) + return + } + var req postTokenReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + var targetUID uuid.UUID + if req.UserID == "" { + targetUID = caller + } else { + tid, err := uuid.Parse(req.UserID) + if err != nil { + httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "user_id parse")) + return + } + targetUID = tid + } + res, err := deps.Tokens.IssueAdmin(r.Context(), IssueAdminInput{ + UserID: targetUID, + Name: req.Name, + Scope: req.Scope, + ExpiresAt: req.ExpiresAt, + CreatedBy: caller, + }) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, postTokenResp{ + ID: res.ID.String(), + Token: res.Plaintext, + ExpiresAt: derefTime(res.ExpiresAt), + }) + } +} + +type tokenRow struct { + ID string `json:"id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope Scope `json:"scope"` + ExpiresAt *string `json:"expires_at,omitempty"` + RevokedAt *string `json:"revoked_at,omitempty"` + LastUsedAt *string `json:"last_used_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +func listTokens(deps AdminHandlerDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + caller, isAdmin, err := resolveSessionUser(r.Context(), deps.Users, r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + var callerFilter *uuid.UUID + if !isAdmin { + callerFilter = &caller + } + activeOnly := r.URL.Query().Get("active_only") == "true" + tokens, err := deps.Tokens.List(r.Context(), callerFilter, activeOnly) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + out := make([]tokenRow, 0, len(tokens)) + for _, t := range tokens { + row := tokenRow{ + ID: t.ID.String(), Name: t.Name, + Issuer: string(t.Issuer), Scope: t.Scope, + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + if t.ExpiresAt != nil { + s := t.ExpiresAt.Format(time.RFC3339) + row.ExpiresAt = &s + } + if t.RevokedAt != nil { + s := t.RevokedAt.Format(time.RFC3339) + row.RevokedAt = &s + } + if t.LastUsedAt != nil { + s := t.LastUsedAt.Format(time.RFC3339) + row.LastUsedAt = &s + } + out = append(out, row) + } + httpx.WriteJSON(w, http.StatusOK, out) + } +} + +func deleteToken(deps AdminHandlerDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + caller, _, err := resolveSessionUser(r.Context(), deps.Users, r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + idStr := chi.URLParam(r, "id") + id, err := uuid.Parse(idStr) + if err != nil { + httpx.WriteError(w, rid, errs.Wrap(err, errs.CodeInvalidInput, "id parse")) + return + } + if err := deps.Tokens.Revoke(r.Context(), id, caller); err != nil { + httpx.WriteError(w, rid, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func resolveSessionUser(ctx context.Context, us user.Service, r *http.Request) (uuid.UUID, bool, error) { + tok := extractBearer(r) + uid, ok, err := us.ResolveSession(ctx, tok) + if err != nil { + return uuid.Nil, false, errs.Wrap(err, errs.CodeInternal, "resolve session") + } + if !ok { + return uuid.Nil, false, errs.New(errs.CodeUnauthorized, "session invalid") + } + u, err := us.Get(ctx, uid) + if err != nil { + return uuid.Nil, false, err + } + return uid, u.IsAdmin, nil +} + +func extractBearer(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + return "" +} + +func derefTime(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} diff --git a/internal/mcp/token_service.go b/internal/mcp/token_service.go index 279c199..b227ac4 100644 --- a/internal/mcp/token_service.go +++ b/internal/mcp/token_service.go @@ -23,6 +23,8 @@ type TokenService interface { Authenticate(ctx context.Context, plaintext string) (*Token, error) Revoke(ctx context.Context, id, byUserID uuid.UUID) error RevokeBySession(ctx context.Context, sessionID uuid.UUID) error + List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error) + GetByID(ctx context.Context, id uuid.UUID) (*Token, error) } // IssueAdminInput 是 admin 签发时的入参。UserID 缺省 = 当前 admin 自己(由调用方填)。 @@ -278,3 +280,11 @@ func (s *tokenService) RevokeBySession(ctx context.Context, sessionID uuid.UUID) } return nil } + +func (s *tokenService) List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error) { + return s.repo.List(ctx, callerUserID, activeOnly) +} + +func (s *tokenService) GetByID(ctx context.Context, id uuid.UUID) (*Token, error) { + return s.repo.GetByID(ctx, id) +}