蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #06 操作审计日志:谁在什么时候做了什么

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #06 操作审计日志:谁在什么时候做了什么

蓝鲸 CMDB 审计日志 cc_AuditLog AuditLog BasicContent HostTransferOpDetail Change Stream v3.14.6

学习重点

  • 理解 AuditLog 顶层结构:谁、在什么时候、对什么资源、做了什么操作
  • 掌握 ActionType 三类:create/update/delete 与 assign_host/transfer_host_module 的区别
  • 理解 BasicContent 快照模式:PreData(变更前)↔ CurData(变更后)↔ UpdateFields(本次变更字段)
  • 理解 HostTransferOpDetail 专门为主机拓扑转移设计,记录 PreData/CurData 双重拓扑快照
  • 理解 WithPrevious / WithCurrent 双时刻快照的短路设计(只执行一次)
  • 理解 SaveAuditLog 写入链路:scene_server → CoreService API → MongoDB cc_AuditLog
  • 理解 DetailFactory:根据 ResourceType 自动派发操作详情类型的 BSON 反序列化机制

一、SRE 痛点:主机被误删了,查不到是谁干的

想象这样一个 SRE 场景:凌晨 2 点,告警系统发现某台主机 192.168.1.100 突然从监控列表中消失了。查了一圈发现,这台主机被某人在 10 分钟前删除了。但"某人"是谁?什么时候删的?删之前这台主机在哪个业务、哪个集群、哪个模块?

这类需求在大型运维团队中极为普遍——谁在什么时候做了什么变更,变更前后的数据是什么。蓝鲸 CMDB 的 auditlog 模块就是为解决这类问题而设计的。

SRE 痛点提炼

  • 合规要求:等保/ISO27001 等合规标准要求记录所有配置变更,且不可篡改
  • 故障定位:变更引发故障后,需要快速定位"这次故障和哪个变更相关"
  • 责任追溯:出现配置错误时,需要知道是谁、在什么时间、做了什么操作

CMDB 的审计日志方案核心在于三点:快照双时刻(变更前 + 变更后)、操作详情分类(通用属性变更用 BasicContent,主机拓扑转移用 HostTransferOpDetail)、不可变存储(写入 cc_AuditLog 后仅追加,不修改不删除)。

2. 审计日志核心数据结构

2.1 AuditLog 顶层结构

src/common/metadata/audit.go 第 174-208 行,定义了审计日志的核心结构:

// AuditLog — 审计日志顶层结构(L174-208)
type AuditLog struct {
    // AuditType 是资源的高层分类:business / host / model / business_resource / cloud_resource / kube 等
    AuditType AuditType `json:"audit_type" bson:"audit_type"`
    // SupplierAccount 多租户隔离字段
    SupplierAccount string `json:"bk_supplier_account" bson:"bk_supplier_account"`
    // User 操作用户名(如 "zuoyang")
    User string `json:"user" bson:"user"`
    // ResourceType 资源类型:business / host / set / module / process 等
    ResourceType ResourceType `json:"resource_type" bson:"resource_type"`
    // Action 操作类型:create / update / delete / assign_host / transfer_host_module 等
    Action ActionType `json:"action" bson:"action"`
    // OperateFrom 操作来源:user(用户操作)/ cc_system(系统升级)/ data_collection(数据采集)
    OperateFrom OperateFromType `json:"operate_from" bson:"operate_from"`
    // OperationDetail 变更详情(类型由 ResourceType 决定,见 DetailFactory 表)
    OperationDetail DetailFactory `json:"operation_detail" bson:"operation_detail"`
    // OperationTime 操作时间(自动填充)
    OperationTime Time `json:"operation_time" bson:"operation_time"`
    // BusinessID 所属业务 ID(仅业务资源填)
    BusinessID int64 `json:"bk_biz_id,omitempty" bson:"bk_biz_id,omitempty"`
    // ResourceID 被操作资源的 ID(主机 ID、业务 ID 等)
    ResourceID interface{} `json:"resource_id" bson:"resource_id"`
    // ResourceName 被操作资源的名称(主机 IP、业务名称等)
    ResourceName string `json:"resource_name" bson:"resource_name"`
    // RequestID 请求链路 ID(用于关联同一个请求内的所有日志)
    RequestID string `json:"rid,omitempty" bson:"rid,omitempty"`
    // ExtendResourceName IPv6 地址(主机名扩展)
    ExtendResourceName string `json:"extend_resource_name" bson:"extend_resource_name"`
}

2.2 ActionType 操作类型常量

src/common/metadata/audit.go 第 789-822 行,定义了所有操作类型:

// ActionType(L789-822)
const (
    AuditCreate           ActionType = "create"                // 创建资源
    AuditUpdate           ActionType = "update"                // 更新资源
    AuditDelete           ActionType = "delete"                // 删除资源
    AuditAssignHost       ActionType = "assign_host"           // 主机从资源池分配到业务
    AuditUnassignHost     ActionType = "unassign_host"         // 主机从业务退回资源池
    AuditTransferHostModule ActionType = "transfer_host_module" // 主机在业务内转移模块
    AuditArchive          ActionType = "archive"                // 归档
    AuditRecover          ActionType = "recover"               // 恢复
    AuditPause            ActionType = "stop"                  // 停用
    AuditResume           ActionType = "resume"                // 启用
)

