All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m8s
- 审计注册表为可由系统任务、定时任务发起的动作声明落库策略(always/on_success/outcome/log_only/none),未声明按 log_only - 人工、开放接口、个人客户、外部系统发起的动作始终写审计表 - log_only 写所在服务 app.log(logger=audit),不含快照 - 对接日志不再补写 integration.attempt_started,无上游审计时 audit_event_id 为空 - 新增 audit.persist_overrides 按动作覆盖策略 - 审计覆盖扫描要求机器动作必须声明策略 - 新增 OpenSpec 提案 refactor-audit-log-persistence 与技术方案文档 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1285 lines
54 KiB
Go
1285 lines
54 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
|
||
// Final 只对"只写结论"策略生效:true 表示本次是最终结论(成功或重试用尽转人工),写审计表;false 只写应用日志。
|
||
Final bool
|
||
// Attempt 与 AttemptRecordID 是自动重试类动作的尝试序号与业务尝试记录标识,只写入应用日志。
|
||
Attempt int
|
||
AttemptRecordID string
|
||
}
|
||
|
||
// 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, "审计结果非法或缺少资源")
|
||
}
|
||
switch decidePersist(action, input) {
|
||
case decisionSkip:
|
||
return nil, nil
|
||
case decisionLog:
|
||
writeAuditLog(ctx, action, input)
|
||
return nil, nil
|
||
}
|
||
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)
|