Files
junhong_cmp_fiber/internal/query/audit/events.go
break b3499adfca 固化七月迭代审计治理进展以隔离线上热修
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 参数类型错误未通过。
2026-08-03 09:47:22 +08:00

397 lines
15 KiB
Go

// 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
}