2.3 DetailFactory:ResourceType 到操作详情的类型映射

src/common/metadata/audit.go 第 251-269 行,resTypeOpDetailTypeMap 根据资源类型自动选择操作详情结构体:

// DetailFactory 接口(L247-249)
type DetailFactory interface {
    WithName() string
}

// resTypeOpDetailTypeMap(L251-269):资源类型 → 操作详情类型
var resTypeOpDetailTypeMap = map[ResourceType]DetailFactory{
    BusinessRes:        new(InstanceOpDetail),        // 业务/集群/模块
    BizSetRes:          new(InstanceOpDetail),
    ProjectRes:         new(InstanceOpDetail),
    SetRes:             new(InstanceOpDetail),
    ModuleRes:          new(InstanceOpDetail),
    ProcessRes:         new(InstanceOpDetail),
    HostRes:            new(InstanceOpDetail),        // 主机通用操作
    CloudAreaRes:       new(InstanceOpDetail),
    ModelInstanceRes:   new(InstanceOpDetail),
    MainlineInstanceRes: new(InstanceOpDetail),
    ServiceInstanceRes: new(ServiceInstanceOpDetail), // 服务实例
    InstanceAssociationRes: new(InstanceAssociationOpDetail), // 关联关系
}

2.4 通用操作详情:BasicContent

最通用的操作详情结构体是 BasicContent,在 src/common/metadata/audit.go 第 570-577 行定义:

// BasicContent(L570-577):create / update / delete 三种操作共用
type BasicContent struct {
    PreData      map[string]interface{} `json:"pre_data" bson:"pre_data"`             // 删除/更新前数据
    CurData      map[string]interface{} `json:"cur_data" bson:"cur_data"`             // 创建后的数据
    UpdateFields map[string]interface{} `json:"update_fields" bson:"update_fields"`     // 本次更新的字段(仅 update)
}

关键设计PreDataCurData 是互斥的——创建操作只填 CurData,删除操作只填 PreData,更新操作两者都填并额外记录 UpdateFields。这个逻辑在 src/common/auditlog/metadata.go 第 49-67 行的 NewBasicContent() 方法中实现。

2.5 cc_AuditLog 表名与索引

审计日志写入 MongoDB 的表名在 src/common/tablenames.go 第 64 行定义:

// src/common/tablenames.go:64
BKTableNameAuditLog = "cc_AuditLog"

索引定义在 src/common/index/collections/auditlog.go 第 25 行注册,索引包含:user(按用户查)、bk_biz_id(按业务查)、operation_time(按时间查)、resource_type(按资源类型查)、action(按操作类型查),支持高并发组合查询。

3. 快照模式:变更前后双时刻记录

3.1 generateAuditCommonParameter:审计参数构建器

src/common/auditlog/metadata.go 第 20-68 行,generateAuditCommonParameter 是 Builder 模式的参数构建器:

// generateAuditCommonParameter(L20-26)
type generateAuditCommonParameter struct {
    kit          *rest.Kit          // 请求上下文(含用户身份、RequestID)
    action       metadata.ActionType // 操作类型(create/update/delete)
    operateFrom  metadata.OperateFromType // 来源(user / cc_system / data_collection)
    updateFields map[string]interface{}  // 本次更新的字段(仅 update)
}

// NewGenerateAuditCommonParameter(L28-34):构造器
func NewGenerateAuditCommonParameter(kit *rest.Kit, action metadata.ActionType) *generateAuditCommonParameter {
    return &generateAuditCommonParameter{
        kit:    kit,
        action: action,
    }
}

// WithUpdateFields(L42-46):链式调用,追加本次更新的字段
func (a *generateAuditCommonParameter) WithUpdateFields(updateFields map[string]interface{}) *generateAuditCommonParameter {
    a.updateFields = updateFields
    return a
}

// NewBasicContent(L48-68):根据 action 自动生成 PreData / CurData / UpdateFields
func (a *generateAuditCommonParameter) NewBasicContent(data map[string]interface{}) *metadata.BasicContent {
    var basicDetail *metadata.BasicContent
    switch a.action {
    case metadata.AuditCreate:
        basicDetail = &metadata.BasicContent{CurData: data}    // 只填 CurData
    case metadata.AuditDelete:
        basicDetail = &metadata.BasicContent{PreData: data}     // 只填 PreData
    case metadata.AuditUpdate:
        basicDetail = &metadata.BasicContent{
            PreData:      data,        // 更新前的完整数据
            UpdateFields: a.updateFields, // 本次修改的字段
        }
    }
    return basicDetail
}

3.2 hostAuditLog.generateAuditLog:主机审计生成

src/common/auditlog/host.go 第 29-97 行,hostAuditLog 实现主机级别的审计生成。导出的 GenerateAuditLog(L29-34)直接转发给内部方法 generateAuditLog(L47-97):

