固化七月迭代审计治理进展以隔离线上热修
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 参数类型错误未通过。
This commit is contained in:
395
internal/query/audit/subject_activities.go
Normal file
395
internal/query/audit/subject_activities.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// SubjectActivityFilter 定义代理资源活动的稳定标识和分页参数。
|
||||
type SubjectActivityFilter struct {
|
||||
ResourceType string
|
||||
Identifier string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// SubjectActivityPage 是不包含平台调查字段的代理资源活动分页结果。
|
||||
type SubjectActivityPage struct {
|
||||
Resource SubjectResourceSummary `json:"resource"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []SubjectActivity `json:"items"`
|
||||
}
|
||||
|
||||
// SubjectActivity 是写入时已生成的主体安全活动投影。
|
||||
type SubjectActivity struct {
|
||||
ActionCode string `json:"action_code"`
|
||||
ActionName string `json:"action_name"`
|
||||
SubjectSummary string `json:"subject_summary"`
|
||||
SubjectData map[string]any `json:"subject_data"`
|
||||
Result string `json:"result"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RelatedResources []SubjectResourceSummary `json:"related_resources"`
|
||||
}
|
||||
|
||||
// SubjectResourceSummary 是主体活动允许公开的资源摘要。
|
||||
type SubjectResourceSummary struct {
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type subjectTarget struct {
|
||||
summary SubjectResourceSummary
|
||||
id string
|
||||
}
|
||||
|
||||
type subjectActivityRow struct {
|
||||
ID uint
|
||||
ActionCode string
|
||||
ActionName string
|
||||
Result string
|
||||
OccurredAt time.Time
|
||||
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 == "" || !agentActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
target, err := q.resolveAgentTarget(ctx, filter.ResourceType, filter.Identifier, shopIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, 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 == "" || !enterpriseActivityResourceType(filter.ResourceType) || filter.Page < 0 || filter.PageSize < 0 || filter.PageSize > constants.MaxPageSize {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
filter.Page, filter.PageSize = normalizePage(filter.Page, filter.PageSize)
|
||||
target, err := q.resolveEnterpriseTarget(ctx, filter.ResourceType, filter.Identifier, enterpriseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.subjectActivitiesForTarget(ctx, filter, target, 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, 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("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_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, authorize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubjectActivityPage{Resource: target.summary, Total: total, Page: filter.Page, PageSize: filter.PageSize, Items: items}, 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, authorize subjectResourceAuthorizer) ([]SubjectActivity, error) {
|
||||
items := make([]SubjectActivity, 0, len(rows))
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
eventIDs := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
eventIDs = append(eventIDs, row.ID)
|
||||
}
|
||||
var resources []model.AuditEventResource
|
||||
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, "批量查询主体可见关联资源失败")
|
||||
}
|
||||
allowed, err := authorize(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, 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
|
||||
}
|
||||
Reference in New Issue
Block a user