You've already forked agentic-coding-workflow
feat(mcp): admin token CRUD HTTP handler (POST/GET/DELETE) + TokenService.List/GetByID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,10 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
}
|
}
|
||||||
mcpServer := mcp.NewMCPServer(mcpDeps)
|
mcpServer := mcp.NewMCPServer(mcpDeps)
|
||||||
mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter)
|
mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter)
|
||||||
|
mcp.MountAdmin(r, mcp.AdminHandlerDeps{
|
||||||
|
Tokens: mcpTokenSvc,
|
||||||
|
Users: userSvc,
|
||||||
|
})
|
||||||
|
|
||||||
// ===== ACP module wiring (Part 2: full SessionService + Supervisor) =====
|
// ===== ACP module wiring (Part 2: full SessionService + Supervisor) =====
|
||||||
acpRepo := acp.NewPostgresRepository(pool)
|
acpRepo := acp.NewPostgresRepository(pool)
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
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 端点:
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type TokenService interface {
|
|||||||
Authenticate(ctx context.Context, plaintext string) (*Token, 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
|
||||||
|
List(ctx context.Context, callerUserID *uuid.UUID, activeOnly bool) ([]*Token, error)
|
||||||
|
GetByID(ctx context.Context, id uuid.UUID) (*Token, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueAdminInput 是 admin 签发时的入参。UserID 缺省 = 当前 admin 自己(由调用方填)。
|
// IssueAdminInput 是 admin 签发时的入参。UserID 缺省 = 当前 admin 自己(由调用方填)。
|
||||||
@@ -278,3 +280,11 @@ func (s *tokenService) RevokeBySession(ctx context.Context, sessionID uuid.UUID)
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user