// GenerateAuditLog(L29-34):公开入口,内部转发给 generateAuditLog
func (h *hostAuditLog) GenerateAuditLog(parameter *generateAuditCommonParameter, bizID int64,
    data []mapstr.MapStr) ([]metadata.AuditLog, error) {
    return h.generateAuditLog(parameter, bizID, data)
}

// generateAuditLog(L47-97):实际生成逻辑,遍历主机列表为每台生成一条 AuditLog
func (h *hostAuditLog) generateAuditLog(parameter *generateAuditCommonParameter, bizID int64,
    data []mapstr.MapStr) ([]metadata.AuditLog, error) {

    kit := parameter.kit
    if len(data) == 0 {
        return nil, kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, "host audit log data")
    }

    auditLogs := make([]metadata.AuditLog, len(data))
    hostIDs := make([]int64, len(data))   // 收集所有主机 ID

    for index, host := range data {
        hostID, err := util.GetInt64ByInterface(host[common.BKHostIDField])
        if err != nil {
            return nil, kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, common.BKHostIDField)
        }
        hostIDs[index] = hostID

        // 根据 action 自动填 PreData / CurData / UpdateFields(NewBasicContent 实现)
        auditLog := metadata.AuditLog{
            AuditType:          metadata.HostType,
            ResourceType:       metadata.HostRes,
            Action:             parameter.action,
            BusinessID:         bizID,
            ResourceID:         hostIDs[index],
            ResourceName:       util.GetStrByInterface(host[common.BKHostInnerIPField]),
            ExtendResourceName: util.GetStrByInterface(host[common.BKHostInnerIPv6Field]),
            OperateFrom:        parameter.operateFrom,
            OperationDetail: &metadata.InstanceOpDetail{
                BasicOpDetail: metadata.BasicOpDetail{
                    Details: parameter.NewBasicContent(host),
                },
                ModelID: common.BKInnerObjIDHost,
            },
        }
        auditLogs[index] = auditLog
    }

    // 若 bizID==0(主机不在任何业务中,如新建主机在资源池),反查业务 ID
    if bizID == 0 {
        hostBizMap, err := h.getBizIDByHostID(parameter.kit, hostIDs)
        if err != nil {
            return nil, err
        }
        for index := range auditLogs {
            auditLogs[index].BusinessID = hostBizMap[hostIDs[index]]
        }
    }

    return auditLogs, nil
}

3.3 审计写入链路:SaveAuditLog

所有审计日志最终通过 src/common/auditlog/audit.go 第 33-36 行的 SaveAuditLog 写入 MongoDB:

// SaveAuditLog(L33-36):经 CoreService API 写入 cc_AuditLog 表
func (a *audit) SaveAuditLog(kit *rest.Kit, logs ...metadata.AuditLog) errors.CCErrorCoder {
    return a.clientSet.Audit().SaveAuditLog(kit.Ctx, kit.Header, logs...)
}

对应的 API 路由在 src/source_controller/coreservice/service/service_initfunc.go 第 346-349 行注册:

// service_initfunc.go:340-352
func (s *coreService) audit(web *restful.WebService) {
    utility.AddHandler(rest.Action{Verb: http.MethodPost, Path: "/create/auditlog",
        Handler: s.CreateAuditLog})
    utility.AddHandler(rest.Action{Verb: http.MethodPost, Path: "/read/auditlog",
        Handler: s.SearchAuditLog})
    utility.AddToRestfulWebService(web)
}

写入链路总结:scene_server(业务逻辑层)→ auditlog 包(生成审计日志)→ CoreService API → MongoDB cc_AuditLog。整个链路通过 rest.Kit 携带用户身份(kit.SupplierAccountkit.User)和请求链路 ID(kit.Rid),确保审计日志的归属清晰可溯。

4. 主机转移的专属审计:HostTransferOpDetail

4.1 为什么要单独设计?

主机在业务间转移(资源池 ↔ 业务、业务 A → 业务 B)和在业务内转移模块(MySQL 模块 → PaymentServer 模块),不仅仅是"属性变更",而是涉及 拓扑结构的改变。通用 BasicContent 只记录主机自身字段,无法描述"从哪个业务的哪个集群哪个模块,转移到了哪里"。

CMDB 为此专门设计了 HostTransferOpDetail,在 src/common/metadata/audit.go 第 464-476 行:

// HostTransferOpDetail(L464-469):主机转移专属操作详情
type HostTransferOpDetail struct {
    // PreData 转移前的业务拓扑(biz + set[] + module[])
    PreData HostBizTopo `json:"pre_data" bson:"pre_data"`
    // CurData 转移后的业务拓扑
    CurData HostBizTopo `json:"cur_data" bson:"cur_data"`
}

// HostBizTopo(L471-476):主机所在业务拓扑的快照
type HostBizTopo struct {
    BizID   int64  `json:"bk_biz_id" bson:"bk_biz_id"`     // 业务 ID
    BizName string `json:"bk_biz_name" bson:"bk_biz_name"`   // 业务名称
    Set     []Topo `json:"set" bson:"set"`                  // 集群列表
}

