任务执行1-19

This commit is contained in:
zxr
2026-06-26 12:51:50 +08:00
parent 175d9f8f94
commit 19908230f2
19 changed files with 2615 additions and 1260 deletions

View File

@@ -0,0 +1,219 @@
package audit
import (
"errors"
"strings"
"time"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
)
const (
RiskNormal = "normal"
RiskDangerous = "dangerous"
ApprovalPending = "pending"
ApprovalApproved = "approved"
ApprovalRejected = "rejected"
)
type Record struct {
TraceID string `json:"trace_id,omitempty"`
SourceService string `json:"source_service,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
Action string `json:"action,omitempty"`
ObjectType string `json:"object_type,omitempty"`
ObjectID string `json:"object_id,omitempty"`
OperationRisk string `json:"operation_risk,omitempty"`
ApprovalID string `json:"approval_id,omitempty"`
RequestMethod string `json:"request_method,omitempty"`
RequestPath string `json:"request_path,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
BeforeJSON string `json:"before_json,omitempty"`
AfterJSON string `json:"after_json,omitempty"`
Result string `json:"result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type ApprovalRequest struct {
RequestID string `json:"request_id,omitempty"`
SourceService string `json:"source_service,omitempty"`
Action string `json:"action,omitempty"`
ObjectType string `json:"object_type,omitempty"`
ObjectID string `json:"object_id,omitempty"`
RequesterID string `json:"requester_id,omitempty"`
RequesterName string `json:"requester_name,omitempty"`
Reason string `json:"reason,omitempty"`
BeforeJSON string `json:"before_json,omitempty"`
AfterJSON string `json:"after_json,omitempty"`
Status string `json:"status,omitempty"`
ReviewerID string `json:"reviewer_id,omitempty"`
ReviewerName string `json:"reviewer_name,omitempty"`
ReviewComment string `json:"review_comment,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
}
func NormalizeRecord(record Record) Record {
record.TraceID = strings.TrimSpace(record.TraceID)
record.SourceService = strings.TrimSpace(record.SourceService)
record.ActorID = strings.TrimSpace(record.ActorID)
record.ActorName = strings.TrimSpace(record.ActorName)
record.Action = strings.TrimSpace(record.Action)
record.ObjectType = strings.TrimSpace(record.ObjectType)
record.ObjectID = strings.TrimSpace(record.ObjectID)
record.OperationRisk = strings.TrimSpace(strings.ToLower(record.OperationRisk))
record.ApprovalID = strings.TrimSpace(record.ApprovalID)
record.RequestMethod = strings.TrimSpace(strings.ToUpper(record.RequestMethod))
record.RequestPath = strings.TrimSpace(record.RequestPath)
record.ClientIP = strings.TrimSpace(record.ClientIP)
record.Result = strings.TrimSpace(record.Result)
if record.Result == "" {
record.Result = "success"
}
if record.OperationRisk == "" {
if IsDangerousOperation(record.Action, record.ObjectType) {
record.OperationRisk = RiskDangerous
} else {
record.OperationRisk = RiskNormal
}
}
return record
}
func ValidateRecord(record Record) error {
record = NormalizeRecord(record)
if record.SourceService == "" {
return errors.New("source_service is required")
}
if record.ActorID == "" {
return errors.New("actor_id is required")
}
if record.Action == "" {
return errors.New("action is required")
}
if record.ObjectType == "" {
return errors.New("object_type is required")
}
if record.ObjectID == "" {
return errors.New("object_id is required")
}
if record.OperationRisk != RiskNormal && record.OperationRisk != RiskDangerous {
return errors.New("operation_risk must be normal or dangerous")
}
if record.OperationRisk == RiskDangerous && record.ApprovalID == "" {
return errors.New("approval_id is required for dangerous operation")
}
return nil
}
func IsDangerousOperation(action, objectType string) bool {
key := strings.ToLower(strings.TrimSpace(action) + " " + strings.TrimSpace(objectType))
dangerWords := []string{
"notification_policy",
"notification policy",
"silence_policy",
"suppression",
"escalation_policy",
"automation_script",
"script.execute",
"script.rollback",
}
for _, word := range dangerWords {
if strings.Contains(key, word) {
return true
}
}
return false
}
func SaveRecord(record Record) (models.AuditLog, error) {
record = NormalizeRecord(record)
if err := ValidateRecord(record); err != nil {
return models.AuditLog{}, err
}
row := models.AuditLog{
TraceID: record.TraceID,
SourceService: record.SourceService,
ActorID: record.ActorID,
ActorName: record.ActorName,
Action: record.Action,
ObjectType: record.ObjectType,
ObjectID: record.ObjectID,
OperationRisk: record.OperationRisk,
ApprovalID: record.ApprovalID,
RequestMethod: record.RequestMethod,
RequestPath: record.RequestPath,
ClientIP: record.ClientIP,
BeforeJSON: record.BeforeJSON,
AfterJSON: record.AfterJSON,
Result: record.Result,
ErrorMessage: record.ErrorMessage,
}
if err := impl.DBService.Create(&row).Error; err != nil {
return models.AuditLog{}, err
}
return row, nil
}
func NormalizeApproval(req ApprovalRequest) ApprovalRequest {
req.RequestID = strings.TrimSpace(req.RequestID)
req.SourceService = strings.TrimSpace(req.SourceService)
req.Action = strings.TrimSpace(req.Action)
req.ObjectType = strings.TrimSpace(req.ObjectType)
req.ObjectID = strings.TrimSpace(req.ObjectID)
req.RequesterID = strings.TrimSpace(req.RequesterID)
req.RequesterName = strings.TrimSpace(req.RequesterName)
req.Status = strings.TrimSpace(strings.ToLower(req.Status))
req.ReviewerID = strings.TrimSpace(req.ReviewerID)
req.ReviewerName = strings.TrimSpace(req.ReviewerName)
if req.Status == "" {
req.Status = ApprovalPending
}
return req
}
func ValidateApprovalRequest(req ApprovalRequest) error {
req = NormalizeApproval(req)
if req.SourceService == "" {
return errors.New("source_service is required")
}
if req.Action == "" {
return errors.New("action is required")
}
if req.ObjectType == "" {
return errors.New("object_type is required")
}
if req.ObjectID == "" {
return errors.New("object_id is required")
}
if req.RequesterID == "" {
return errors.New("requester_id is required")
}
if !IsDangerousOperation(req.Action, req.ObjectType) {
return errors.New("operation is not classified as dangerous")
}
return nil
}
func Transition(req ApprovalRequest, nextStatus, reviewerID, comment string) (ApprovalRequest, error) {
req = NormalizeApproval(req)
nextStatus = strings.TrimSpace(strings.ToLower(nextStatus))
reviewerID = strings.TrimSpace(reviewerID)
if req.Status != ApprovalPending {
return ApprovalRequest{}, errors.New("only pending approval can be reviewed")
}
if nextStatus != ApprovalApproved && nextStatus != ApprovalRejected {
return ApprovalRequest{}, errors.New("next status must be approved or rejected")
}
if reviewerID == "" {
return ApprovalRequest{}, errors.New("reviewer_id is required")
}
now := time.Now()
req.Status = nextStatus
req.ReviewerID = reviewerID
req.ReviewComment = strings.TrimSpace(comment)
req.ReviewedAt = &now
return req, nil
}

