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