// Topo(hostserver.go L678-682):单个集群及其模块信息
type Topo struct {
    SetID   int64    `json:"bk_set_id" bson:"bk_set_id"`     // 集群 ID
    SetName string   `json:"bk_set_name" bson:"bk_set_name"`   // 集群名称
    Module  []Module `json:"module" bson:"module"`            // 该集群下的所有模块
}

// Module(hostserver.go L704-707):单个模块信息
type Module struct {
    ModuleID   int64  `json:"bk_module_id" bson:"bk_module_id"`     // 模块 ID
    ModuleName string `json:"bk_module_name" bson:"bk_module_name"` // 模块名称
}

4.2 WithPrevious / WithCurrent:拓扑快照捕获

src/common/auditlog/hostmodule.go 第 28-69 行,hostModuleLog 实现了双时刻拓扑快照:

type hostModuleLog struct {
    audit     audit                      // CoreService 客户端
    hostIDArr []int64                    // 待审计的主机 ID 列表
    pre       []metadata.ModuleHost      // 转移前拓扑快照
    cur       []metadata.ModuleHost      // 转移后拓扑快照
}

// NewHostModuleLog(L36-43):创建主机拓扑审计记录器
func NewHostModuleLog(clientSet coreservice.CoreServiceClientInterface, hostID []int64) *hostModuleLog {
    return &hostModuleLog{
        audit:     audit{clientSet: clientSet},
        hostIDArr: hostID,
    }
}

// WithPrevious(L45-56):捕获转移前的拓扑快照(只执行一次)
func (h *hostModuleLog) WithPrevious(kit *rest.Kit) errors.CCError {
    if h.pre != nil {
        return nil  // 已捕获则跳过
    }
    h.pre, err = h.getHostModuleConfig(kit)  // 查询 cc_ModuleHostConfig 表
    return err
}

// WithCurrent(L58-69):捕获转移后的拓扑快照(只执行一次)
func (h *hostModuleLog) WithCurrent(kit *rest.Kit) errors.CCError {
    if h.cur != nil {
        return nil  // 已捕获则跳过
    }
    h.cur, err = h.getHostModuleConfig(kit)
    return err
}

// getHostModuleConfig(L171-184):查询主机当前所属业务-集群-模块关系
func (h *hostModuleLog) getHostModuleConfig(kit *rest.Kit) ([]metadata.ModuleHost, errors.CCError) {
    conds := &metadata.HostModuleRelationRequest{
        HostIDArr: h.hostIDArr,
        Fields:    []string{common.BKAppIDField, common.BKSetIDField,
                            common.BKModuleIDField, common.BKHostIDField},
    }
    result, err := h.audit.clientSet.Host().GetHostModuleRelation(kit.Ctx, kit.Header, conds)
    if err != nil {
        return nil, kit.CCError.Error(common.CCErrCommHTTPDoRequestFailed)
    }
    return result.Info, nil
}

4.3 SaveAudit:主机转移审计的完整生成与写入

src/common/auditlog/hostmodule.go 第 72-169 行,SaveAudit 是主机转移审计的核心实现:

// SaveAudit(L72-169):生成主机转移审计日志并写入 cc_AuditLog
func (h *hostModuleLog) SaveAudit(kit *rest.Kit) errors.CCError {
    // 1. 获取主机 IP/IPv6 信息
    hostInfos, err := h.getInnerIPAndInnerIPv6(kit)
    if err != nil { return err }

    // 2. 确保捕获了当前拓扑快照(如果调用方没先调 WithCurrent,这里补上)
    if err := h.WithCurrent(kit); err != nil { return err }

    // 3. 获取默认业务 ID(资源池业务,用于判断主机是否在"未分配"状态)
    defaultBizID, err := h.audit.getDefaultAppID(kit)
    if err != nil { return err }

    // 4. 收集所有涉及到的业务/集群/模块 ID,反查名称
    var setIDs, moduleIDs, appIDs []int64
    for _, val := range append(h.pre, h.cur...) {
        setIDs = append(setIDs, val.SetID)
        moduleIDs = append(moduleIDs, val.ModuleID)
        appIDs = append(appIDs, val.AppID)
    }
    moduleMap := h.getInstIDNameMap(kit, common.BKInnerObjIDModule, moduleIDs)
    setMap    := h.getInstIDNameMap(kit, common.BKInnerObjIDSet, setIDs)
    bizMap    := h.getInstIDNameMap(kit, common.BKInnerObjIDApp, appIDs)

    // 5. 组装 PreData 和 CurData(HostBizTopo 格式)
    preDataMap := h.getHostTransferDataMap(h.pre, bizMap, setMap, moduleMap)
    curDataMap := h.getHostTransferDataMap(h.cur, bizMap, setMap, moduleMap)

    // 6. 为每台主机生成一条 AuditLog
    var logs = make([]metadata.AuditLog, 0)
    for _, host := range hostInfos {
        hostID, _ := util.GetInt64ByInterface(host[common.BKHostIDField])
        hostIP, _ := host.String(common.BKHostInnerIPField)
        hostIPv6, _ := host.String(common.BKHostInnerIPv6Field)

        preData, curData := preDataMap[hostID], curDataMap[hostID]
        preBizID, curBizID := preData.BizID, curData.BizID

        // 7. 根据业务 ID 变化判断 Action 类型
        var action metadata.ActionType
        var bizID int64
        if preBizID != curBizID && preBizID == defaultBizID {
            action = metadata.AuditAssignHost    // 资源池 → 业务(分配)
            bizID = curBizID
        } else if preBizID != curBizID && curBizID == defaultBizID {
            action = metadata.AuditUnassignHost  // 业务 → 资源池(退回)
            bizID = preBizID
        } else {
            action = metadata.AuditTransferHostModule // 业务内转移模块
            bizID = curBizID
        }

        // 8. 构造 HostTransferOpDetail
        logs = append(logs, metadata.AuditLog{
            AuditType:           metadata.HostType,   // "host"
            ResourceType:        metadata.HostRes,     // "host"
            Action:              action,
            BusinessID:          bizID,
            ResourceID:          hostID,
            ResourceName:        hostIP,
            ExtendResourceName:  hostIPv6,
            OperationDetail: &metadata.HostTransferOpDetail{
                PreData: preData,  // 转移前:{ biz_id, biz_name, set[], module[] }
                CurData: curData,  // 转移后:{ biz_id, biz_name, set[], module[] }
            },
        })
    }

    // 9. 写入 cc_AuditLog
    if err := h.audit.SaveAuditLog(kit, logs...); err != nil {
        blog.Errorf("save audit log failed, err: %v, rid: %s", err, kit.Rid)
        return err
    }
    return nil
}

