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

431 lines
19 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
import (
"context"
"strconv"
"time"
"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"
)
// SubjectActivityFilter 定义代理资源活动的稳定标识和分页参数。
type SubjectActivityFilter struct {
ResourceType string
Identifier string
CreatedFrom *time.Time
CreatedTo *time.Time
Page int
PageSize int
}
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
type SubjectActivityPage struct {
Resource SubjectResourceSummary `json:"resource" description:"已完成授权校验的目标资源"`
Total int64 `json:"total" description:"主体可见活动总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
Items []SubjectActivity `json:"items" description:"不包含平台内部调查字段的安全活动列表"`
Retention retentionquery.Info `json:"retention" description:"在线查询留存边界"`
}
// SubjectActivity 是写入时已生成的主体安全活动投影。
type SubjectActivity struct {
ActionCode string `json:"action_code" description:"稳定动作编码"`
ActionName string `json:"action_name" description:"action_code对应的中文展示名称"`
SubjectSummary string `json:"subject_summary" description:"写入时生成的主体安全摘要"`
SubjectData map[string]any `json:"subject_data" description:"写入时生成的主体安全业务字段不包含平台before/after或内部原因"`
Result string `json:"result" enum:"success,failed,denied,partial,unknown" description:"活动结果稳定编码"`
OccurredAt time.Time `json:"occurred_at" description:"业务事实发生时间"`
RelatedResources []SubjectResourceSummary `json:"related_resources" description:"当前主体授权范围内的相关资源摘要"`
}
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
type SubjectResourceSummary struct {
ResourceType string `json:"resource_type" description:"资源类型稳定编码"`
ResourceID string `json:"resource_id" description:"资源内部稳定ID主体前端不据此调用平台审计接口"`
ResourceKey string `json:"resource_key" description:"资源业务稳定Key"`
DisplayName string `json:"display_name" description:"资源安全展示名称"`
}
type subjectTarget struct {
summary SubjectResourceSummary
id string
}
type subjectActivityRow struct {
ID uint
ActionCode string
ActionName string
Result string
OccurredAt time.Time
SubjectVisibility string
SubjectSummary string
SubjectData datatypes.JSON
TargetResourceID uint
}
type subjectResourceAuthorizer func(context.Context, []model.AuditEventResource) (map[string]bool, error)
// AgentResourceActivities 查询代理自身及下级店铺范围内的安全资源活动。
func (q *Query) AgentResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
shopIDs, err := q.agentShopScope(ctx)
if err != nil {
return nil, err
}
if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
if !agentActivityResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
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
}
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.agentAllowedResourceIDs(ctx, resources, shopIDs)
})
}
// EnterpriseResourceActivities 查询企业当前有效授权卡或设备的安全资源活动。
func (q *Query) EnterpriseResourceActivities(ctx context.Context, filter SubjectActivityFilter) (*SubjectActivityPage, error) {
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
if enterpriseID == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if filter.Identifier == "" || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
return nil, errors.New(errors.CodeInvalidParam)
}
if !enterpriseActivityResourceType(filter.ResourceType) {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
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
}
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
if err != nil {
return nil, err
}
return q.subjectActivitiesForTarget(ctx, filter, target, retention, func(ctx context.Context, resources []model.AuditEventResource) (map[string]bool, error) {
return q.enterpriseAllowedResourceIDs(ctx, resources, enterpriseID)
})
}
func (q *Query) subjectActivitiesForTarget(ctx context.Context, filter SubjectActivityFilter, target subjectTarget, retention retentionquery.Info, authorize subjectResourceAuthorizer) (*SubjectActivityPage, error) {
resourceMatch := q.db.Table("tb_audit_event_resource AS target").Select("1").
Where("target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail})
base := q.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("occurred_at >= ? AND occurred_at < ?", filter.CreatedFrom.UTC(), filter.CreatedTo.UTC()).Where("EXISTS (?)", resourceMatch)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理资源活动失败")
}
rows := make([]subjectActivityRow, 0, filter.PageSize)
if err := base.Select("tb_audit_event.id, action_code, action_name, result, occurred_at, target.subject_visibility, target.subject_summary, target.subject_data, target.id AS target_resource_id").
Joins("JOIN tb_audit_event_resource AS target ON target.audit_event_id = tb_audit_event.id AND target.resource_type = ? AND target.resource_id = ?", filter.ResourceType, target.id).
Where("target.subject_visibility IN ?", []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}).
Order("occurred_at DESC, tb_audit_event.id DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理资源活动失败")
}
items, err := q.projectSubjectActivities(ctx, rows, target, authorize)
if err != nil {
return nil, err
}
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items, Retention: retention}, nil
}
func (q *Query) resolveEnterpriseTarget(ctx context.Context, resourceType, identifier string, enterpriseID uint) (subjectTarget, error) {
var target subjectTarget
switch resourceType {
case constants.AuditResourceIotCard:
var row model.IotCard
err := q.db.WithContext(ctx).Table("tb_iot_card AS card").
Joins("JOIN tb_enterprise_card_authorization AS auth ON auth.card_id = card.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
Where("card.iccid = ? AND auth.enterprise_id = ? AND card.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
case constants.AuditResourceDevice:
var row model.Device
err := q.db.WithContext(ctx).Table("tb_device AS device").
Joins("JOIN tb_enterprise_device_authorization AS auth ON auth.device_id = device.id AND auth.deleted_at IS NULL AND auth.revoked_at IS NULL").
Where("device.virtual_no = ? AND auth.enterprise_id = ? AND device.deleted_at IS NULL", identifier, enterpriseID).First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
}
return target, nil
}
func (q *Query) agentShopScope(ctx context.Context) ([]uint, error) {
if q == nil || q.db == nil || middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(shopIDs) == 0 {
if shopID := middleware.GetShopIDFromContext(ctx); shopID > 0 {
return []uint{shopID}, nil
}
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return shopIDs, nil
}
func (q *Query) resolveAgentTarget(ctx context.Context, resourceType, identifier string, shopIDs []uint) (subjectTarget, error) {
var target subjectTarget
query := q.db.WithContext(ctx)
switch resourceType {
case constants.AuditResourceIotCard:
var row model.IotCard
if err := query.Where("iccid = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ICCID, row.ICCID)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceDevice:
var row model.Device
if err := query.Where("virtual_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.VirtualNo, row.VirtualNo)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceShop:
var row model.Shop
if err := query.Where("shop_code = ? AND id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ShopCode, row.ShopName)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceEnterprise:
var row model.Enterprise
if err := query.Where("enterprise_code = ? AND owner_shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.EnterpriseCode, row.EnterpriseName)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceExchangeOrder:
var row model.ExchangeOrder
if err := query.Where("exchange_no = ? AND shop_id IN ?", identifier, shopIDs).First(&row).Error; err == nil {
target = newSubjectTarget(resourceType, row.ID, row.ExchangeNo, row.ExchangeNo)
} else {
return target, q.subjectTargetError(err)
}
case constants.AuditResourceAssetAllocationRecord:
var row model.AssetAllocationRecord
err := agentAllocationScope(query.Where("allocation_no = ?", identifier), shopIDs).Order("id DESC").First(&row).Error
if err != nil {
return target, q.subjectTargetError(err)
}
target = newSubjectTarget(resourceType, row.ID, row.AllocationNo, row.AllocationNo)
}
return target, nil
}
func agentAllocationScope(query *gorm.DB, shopIDs []uint) *gorm.DB {
return query.Where(`
(from_owner_type = 'shop' AND from_owner_id IN ?) OR
(to_owner_type = 'shop' AND to_owner_id IN ?) OR
(asset_type = 'iot_card' AND EXISTS (SELECT 1 FROM tb_iot_card c WHERE c.id = asset_id AND c.deleted_at IS NULL AND c.shop_id IN ?)) OR
(asset_type = 'device' AND EXISTS (SELECT 1 FROM tb_device d WHERE d.id = asset_id AND d.deleted_at IS NULL AND d.shop_id IN ?))`,
shopIDs, shopIDs, shopIDs, shopIDs)
}
func (q *Query) subjectTargetError(err error) error {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理资源范围失败")
}
func newSubjectTarget(resourceType string, id uint, key, name string) subjectTarget {
resourceID := strconv.FormatUint(uint64(id), 10)
return subjectTarget{summary: SubjectResourceSummary{ResourceType: resourceType, ResourceID: resourceID, ResourceKey: key, DisplayName: name}, id: resourceID}
}
func (q *Query) projectSubjectActivities(ctx context.Context, rows []subjectActivityRow, target subjectTarget, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) {
items := make([]SubjectActivity, 0, len(rows))
eventIDs := make([]uint, 0, len(rows))
for _, row := range rows {
eventIDs = append(eventIDs, row.ID)
}
resources := make([]model.AuditEventResource, 0)
if len(eventIDs) > 0 {
if err := q.db.WithContext(ctx).Where("audit_event_id IN ? AND subject_visibility IN ?", eventIDs, []string{constants.AuditSubjectResult, constants.AuditSubjectDetail}).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询主体可见关联资源失败")
}
}
targetResourceID := target.id
authorizationResources := append(resources, model.AuditEventResource{ResourceType: target.summary.ResourceType, ResourceID: &targetResourceID})
allowed, err := authorize(ctx, authorizationResources)
if err != nil {
return nil, err
}
if !allowed[resourceAccessKey(target.summary.ResourceType, target.id)] {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
related := make(map[uint][]SubjectResourceSummary, len(rows))
for _, resource := range resources {
if resource.ResourceID == nil || !allowed[resourceAccessKey(resource.ResourceType, *resource.ResourceID)] {
continue
}
related[resource.AuditEventID] = append(related[resource.AuditEventID], SubjectResourceSummary{
ResourceType: resource.ResourceType, ResourceID: *resource.ResourceID,
ResourceKey: resource.ResourceKey, DisplayName: resource.DisplayName,
})
}
for _, row := range rows {
data := map[string]any{}
if row.SubjectVisibility == constants.AuditSubjectDetail {
data, err = decodeObject(row.SubjectData)
if err != nil {
return nil, err
}
}
items = append(items, SubjectActivity{ActionCode: row.ActionCode, ActionName: row.ActionName,
SubjectSummary: row.SubjectSummary, SubjectData: data, Result: row.Result,
OccurredAt: row.OccurredAt, RelatedResources: related[row.ID]})
if items[len(items)-1].RelatedResources == nil {
items[len(items)-1].RelatedResources = []SubjectResourceSummary{}
}
}
return items, nil
}
func (q *Query) enterpriseAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, enterpriseID uint) (map[string]bool, error) {
idsByType := collectResourceIDs(resources)
allowed := make(map[string]bool)
queries := []struct {
resourceType string
table string
resourceID string
}{
{constants.AuditResourceIotCard, "tb_enterprise_card_authorization", "card_id"},
{constants.AuditResourceDevice, "tb_enterprise_device_authorization", "device_id"},
}
for _, spec := range queries {
ids := idsByType[spec.resourceType]
if len(ids) == 0 {
continue
}
var visible []uint
if err := q.db.WithContext(ctx).Table(spec.table).
Where("enterprise_id = ? AND "+spec.resourceID+" IN ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID, ids).
Pluck(spec.resourceID, &visible).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验企业关联资源授权失败")
}
markAllowedIDs(allowed, spec.resourceType, visible)
}
return allowed, nil
}
func (q *Query) agentAllowedResourceIDs(ctx context.Context, resources []model.AuditEventResource, shopIDs []uint) (map[string]bool, error) {
idsByType := collectResourceIDs(resources)
allowed := make(map[string]bool)
queries := []struct {
resourceType string
table string
condition string
}{
{constants.AuditResourceIotCard, "tb_iot_card", "shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceDevice, "tb_device", "shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceShop, "tb_shop", "id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceEnterprise, "tb_enterprise", "owner_shop_id IN ? AND deleted_at IS NULL"},
{constants.AuditResourceExchangeOrder, "tb_exchange_order", "shop_id IN ? AND deleted_at IS NULL"},
}
for _, spec := range queries {
if err := q.collectAgentAllowedIDs(ctx, allowed, spec.resourceType, spec.table, spec.condition, idsByType[spec.resourceType], shopIDs); err != nil {
return nil, err
}
}
if ids := idsByType[constants.AuditResourceAssetAllocationRecord]; len(ids) > 0 {
var visible []uint
if err := agentAllocationScope(q.db.WithContext(ctx).Table("tb_asset_allocation_record").Where("id IN ? AND deleted_at IS NULL", ids), shopIDs).
Pluck("id", &visible).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验代理分配记录关联资源失败")
}
markAllowedIDs(allowed, constants.AuditResourceAssetAllocationRecord, visible)
}
return allowed, nil
}
func collectResourceIDs(resources []model.AuditEventResource) map[string][]uint {
idsByType := make(map[string][]uint)
for _, resource := range resources {
if resource.ResourceID == nil {
continue
}
id, err := strconv.ParseUint(*resource.ResourceID, 10, 64)
if err == nil {
idsByType[resource.ResourceType] = append(idsByType[resource.ResourceType], uint(id))
}
}
return idsByType
}
func (q *Query) collectAgentAllowedIDs(ctx context.Context, allowed map[string]bool, resourceType, table, condition string, ids, shopIDs []uint) error {
if len(ids) == 0 {
return nil
}
var visible []uint
if err := q.db.WithContext(ctx).Table(table).Where("id IN ?", ids).Where(condition, shopIDs).Pluck("id", &visible).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验代理关联资源范围失败")
}
markAllowedIDs(allowed, resourceType, visible)
return nil
}
func markAllowedIDs(allowed map[string]bool, resourceType string, ids []uint) {
for _, id := range ids {
allowed[resourceAccessKey(resourceType, strconv.FormatUint(uint64(id), 10))] = true
}
}
func resourceAccessKey(resourceType, resourceID string) string {
return resourceType + ":" + resourceID
}
func agentActivityResourceType(resourceType string) bool {
switch resourceType {
case constants.AuditResourceIotCard, constants.AuditResourceDevice, constants.AuditResourceAssetAllocationRecord,
constants.AuditResourceExchangeOrder, constants.AuditResourceShop, constants.AuditResourceEnterprise:
return true
default:
return false
}
}
func enterpriseActivityResourceType(resourceType string) bool {
return resourceType == constants.AuditResourceIotCard || resourceType == constants.AuditResourceDevice
}