From 6fa703e02d3e3c0e463a4014f8c5daffcd8bb806 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Apr 2026 11:19:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E5=AE=9E=E7=8E=B0Audit=20HTTP?= =?UTF-8?q?=20Handler=E5=B9=B6=E6=8F=90=E5=8D=87IAM=20Middleware=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增Audit HTTP Handler (AUD-05, AUD-06完成) - POST /api/v1/audit/events - 创建审计事件(支持幂等) - GET /api/v1/audit/events - 查询事件列表(支持分页和过滤) 2. 提升IAM Middleware测试覆盖率 - 从63.8%提升至83.5% - 新增SetRouteScopePolicy测试 - 新增RequireRole/RequireMinLevel中间件测试 - 新增hasAnyScope测试 TDD完成:33/33任务 (100%) --- .../internal/audit/handler/audit_handler.go | 183 +++++++++++++++ .../audit/handler/audit_handler_test.go | 222 ++++++++++++++++++ .../iam/middleware/scope_auth_test.go | 156 +++++++++++- 3 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 supply-api/internal/audit/handler/audit_handler.go create mode 100644 supply-api/internal/audit/handler/audit_handler_test.go diff --git a/supply-api/internal/audit/handler/audit_handler.go b/supply-api/internal/audit/handler/audit_handler.go new file mode 100644 index 0000000..fc776e8 --- /dev/null +++ b/supply-api/internal/audit/handler/audit_handler.go @@ -0,0 +1,183 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "lijiaoqiao/supply-api/internal/audit/model" + "lijiaoqiao/supply-api/internal/audit/service" +) + +// AuditHandler HTTP处理器 +type AuditHandler struct { + svc *service.AuditService +} + +// NewAuditHandler 创建审计处理器 +func NewAuditHandler(svc *service.AuditService) *AuditHandler { + return &AuditHandler{svc: svc} +} + +// CreateEventRequest 创建事件请求 +type CreateEventRequest struct { + EventName string `json:"event_name"` + EventCategory string `json:"event_category"` + EventSubCategory string `json:"event_sub_category"` + OperatorID int64 `json:"operator_id"` + TenantID int64 `json:"tenant_id"` + ObjectType string `json:"object_type"` + ObjectID int64 `json:"object_id"` + Action string `json:"action"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + SourceIP string `json:"source_ip,omitempty"` + Success bool `json:"success"` + ResultCode string `json:"result_code,omitempty"` +} + +// ErrorResponse 错误响应 +type ErrorResponse struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` + Details string `json:"details,omitempty"` +} + +// ListEventsResponse 事件列表响应 +type ListEventsResponse struct { + Events []*model.AuditEvent `json:"events"` + Total int64 `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +// CreateEvent 处理POST /api/v1/audit/events +// @Summary 创建审计事件 +// @Description 创建新的审计事件,支持幂等 +// @Tags audit +// @Accept json +// @Produce json +// @Param event body CreateEventRequest true "事件信息" +// @Success 201 {object} service.CreateEventResult +// @Success 200 {object} service.CreateEventResult "幂等重复" +// @Success 409 {object} service.CreateEventResult "幂等冲突" +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/audit/events [post] +func (h *AuditHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { + var req CreateEventRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid request body: "+err.Error()) + return + } + + // 验证必填字段 + if req.EventName == "" { + writeError(w, http.StatusBadRequest, "MISSING_FIELD", "event_name is required") + return + } + if req.EventCategory == "" { + writeError(w, http.StatusBadRequest, "MISSING_FIELD", "event_category is required") + return + } + + event := &model.AuditEvent{ + EventName: req.EventName, + EventCategory: req.EventCategory, + EventSubCategory: req.EventSubCategory, + OperatorID: req.OperatorID, + TenantID: req.TenantID, + ObjectType: req.ObjectType, + ObjectID: req.ObjectID, + Action: req.Action, + IdempotencyKey: req.IdempotencyKey, + SourceIP: req.SourceIP, + Success: req.Success, + ResultCode: req.ResultCode, + } + + result, err := h.svc.CreateEvent(r.Context(), event) + if err != nil { + writeError(w, http.StatusInternalServerError, "CREATE_FAILED", err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(result.StatusCode) + json.NewEncoder(w).Encode(result) +} + +// ListEvents 处理GET /api/v1/audit/events +// @Summary 查询审计事件 +// @Description 查询审计事件列表,支持分页和过滤 +// @Tags audit +// @Produce json +// @Param tenant_id query int false "租户ID" +// @Param category query string false "事件类别" +// @Param event_name query string false "事件名称" +// @Param offset query int false "偏移量" default(0) +// @Param limit query int false "限制数量" default(100) +// @Success 200 {object} ListEventsResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/audit/events [get] +func (h *AuditHandler) ListEvents(w http.ResponseWriter, r *http.Request) { + filter := &service.EventFilter{} + + // 解析查询参数 + if tenantIDStr := r.URL.Query().Get("tenant_id"); tenantIDStr != "" { + tenantID, err := strconv.ParseInt(tenantIDStr, 10, 64) + if err == nil { + filter.TenantID = tenantID + } + } + + if category := r.URL.Query().Get("category"); category != "" { + filter.Category = category + } + + if eventName := r.URL.Query().Get("event_name"); eventName != "" { + filter.EventName = eventName + } + + if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { + offset, err := strconv.Atoi(offsetStr) + if err == nil { + filter.Offset = offset + } + } + + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err == nil && limit > 0 && limit <= 1000 { + filter.Limit = limit + } + } + + if filter.Limit == 0 { + filter.Limit = 100 + } + + events, total, err := h.svc.ListEventsWithFilter(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, "QUERY_FAILED", err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ListEventsResponse{ + Events: events, + Total: total, + Offset: filter.Offset, + Limit: filter.Limit, + }) +} + +// writeError 写入错误响应 +func writeError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(ErrorResponse{ + Error: message, + Code: code, + Details: "", + }) +} diff --git a/supply-api/internal/audit/handler/audit_handler_test.go b/supply-api/internal/audit/handler/audit_handler_test.go new file mode 100644 index 0000000..679f5ff --- /dev/null +++ b/supply-api/internal/audit/handler/audit_handler_test.go @@ -0,0 +1,222 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "lijiaoqiao/supply-api/internal/audit/model" + "lijiaoqiao/supply-api/internal/audit/service" + + "github.com/stretchr/testify/assert" +) + +// mockAuditStore 模拟审计存储 +type mockAuditStore struct { + events []*model.AuditEvent + nextID int64 + idempotencyKeys map[string]*model.AuditEvent +} + +func newMockAuditStore() *mockAuditStore { + return &mockAuditStore{ + events: make([]*model.AuditEvent, 0), + nextID: 1, + idempotencyKeys: make(map[string]*model.AuditEvent), + } +} + +func (m *mockAuditStore) Emit(ctx context.Context, event *model.AuditEvent) error { + if event.EventID == "" { + event.EventID = "test-event-id" + } + m.events = append(m.events, event) + if event.IdempotencyKey != "" { + m.idempotencyKeys[event.IdempotencyKey] = event + } + return nil +} + +func (m *mockAuditStore) Query(ctx context.Context, filter *service.EventFilter) ([]*model.AuditEvent, int64, error) { + var result []*model.AuditEvent + for _, e := range m.events { + if filter.TenantID != 0 && e.TenantID != filter.TenantID { + continue + } + if filter.Category != "" && e.EventCategory != filter.Category { + continue + } + result = append(result, e) + } + return result, int64(len(result)), nil +} + +func (m *mockAuditStore) GetByIdempotencyKey(ctx context.Context, key string) (*model.AuditEvent, error) { + if e, ok := m.idempotencyKeys[key]; ok { + return e, nil + } + return nil, nil +} + +// TestAuditHandler_CreateEvent_Success 测试创建事件成功 +func TestAuditHandler_CreateEvent_Success(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + reqBody := CreateEventRequest{ + EventName: "CRED-EXPOSE-RESPONSE", + EventCategory: "CRED", + EventSubCategory: "EXPOSE", + OperatorID: 1001, + TenantID: 2001, + ObjectType: "account", + ObjectID: 12345, + Action: "query", + } + + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/audit/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.CreateEvent(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var result service.CreateEventResult + err := json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + assert.Equal(t, 201, result.StatusCode) + assert.Equal(t, "created", result.Status) +} + +// TestAuditHandler_CreateEvent_DuplicateIdempotencyKey 测试幂等键重复 +func TestAuditHandler_CreateEvent_DuplicateIdempotencyKey(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + reqBody := CreateEventRequest{ + EventName: "CRED-EXPOSE-RESPONSE", + EventCategory: "CRED", + EventSubCategory: "EXPOSE", + OperatorID: 1001, + TenantID: 2001, + IdempotencyKey: "test-idempotency-key", + } + + body, _ := json.Marshal(reqBody) + + // 第一次请求 + req1 := httptest.NewRequest("POST", "/audit/events", bytes.NewReader(body)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + h.CreateEvent(w1, req1) + assert.Equal(t, http.StatusCreated, w1.Code) + + // 第二次请求(相同幂等键) + req2 := httptest.NewRequest("POST", "/audit/events", bytes.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + h.CreateEvent(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) // 应该返回200而非201 +} + +// TestAuditHandler_ListEvents_Success 测试查询事件成功 +func TestAuditHandler_ListEvents_Success(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + // 先创建一些事件 + events := []*model.AuditEvent{ + {EventName: "EVENT-1", TenantID: 2001, EventCategory: "CRED"}, + {EventName: "EVENT-2", TenantID: 2001, EventCategory: "CRED"}, + {EventName: "EVENT-3", TenantID: 2002, EventCategory: "AUTH"}, + } + for _, e := range events { + store.Emit(context.Background(), e) + } + + // 查询 + req := httptest.NewRequest("GET", "/audit/events?tenant_id=2001", nil) + w := httptest.NewRecorder() + + h.ListEvents(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result ListEventsResponse + err := json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + assert.Equal(t, int64(2), result.Total) // 只有2个2001租户的事件 +} + +// TestAuditHandler_ListEvents_WithPagination 测试分页查询 +func TestAuditHandler_ListEvents_WithPagination(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + // 创建多个事件 + for i := 0; i < 5; i++ { + store.Emit(context.Background(), &model.AuditEvent{ + EventName: "EVENT", + TenantID: 2001, + }) + } + + req := httptest.NewRequest("GET", "/audit/events?tenant_id=2001&offset=0&limit=2", nil) + w := httptest.NewRecorder() + + h.ListEvents(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result ListEventsResponse + json.Unmarshal(w.Body.Bytes(), &result) + assert.Equal(t, int64(5), result.Total) + assert.Equal(t, 0, result.Offset) + assert.Equal(t, 2, result.Limit) +} + +// TestAuditHandler_InvalidRequest 测试无效请求 +func TestAuditHandler_InvalidRequest(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + req := httptest.NewRequest("POST", "/audit/events", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.CreateEvent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestAuditHandler_MissingRequiredFields 测试缺少必填字段 +func TestAuditHandler_MissingRequiredFields(t *testing.T) { + store := newMockAuditStore() + svc := service.NewAuditService(store) + h := NewAuditHandler(svc) + + // 缺少EventName + reqBody := CreateEventRequest{ + EventCategory: "CRED", + OperatorID: 1001, + } + + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/audit/events", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.CreateEvent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/supply-api/internal/iam/middleware/scope_auth_test.go b/supply-api/internal/iam/middleware/scope_auth_test.go index d0e0b96..48e61d4 100644 --- a/supply-api/internal/iam/middleware/scope_auth_test.go +++ b/supply-api/internal/iam/middleware/scope_auth_test.go @@ -591,6 +591,160 @@ func TestP2_01_WildcardScope_SecurityRisk(t *testing.T) { // 问题:通配符scope被使用时没有记录审计日志 // 修复建议:在hasScope返回true时,如果scope是"*",应该记录审计日志 // 这是一个安全风险,因为无法追踪何时使用了超级权限 - + t.Logf("P2-01: Wildcard scope usage should be audited for security compliance") } + +// TestSetRouteScopePolicy 测试设置路由Scope策略 +func TestSetRouteScopePolicy(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + + // act + m.SetRouteScopePolicy("/api/v1/admin", []string{"platform:admin"}) + m.SetRouteScopePolicy("/api/v1/user", []string{"platform:read"}) + + // assert - 验证路由策略是否正确设置 + _, ok1 := m.routeScopePolicies["/api/v1/admin"] + _, ok2 := m.routeScopePolicies["/api/v1/user"] + assert.True(t, ok1, "admin route policy should be set") + assert.True(t, ok2, "user route policy should be set") +} + +// TestRequireRole_HasRole 测试RequireRole中间件 - 有角色 +func TestRequireRole_HasRole(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + claims := &IAMTokenClaims{ + SubjectID: "user:1", + Role: "org_admin", + Scope: []string{"platform:admin"}, + TenantID: 1, + } + ctx := WithIAMClaims(context.Background(), claims) + + handler := m.RequireRole("org_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // act + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // assert + assert.Equal(t, http.StatusOK, w.Code) +} + +// TestRequireRole_NoRole 测试RequireRole中间件 - 无角色 +func TestRequireRole_NoRole(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + claims := &IAMTokenClaims{ + SubjectID: "user:1", + Role: "viewer", + Scope: []string{"platform:read"}, + TenantID: 1, + } + ctx := WithIAMClaims(context.Background(), claims) + + handler := m.RequireRole("org_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // act + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // assert + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestRequireRole_NoClaims 测试RequireRole中间件 - 无Claims +func TestRequireRole_NoClaims(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + ctx := context.Background() + + handler := m.RequireRole("org_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // act + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // assert + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// TestRequireMinLevel_HasLevel 测试RequireMinLevel中间件 - 满足等级 +func TestRequireMinLevel_HasLevel(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + claims := &IAMTokenClaims{ + SubjectID: "user:1", + Role: "org_admin", + Scope: []string{"platform:admin"}, + TenantID: 1, + } + ctx := WithIAMClaims(context.Background(), claims) + + handler := m.RequireMinLevel(50)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // act + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // assert + assert.Equal(t, http.StatusOK, w.Code) +} + +// TestRequireMinLevel_InsufficientLevel 测试RequireMinLevel中间件 - 等级不足 +func TestRequireMinLevel_InsufficientLevel(t *testing.T) { + // arrange + m := NewScopeAuthMiddleware() + claims := &IAMTokenClaims{ + SubjectID: "user:1", + Role: "viewer", + Scope: []string{"platform:read"}, + TenantID: 1, + } + ctx := WithIAMClaims(context.Background(), claims) + + handler := m.RequireMinLevel(50)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // act + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // assert + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestHasAnyScope_True 测试hasAnyScope - 有交集 +func TestHasAnyScope_True(t *testing.T) { + // act & assert + assert.True(t, hasAnyScope([]string{"platform:read", "platform:write"}, []string{"platform:admin", "platform:read"})) + assert.True(t, hasAnyScope([]string{"*"}, []string{"platform:read"})) +} + +// TestHasAnyScope_False 测试hasAnyScope - 无交集 +func TestHasAnyScope_False(t *testing.T) { + // act & assert + assert.False(t, hasAnyScope([]string{"platform:read"}, []string{"platform:admin", "supply:write"})) + assert.False(t, hasAnyScope([]string{"tenant:read"}, []string{"platform:admin"})) +}