♦ 关键设计细节:Action 的三元判断

第 135-144 行的三元判断是整个主机转移审计的核心:

  • preBizID != curBizID && preBizID == defaultBizID分配(资源池 → 业务):主机从 ID=0(资源池)转移到业务,Action = assign_host
  • preBizID != curBizID && curBizID == defaultBizID退回(业务 → 资源池):主机从业务退回资源池,Action = unassign_host
  • 其余情况 → 模块转移(业务内):主机在同一业务内跨模块移动,Action = transfer_host_module

defaultBizID 通过 src/common/auditlog/audit.go 第 115-149 行的 getDefaultAppID() 查询 cc_ApplicationBase 表中 bk_default=1 的记录得到(即"资源池"业务的 ID)。

5. 审计日志写入链路

完整的审计写入链路如下(以主机删除为例):

SRE 操作:删除主机 192.168.1.1
    ↓
host_server/service/host.go: DeleteHostBatch()
    ↓
    auditlog.NewHostAudit(clientSet).WithPrevious(kit)  ← WithPrevious/WithCurrent 是 hostModuleLog 的方法(拓扑快照),主机删除用 hostAuditLog
    ↓
    执行删除操作(MongoDB DELETE)
    ↓
    auditlog.NewHostAudit(clientSet).WithCurrent(kit)
    ↓
    auditlog.NewHostAudit(clientSet).GenerateAuditLog()
    ↓
    audit.saveAuditLog()  → CoreService API
    ↓
    source_controller: CreateAuditLog()
    ↓
MongoDB cc_AuditLog 写入文档:
{
    "audit_type": "host",
    "resource_type": "host",
    "action": "delete",
    "bk_supplier_account": "0",
    "user": "zuoyang",
    "bk_biz_id": 2,
    "resource_id": 12345,
    "resource_name": "192.168.1.1",
    "rid": "abc123",          ← 请求链路 ID
    "operation_time": "2026-07-04T10:00:00Z",
    "operation_detail": {
        "pre_data": {            ← 变更前完整主机数据
            "bk_host_innerip": "192.168.1.1",
            "bk_os_name": "linux",
            "bk_biz_id": 2,
            "bk_set_ids": [100],
            "bk_module_ids": [200]
        },
        "cur_data": {}           ← 删除后为空
    }
}

6. 主机转移全链路追踪实战

6.1 场景:主机从 MySQL 模块转移到 PaymentServer 模块

SRE 操作:主机 192.168.1.1 从 MySQL 模块 → PaymentServer 模块
    ↓
scene_server/host_server/service/transfer.go: TransferHostModule()
    ↓
    auditlog.NewHostModuleLog(clientSet, []int64{hostID})
        → 初始化审计记录器(传入主机 ID 列表)
    ↓
    .WithPrevious(kit)
        → 查询 cc_ModuleHostConfig:当前主机在 MySQL 模块(biz=2, set=100, module=201)
    ↓
    执行主机模块关系变更(MongoDB UPDATE cc_ModuleHostConfig)
    ↓
    .WithCurrent(kit)
        → 查询 cc_ModuleHostConfig:主机现在在 PaymentServer 模块(biz=2, set=100, module=202)
    ↓
    .SaveAudit(kit)
        → 生成 HostTransferOpDetail + 写入 cc_AuditLog
    ↓
