From 8a3229aa5a854bbf0e44e8a9487eed077193ea9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:10:07 +0800 Subject: [PATCH] Add files via upload --- internal/handler/audit_query.go | 16 ++--- internal/handler/audit_query_test.go | 19 ++++++ internal/handler/rbac.go | 39 +++++++++--- internal/handler/rbac_test.go | 91 ++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 internal/handler/audit_query_test.go diff --git a/internal/handler/audit_query.go b/internal/handler/audit_query.go index 9c08826d..a355fe2e 100644 --- a/internal/handler/audit_query.go +++ b/internal/handler/audit_query.go @@ -10,13 +10,15 @@ import ( func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter { filter := database.ListAuditLogsFilter{ - Level: c.Query("level"), - Category: c.Query("category"), - Action: c.Query("action"), - Result: c.Query("result"), - Query: c.Query("q"), - ResourceType: c.Query("resource_type"), - ResourceID: c.Query("resource_id"), + Actor: c.Query("actor"), + Level: c.Query("level"), + Category: c.Query("category"), + Action: c.Query("action"), + Result: c.Query("result"), + Query: c.Query("q"), + ResourceType: c.Query("resource_type"), + ResourceID: c.Query("resource_id"), + RelatedUserID: c.Query("related_user_id"), } if since := c.Query("since"); since != "" { if t, err := database.ParseRFC3339Time(since); err == nil { diff --git a/internal/handler/audit_query_test.go b/internal/handler/audit_query_test.go new file mode 100644 index 00000000..0e85513a --- /dev/null +++ b/internal/handler/audit_query_test.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestAuditFilterFromQueryIncludesActorAndMemberFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest("GET", "/api/audit/logs?actor=operator-user&action=assign_resource&resource_type=conversation&related_user_id=user-1", nil) + + filter := auditFilterFromQuery(context) + if filter.Actor != "operator-user" || filter.Action != "assign_resource" || filter.ResourceType != "conversation" || filter.RelatedUserID != "user-1" { + t.Fatalf("filter = %#v", filter) + } +} diff --git a/internal/handler/rbac.go b/internal/handler/rbac.go index 4e21ce41..f4784e5e 100644 --- a/internal/handler/rbac.go +++ b/internal/handler/rbac.go @@ -324,6 +324,7 @@ type assignResourceRequest struct { ResourceType string `json:"resource_type" binding:"required"` ResourceID string `json:"resource_id"` ResourceIDs []string `json:"resource_ids"` + AutoDetect bool `json:"auto_detect"` } func (h *RBACHandler) AssignResource(c *gin.Context) { @@ -340,21 +341,33 @@ func (h *RBACHandler) AssignResource(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要一个资源 ID"}) return } - created, err := h.db.AssignResourcesToUser(req.UserID, req.ResourceType, resourceIDs) + var created int64 + var detectedTypes map[string]string + var err error + if req.AutoDetect { + created, detectedTypes, err = h.db.AssignResourcesToUserAuto(req.UserID, resourceIDs) + } else { + created, err = h.db.AssignResourcesToUser(req.UserID, req.ResourceType, resourceIDs) + } if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if h.audit != nil { for _, resourceID := range resourceIDs { - h.audit.RecordOK(c, "rbac", "assign_resource", "授权资源访问", req.ResourceType, strings.TrimSpace(resourceID), map[string]interface{}{"user_id": req.UserID}) + resourceType := req.ResourceType + if detectedTypes != nil { + resourceType = detectedTypes[strings.TrimSpace(resourceID)] + } + h.audit.RecordOK(c, "rbac", "assign_resource", "授权资源访问", resourceType, strings.TrimSpace(resourceID), map[string]interface{}{"user_id": req.UserID}) } } c.JSON(http.StatusOK, gin.H{ - "success": true, - "requested": len(resourceIDs), - "created": created, - "skipped": int64(len(resourceIDs)) - created, + "success": true, + "requested": len(resourceIDs), + "created": created, + "skipped": int64(len(resourceIDs)) - created, + "detected_types": detectedTypes, }) } @@ -385,22 +398,32 @@ func (h *RBACHandler) ListAssignableResources(c *gin.Context) { if hasMore { resources = resources[:limit] } + total, err := h.db.CountAssignableRBACResources(c.Query("type"), c.Query("q")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{ "resources": resources, "has_more": hasMore, "limit": limit, "offset": offset, + "total": total, }) } func (h *RBACHandler) DeleteResourceAssignment(c *gin.Context) { id := strings.TrimSpace(c.Param("id")) - if err := h.db.DeleteRBACResourceAssignment(id); err != nil { + assignment, err := h.db.DeleteRBACResourceAssignmentWithDetails(id) + if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if h.audit != nil { - h.audit.RecordOK(c, "rbac", "delete_resource_assignment", "撤销资源授权", "resource_assignment", id, nil) + h.audit.RecordOK(c, "rbac", "delete_resource_assignment", "撤销资源授权", assignment.ResourceType, assignment.ResourceID, map[string]interface{}{ + "user_id": assignment.UserID, + "assignment_id": assignment.ID, + }) } c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/internal/handler/rbac_test.go b/internal/handler/rbac_test.go index f4986983..e53cfddd 100644 --- a/internal/handler/rbac_test.go +++ b/internal/handler/rbac_test.go @@ -2,7 +2,10 @@ package handler import ( "bytes" + "cyberstrike-ai/internal/audit" + "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" "encoding/json" "net/http" "net/http/httptest" @@ -69,6 +72,40 @@ func TestRBACAssignResourceBatchIsAtomicAndLegacyCompatible(t *testing.T) { } } +func TestRBACAssignResourceAutoDetectsActualType(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "rbac-auto-detect.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + user, err := db.CreateRBACUser("auto-member", "Auto Member", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + project, err := db.CreateProject(&database.Project{Name: "auto-project"}) + if err != nil { + t.Fatal(err) + } + h := NewRBACHandler(db, zap.NewNop()) + router := gin.New() + router.POST("/api/rbac/resource-assignments", h.AssignResource) + + response := performRBACJSONRequest(t, router, map[string]interface{}{ + "user_id": user.ID, "resource_type": "conversation", "resource_ids": []string{project.ID}, "auto_detect": true, + }) + if response.Code != http.StatusOK { + t.Fatalf("auto-detect status = %d, body = %s", response.Code, response.Body.String()) + } + rows, err := db.ListRBACResourceAssignments(user.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ResourceType != "project" || rows[0].ResourceID != project.ID { + t.Fatalf("auto-detected assignments = %#v, want project/%s", rows, project.ID) + } +} + func TestRBACAssignableResourcesArePaged(t *testing.T) { gin.SetMode(gin.TestMode) db, err := database.NewDB(filepath.Join(t.TempDir(), "rbac-picker.db"), zap.NewNop()) @@ -104,6 +141,60 @@ func TestRBACAssignableResourcesArePaged(t *testing.T) { } } +func TestRBACDeleteResourceAssignmentAuditsTargetResource(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "rbac-revoke-audit.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + user, err := db.CreateRBACUser("audit-member", "Audit Member", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + project, err := db.CreateProject(&database.Project{Name: "audit-project"}) + if err != nil { + t.Fatal(err) + } + if _, err := db.AssignResourcesToUser(user.ID, "project", []string{project.ID}); err != nil { + t.Fatal(err) + } + assignments, err := db.ListRBACResourceAssignments(user.ID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments = %#v, err = %v", assignments, err) + } + + h := NewRBACHandler(db, zap.NewNop()) + h.SetAudit(audit.NewService(db, &config.Config{}, zap.NewNop())) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(security.ContextUsernameKey, "operator-user") + c.Next() + }) + router.DELETE("/api/rbac/resource-assignments/:id", h.DeleteResourceAssignment) + request := httptest.NewRequest(http.MethodDelete, "/api/rbac/resource-assignments/"+assignments[0].ID, nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + logs, err := db.ListAuditLogs(database.ListAuditLogsFilter{Category: "rbac", RelatedUserID: user.ID, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(logs) != 1 { + t.Fatalf("audit logs = %#v, want one member-related revoke", logs) + } + log := logs[0] + if log.Action != "delete_resource_assignment" || log.Actor != "operator-user" || log.ResourceType != "project" || log.ResourceID != project.ID { + t.Fatalf("audit log = %#v", log) + } + if log.Detail["user_id"] != user.ID || log.Detail["assignment_id"] != assignments[0].ID { + t.Fatalf("audit detail = %#v", log.Detail) + } +} + func performRBACJSONRequest(t *testing.T, router http.Handler, payload map[string]interface{}) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(payload)