View File

@@ -0,0 +1,61 @@
package audit
import (
"testing"
)
func TestValidateRecordRequiresDangerousOperationsToCarryReviewID(t *testing.T) {
record := Record{
SourceService: "alert",
ActorID: "u-1",
Action: "policy.update",
ObjectType: "notification_policy",
ObjectID: "np-1",
OperationRisk: RiskDangerous,
}
if err := ValidateRecord(record); err == nil {
t.Fatal("expected dangerous operation without approval id to fail")
}
record.ApprovalID = "apr-1"
if err := ValidateRecord(record); err != nil {
t.Fatalf("expected valid dangerous audit record, got %v", err)
}
}
func TestNormalizeRecordClassifiesDangerousActions(t *testing.T) {
record := NormalizeRecord(Record{
SourceService: " alert ",
Action: "notification_policy.update",
ObjectType: " notification_policy ",
ObjectID: " np-1 ",
ActorID: " u-1 ",
})
if record.SourceService != "alert" || record.ObjectType != "notification_policy" || record.ObjectID != "np-1" {
t.Fatalf("record was not normalized: %#v", record)
}
if record.OperationRisk != RiskDangerous {
t.Fatalf("notification policy changes must be dangerous, got %q", record.OperationRisk)
}
}
func TestApprovalTransitionAllowsApproveOnlyFromPending(t *testing.T) {
req := ApprovalRequest{Status: ApprovalPending}
approved, err := Transition(req, ApprovalApproved, "reviewer-1", "ok")
if err != nil {
t.Fatalf("expected pending approval to approve: %v", err)
}
if approved.Status != ApprovalApproved {
t.Fatalf("unexpected status: %s", approved.Status)
}
if approved.ReviewerID != "reviewer-1" || approved.ReviewComment != "ok" {
t.Fatalf("review metadata not stored: %#v", approved)
}
if _, err := Transition(approved, ApprovalRejected, "reviewer-2", "late"); err == nil {
t.Fatal("expected approved request to reject further transition")
}
}