cc_AuditLog 写入文档(关键字段):
{
    "audit_type": "host",
    "resource_type": "host",
    "action": "transfer_host_module",
    "user": "zuoyang",
    "bk_biz_id": 2,
    "resource_id": 12345,
    "resource_name": "192.168.1.1",
    "operation_detail": {
        "pre_data": {           ← 转移前
            "bk_biz_id": 2,
            "bk_biz_name": "游戏业务",
            "set": [{"SetID": "100", "SetName": "北京一区",
                      "ModuleID": "201", "ModuleName": "MySQL"}]
        },
        "cur_data": {           ← 转移后
            "bk_biz_id": 2,
            "bk_biz_name": "游戏业务",
            "set": [{"SetID": "100", "SetName": "北京一区",
                      "ModuleID": "202", "ModuleName": "PaymentServer"}]
        }
    }
}
    ↓
SRE 查询审计日志:GET /api/v3/audit/search
    返回:zuoyang 于 10:00 将 192.168.1.1 从 MySQL 转移到 PaymentServer

6.2 审计日志查询接口

cc_AuditLog 支持按以下维度组合查询(在 src/common/metadata/audit.go 第 160-171 行的 InstAuditCondition 中定义):

查询字段说明示例
user 操作用户 "zuoyang" → 查该用户所有操作
bk_biz_id 所属业务 2 → 查该业务下所有操作
resource_type 资源类型 "host" → 主机相关操作
action 操作类型 ["delete", "transfer_host_module"]
operation_time 操作时间范围 精确到秒,支持区间查询
resource_name 资源名称 "192.168.1.1" → 主机 IP 精确查

7. FAQ

FAQ 共 20 组,涵盖:①数据结构理解 ②方法调用规则 ③Action类型判断 ④查询接口 ⑤与拓扑缓存/Change Stream的关系 ⑥边界与坑点

  • ① 数据结构(Q1-Q5):BasicContent vs HostTransferOpDetail / DetailFactory 派发规则 / AuditType vs ResourceType vs Action / HostBizTopo vs Topo vs Module 的嵌套关系 / PreData/CurData 在不同 Action 下的填充规则
  • ② 方法调用规则(Q6-Q10):WithPrevious/WithCurrent 的短路设计 / hostAuditLog vs hostModuleLog 的分工 / GenerateAuditLog 自动反查 bizID / SaveAuditLog 异步不阻塞 / 谁负责调用这些方法
  • ③ Action 类型判断(Q11-Q13):三元判断逻辑 / assign_host vs unassign_host 的边界 / transfer_host_module 何时触发
  • ④ 查询接口(Q14-Q16):SearchAuditLog 的查询字段 / InstAuditCondition 结构 / 按用户/业务/时间组合查询
  • ⑤ 边界与坑点(Q17-Q20):CurData 在删除时为空的原因 / 审计日志不可修改删除 / WithPrevious/WithCurrent 各自只调一次 / getDefaultAppID 查资源池业务

Q1:BasicContent 和 HostTransferOpDetail 用哪个?

一句话结论:通用资源(业务/集群/模块/主机属性)用 BasicContent,主机拓扑转移用 HostTransferOpDetail。src/common/metadata/audit.go 第 361-368 行,当 Action 为 transfer_host_module / assign_host / unassign_host 时,UnmarshalBSON 会强制使用 HostTransferOpDetail;其余 Action 则走 resTypeOpDetailTypeMap(L251-269)派发——主机走 InstanceOpDetail(内含 BasicContent)。

Q2:DetailFactory 是如何根据 ResourceType 自动派发操作详情类型的?

一句话结论:通过 resTypeOpDetailTypeMap 查表,根据 ResourceType 找到对应的 DetailFactory 实现类。src/common/metadata/audit.go 第 251-269 行定义了派发表:BusinessResInstanceOpDetailServiceInstanceResServiceInstanceOpDetailInstanceAssociationResInstanceAssociationOpDetail。UnmarshalBSON(L380-393)通过查表拿到具体类型,再用反射实例化并反序列化。

Q3:AuditType、ResourceType、Action 三者有什么区别?

一句话结论:AuditType 是大类(决定审计来源系统),ResourceType 是具体资源(决定 DetailFactory 类型),Action 是操作动词(决定填充 PreData 还是 CurData)。以主机转移为例:AuditType="host"(主机大类)、ResourceType="host"(具体资源)、Action="transfer_host_module"(操作动词)。AuditType 还在 src/common/metadata/audit.go 第 596-664 行定义了 business / business_resource / model / cloud_resource / kube 等大类,用于审计后台按大类聚合。

Q4:HostBizTopo 里的 Set 是 []Topo,Topo 里的 Module 是 []Module,这两层嵌套是怎么来的?

一句话结论:getHostTransferDataMap(hostmodule.go L239-274)根据主机模块关系实时构建这两层嵌套结构。src/common/auditlog/hostmodule.go 第 239-274 行,先用 ModuleHost 列表按 HostID + SetID 分组,同一 SetID 下的所有 ModuleID 聚合成一个 Topo,再把所有 Topo 归入对应的 HostBizTopo。所以 PreData 和 CurData 天然就是树状结构——一台主机可能属于多个集群(多 Set),每个集群下有多个模块(多 Module)。

Q5:PreData 和 CurData 在不同 Action 下分别填什么?

