All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史 - H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列 - 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案 - 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容 - 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏 - 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
1273 lines
53 KiB
Go
1273 lines
53 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/bytedance/sonic"
|
|
"github.com/google/uuid"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
|
accountauditapp "github.com/break/junhong_cmp_fiber/internal/application/accountaudit"
|
|
outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox"
|
|
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
|
wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom"
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
|
)
|
|
|
|
// WriteAccountLifecycle 将账号生命周期业务事实转换为统一 Audit Event。
|
|
func (w *Writer) WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit accountauditapp.LifecycleAudit) error {
|
|
if audit.Account == nil || audit.Account.Username == "" {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号生命周期审计资源不完整")
|
|
}
|
|
resources := []ResourceInput{{
|
|
Type: constants.AuditResourceAccount, ID: optionalResourceID(audit.Account.ID),
|
|
Key: accountResourceKey(audit.Account), DisplayName: audit.Account.Username,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
|
|
IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData,
|
|
}}
|
|
if audit.Shop != nil {
|
|
shopID := strconv.FormatUint(uint64(audit.Shop.ID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceShop, ID: &shopID, Key: shopID, DisplayName: audit.Shop.ShopName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
|
|
IdentitySnapshot: map[string]any{"id": audit.Shop.ID, "shop_code": audit.Shop.ShopCode, "shop_name": audit.Shop.ShopName, "parent_id": audit.Shop.ParentID, "level": audit.Shop.Level},
|
|
})
|
|
}
|
|
if audit.Enterprise != nil {
|
|
enterpriseID := strconv.FormatUint(uint64(audit.Enterprise.ID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceEnterprise, ID: &enterpriseID, Key: enterpriseID, DisplayName: audit.Enterprise.EnterpriseName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
|
|
IdentitySnapshot: map[string]any{"id": audit.Enterprise.ID, "enterprise_code": audit.Enterprise.EnterpriseCode, "enterprise_name": audit.Enterprise.EnterpriseName, "owner_shop_id": audit.Enterprise.OwnerShopID},
|
|
})
|
|
}
|
|
for _, role := range audit.Roles {
|
|
roleID := strconv.FormatUint(uint64(role.ID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceRole, ID: &roleID, Key: roleID, DisplayName: role.RoleName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountRole,
|
|
IdentitySnapshot: map[string]any{"id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status},
|
|
})
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: audit.ActionCode, Summary: audit.Summary,
|
|
Actor: ActorInput{Kind: constants.AuditActorAccount}, Source: constants.AuditSourceAdminAPI,
|
|
ScopeType: constants.AuditScopePlatform, Result: audit.Result,
|
|
ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary, Resources: resources,
|
|
})
|
|
}
|
|
|
|
// WriteAccountSecurity 将不含密码、验证码、Token 或 Cookie 的账号安全事实转换为统一 Audit Event。
|
|
func (w *Writer) WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit accountauditapp.SecurityAudit) error {
|
|
if audit.Account == nil || audit.Account.ID == 0 || audit.Account.Username == "" || audit.AuthenticationKey == "" {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "账号安全审计资源不完整")
|
|
}
|
|
accountID := strconv.FormatUint(uint64(audit.Account.ID), 10)
|
|
actorID := audit.ActorID
|
|
if actorID == 0 {
|
|
actorID = audit.Account.ID
|
|
}
|
|
resources := []ResourceInput{
|
|
{
|
|
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: audit.Account.Username,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
|
|
IdentitySnapshot: accountIdentity(audit.Account), BeforeData: audit.BeforeData, AfterData: audit.AfterData,
|
|
},
|
|
{
|
|
Type: constants.AuditResourceAuthentication, Key: audit.AuthenticationKey, DisplayName: "认证状态",
|
|
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAuthentication,
|
|
IdentitySnapshot: audit.Authentication,
|
|
},
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: audit.ActionCode, Summary: audit.Summary,
|
|
Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(actorID), 10), Name: audit.ActorName},
|
|
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
|
Result: audit.Result, ErrorCode: audit.ErrorCode, ErrorSummary: audit.ErrorSummary,
|
|
Resources: resources,
|
|
})
|
|
}
|
|
|
|
func optionalResourceID(id uint) *string {
|
|
if id == 0 {
|
|
return nil
|
|
}
|
|
value := strconv.FormatUint(uint64(id), 10)
|
|
return &value
|
|
}
|
|
|
|
func accountResourceKey(account *model.Account) string {
|
|
if account.ID == 0 {
|
|
return account.Username
|
|
}
|
|
return strconv.FormatUint(uint64(account.ID), 10)
|
|
}
|
|
|
|
func accountIdentity(account *model.Account) map[string]any {
|
|
return map[string]any{
|
|
"id": account.ID, "username": account.Username, "phone": account.Phone, "user_type": account.UserType,
|
|
"shop_id": account.ShopID, "enterprise_id": account.EnterpriseID,
|
|
"wecom_userid": account.WeComUserID, "wecom_name": account.WeComName,
|
|
}
|
|
}
|
|
|
|
// WriteAccessChange 将账号权限与组织变化转换为统一 Audit Event。
|
|
func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change accessauditapp.ChangeAudit) error {
|
|
action, ok := w.registry.Action(change.ActionCode)
|
|
if !ok {
|
|
recordBusinessWriteFailure(ctx, change.ActionCode, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计动作未注册"))
|
|
return nil
|
|
}
|
|
if change.OperatorID == 0 {
|
|
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), pkgerrors.New(pkgerrors.CodeInvalidParam, "账号权限或组织审计操作者不完整"))
|
|
return nil
|
|
}
|
|
resources, err := accessResources(change, action.PrimaryResource)
|
|
if err != nil {
|
|
recordBusinessWriteFailure(ctx, action.Code, accessChangeResourceKey(change), err)
|
|
return nil
|
|
}
|
|
result := change.Result
|
|
if result == "" {
|
|
result = constants.AuditResultSuccess
|
|
}
|
|
actorKind := change.ActorKind
|
|
if actorKind == "" {
|
|
actorKind = constants.AuditActorAccount
|
|
}
|
|
actorName := change.ActorName
|
|
if actorName == "" {
|
|
actorName = middleware.GetUsernameFromContext(ctx)
|
|
}
|
|
source := change.Source
|
|
if source == "" {
|
|
source = constants.AuditSourceAdminAPI
|
|
}
|
|
scopeType := change.ScopeType
|
|
if scopeType == "" {
|
|
scopeType = constants.AuditScopePlatform
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: action.Code, Summary: change.Summary,
|
|
Actor: ActorInput{
|
|
Kind: actorKind, ID: strconv.FormatUint(uint64(change.OperatorID), 10), Name: actorName,
|
|
},
|
|
Source: source, ScopeType: scopeType,
|
|
Result: result, ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, Resources: resources,
|
|
})
|
|
}
|
|
|
|
func accessChangeResourceKey(change accessauditapp.ChangeAudit) string {
|
|
if change.PersonalCustomer != nil {
|
|
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
|
|
}
|
|
if change.Account != nil {
|
|
return accountResourceKey(change.Account)
|
|
}
|
|
if change.Shop != nil {
|
|
return shopResourceKey(change.Shop)
|
|
}
|
|
if change.Enterprise != nil {
|
|
return enterpriseResourceKey(change.Enterprise)
|
|
}
|
|
if change.Role != nil {
|
|
return strconv.FormatUint(uint64(change.Role.ID), 10)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) {
|
|
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.PhoneAssociations)*2+len(change.Roles)+len(change.Permissions))
|
|
switch primaryResource {
|
|
case constants.AuditResourceAccount:
|
|
if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "账号授权审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceAccount, ID: optionalResourceID(change.Account.ID),
|
|
Key: accountResourceKey(change.Account), DisplayName: change.Account.Username,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccountTarget,
|
|
IdentitySnapshot: accountIdentity(change.Account), BeforeData: change.BeforeData, AfterData: change.AfterData,
|
|
})
|
|
if change.Shop != nil {
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
|
|
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleAccountScope,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
|
|
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
|
|
},
|
|
})
|
|
}
|
|
case constants.AuditResourceShop:
|
|
if change.Shop == nil || (change.Shop.ID == 0 && change.Shop.ShopCode == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "店铺授权审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
|
|
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleShopTarget,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
|
|
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
|
|
},
|
|
BeforeData: change.BeforeData, AfterData: change.AfterData,
|
|
SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData,
|
|
})
|
|
if change.ParentShop != nil {
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceShop, ID: optionalResourceID(change.ParentShop.ID),
|
|
Key: shopResourceKey(change.ParentShop), DisplayName: change.ParentShop.ShopName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopParent,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": change.ParentShop.ID, "shop_code": change.ParentShop.ShopCode, "shop_name": change.ParentShop.ShopName,
|
|
"parent_id": change.ParentShop.ParentID, "level": change.ParentShop.Level,
|
|
},
|
|
})
|
|
}
|
|
case constants.AuditResourceEnterprise:
|
|
if change.Enterprise == nil || (change.Enterprise.ID == 0 && change.Enterprise.EnterpriseCode == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceEnterprise, ID: optionalResourceID(change.Enterprise.ID),
|
|
Key: enterpriseResourceKey(change.Enterprise), DisplayName: change.Enterprise.EnterpriseName,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleEnterpriseTarget,
|
|
IdentitySnapshot: enterpriseIdentity(change.Enterprise), BeforeData: change.BeforeData, AfterData: change.AfterData,
|
|
})
|
|
if change.Shop != nil {
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceShop, ID: optionalResourceID(change.Shop.ID),
|
|
Key: shopResourceKey(change.Shop), DisplayName: change.Shop.ShopName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleEnterpriseOwnerShop,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": change.Shop.ID, "shop_code": change.Shop.ShopCode, "shop_name": change.Shop.ShopName,
|
|
"parent_id": change.Shop.ParentID, "level": change.Shop.Level,
|
|
},
|
|
})
|
|
}
|
|
case constants.AuditResourcePersonalCustomer:
|
|
if change.PersonalCustomer == nil || change.PersonalCustomer.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePersonalCustomer, ID: optionalResourceID(change.PersonalCustomer.ID),
|
|
Key: strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10), DisplayName: change.PersonalCustomer.Nickname,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePersonalCustomerTarget,
|
|
IdentitySnapshot: personalCustomerIdentity(change.PersonalCustomer), BeforeData: change.BeforeData, AfterData: change.AfterData,
|
|
SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData,
|
|
})
|
|
case constants.AuditResourcePhoneAssetAssociation:
|
|
if len(change.PhoneAssociations) == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整")
|
|
}
|
|
for index, item := range change.PhoneAssociations {
|
|
if item.AssociationID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整")
|
|
}
|
|
relation := constants.AuditResourceRelationReference
|
|
if index == 0 {
|
|
relation = constants.AuditResourceRelationPrimary
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePhoneAssetAssociation, ID: optionalResourceID(item.AssociationID),
|
|
Key: strconv.FormatUint(uint64(item.AssociationID), 10), DisplayName: item.PhoneMasked,
|
|
Relation: relation, Role: constants.AuditResourceRolePhoneAssetAssociationTarget,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": item.AssociationID, "phone_masked": item.PhoneMasked,
|
|
"asset_type": item.AssetType, "asset_id": item.AssetID, "status": item.Status,
|
|
"source": item.Source, "invalidated_at": item.InvalidatedAt,
|
|
"invalidation_method": item.InvalidationMethod, "invalidation_reason": item.InvalidationReason,
|
|
},
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
// 关联指向的资产以参考资源落事件,便于按资产回溯;资产标识缺失时不写资产资源。
|
|
if item.AssetType == "" || item.AssetID == 0 {
|
|
continue
|
|
}
|
|
assetID := strconv.FormatUint(uint64(item.AssetID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: item.AssetType, ID: &assetID, Key: assetID, DisplayName: item.AssetDisplayName,
|
|
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePhoneAssetAssociationAsset,
|
|
IdentitySnapshot: map[string]any{"id": item.AssetID},
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
|
})
|
|
}
|
|
}
|
|
for index, item := range change.Accounts {
|
|
if item.Account == nil || (item.Account.ID == 0 && item.Account.Username == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "组织关联账号审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
resourceRole := item.Role
|
|
if resourceRole == "" {
|
|
resourceRole = constants.AuditResourceRoleShopAccount
|
|
if primaryResource == constants.AuditResourceEnterprise {
|
|
resourceRole = constants.AuditResourceRoleEnterpriseAccount
|
|
}
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceAccount, ID: optionalResourceID(item.Account.ID),
|
|
Key: accountResourceKey(item.Account), DisplayName: item.Account.Username,
|
|
Relation: relation, Role: resourceRole, IdentitySnapshot: accountIdentity(item.Account),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.Cards {
|
|
if item.Card == nil || (item.Card.ID == 0 && item.Card.ICCID == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联卡审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRoleEnterpriseAuthorizedCard
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceIotCard, ID: optionalResourceID(item.Card.ID),
|
|
Key: iotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
|
|
Relation: relation, Role: role, IdentitySnapshot: iotCardIdentity(item.Card),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary,
|
|
SubjectData: item.SubjectData, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.CardAuthorizations {
|
|
if item.Authorization == nil || item.Authorization.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业卡授权审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceEnterpriseCardAuthorization, ID: optionalResourceID(item.Authorization.ID),
|
|
Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业卡授权记录",
|
|
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleEnterpriseCardAuthorization,
|
|
IdentitySnapshot: enterpriseCardAuthorizationIdentity(item.Authorization),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.Devices {
|
|
if item.Device == nil || (item.Device.ID == 0 && item.Device.VirtualNo == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业关联设备审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRoleEnterpriseAuthorizedDevice
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceDevice, ID: optionalResourceID(item.Device.ID),
|
|
Key: deviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo,
|
|
Relation: relation, Role: role, IdentitySnapshot: deviceIdentity(item.Device),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: item.SubjectVisibility, SubjectSummary: item.SubjectSummary,
|
|
SubjectData: item.SubjectData, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.DeviceBindings {
|
|
if item.Binding == nil || item.Binding.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备卡槽绑定审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationReference
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRoleEnterpriseDeviceBinding
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceDeviceSIMBinding, ID: optionalResourceID(item.Binding.ID),
|
|
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: "设备卡槽绑定",
|
|
Relation: relation, Role: role, IdentitySnapshot: deviceSimBindingIdentity(item.Binding),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.DeviceAuthorizations {
|
|
if item.Authorization == nil || item.Authorization.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "企业设备授权审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRoleEnterpriseDeviceAuthorization
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceEnterpriseDeviceAuthorization, ID: optionalResourceID(item.Authorization.ID),
|
|
Key: strconv.FormatUint(uint64(item.Authorization.ID), 10), DisplayName: "企业设备授权记录",
|
|
Relation: relation, Role: role, IdentitySnapshot: enterpriseDeviceAuthorizationIdentity(item.Authorization),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.PersonalPhones {
|
|
if item.Phone == nil || item.Phone.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户手机号审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePersonalCustomerPhone, ID: optionalResourceID(item.Phone.ID),
|
|
Key: strconv.FormatUint(uint64(item.Phone.ID), 10), DisplayName: sanitizer.MaskPhone(item.Phone.Phone),
|
|
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerPhone,
|
|
IdentitySnapshot: personalCustomerPhoneIdentity(item.Phone), BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.PersonalOpenIDs {
|
|
if item.OpenID == nil || item.OpenID.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户微信主体审计资源不完整")
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePersonalCustomerOpenID, ID: optionalResourceID(item.OpenID.ID),
|
|
Key: strconv.FormatUint(uint64(item.OpenID.ID), 10), DisplayName: item.OpenID.AppType,
|
|
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerWechatIdentity,
|
|
IdentitySnapshot: personalCustomerOpenIDIdentity(item.OpenID), BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.PersonalDevices {
|
|
if item.Binding == nil || item.Binding.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户设备绑定审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRolePersonalCustomerAssetBinding
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePersonalCustomerDevice, ID: optionalResourceID(item.Binding.ID),
|
|
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.VirtualNo,
|
|
Relation: relation, Role: role, IdentitySnapshot: personalCustomerDeviceIdentity(item.Binding),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
for index, item := range change.PersonalICCIDs {
|
|
if item.Binding == nil || item.Binding.ID == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户 ICCID 绑定审计资源不完整")
|
|
}
|
|
relation := item.Relation
|
|
if relation == "" {
|
|
relation = constants.AuditResourceRelationAffected
|
|
}
|
|
role := item.Role
|
|
if role == "" {
|
|
role = constants.AuditResourceRolePersonalCustomerAssetBinding
|
|
}
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourcePersonalCustomerICCID, ID: optionalResourceID(item.Binding.ID),
|
|
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.ICCID,
|
|
Relation: relation, Role: role, IdentitySnapshot: personalCustomerICCIDIdentity(item.Binding),
|
|
BeforeData: item.BeforeData, AfterData: item.AfterData,
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
|
|
})
|
|
}
|
|
if primaryResource == constants.AuditResourceRole {
|
|
if change.Role == nil || (change.Role.ID == 0 && change.Role.RoleName == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "角色审计资源不完整")
|
|
}
|
|
resources = append(resources, roleResource(change.Role, change.BeforeData, change.AfterData))
|
|
}
|
|
for index, item := range change.Roles {
|
|
if item.Role == nil || (item.Role.ID == 0 && item.Role.RoleName == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体授权角色审计资源不完整")
|
|
}
|
|
resourceRole := constants.AuditResourceRoleAccountRole
|
|
if primaryResource == constants.AuditResourceShop {
|
|
resourceRole = constants.AuditResourceRoleShopRole
|
|
}
|
|
resources = append(resources, authorizationRoleResource(item, resourceRole, index+1))
|
|
}
|
|
for index, item := range change.Permissions {
|
|
if item.Permission == nil || (item.Permission.ID == 0 && item.Permission.PermCode == "") {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限审计资源不完整")
|
|
}
|
|
relation := constants.AuditResourceRelationAffected
|
|
if primaryResource == constants.AuditResourcePermission && index == 0 {
|
|
relation = constants.AuditResourceRelationPrimary
|
|
}
|
|
beforeData, afterData := item.BeforeData, item.AfterData
|
|
if primaryResource == constants.AuditResourcePermission && index == 0 {
|
|
if beforeData == nil {
|
|
beforeData = change.BeforeData
|
|
}
|
|
if afterData == nil {
|
|
afterData = change.AfterData
|
|
}
|
|
}
|
|
resources = append(resources, permissionResource(item.Permission, relation, beforeData, afterData, index+1))
|
|
}
|
|
if primaryResource == constants.AuditResourcePermission && len(resources) != 1 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "权限 CRUD 审计必须包含一个权限资源")
|
|
}
|
|
return resources, nil
|
|
}
|
|
|
|
func authorizationRoleResource(change accessauditapp.RoleChange, resourceRole string, sortOrder int) ResourceInput {
|
|
return ResourceInput{
|
|
Type: constants.AuditResourceRole, ID: optionalResourceID(change.Role.ID),
|
|
Key: roleResourceKey(change.Role), DisplayName: change.Role.RoleName,
|
|
Relation: constants.AuditResourceRelationAffected, Role: resourceRole,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": change.Role.ID, "role_name": change.Role.RoleName,
|
|
"role_type": change.Role.RoleType, "status": change.Role.Status,
|
|
},
|
|
BeforeData: change.BeforeData, AfterData: change.AfterData, SortOrder: sortOrder,
|
|
}
|
|
}
|
|
|
|
func shopResourceKey(shop *model.Shop) string {
|
|
if shop.ID != 0 {
|
|
return strconv.FormatUint(uint64(shop.ID), 10)
|
|
}
|
|
return shop.ShopCode
|
|
}
|
|
|
|
func enterpriseResourceKey(enterprise *model.Enterprise) string {
|
|
if enterprise.ID != 0 {
|
|
return strconv.FormatUint(uint64(enterprise.ID), 10)
|
|
}
|
|
return enterprise.EnterpriseCode
|
|
}
|
|
|
|
func enterpriseIdentity(enterprise *model.Enterprise) map[string]any {
|
|
return map[string]any{
|
|
"id": enterprise.ID, "enterprise_code": enterprise.EnterpriseCode,
|
|
"enterprise_name": enterprise.EnterpriseName, "owner_shop_id": enterprise.OwnerShopID,
|
|
}
|
|
}
|
|
|
|
func iotCardResourceKey(card *model.IotCard) string {
|
|
if card.ID != 0 {
|
|
return strconv.FormatUint(uint64(card.ID), 10)
|
|
}
|
|
return card.ICCID
|
|
}
|
|
|
|
func iotCardIdentity(card *model.IotCard) map[string]any {
|
|
return map[string]any{
|
|
"id": card.ID, "iccid": card.ICCID, "iccid_19": card.ICCID19, "iccid_20": card.ICCID20,
|
|
"virtual_no": card.VirtualNo, "msisdn": card.MSISDN, "carrier_type": card.CarrierType,
|
|
"shop_id": card.ShopID, "series_id": card.SeriesID, "generation": card.Generation,
|
|
}
|
|
}
|
|
|
|
// IotCardIdentitySnapshot 返回统一 Registry 允许的 IoT 卡身份快照。
|
|
func IotCardIdentitySnapshot(card *model.IotCard) map[string]any {
|
|
return iotCardIdentity(card)
|
|
}
|
|
|
|
// IotCardResourceKey 返回 IoT 卡审计使用的稳定资源 Key。
|
|
func IotCardResourceKey(card *model.IotCard) string {
|
|
return iotCardResourceKey(card)
|
|
}
|
|
|
|
func deviceResourceKey(device *model.Device) string {
|
|
if device.ID != 0 {
|
|
return strconv.FormatUint(uint64(device.ID), 10)
|
|
}
|
|
return device.VirtualNo
|
|
}
|
|
|
|
func deviceIdentity(device *model.Device) map[string]any {
|
|
return map[string]any{
|
|
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI,
|
|
"sn": device.SN, "device_name": device.DeviceName, "device_model": device.DeviceModel,
|
|
"device_type": device.DeviceType, "manufacturer": device.Manufacturer,
|
|
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
|
|
}
|
|
}
|
|
|
|
// DeviceIdentitySnapshot 返回统一 Registry 允许的设备身份快照。
|
|
func DeviceIdentitySnapshot(device *model.Device) map[string]any {
|
|
return deviceIdentity(device)
|
|
}
|
|
|
|
// DeviceResourceKey 返回设备审计使用的稳定资源 Key。
|
|
func DeviceResourceKey(device *model.Device) string {
|
|
return deviceResourceKey(device)
|
|
}
|
|
|
|
func deviceSimBindingIdentity(binding *model.DeviceSimBinding) map[string]any {
|
|
return map[string]any{
|
|
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
|
|
"iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent,
|
|
}
|
|
}
|
|
|
|
func enterpriseCardAuthorizationIdentity(auth *model.EnterpriseCardAuthorization) map[string]any {
|
|
return map[string]any{
|
|
"id": auth.ID, "enterprise_id": auth.EnterpriseID, "card_id": auth.CardID,
|
|
"authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType,
|
|
"authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy,
|
|
"revoked_at": auth.RevokedAt, "device_auth_id": auth.DeviceAuthID,
|
|
}
|
|
}
|
|
|
|
func enterpriseDeviceAuthorizationIdentity(auth *model.EnterpriseDeviceAuthorization) map[string]any {
|
|
return map[string]any{
|
|
"id": auth.ID, "enterprise_id": auth.EnterpriseID, "device_id": auth.DeviceID,
|
|
"authorized_by": auth.AuthorizedBy, "authorizer_type": auth.AuthorizerType,
|
|
"authorized_at": auth.AuthorizedAt, "revoked_by": auth.RevokedBy, "revoked_at": auth.RevokedAt,
|
|
}
|
|
}
|
|
|
|
func personalCustomerIdentity(customer *model.PersonalCustomer) map[string]any {
|
|
return map[string]any{
|
|
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
|
|
"wx_union_id": customer.WxUnionID, "status": customer.Status,
|
|
}
|
|
}
|
|
|
|
// personalCustomerPhoneIdentity 生成个人客户手机号资源身份快照。
|
|
// 手机号一律为脱敏值:审计只出现前 3 位 + **** + 后 4 位,资源行 ID 仍可回溯真实记录。
|
|
func personalCustomerPhoneIdentity(phone *model.PersonalCustomerPhone) map[string]any {
|
|
return map[string]any{
|
|
"id": phone.ID, "customer_id": phone.CustomerID, "phone": sanitizer.MaskPhone(phone.Phone),
|
|
"is_primary": phone.IsPrimary, "verified_at": phone.VerifiedAt, "status": phone.Status,
|
|
}
|
|
}
|
|
|
|
func personalCustomerOpenIDIdentity(openID *model.PersonalCustomerOpenID) map[string]any {
|
|
return map[string]any{
|
|
"id": openID.ID, "customer_id": openID.CustomerID, "app_id": openID.AppID,
|
|
"open_id": openID.OpenID, "union_id": openID.UnionID, "app_type": openID.AppType,
|
|
}
|
|
}
|
|
|
|
func personalCustomerDeviceIdentity(binding *model.PersonalCustomerDevice) map[string]any {
|
|
return map[string]any{
|
|
"id": binding.ID, "customer_id": binding.CustomerID, "virtual_no": binding.VirtualNo,
|
|
"bind_at": binding.BindAt, "last_used_at": binding.LastUsedAt, "status": binding.Status,
|
|
}
|
|
}
|
|
|
|
func personalCustomerICCIDIdentity(binding *model.PersonalCustomerICCID) map[string]any {
|
|
return map[string]any{
|
|
"id": binding.ID, "customer_id": binding.CustomerID, "iccid": binding.ICCID,
|
|
"iccid_19": binding.ICCID19, "bind_at": binding.BindAt,
|
|
"last_used_at": binding.LastUsedAt, "status": binding.Status,
|
|
}
|
|
}
|
|
|
|
func roleResource(role *model.Role, beforeData, afterData map[string]any) ResourceInput {
|
|
return ResourceInput{
|
|
Type: constants.AuditResourceRole, ID: optionalResourceID(role.ID), Key: roleResourceKey(role), DisplayName: role.RoleName,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleAccessRole,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": role.ID, "role_name": role.RoleName, "role_type": role.RoleType, "status": role.Status,
|
|
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
|
|
},
|
|
BeforeData: beforeData, AfterData: afterData,
|
|
}
|
|
}
|
|
|
|
func permissionResource(permission *model.Permission, relation string, beforeData, afterData map[string]any, sortOrder int) ResourceInput {
|
|
return ResourceInput{
|
|
Type: constants.AuditResourcePermission, ID: optionalResourceID(permission.ID), Key: permissionResourceKey(permission), DisplayName: permission.PermName,
|
|
Relation: relation, Role: constants.AuditResourceRoleAccessPermission,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": permission.ID, "perm_name": permission.PermName, "perm_code": permission.PermCode,
|
|
"perm_type": permission.PermType, "platform": permission.Platform,
|
|
"available_for_role_types": permission.AvailableForRoleTypes, "parent_id": permission.ParentID, "status": permission.Status,
|
|
},
|
|
BeforeData: beforeData, AfterData: afterData, SortOrder: sortOrder,
|
|
}
|
|
}
|
|
|
|
func roleResourceKey(role *model.Role) string {
|
|
if role.ID != 0 {
|
|
return strconv.FormatUint(uint64(role.ID), 10)
|
|
}
|
|
return role.RoleName
|
|
}
|
|
|
|
func permissionResourceKey(permission *model.Permission) string {
|
|
if permission.ID != 0 {
|
|
return strconv.FormatUint(uint64(permission.ID), 10)
|
|
}
|
|
return permission.PermCode
|
|
}
|
|
|
|
// ActorInput 是事件发生时的真实操作者快照。
|
|
type ActorInput struct {
|
|
Kind string
|
|
ID string
|
|
Name string
|
|
ShopID *uint
|
|
ShopName string
|
|
EnterpriseID *uint
|
|
EnterpriseName string
|
|
}
|
|
|
|
// WriteSensitiveRead 在返回企业微信明文凭据前同步追加读取审计。
|
|
func (w *Writer) WriteSensitiveRead(ctx context.Context, tx *gorm.DB, read wecomapp.SensitiveReadAudit) error {
|
|
action, ok := w.registry.Action(constants.AuditActionWeComCredentialsRead)
|
|
if !ok || !action.SensitiveRead {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取动作未注册")
|
|
}
|
|
if read.OperatorID == 0 || len(read.Applications) == 0 || len(read.FieldClasses) == 0 {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "敏感读取审计事实不完整")
|
|
}
|
|
resources := make([]ResourceInput, 0, len(read.Applications))
|
|
for index, application := range read.Applications {
|
|
relation := constants.AuditResourceRelationReference
|
|
if index == 0 {
|
|
relation = constants.AuditResourceRelationPrimary
|
|
}
|
|
resourceID := strconv.FormatUint(uint64(application.ID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceWeComApplication, ID: &resourceID,
|
|
Key: resourceID, DisplayName: application.Name, Relation: relation,
|
|
Role: constants.AuditResourceRoleSensitiveReadTarget,
|
|
IdentitySnapshot: map[string]any{
|
|
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
|
|
"name": application.Name, "status": application.Status,
|
|
"credentials_configured": application.CredentialsConfigured,
|
|
},
|
|
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index,
|
|
})
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: action.Code, Summary: "读取企业微信应用明文凭据",
|
|
Actor: ActorInput{
|
|
Kind: constants.AuditActorAccount,
|
|
ID: strconv.FormatUint(uint64(read.OperatorID), 10), Name: middleware.GetUsernameFromContext(ctx),
|
|
},
|
|
Source: action.Source, ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
|
RequestID: read.RequestID, CorrelationID: read.CorrelationID,
|
|
Metadata: map[string]any{"field_classes": read.FieldClasses}, Resources: resources,
|
|
})
|
|
}
|
|
|
|
// ResourceInput 是一个独立审计资源的写入事实。
|
|
type ResourceInput struct {
|
|
Type string
|
|
ID *string
|
|
Key string
|
|
DisplayName string
|
|
Relation string
|
|
Role string
|
|
IdentitySnapshot map[string]any
|
|
BeforeData map[string]any
|
|
AfterData map[string]any
|
|
SubjectVisibility string
|
|
SubjectSummary string
|
|
SubjectData map[string]any
|
|
SortOrder int
|
|
}
|
|
|
|
// AppendInput 是统一 Append Writer 的最小事件输入。
|
|
type AppendInput struct {
|
|
EventID string
|
|
OccurredAt time.Time
|
|
ActionCode string
|
|
Summary string
|
|
Actor ActorInput
|
|
Source string
|
|
RequestPath string
|
|
RequestMethod string
|
|
IPAddress string
|
|
UserAgent string
|
|
ScopeType string
|
|
ScopeID string
|
|
ScopeName string
|
|
Result string
|
|
ErrorCode string
|
|
ErrorSummary string
|
|
RequestID string
|
|
CorrelationID string
|
|
ParentEventID string
|
|
BatchTotal int
|
|
SuccessCount int
|
|
FailCount int
|
|
Metadata map[string]any
|
|
Resources []ResourceInput
|
|
}
|
|
|
|
// Writer 只提供不可变 Audit Event 追加能力。
|
|
type Writer struct {
|
|
registry *Registry
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewWriter 创建统一 Audit Event Append Writer。
|
|
func NewWriter(registry *Registry, now func() time.Time) *Writer {
|
|
if registry == nil {
|
|
registry = NewRegistry()
|
|
}
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Writer{registry: registry, now: now}
|
|
}
|
|
|
|
// WriteConfigChange 将受控系统配置变化转换为统一 Audit Event。
|
|
func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change systemconfigapp.ChangeAudit) error {
|
|
action, ok := w.registry.ActionByOperation(change.OperationType)
|
|
if !ok {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
|
|
}
|
|
if change.OperatorID == 0 || change.ConfigKey == "" {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "系统配置审计资源不完整")
|
|
}
|
|
result := change.Result
|
|
if result == "" {
|
|
result = constants.AuditResultSuccess
|
|
}
|
|
displayName := change.DisplayName
|
|
if displayName == "" {
|
|
displayName = change.ConfigKey
|
|
}
|
|
identity := change.Identity
|
|
if identity == nil {
|
|
identity = map[string]any{"config_key": change.ConfigKey, "module": change.Module}
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: action.Code, Summary: change.Description,
|
|
Actor: ActorInput{
|
|
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10),
|
|
Name: middleware.GetUsernameFromContext(ctx),
|
|
},
|
|
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
|
|
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
|
|
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
|
|
ScopeType: constants.AuditScopePlatform, Result: result,
|
|
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
|
|
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
|
|
Resources: []ResourceInput{{
|
|
Type: action.PrimaryResource, ID: change.ResourceID, Key: change.ConfigKey, DisplayName: displayName,
|
|
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleConfig,
|
|
IdentitySnapshot: identity,
|
|
BeforeData: change.BeforeData, AfterData: change.AfterData,
|
|
SubjectVisibility: action.DefaultVisibility,
|
|
}},
|
|
})
|
|
}
|
|
|
|
// WriteRecovery 将 Outbox 人工恢复裁决转换为统一 Audit Event。
|
|
func (w *Writer) WriteRecovery(ctx context.Context, tx *gorm.DB, recovery outboxapp.RecoveryAudit) error {
|
|
action, ok := w.registry.ActionByOperation(recovery.OperationType)
|
|
if !ok {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
|
|
}
|
|
if recovery.OperatorID == 0 || recovery.BatchID == "" || recovery.Reason == "" || len(recovery.Events) == 0 {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计事实不完整")
|
|
}
|
|
result := recovery.Result
|
|
if result == "" {
|
|
result = constants.AuditResultSuccess
|
|
}
|
|
resources := make([]ResourceInput, 0, len(recovery.Events))
|
|
for index, event := range recovery.Events {
|
|
if event.ID == 0 || event.EventID == "" || event.EventType == "" {
|
|
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Outbox 恢复审计资源不完整")
|
|
}
|
|
relation := constants.AuditResourceRelationAffected
|
|
if index == 0 {
|
|
relation = constants.AuditResourceRelationPrimary
|
|
}
|
|
resourceID := strconv.FormatUint(uint64(event.ID), 10)
|
|
resources = append(resources, ResourceInput{
|
|
Type: constants.AuditResourceOutboxEvent, ID: &resourceID,
|
|
Key: event.EventID, DisplayName: event.EventID,
|
|
Relation: relation, Role: constants.AuditResourceRoleRecoveryTarget,
|
|
IdentitySnapshot: map[string]any{
|
|
"event_id": event.EventID, "event_type": event.EventType,
|
|
"aggregate_type": event.AggregateType, "aggregate_id": event.AggregateID,
|
|
"resource_type": event.ResourceType, "resource_id": event.ResourceID,
|
|
"business_key": event.BusinessKey,
|
|
},
|
|
BeforeData: recoveryStateData(event.BeforeStatus, event.BeforeNextAttempt, event.BeforeLeaseOwner, event.BeforeLeaseExpires),
|
|
AfterData: recoveryStateData(event.AfterStatus, event.AfterNextAttempt, event.AfterLeaseOwner, event.AfterLeaseExpires),
|
|
SubjectVisibility: action.DefaultVisibility, SortOrder: index,
|
|
})
|
|
}
|
|
return w.Append(ctx, tx, AppendInput{
|
|
ActionCode: action.Code, Summary: recovery.Description,
|
|
Actor: ActorInput{
|
|
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(recovery.OperatorID), 10),
|
|
Name: middleware.GetUsernameFromContext(ctx),
|
|
},
|
|
Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)),
|
|
RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)),
|
|
IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)),
|
|
ScopeType: constants.AuditScopePlatform, Result: result,
|
|
ErrorCode: recovery.ErrorCode, ErrorSummary: recovery.ErrorSummary,
|
|
RequestID: recovery.RequestID, CorrelationID: recovery.CorrelationID,
|
|
BatchTotal: len(resources), SuccessCount: recoverySuccessCount(result, len(resources)),
|
|
FailCount: recoveryFailCount(result, len(resources)),
|
|
Metadata: map[string]any{"batch_id": recovery.BatchID, "reason": recovery.Reason},
|
|
Resources: resources,
|
|
})
|
|
}
|
|
|
|
// Append 在调用方提供的 GORM 事务中顺序追加事件及资源。
|
|
func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) error {
|
|
_, err := w.AppendAndGet(ctx, tx, input)
|
|
if err != nil {
|
|
recordBusinessAppendFailure(ctx, input, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendAndGet 追加事件并返回已持久化的审计事件,幂等重放返回已有事件。
|
|
func (w *Writer) AppendAndGet(ctx context.Context, tx *gorm.DB, input AppendInput) (*model.AuditEvent, error) {
|
|
if w == nil || w.registry == nil || tx == nil {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一审计 Writer 未正确配置")
|
|
}
|
|
input = fillFromContext(ctx, input)
|
|
action, ok := w.registry.Action(input.ActionCode)
|
|
if !ok {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
|
|
}
|
|
if !actionAllowsOrigin(action, input.Actor.Kind, input.Source) || input.Actor.ID == "" {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计操作者或入口不符合动作注册规则")
|
|
}
|
|
if !validResult(input.Result) || len(input.Resources) == 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计结果非法或缺少资源")
|
|
}
|
|
resources, err := w.buildResources(input.Resources, action)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metadata, err := safeObject(input.Metadata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
occurredAt := input.OccurredAt
|
|
if occurredAt.IsZero() {
|
|
occurredAt = w.now().UTC()
|
|
}
|
|
event := model.AuditEvent{
|
|
OccurredAt: occurredAt, Category: action.Category, ActionCode: action.Code, ActionName: action.Name,
|
|
Summary: sanitizer.SanitizeText(input.Summary), ActorKind: input.Actor.Kind, ActorID: input.Actor.ID, ActorName: sanitizer.SanitizeText(input.Actor.Name),
|
|
ActorShopID: input.Actor.ShopID, ActorShopName: sanitizer.SanitizeText(input.Actor.ShopName),
|
|
ActorEnterpriseID: input.Actor.EnterpriseID, ActorEnterpriseName: sanitizer.SanitizeText(input.Actor.EnterpriseName),
|
|
Source: input.Source, RequestPath: sanitizer.SanitizeText(input.RequestPath), RequestMethod: input.RequestMethod,
|
|
IPAddress: input.IPAddress, UserAgent: sanitizer.SanitizeText(input.UserAgent),
|
|
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: sanitizer.SanitizeText(input.ScopeName),
|
|
Result: input.Result, RiskLevel: action.Risk, RequestID: input.RequestID,
|
|
ErrorCode: input.ErrorCode, ErrorSummary: sanitizer.SanitizeText(input.ErrorSummary),
|
|
CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID, Metadata: metadata,
|
|
BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount,
|
|
}
|
|
event.ContentHash, err = contentHash(event, resources)
|
|
if err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "计算审计内容哈希失败")
|
|
}
|
|
event.EventID = input.EventID
|
|
if event.EventID == "" {
|
|
event.EventID = "evt_" + uuid.NewString()
|
|
}
|
|
create := tx.WithContext(ctx).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true,
|
|
}).Create(&event)
|
|
if create.Error != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, create.Error, "写入审计事件失败")
|
|
}
|
|
if create.RowsAffected == 0 {
|
|
var existing model.AuditEvent
|
|
if err := tx.WithContext(ctx).Where("event_id = ?", event.EventID).First(&existing).Error; err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取已存在审计事件失败")
|
|
}
|
|
return &existing, nil
|
|
}
|
|
for index := range resources {
|
|
resources[index].AuditEventID = event.ID
|
|
resources[index].CreatedAt = event.CreatedAt
|
|
}
|
|
if err := tx.WithContext(ctx).Create(&resources).Error; err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入审计事件资源失败")
|
|
}
|
|
return &event, nil
|
|
}
|
|
|
|
func actionAllowsOrigin(action ActionDefinition, actor, source string) bool {
|
|
if action.AllowedActor == actor && action.Source == source {
|
|
return true
|
|
}
|
|
for _, origin := range action.AllowedOrigins {
|
|
if origin.Actor == actor && origin.Source == source {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func fillFromContext(ctx context.Context, input AppendInput) AppendInput {
|
|
value := auditcontext.From(ctx)
|
|
if input.Actor.Kind == "" {
|
|
input.Actor.Kind = value.ActorKind
|
|
}
|
|
if input.Actor.ID == "" {
|
|
input.Actor.ID = value.ActorID
|
|
}
|
|
if input.Actor.Name == "" {
|
|
input.Actor.Name = value.ActorName
|
|
}
|
|
if input.Actor.ShopID == nil {
|
|
input.Actor.ShopID = value.ActorShopID
|
|
}
|
|
if input.Actor.EnterpriseID == nil {
|
|
input.Actor.EnterpriseID = value.ActorEnterpriseID
|
|
}
|
|
if input.Source == "" {
|
|
input.Source = value.Source
|
|
}
|
|
if input.RequestID == "" {
|
|
input.RequestID = value.RequestID
|
|
}
|
|
if input.CorrelationID == "" {
|
|
input.CorrelationID = value.CorrelationID
|
|
}
|
|
if input.ParentEventID == "" {
|
|
input.ParentEventID = value.ParentEventID
|
|
}
|
|
if input.RequestPath == "" {
|
|
input.RequestPath = value.RequestPath
|
|
}
|
|
if input.RequestMethod == "" {
|
|
input.RequestMethod = value.RequestMethod
|
|
}
|
|
if input.IPAddress == "" {
|
|
input.IPAddress = value.IPAddress
|
|
}
|
|
if input.UserAgent == "" {
|
|
input.UserAgent = value.UserAgent
|
|
}
|
|
return input
|
|
}
|
|
|
|
func (w *Writer) buildResources(inputs []ResourceInput, action ActionDefinition) ([]model.AuditEventResource, error) {
|
|
resources := make([]model.AuditEventResource, 0, len(inputs))
|
|
primaryCount := 0
|
|
for _, input := range inputs {
|
|
definition, ok := w.registry.Resource(input.Type)
|
|
if !ok {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源未注册")
|
|
}
|
|
if input.Key == "" || input.Relation == "" || input.Role == "" {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计资源关系不完整")
|
|
}
|
|
if input.Relation == constants.AuditResourceRelationPrimary {
|
|
primaryCount++
|
|
if input.Type != action.PrimaryResource {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主要资源不符合动作注册规则")
|
|
}
|
|
}
|
|
identity, err := registeredIdentity(input.IdentitySnapshot, definition.IdentityFields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
before, err := safeObject(input.BeforeData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
after, err := safeObject(input.AfterData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
visibility := input.SubjectVisibility
|
|
if visibility == "" {
|
|
visibility = constants.AuditSubjectInternalOnly
|
|
if input.Relation == constants.AuditResourceRelationPrimary {
|
|
visibility = action.DefaultVisibility
|
|
}
|
|
}
|
|
subjectDataInput, err := validateSubjectProjection(input, action, visibility)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subjectData, err := safeObject(subjectDataInput)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resources = append(resources, model.AuditEventResource{
|
|
ResourceType: input.Type, ResourceID: input.ID, ResourceKey: sanitizer.SanitizeText(input.Key), DisplayName: sanitizer.SanitizeText(input.DisplayName),
|
|
Relation: input.Relation, Role: input.Role, IdentitySnapshot: identity,
|
|
BeforeData: before, AfterData: after, SubjectVisibility: visibility,
|
|
SubjectSummary: sanitizer.SanitizeText(input.SubjectSummary), SubjectData: subjectData, SortOrder: input.SortOrder,
|
|
})
|
|
}
|
|
if primaryCount != 1 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "审计事件必须且只能有一个主要资源")
|
|
}
|
|
return resources, nil
|
|
}
|
|
|
|
func validateSubjectProjection(input ResourceInput, action ActionDefinition, visibility string) (map[string]any, error) {
|
|
if !containsString(action.AllowedVisibility, visibility) {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别不符合动作注册规则")
|
|
}
|
|
switch visibility {
|
|
case constants.AuditSubjectInternalOnly:
|
|
if input.SubjectSummary != "" || len(input.SubjectData) > 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "内部事件不得写入主体投影")
|
|
}
|
|
return nil, nil
|
|
case constants.AuditSubjectResult:
|
|
if input.SubjectSummary == "" || len(input.SubjectData) > 0 {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体结论投影必须仅包含安全摘要")
|
|
}
|
|
return nil, nil
|
|
case constants.AuditSubjectDetail:
|
|
if input.SubjectSummary == "" {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情投影缺少安全摘要")
|
|
}
|
|
for field := range input.SubjectData {
|
|
if !containsString(action.SubjectFields, field) {
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体详情包含未注册字段")
|
|
}
|
|
}
|
|
return input.SubjectData, nil
|
|
default:
|
|
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "主体可见级别非法")
|
|
}
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func registeredIdentity(value map[string]any, fields []string) (datatypes.JSON, error) {
|
|
registered := make(map[string]any, len(fields))
|
|
for _, field := range fields {
|
|
if item, ok := value[field]; ok {
|
|
registered[field] = item
|
|
}
|
|
}
|
|
return boundedObject(registered)
|
|
}
|
|
|
|
func safeObject(value map[string]any) (datatypes.JSON, error) {
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
encoded, err := sanitizer.MarshalSummary(value)
|
|
if err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 清理失败")
|
|
}
|
|
var object map[string]any
|
|
if err := sonic.Unmarshal(encoded, &object); err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 必须是对象")
|
|
}
|
|
return boundedObject(object)
|
|
}
|
|
|
|
func boundedObject(value map[string]any) (datatypes.JSON, error) {
|
|
encoded, err := sonic.ConfigStd.Marshal(value)
|
|
if err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 编码失败")
|
|
}
|
|
if len(encoded) <= constants.AuditJSONMaxBytes {
|
|
return datatypes.JSON(encoded), nil
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
truncated, err := sonic.ConfigStd.Marshal(map[string]any{
|
|
"truncated": true, "original_bytes": len(encoded), "sha256": hex.EncodeToString(sum[:]),
|
|
})
|
|
if err != nil {
|
|
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "审计 JSON 摘要编码失败")
|
|
}
|
|
return datatypes.JSON(truncated), nil
|
|
}
|
|
|
|
func contentHash(event model.AuditEvent, resources []model.AuditEventResource) (string, error) {
|
|
event.EventID = ""
|
|
event.ContentHash = ""
|
|
event.ID = 0
|
|
event.CreatedAt = time.Time{}
|
|
for index := range resources {
|
|
resources[index].ID = 0
|
|
resources[index].AuditEventID = 0
|
|
resources[index].CreatedAt = time.Time{}
|
|
}
|
|
encoded, err := sonic.ConfigStd.Marshal(struct {
|
|
Event model.AuditEvent `json:"event"`
|
|
Resources []model.AuditEventResource `json:"resources"`
|
|
}{Event: event, Resources: resources})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func contextString(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func recoveryStateData(status int, nextAttempt time.Time, leaseOwner *string, leaseExpiresAt *time.Time) map[string]any {
|
|
return map[string]any{
|
|
"status": status, "status_name": constants.GetOutboxStatusName(status),
|
|
"next_attempt_at": nextAttempt, "lease_owner": leaseOwner, "lease_expires_at": leaseExpiresAt,
|
|
}
|
|
}
|
|
|
|
func validResult(result string) bool {
|
|
switch result {
|
|
case constants.AuditResultSuccess, constants.AuditResultFailed, constants.AuditResultDenied,
|
|
constants.AuditResultPartial, constants.AuditResultUnknown:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func recoverySuccessCount(result string, count int) int {
|
|
if result == constants.AuditResultSuccess {
|
|
return count
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func recoveryFailCount(result string, count int) int {
|
|
if result == constants.AuditResultFailed || result == constants.AuditResultDenied {
|
|
return count
|
|
}
|
|
return 0
|
|
}
|
|
|
|
var _ systemconfigapp.AuditWriter = (*Writer)(nil)
|
|
var _ outboxapp.AuditWriter = (*Writer)(nil)
|
|
var _ accountauditapp.Writer = (*Writer)(nil)
|
|
var _ accessauditapp.Writer = (*Writer)(nil)
|