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 参数类型错误未通过。
397 lines
15 KiB
Go
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
|
|
}
|