一句话结论:Create 填 CurData、Delete 填 PreData、Update 两者都填并额外记录 UpdateFields。src/common/auditlog/metadata.go 第 48-68 行的 NewBasicContent() 中用 switch 语句实现这一逻辑:AuditCreateCurData=data(新建资源完整的当前状态);AuditDeletePreData=data(被删资源删除前的完整状态);AuditUpdatePreData=data + UpdateFields=updateFields(变更前的完整数据加上本次修改的字段列表)。

Q6:WithPrevious 和 WithCurrent 为什么各自最多只调用一次?

一句话结论:方法内部有 if h.pre != nil { return nil } 的短路设计,第二次调用直接返回,避免重复查询 MongoDB。src/common/auditlog/hostmodule.go 第 46-56 行(WithPrevious)和第 58-69 行(WithCurrent),方法入口都先判断字段是否已赋值——已赋值则跳过查询。这确保了业务层可以安全地多次调用,不会因为重复查询导致数据不一致或性能浪费。

Q7:hostAuditLog 和 hostModuleLog 有什么区别?分别在哪里用?

一句话结论:hostAuditLog 处理主机属性的 CRUD 审计(Create/Update/Delete),hostModuleLog 处理主机拓扑转移审计(assign/unassign/transfer)。src/common/auditlog/host.go 第 29-97 行,hostAuditLogGenerateAuditLog() 遍历主机列表,用 BasicContent 记录属性变化。在 src/common/auditlog/hostmodule.go 第 28-169 行,hostModuleLogSaveAudit() 处理主机在业务间的归属变化和模块间的拓扑转移,用 HostTransferOpDetail 记录完整的拓扑快照。

Q8:GenerateAuditLog 里 bizID==0 时反查业务 ID 的逻辑是怎么工作的?

一句话结论:当主机不在任何业务中(bizID=0,如刚录入资源池的主机),GenerateAuditLog 调用 getBizIDByHostID 从主机记录里反查所属业务。src/common/auditlog/host.go 第 84-94 行,当传入的 bizID == 0 时,遍历 hostIDs 列表调用 getBizIDByHostID() 反查,然后把结果回填到每条 AuditLog.BusinessID。这一步确保审计日志始终有正确的业务归属,否则按业务过滤审计记录会漏掉资源池主机的变更。

Q9:SaveAuditLog 写入失败会阻塞业务主操作吗?

一句话结论:不会。SaveAuditLog 是旁路调用,不在业务主事务中,失败只记录 error 日志。src/common/auditlog/hostmodule.go 第 954-958 行,SaveAudit 返回的错误只被 blog.Errorf 记录,业务层可以选择忽略。这是符合"审计是旁路能力"的设计原则——审计失败不影响业务数据一致性。

Q10:谁负责调用 auditlog 包的方法?业务层怎么接入?

一句话结论:业务层(scene_server)在执行变更操作前后,显式调用 auditlog 包的 WithPrevious/GenerateAuditLog/SaveAudit 方法。src/common/auditlog/hostmodule.go 第 946-953 行,auditlog.NewHostModuleLog(clientSet, hostIDArr) 构造审计记录器,业务层传入 CoreService clientSet(用于查询数据)和主机 ID 列表。这种接入方式要求业务逻辑和审计逻辑显式分离,通过 rest.Kit 隐式传递用户身份。

Q11:Action 的三元判断(assign_host / unassign_host / transfer_host_module)具体怎么判断?

一句话结论:比较转移前后的业务 ID 是否变化,以及变化方向是否是"资源池 ↔ 业务"。src/common/auditlog/hostmodule.go 第 133-144 行:若 preBizID != curBizID && preBizID == defaultBizID,说明主机从资源池进入业务,Action = assign_host;若 preBizID != curBizID && curBizID == defaultBizID,说明主机从业务退回资源池,Action = unassign_host;其余情况(同一业务内跨 Set/Module 转移),Action = transfer_host_module

Q12:defaultBizID 是怎么得到的?为什么用它判断资源池?

一句话结论:通过查询 cc_ApplicationBase 表中 bk_default=1 的记录得到,CMDB 里这个默认业务就是资源池业务。src/common/auditlog/audit.go 第 115-149 行,getDefaultAppID() 查询条件为 mapstr.MapStr{common.BKDefaultField: common.DefaultAppFlag}。CMDB 设计中,资源池不是独立数据库,而是一个特殊标记的业务(bk_default=1),主机在该业务下意味着"未分配"。

Q13:transfer_host_module 和 assign_host 在 Action 判断上会不会冲突?

一句话结论:不会。两个条件互斥,因为 preBizID==curBizID(业务不变)是 transfer_host_module 的必要条件,而 assign_host 要求 preBizID==defaultBizID(资源池)。资源池业务 ID(defaultBizID)和普通业务 ID 完全不同,所以三元判断的三个分支(资源池→业务 / 业务→资源池 / 同业务内)逻辑上完全互斥,不会出现歧义。

Q14:SearchAuditLog 支持哪些查询条件?

一句话结论:支持按 user / bk_biz_id / resource_type / action / operation_time / resource_name / resource_id / obj_id 组合查询。src/common/metadata/audit.go 第 160-171 行的 InstAuditCondition 中定义了查询条件结构,对应 src/source_controller/coreservice/service/service_initfunc.go 第 348 行注册的 /read/auditlog 接口。这些字段全部有索引(auditlog.go L25),支持高并发组合查询。

