All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s
440 lines
21 KiB
Go
440 lines
21 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"
|
||
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}/timeline;resource_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, "批量查询审计事件资源失败")
|
||
}
|
||
var integrationRows []model.IntegrationLog
|
||
if err := q.db.WithContext(ctx).Where("audit_event_id IN ?", ids).Find(&integrationRows).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询审计事件外部交互引用失败")
|
||
}
|
||
integrationByAudit := integrationRefsByAuditID(integrationRows)
|
||
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)
|
||
items[index].InvestigationRefs.IntegrationRefs = uniqueIntegrationRefs(integrationByAudit[row.ID])
|
||
}
|
||
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
|
||
}
|