View File

@@ -0,0 +1,207 @@
package audit
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
"github.com/gin-gonic/gin"
)
func ListAuditLogs(ctx *gin.Context) {
page, size := pageAndSize(ctx.DefaultQuery("page", "1"), ctx.DefaultQuery("page_size", "50"))
q := impl.DBService.Model(&models.AuditLog{})
if v := strings.TrimSpace(ctx.Query("source_service")); v != "" {
q = q.Where("source_service = ?", v)
}
if v := strings.TrimSpace(ctx.Query("actor_id")); v != "" {
q = q.Where("actor_id = ?", v)
}
if v := strings.TrimSpace(ctx.Query("action")); v != "" {
q = q.Where("action = ?", v)
}
if v := strings.TrimSpace(ctx.Query("object_type")); v != "" {
q = q.Where("object_type = ?", v)
}
if v := strings.TrimSpace(ctx.Query("object_id")); v != "" {
q = q.Where("object_id = ?", v)
}
if v := strings.TrimSpace(ctx.Query("operation_risk")); v != "" {
q = q.Where("operation_risk = ?", v)
}
if v := strings.TrimSpace(ctx.Query("result")); v != "" {
q = q.Where("result = ?", v)
}
var total int64
_ = q.Count(&total).Error
var rows []models.AuditLog
if err := q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows})
}
func CreateAuditLog(ctx *gin.Context) {
var req Record
if err := ctx.ShouldBindJSON(&req); err != nil {
infra.Response.Error(ctx, err)
return
}
req.ClientIP = firstNonEmpty(req.ClientIP, ctx.ClientIP())
req.RequestMethod = firstNonEmpty(req.RequestMethod, ctx.Request.Method)
req.RequestPath = firstNonEmpty(req.RequestPath, ctx.FullPath())
row, err := SaveRecord(req)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, row)
}
func ListApprovals(ctx *gin.Context) {
page, size := pageAndSize(ctx.DefaultQuery("page", "1"), ctx.DefaultQuery("page_size", "50"))
q := impl.DBService.Model(&models.DangerousOperationApproval{})
if v := strings.TrimSpace(ctx.Query("source_service")); v != "" {
q = q.Where("source_service = ?", v)
}
if v := strings.TrimSpace(ctx.Query("status")); v != "" {
q = q.Where("status = ?", v)
}
if v := strings.TrimSpace(ctx.Query("requester_id")); v != "" {
q = q.Where("requester_id = ?", v)
}
if v := strings.TrimSpace(ctx.Query("object_type")); v != "" {
q = q.Where("object_type = ?", v)
}
var total int64
_ = q.Count(&total).Error
var rows []models.DangerousOperationApproval
if err := q.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "page": page, "page_size": size, "items": rows})
}
func CreateApproval(ctx *gin.Context) {
var req ApprovalRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
infra.Response.Error(ctx, err)
return
}
req = NormalizeApproval(req)
if req.RequestID == "" {
req.RequestID = fmt.Sprintf("apr-%d", time.Now().UnixNano())
}
if err := ValidateApprovalRequest(req); err != nil {
infra.Response.Error(ctx, err)
return
}
row := models.DangerousOperationApproval{
RequestID: req.RequestID,
SourceService: req.SourceService,
Action: req.Action,
ObjectType: req.ObjectType,
ObjectID: req.ObjectID,
RequesterID: req.RequesterID,
RequesterName: req.RequesterName,
Reason: req.Reason,
BeforeJSON: req.BeforeJSON,
AfterJSON: req.AfterJSON,
Status: ApprovalPending,
}
if err := impl.DBService.Create(&row).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, row)
}
func ApproveApproval(ctx *gin.Context) {
reviewApproval(ctx, ApprovalApproved)
}
func RejectApproval(ctx *gin.Context) {
reviewApproval(ctx, ApprovalRejected)
}
func reviewApproval(ctx *gin.Context, next string) {
id, err := parseUintParam(ctx, "id")
if err != nil {
infra.Response.Error(ctx, err)
return
}
var body struct {
ReviewerID string `json:"reviewer_id"`
ReviewerName string `json:"reviewer_name"`
ReviewComment string `json:"review_comment"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
infra.Response.Error(ctx, err)
return
}
var row models.DangerousOperationApproval
if err := impl.DBService.First(&row, id).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
req := ApprovalRequest{
RequestID: row.RequestID,
SourceService: row.SourceService,
Action: row.Action,
ObjectType: row.ObjectType,
ObjectID: row.ObjectID,
RequesterID: row.RequesterID,
Status: row.Status,
}
nextReq, err := Transition(req, next, body.ReviewerID, body.ReviewComment)
if err != nil {
infra.Response.Error(ctx, err)
return
}
row.Status = nextReq.Status
row.ReviewerID = nextReq.ReviewerID
row.ReviewerName = strings.TrimSpace(body.ReviewerName)
row.ReviewComment = nextReq.ReviewComment
row.ReviewedAt = nextReq.ReviewedAt
if err := impl.DBService.Save(&row).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, row)
}
func parseUintParam(ctx *gin.Context, name string) (uint, error) {
v, err := strconv.ParseUint(ctx.Param(name), 10, 32)
if err != nil || v == 0 {
return 0, errors.New("invalid id")
}
return uint(v), nil
}
func pageAndSize(pageText, sizeText string) (int, int) {
page, _ := strconv.Atoi(pageText)
size, _ := strconv.Atoi(sizeText)
if page < 1 {
page = 1
}
if size < 1 || size > 500 {
size = 50
}
return page, size
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -0,0 +1,66 @@
package audit
import (
"bytes"
"io"
"strings"
"github.com/gin-gonic/gin"
)
type ActorResolver func(*gin.Context) (id string, name string)
func Middleware(sourceService string, resolveActor ActorResolver) gin.HandlerFunc {
sourceService = strings.TrimSpace(sourceService)
return func(ctx *gin.Context) {
if ctx.Request.Method == "GET" || ctx.Request.Method == "HEAD" || ctx.Request.Method == "OPTIONS" {
ctx.Next()
return
}
var body []byte
if ctx.Request.Body != nil {
body, _ = io.ReadAll(ctx.Request.Body)
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
}
ctx.Next()
actorID, actorName := "", ""
if resolveActor != nil {
actorID, actorName = resolveActor(ctx)
}
if actorID == "" {
actorID = firstNonEmpty(ctx.GetHeader("X-User-Id"), ctx.GetHeader("X-Actor-Id"), "unknown")
}
if actorName == "" {
actorName = firstNonEmpty(ctx.GetHeader("X-User-Name"), ctx.GetHeader("X-Actor-Name"))
}
result := "success"
if len(ctx.Errors) > 0 || ctx.Writer.Status() >= 400 {
result = "failed"
}
_, _ = SaveRecord(Record{
TraceID: firstNonEmpty(ctx.GetHeader("X-Trace-Id"), ctx.GetHeader("Request-Id")),
SourceService: sourceService,
ActorID: actorID,
ActorName: actorName,
Action: ctx.Request.Method + " " + ctx.FullPath(),
ObjectType: routeObjectType(ctx.FullPath()),
ObjectID: firstNonEmpty(ctx.Param("id"), ctx.Query("id"), ctx.FullPath()),
RequestMethod: ctx.Request.Method,
RequestPath: ctx.FullPath(),
ClientIP: ctx.ClientIP(),
AfterJSON: string(body),
Result: result,
})
}
}
func routeObjectType(path string) string {
path = strings.Trim(path, "/")
if path == "" {
return "unknown"
}
parts := strings.Split(path, "/")
return parts[len(parts)-1]
}