Files
junhong_cmp_fiber/internal/query/audit/events.go
break c64f3d8b80
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
全局审计完成
2026-08-07 11:02:52 +08:00

434 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention"
"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" description:"符合条件的事件总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
Items []EventView `json:"items" description:"审计事件列表,按发生时间和主键稳定倒序"`
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
}
// EventDetail 是单个审计事件及在线留存边界。
type EventDetail struct {
EventView
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
}
// EventView 是不暴露 GORM Model 的审计事件投影。
type EventView struct {
EventID string `json:"event_id" description:"稳定审计事件ID可传给事件详情接口"`
OccurredAt time.Time `json:"occurred_at" description:"业务事实发生时间"`
Category string `json:"category" enum:"configuration,reliability,asset,security,identity,business" description:"动作类别稳定编码"`
ActionCode string `json:"action_code" description:"稳定动作编码;筛选和跳转必须使用该值"`
ActionName string `json:"action_name" description:"action_code对应的中文展示名称"`
Summary string `json:"summary" description:"事件中文摘要"`
ActorKind string `json:"actor_kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型稳定编码"`
ActorID string `json:"actor_id" description:"操作者稳定ID与actor_kind共同定位操作者时间线"`
ActorName string `json:"actor_name" description:"事件发生时的操作者名称快照"`
ActorShopID *uint `json:"actor_shop_id,omitempty" description:"操作者所属店铺ID快照"`
ActorShopName string `json:"actor_shop_name" description:"操作者所属店铺名称快照"`
ActorEnterpriseID *uint `json:"actor_enterprise_id,omitempty" description:"操作者所属企业ID快照"`
ActorEnterpriseName string `json:"actor_enterprise_name" description:"操作者所属企业名称快照"`
Source string `json:"source" enum:"admin_api,personal_api,openapi,worker,scheduler,callback" description:"操作入口来源稳定编码"`
RequestPath string `json:"request_path" description:"触发操作的HTTP路径非HTTP入口可为空"`
RequestMethod string `json:"request_method" description:"触发操作的HTTP方法非HTTP入口可为空"`
IPAddress string `json:"ip_address" description:"触发请求的IP地址非HTTP入口可为空"`
UserAgent string `json:"user_agent" description:"触发请求的User-Agent非HTTP入口可为空"`
ScopeType string `json:"scope_type" enum:"platform,shop,personal_customer" description:"业务范围类型稳定编码"`
ScopeID string `json:"scope_id" description:"业务范围稳定ID与scope_type共同使用"`
ScopeName string `json:"scope_name" description:"业务范围名称快照"`
Result string `json:"result" enum:"success,failed,denied,partial,unknown" description:"事件结果稳定编码"`
RiskLevel string `json:"risk_level" enum:"low,normal,high,critical" description:"风险等级稳定编码"`
ErrorCode string `json:"error_code" description:"失败或拒绝时的稳定错误码"`
ErrorSummary string `json:"error_summary" description:"已脱敏的失败原因摘要"`
RequestID string `json:"request_id" description:"HTTP请求关联ID可传给请求时间线接口"`
CorrelationID string `json:"correlation_id" description:"跨请求业务链路ID可传给关联时间线接口"`
ParentEventID string `json:"parent_event_id" description:"批量或异步链路的父审计事件ID"`
BatchTotal int `json:"batch_total" description:"批次声明处理总数非批次为0"`
SuccessCount int `json:"success_count" description:"批次成功数非批次为0"`
FailCount int `json:"fail_count" description:"批次失败数非批次为0"`
Metadata map[string]any `json:"metadata" description:"已脱敏的动作扩展元数据字段由action_code定义"`
ContentHash string `json:"content_hash" description:"事件不可变内容摘要"`
CreatedAt time.Time `json:"created_at" description:"审计记录写入时间"`
Resources []ResourceView `json:"resources" description:"事件涉及的全部资源及各自前后快照"`
InvestigationRefs InvestigationRefs `json:"investigation_refs" description:"跨审计视角的稳定跳转参数集合"`
}
// InvestigationRefs 是平台调查视角间唯一允许使用的稳定跳转引用。
type InvestigationRefs struct {
EventID *string `json:"event_id" description:"传给GET /audit/events/{event_id}"`
ActorRef *ActorRef `json:"actor_ref" description:"kind/id传给GET /audit/actors/{kind}/{id}/events"`
ResourceRefs []InvestigationResourceRef `json:"resource_refs" description:"resource_type/resource_id传给GET /audit/resources/{resource_type}/{resource_id}/timelineresource_id为空时不可跳转"`
RequestID *string `json:"request_id" description:"传给GET /audit/requests/{request_id}/timeline"`
CorrelationID *string `json:"correlation_id" description:"传给GET /audit/correlations/{correlation_id}/timeline"`
IntegrationRefs []IntegrationRef `json:"integration_refs" description:"integration_id传给GET /audit/integrations/{integration_id}"`
}
// ActorRef 是操作者时间线的稳定引用。
type ActorRef struct {
Kind string `json:"kind" enum:"account,personal_customer,openapi,system_task,scheduled_job,external_system" description:"操作者类型"`
ID string `json:"id" description:"操作者稳定ID"`
}
// InvestigationResourceRef 是通用资源时间线的稳定引用。
type InvestigationResourceRef struct {
ResourceType string `json:"resource_type" description:"Resource Registry注册类型"`
ResourceID *string `json:"resource_id" description:"资源内部稳定ID为空时不展示平台资源时间线入口"`
ResourceKey string `json:"resource_key" description:"资源业务稳定Key用于展示或精确搜索"`
DisplayName string `json:"display_name" description:"事件发生时的资源展示名称"`
}
// IntegrationRef 是 Integration 详情的稳定引用。
type IntegrationRef struct {
IntegrationID string `json:"integration_id" description:"稳定外部集成记录ID"`
}
// ResourceView 是事件发生时独立资源身份与变化的只读投影。
type ResourceView struct {
ResourceType string `json:"resource_type" description:"Resource Registry注册类型"`
ResourceID *string `json:"resource_id,omitempty" description:"资源内部稳定ID"`
ResourceKey string `json:"resource_key" description:"资源业务稳定Key"`
DisplayName string `json:"display_name" description:"事件发生时的资源展示名称"`
Relation string `json:"relation" enum:"primary,affected,reference" description:"资源关系 (primary:主要资源, affected:受影响资源, reference:引用资源)"`
Role string `json:"role" description:"Resource Registry定义的资源业务角色编码"`
IdentitySnapshot map[string]any `json:"identity_snapshot" description:"事件发生时的资源身份快照"`
BeforeData map[string]any `json:"before_data" description:"该资源变更前的完整平台审计数据"`
AfterData map[string]any `json:"after_data" description:"该资源变更后的完整平台审计数据"`
SubjectVisibility string `json:"subject_visibility" enum:"internal_only,subject_result,subject_detail" description:"主体可见性 (internal_only:仅平台, subject_result:主体可见结论, subject_detail:主体可见安全详情)"`
SubjectSummary string `json:"subject_summary" description:"允许代理或企业查看的安全摘要"`
SubjectData map[string]any `json:"subject_data" description:"写入时生成的主体安全字段不等同于before_data或after_data"`
SortOrder int `json:"sort_order" description:"资源在事件内的稳定展示顺序"`
CreatedAt time.Time `json:"created_at" description:"资源关联记录写入时间"`
}
// 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
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
filter.CreatedFrom, filter.CreatedTo, err = retentionquery.NormalizeRange(retention, filter.CreatedFrom, filter.CreatedTo)
if 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, err := q.loadEventPage(ctx, query, filter.Page, filter.PageSize)
if err != nil {
return nil, 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, Retention: retention}, nil
}
// loadEventPage 先分页主键,再批量读取事件宽行,避免排序阶段加载 JSON 字段。
func (q *Query) loadEventPage(ctx context.Context, query *gorm.DB, page, pageSize int) ([]model.AuditEvent, error) {
ids := make([]uint, 0, pageSize)
if err := query.Select("id").Order("occurred_at DESC, id DESC").
Offset((page-1)*pageSize).Limit(pageSize).Pluck("id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审计事件分页ID失败")
}
rows := make([]model.AuditEvent, 0, len(ids))
if len(ids) == 0 {
return rows, nil
}
if err := q.db.WithContext(ctx).Where("id IN ?", ids).
Order("occurred_at DESC, id DESC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量投影审计事件失败")
}
return rows, 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) (*EventDetail, error) {
if err := q.authorize(ctx); err != nil {
return nil, err
}
if eventID == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
retention, err := retentionquery.Load(ctx, q.db, retentionquery.SourceAudit)
if err != nil {
return nil, err
}
var row model.AuditEvent
if err := q.db.WithContext(ctx).Where("event_id = ? AND occurred_at >= ?", eventID, retention.OnlineFrom.UTC()).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 &EventDetail{EventView: items[0], Retention: retention}, 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
}