chore: initial import

This commit is contained in:
phamnazage-jpg
2026-05-12 17:47:32 +08:00
commit fc54ba84b2
104 changed files with 11575 additions and 0 deletions

112
internal/middleware/auth.go Normal file
View File

@@ -0,0 +1,112 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/company/ai-ops/internal/config"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
"github.com/golang-jwt/jwt/v5"
)
// Auth 中间件检查认证
func Auth(cfg config.ServerConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 白名单路径免认证
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// API Key 检查(用于 /metrics 等机器对机器接口)
if strings.HasPrefix(r.URL.Path, "/metrics") {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
apiKey = r.URL.Query().Get("api_key")
}
if apiKey == cfg.MetricsAuth {
next.ServeHTTP(w, r)
return
}
}
// JWT 检查
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
response.Error(w, errors.ErrUnauthorized)
return
}
tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !token.Valid {
response.Error(w, errors.ErrUnauthorized)
return
}
// 将用户ID和角色写入上下文
if claims, ok := token.Claims.(jwt.MapClaims); ok {
if userID, ok := claims["user_id"].(string); ok {
r = r.WithContext(context.WithValue(r.Context(), "user_id", userID))
}
if role, ok := claims["role"].(string); ok {
r = r.WithContext(context.WithValue(r.Context(), "role", role))
}
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole 角色权限中间件
func RequireRole(roles ...string) func(http.Handler) http.Handler {
roleSet := make(map[string]bool)
for _, r := range roles {
roleSet[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, _ := r.Context().Value("role").(string)
if !roleSet[role] {
response.Error(w, errors.ErrForbidden.WithDetail(map[string]any{
"error": "insufficient permissions",
"code": "OPS_AUTH_1001",
"required": roles,
"current": role,
}))
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWrite 允许 GET 或需要写权限
func RequireWrite(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" || r.Method == "HEAD" {
next.ServeHTTP(w, r)
return
}
role, _ := r.Context().Value("role").(string)
if role != "operator" && role != "admin" {
response.Error(w, errors.ErrForbidden.WithDetail(map[string]any{
"error": "write permission required",
"code": "OPS_AUTH_1001",
"current": role,
}))
return
}
next.ServeHTTP(w, r)
})
}
func isPublicPath(path string) bool {
return path == "/health" || strings.HasPrefix(path, "/actuator/health") || path == "/api/v1/ai-ops/login" || path == "/openapi.json" || strings.HasPrefix(path, "/ops/dashboard")
}

View File

@@ -0,0 +1,100 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/company/ai-ops/internal/config"
"github.com/company/ai-ops/internal/service"
)
func TestAuthAllowsPublicPaths(t *testing.T) {
cfg := config.ServerConfig{JWTSecret: "secret", MetricsAuth: "metrics-key"}
for _, path := range []string{"/health", "/actuator/health/ready", "/api/v1/ai-ops/login", "/openapi.json", "/ops/dashboard"} {
t.Run(path, func(t *testing.T) {
called := false
h := Auth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, path, nil))
if !called {
t.Fatalf("public path %s was blocked", path)
}
})
}
}
func TestAuthMetricsAPIKeyAndJWT(t *testing.T) {
cfg := config.ServerConfig{JWTSecret: "secret", MetricsAuth: "metrics-key"}
t.Run("metrics api key", func(t *testing.T) {
called := false
h := Auth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.Header.Set("X-API-Key", "metrics-key")
h.ServeHTTP(httptest.NewRecorder(), req)
if !called {
t.Fatal("metrics api key did not pass")
}
})
t.Run("missing token rejected", func(t *testing.T) {
h := Auth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call next") }))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", w.Code)
}
})
t.Run("valid jwt sets context", func(t *testing.T) {
token, err := service.NewAuthService("secret").IssueToken("u1", "operator")
if err != nil {
t.Fatal(err)
}
h := Auth(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value("user_id") != "u1" || r.Context().Value("role") != "operator" {
t.Fatalf("context not populated: user=%v role=%v", r.Context().Value("user_id"), r.Context().Value("role"))
}
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil)
req.Header.Set("Authorization", "Bearer "+token)
h.ServeHTTP(httptest.NewRecorder(), req)
})
}
func TestRequireRoleAndRequireWrite(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) })
allowed := RequireRole("admin")(next)
req := httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(context.WithValue(context.Background(), "role", "admin"))
w := httptest.NewRecorder()
allowed.ServeHTTP(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("role allowed status = %d", w.Code)
}
denied := httptest.NewRecorder()
RequireRole("admin")(next).ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/x", nil))
if denied.Code != http.StatusForbidden {
t.Fatalf("role denied status = %d", denied.Code)
}
read := httptest.NewRecorder()
RequireWrite(next).ServeHTTP(read, httptest.NewRequest(http.MethodGet, "/x", nil))
if read.Code != http.StatusAccepted {
t.Fatalf("read status = %d", read.Code)
}
writeDenied := httptest.NewRecorder()
RequireWrite(next).ServeHTTP(writeDenied, httptest.NewRequest(http.MethodPost, "/x", nil))
if writeDenied.Code != http.StatusForbidden {
t.Fatalf("write denied status = %d", writeDenied.Code)
}
writeAllowed := httptest.NewRecorder()
writeReq := httptest.NewRequest(http.MethodPost, "/x", nil).WithContext(context.WithValue(context.Background(), "role", "operator"))
RequireWrite(next).ServeHTTP(writeAllowed, writeReq)
if writeAllowed.Code != http.StatusAccepted {
t.Fatalf("write allowed status = %d", writeAllowed.Code)
}
}

View File

@@ -0,0 +1,37 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter 是用于捕获状态码的响应写入器
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Logging 中间件记录请求日志
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
duration := time.Since(start)
slog.Info("http_request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", float64(duration.Microseconds())/1000,
"remote_addr", r.RemoteAddr,
)
})
}

View File

@@ -0,0 +1,41 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLoggingCapturesStatusCode(t *testing.T) {
h := Logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("created"))
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", nil))
if w.Code != http.StatusCreated || w.Body.String() != "created" {
t.Fatalf("response = %d %q", w.Code, w.Body.String())
}
}
func TestRecoveryConvertsPanicToInternalError(t *testing.T) {
h := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("boom")
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/panic", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
func TestRecoveryPassesThroughNormalRequest(t *testing.T) {
h := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ok", nil))
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d", w.Code)
}
}

View File

@@ -0,0 +1,27 @@
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
"github.com/company/ai-ops/pkg/errors"
"github.com/company/ai-ops/pkg/response"
)
// Recovery 中间件捕获 panic
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic_recovered",
"error", rec,
"stack", string(debug.Stack()),
"path", r.URL.Path,
)
response.Error(w, errors.ErrInternal)
}
}()
next.ServeHTTP(w, r)
})
}