Q15:能不能同时按用户和业务来过滤审计日志?

一句话结论:可以。InstAuditCondition 支持多字段组合,MongoDB 会用复合索引加速。src/common/metadata/audit.go 第 160-171 行定义的 InstAuditCondition 中,UserBizID 是独立字段,查询时同时设置即可。例如查询用户 zhangsan 在游戏业务下的所有操作:user="zhangsan" + bk_biz_id=2

Q16:OperateFrom 有哪些枚举值?如何区分人工操作和系统同步?

一句话结论:有 5 种来源,其中 user 是人工操作,其余为系统自动同步。src/common/metadata/audit.go 第 772-786 行:user(Web 用户操作)、cc_system(系统升级)、data_collection(数据采集器)、synchronizer(跨 CMDB 同步)、cloud_sync(云平台同步)。SRE 可以通过 operate_from 字段过滤出纯人工操作,满足合规审计"只统计人工作为"的需求。

Q17:删除主机的审计日志为什么 CurData 是空的?

一句话结论:因为 CurData 是在操作执行后查询的,此时主机已从数据库中消失。src/common/auditlog/host.go 第 29-97 行,GenerateAuditLog() 使用操作前的数据(由调用方在删除前传入)作为 PreData,而 CurData 字段保持 Go 零值(nil / 空 map)。这是符合审计逻辑的——删除操作的本质就是"记录删除前有什么",不需要记录删除后是什么(自然为空)。

Q18:审计日志可以修改或删除吗?

一句话结论:不可以。auditlog 模块只提供写入和查询接口,没有任何更新或删除接口。src/source_controller/coreservice/service/service_initfunc.go 第 346-349 行,audit 模块只注册了 CreateAuditLogSearchAuditLog 两个接口,UpdateAuditLogDeleteAuditLog 完全不存在。这是合规审计的强制要求——审计日志是不可篡改的法律记录。

Q19:WithPrevious 和 WithCurrent 的调用顺序有没有严格要求?

一句话结论:WithPrevious 必须在业务操作前调用,WithCurrent 必须在业务操作后调用,顺序不能颠倒。以主机转移为例:调用方先调 WithPrevious(kit) 捕获转移前的拓扑(查询 cc_ModuleHostConfig),然后执行主机模块关系变更(MongoDB UPDATE),最后调 WithCurrent(kit) 捕获转移后的拓扑。如果顺序颠倒,WithPrevious 会拿到变更后的状态,WithCurrent 拿到变更前的状态,导致 PreData 和 CurData 颠倒。

Q20:getHostTransferDataMap 为什么要先按 SetID 分组再构建 Topo,而不是直接按 Module 构建?

一句话结论:因为一台主机在同一 Set 下可能有多个 Module(属于多个服务),必须保留 Set 层级才能完整还原拓扑结构。src/common/auditlog/hostmodule.go 第 239-274 行,先用 val.SetID 分组(hostRelationMap[hostID][setID] = []Module),再把所有 Module 聚合到对应的 Topo 中,最后组装成 HostBizTopo。这样 PreData 和 CurData 才能完整还原"主机在哪些 Set 的哪些 Module 中"的信息,满足 SRE 审计"从哪到哪"的核心诉求。

全篇总结:auditlog 模块设计思想

  • 一个核心接口:DetailFactory,ResourceType 到操作详情的类型派发表(resTypeOpDetailTypeMap)
  • 两条审计链路:hostAuditLog.GenerateAuditLog(属性 CRUD,BasicContent) + hostModuleLog.SaveAudit(拓扑转移,HostTransferOpDetail)
  • 三个快照时刻:WithPrevious(操作前拓扑) → 执行业务变更 → WithCurrent(操作后拓扑)
  • 三个 Action 判断分支:preBizID==defaultBizID(assign) / curBizID==defaultBizID(unassign) / 其余(transfer_module)
  • 一个不可变原则:cc_AuditLog 只有 Create/Search 接口,无 Update/Delete

8. Roadmap

审计日志模块的底层依赖是 MongoDB 的 Change Stream 能力——第 5 篇《拓扑缓存加速》涉及的 source_controller 缓存服务,正是通过 Change Stream 监听了 cc_AuditLog 的变更事件。后续将依次介绍:

  • #07 批量操作与异步任务:host_server 如何用 RabbitMQ 队列实现主机批量转移的异步任务,auditlog 如何与任务系统联动
  • #08 主机属性自动应用:模块绑定属性模板后,新进入模块的主机如何自动继承属性,auditlog 记录的是哪一步的变更
  • #09 服务实例管理:进程配置模板(ServiceTemplate)如何生成服务实例,auditlog 中 ServiceInstanceRes 类型日志的结构
  • #10 k8s 资源纳管:KubeCluster/KubeNode/KubePod 等 Kube 类型审计日志(AuditType="kube")与通用 ResourceType 的区别

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】· #06 操作审计日志:谁在什么时候做了什么 · 版本:v3.14.6 LTS

posted @ 2026-07-04 20:33  左扬  阅读(27)  评论(0)    收藏  举报