固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
This commit is contained in:
396
internal/query/audit/events.go
Normal file
396
internal/query/audit/events.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Package audit 提供统一审计事件的只读调查查询。
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// EventFilter 定义平台全局事件列表的受控组合筛选。
|
||||
type EventFilter struct {
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Action string
|
||||
Category string
|
||||
ActorKind string
|
||||
ActorID string
|
||||
Source string
|
||||
Result string
|
||||
Risk string
|
||||
ScopeType string
|
||||
ScopeID string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
ResourceKey string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// EventPage 是平台全局事件稳定分页结果。
|
||||
type EventPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []EventView `json:"items"`
|
||||
}
|
||||
|
||||
// EventView 是不暴露 GORM Model 的审计事件投影。
|
||||
type EventView struct {
|
||||
EventID string `json:"event_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Category string `json:"category"`
|
||||
ActionCode string `json:"action_code"`
|
||||
ActionName string `json:"action_name"`
|
||||
Summary string `json:"summary"`
|
||||
ActorKind string `json:"actor_kind"`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
ActorShopID *uint `json:"actor_shop_id,omitempty"`
|
||||
ActorShopName string `json:"actor_shop_name"`
|
||||
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty"`
|
||||
ActorEnterpriseName string `json:"actor_enterprise_name"`
|
||||
Source string `json:"source"`
|
||||
RequestPath string `json:"request_path"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID string `json:"scope_id"`
|
||||
ScopeName string `json:"scope_name"`
|
||||
Result string `json:"result"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorSummary string `json:"error_summary"`
|
||||
RequestID string `json:"request_id"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
ParentEventID string `json:"parent_event_id"`
|
||||
BatchTotal int `json:"batch_total"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailCount int `json:"fail_count"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Resources []ResourceView `json:"resources"`
|
||||
InvestigationRefs InvestigationRefs `json:"investigation_refs"`
|
||||
}
|
||||
|
||||
// InvestigationRefs 是平台调查视角间唯一允许使用的稳定跳转引用。
|
||||
type InvestigationRefs struct {
|
||||
EventID *string `json:"event_id"`
|
||||
ActorRef *ActorRef `json:"actor_ref"`
|
||||
ResourceRefs []InvestigationResourceRef `json:"resource_refs"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CorrelationID *string `json:"correlation_id"`
|
||||
IntegrationRefs []IntegrationRef `json:"integration_refs"`
|
||||
}
|
||||
|
||||
// ActorRef 是操作者时间线的稳定引用。
|
||||
type ActorRef struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// InvestigationResourceRef 是通用资源时间线的稳定引用。
|
||||
type InvestigationResourceRef struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// IntegrationRef 是 Integration 详情的稳定引用。
|
||||
type IntegrationRef struct {
|
||||
IntegrationID string `json:"integration_id"`
|
||||
}
|
||||
|
||||
// ResourceView 是事件发生时独立资源身份与变化的只读投影。
|
||||
type ResourceView struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id,omitempty"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Relation string `json:"relation"`
|
||||
Role string `json:"role"`
|
||||
IdentitySnapshot map[string]any `json:"identity_snapshot"`
|
||||
BeforeData map[string]any `json:"before_data"`
|
||||
AfterData map[string]any `json:"after_data"`
|
||||
SubjectVisibility string `json:"subject_visibility"`
|
||||
SubjectSummary string `json:"subject_summary"`
|
||||
SubjectData map[string]any `json:"subject_data"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Query 提供平台统一审计事件列表与详情读取。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New 创建统一审计事件 Query。
|
||||
func New(db *gorm.DB) *Query {
|
||||
return &Query{db: db}
|
||||
}
|
||||
|
||||
// List 查询平台范围的全局审计事件。
|
||||
func (q *Query) List(ctx context.Context, filter EventFilter) (*EventPage, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validEventFilter(filter) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
query := q.applyFilters(q.db.WithContext(ctx).Model(&model.AuditEvent{}), filter)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计审计事件失败")
|
||||
}
|
||||
rows := make([]model.AuditEvent, 0, filter.PageSize)
|
||||
if err := query.Order("occurred_at DESC, id DESC").
|
||||
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件失败")
|
||||
}
|
||||
items, err := q.project(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EventPage{Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, nil
|
||||
}
|
||||
|
||||
func validEventFilter(filter EventFilter) bool {
|
||||
return validOptionalValue(filter.Result, constants.AuditResultSuccess, constants.AuditResultFailed,
|
||||
constants.AuditResultDenied, constants.AuditResultPartial, constants.AuditResultUnknown) &&
|
||||
validOptionalValue(filter.Risk, constants.AuditRiskLow, constants.AuditRiskNormal,
|
||||
constants.AuditRiskHigh, constants.AuditRiskCritical) &&
|
||||
validOptionalValue(filter.Source, constants.AuditSourceAdminAPI, constants.AuditSourcePersonalAPI,
|
||||
constants.AuditSourceOpenAPI, constants.AuditSourceWorker, constants.AuditSourceScheduler, constants.AuditSourceCallback) &&
|
||||
(filter.ActorKind == "" || validActorKind(filter.ActorKind)) &&
|
||||
filter.Page >= 0 && filter.PageSize >= 0 && filter.PageSize <= constants.MaxPageSize
|
||||
}
|
||||
|
||||
func validOptionalValue(value string, allowed ...string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Get 查询平台范围的单个稳定审计事件详情。
|
||||
func (q *Query) Get(ctx context.Context, eventID string) (*EventView, error) {
|
||||
if err := q.authorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var row model.AuditEvent
|
||||
if err := q.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "审计事件不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件详情失败")
|
||||
}
|
||||
items, err := q.project(ctx, []model.AuditEvent{row})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
func (q *Query) authorize(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审计查询能力未配置")
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Query) applyFilters(query *gorm.DB, filter EventFilter) *gorm.DB {
|
||||
if filter.CreatedFrom != nil {
|
||||
query = query.Where("occurred_at >= ?", filter.CreatedFrom.UTC())
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
query = query.Where("occurred_at < ?", filter.CreatedTo.UTC())
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"action_code": filter.Action, "category": filter.Category,
|
||||
"actor_kind": filter.ActorKind, "actor_id": filter.ActorID,
|
||||
"source": filter.Source, "result": filter.Result, "risk_level": filter.Risk,
|
||||
"scope_type": filter.ScopeType, "scope_id": filter.ScopeID,
|
||||
"request_id": filter.RequestID, "correlation_id": filter.CorrelationID,
|
||||
} {
|
||||
if value != "" {
|
||||
query = query.Where(column+" = ?", value)
|
||||
}
|
||||
}
|
||||
if filter.ResourceType != "" || filter.ResourceID != "" || filter.ResourceKey != "" {
|
||||
resource := q.db.Table("tb_audit_event_resource AS aer").Select("1").
|
||||
Where("aer.audit_event_id = tb_audit_event.id")
|
||||
if filter.ResourceType != "" {
|
||||
resource = resource.Where("aer.resource_type = ?", filter.ResourceType)
|
||||
}
|
||||
if filter.ResourceID != "" {
|
||||
resource = resource.Where("aer.resource_id = ?", filter.ResourceID)
|
||||
}
|
||||
if filter.ResourceKey != "" {
|
||||
resource = resource.Where("aer.resource_key = ?", filter.ResourceKey)
|
||||
}
|
||||
query = query.Where("EXISTS (?)", resource)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (q *Query) project(ctx context.Context, rows []model.AuditEvent) ([]EventView, error) {
|
||||
items := make([]EventView, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
var resources []model.AuditEventResource
|
||||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
|
||||
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询审计事件资源失败")
|
||||
}
|
||||
resourcesByEvent := make(map[uint][]ResourceView, len(rows))
|
||||
for _, resource := range resources {
|
||||
view, err := projectResource(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourcesByEvent[resource.AuditEventID] = append(resourcesByEvent[resource.AuditEventID], view)
|
||||
}
|
||||
for index, row := range rows {
|
||||
metadata, err := decodeObject(row.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[index] = EventView{
|
||||
EventID: row.EventID, OccurredAt: row.OccurredAt, Category: row.Category,
|
||||
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
|
||||
ActorKind: row.ActorKind, ActorID: row.ActorID, ActorName: row.ActorName,
|
||||
ActorShopID: row.ActorShopID, ActorShopName: row.ActorShopName,
|
||||
ActorEnterpriseID: row.ActorEnterpriseID, ActorEnterpriseName: row.ActorEnterpriseName,
|
||||
Source: row.Source, RequestPath: row.RequestPath, RequestMethod: row.RequestMethod,
|
||||
IPAddress: row.IPAddress, UserAgent: row.UserAgent,
|
||||
ScopeType: row.ScopeType, ScopeID: row.ScopeID, ScopeName: row.ScopeName,
|
||||
Result: row.Result, RiskLevel: row.RiskLevel, ErrorCode: row.ErrorCode, ErrorSummary: row.ErrorSummary,
|
||||
RequestID: row.RequestID, CorrelationID: row.CorrelationID, ParentEventID: row.ParentEventID,
|
||||
BatchTotal: row.BatchTotal, SuccessCount: row.SuccessCount, FailCount: row.FailCount,
|
||||
Metadata: metadata, ContentHash: row.ContentHash, CreatedAt: row.CreatedAt,
|
||||
Resources: resourcesByEvent[row.ID],
|
||||
}
|
||||
if items[index].Resources == nil {
|
||||
items[index].Resources = []ResourceView{}
|
||||
}
|
||||
items[index].InvestigationRefs = investigationRefs(row, items[index].Resources)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func investigationRefs(event model.AuditEvent, resources []ResourceView) InvestigationRefs {
|
||||
refs := InvestigationRefs{
|
||||
EventID: stringPointer(event.EventID), ActorRef: investigationActorRef(event.ActorKind, event.ActorID),
|
||||
ResourceRefs: make([]InvestigationResourceRef, 0, len(resources)),
|
||||
RequestID: stringPointer(event.RequestID), CorrelationID: stringPointer(event.CorrelationID),
|
||||
IntegrationRefs: []IntegrationRef{},
|
||||
}
|
||||
for _, resource := range resources {
|
||||
refs.ResourceRefs = append(refs.ResourceRefs, InvestigationResourceRef{
|
||||
ResourceType: resource.ResourceType, ResourceID: resource.ResourceID,
|
||||
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func investigationActorRef(kind, id string) *ActorRef {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case constants.AuditActorAccount, constants.AuditActorOpenAPI, constants.AuditActorSystemTask,
|
||||
constants.AuditActorScheduledJob, constants.AuditActorExternalSystem:
|
||||
return &ActorRef{Kind: kind, ID: id}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func projectResource(row model.AuditEventResource) (ResourceView, error) {
|
||||
identity, err := decodeObject(row.IdentitySnapshot)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
before, err := decodeObject(row.BeforeData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
after, err := decodeObject(row.AfterData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
subject, err := decodeObject(row.SubjectData)
|
||||
if err != nil {
|
||||
return ResourceView{}, err
|
||||
}
|
||||
return ResourceView{
|
||||
ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResourceKey: row.ResourceKey,
|
||||
DisplayName: row.DisplayName, Relation: row.Relation, Role: row.Role,
|
||||
IdentitySnapshot: identity, BeforeData: before, AfterData: after,
|
||||
SubjectVisibility: row.SubjectVisibility, SubjectSummary: row.SubjectSummary, SubjectData: subject,
|
||||
SortOrder: row.SortOrder, CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeObject(value datatypes.JSON) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
if len(value) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := sonic.Unmarshal(value, &result); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析审计结构化字段失败")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
Reference in New Issue
Block a user