package oci import ( "context" "encoding/json" "fmt" "net" "sort" "strings" "time" "github.com/oracle/oci-go-sdk/v65/audit" "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/loggingsearch" ) // maxAuditPages 限制单次查询的翻页数:每页最多 auditSearchPageLimit 条, // 到限即截断(窗口式回传 Truncated,批式留游标),由调用方续查。 const maxAuditPages = 10 // auditBatchTimeBudget 是批式查询的单批耗时预算:全文检索命中稀疏时 // 大窗扫描单页可达十余秒,超时即带游标返回,把长回溯拆成多个有界请求, // 前端按已回溯位置展示进度并自动续查。 const auditBatchTimeBudget = 20 * time.Second // auditSearchPageLimit 是 SearchLogs 单页条数(API 上限 1000):批式查询 // 攒满目标条数(~100)即携整页返回,页取 200 兼顾单页凑满一批与响应体量。 const auditSearchPageLimit = 200 // AuditEvent 是审计事件的列表精简视图;EventId 为 CloudEvents 全局唯一 id, // 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存, // 列表响应不再携带(详情接口按 eventId 取回)。 type AuditEvent struct { EventId string `json:"eventId"` EventTime *time.Time `json:"eventTime"` EventName string `json:"eventName"` Source string `json:"source"` ResourceName string `json:"resourceName"` CompartmentName string `json:"compartmentName"` PrincipalName string `json:"principalName"` IPAddress string `json:"ipAddress"` Status string `json:"status"` RequestAction string `json:"requestAction"` RequestPath string `json:"requestPath"` Raw json.RawMessage `json:"raw,omitempty"` } // AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断, // 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。 type AuditEventsResult struct { Items []AuditEvent `json:"items"` Truncated bool `json:"truncated"` NextPage string `json:"nextPage,omitempty"` } // auditSearchClient 构造区域化的日志搜索客户端。审计数据源为 Logging Search // 的 _Audit 日志:Audit API 无排序参数、窗口内固定按处理时间正序,首批只能 // 拿到窗口内最旧的一段;Logging Search 支持 datetime 倒序,才能从最新回溯。 func (c *RealClient) auditSearchClient(cred Credentials, region string) (loggingsearch.LogSearchClient, error) { sc, err := loggingsearch.NewLogSearchClientWithConfigurationProvider(provider(cred)) if err != nil { return sc, fmt.Errorf("new logging search client: %w", err) } applyProxy(&sc.BaseClient, cred) if region != "" { sc.SetRegion(normalizeRegion(region)) } return sc, nil } // auditSearchQuery 组装租户根 compartment 审计日志的倒序检索语句; // SummarizeMetricsData 遥测噪声占比高,服务端先滤一道减少无效翻页。 // q 非空时追加 logContent 全文包含匹配——它扫的是整条 JSON 的所有值,只当 // 粗筛;可见字段的精筛由 filterAuditTerm 兜底,避免隐藏元数据误命中。 func auditSearchQuery(tenancyOCID, q string) string { query := fmt.Sprintf("search %q | where data.eventName != 'SummarizeMetricsData'", tenancyOCID+"/_Audit") if term := SanitizeAuditTerm(q); term != "" { query += fmt.Sprintf(" and logContent = '*%s*'", term) } return query + " | sort by datetime desc" } // auditTermMaxLen 限制检索关键字长度,防止游标与查询语句被撑爆。 const auditTermMaxLen = 100 // SanitizeAuditTerm 归一检索关键字:去除引号/反斜杠/控制字符防语句注入 // (查询目标已锁定本租户 _Audit 流,注入最坏只是语法错),截断超长输入; // 保留 * 供用户通配。返回空串表示不追加过滤子句。 // service 构造首查游标与本包组装语句共用,对篡改游标二次消毒兜底。 func SanitizeAuditTerm(q string) string { out := make([]rune, 0, len(q)) for _, r := range q { if r == '\'' || r == '"' || r == '\\' || r < 0x20 { continue } out = append(out, r) if len(out) >= auditTermMaxLen { break } } return strings.TrimSpace(string(out)) } // ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的 // 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。 // page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。 // Search 配额为零的租户自动回退 Audit API 重查同一窗口(page 跨通道失效,重头吃窗)。 func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) { f := c.newAuditFetchers(cred, region) res, err := listAuditWindow(ctx, f.search, AuditCursor{Start: start, End: end, Page: page}) if err != nil && isSearchQuotaZero(err) { res, err = listAuditWindow(ctx, f.audit, AuditCursor{Start: start, End: end}) } return res, err } // listAuditWindow 用给定取页函数吃一个固定时间窗,最多 maxAuditPages 页; // 页预算耗尽即截断,NextPage 携带未消费的窗内游标。 func listAuditWindow(ctx context.Context, fetch auditPageFetch, cur AuditCursor) (AuditEventsResult, error) { result := AuditEventsResult{Items: []AuditEvent{}} for i := 0; i < maxAuditPages; i++ { items, next, err := fetch(ctx, cur) if err != nil { return AuditEventsResult{}, err } result.Items = appendKeptAuditEvents(result.Items, items) if next == "" { sortAuditEvents(result.Items) return result, nil } cur.Page = next } result.Truncated = true result.NextPage = cur.Page sortAuditEvents(result.Items) return result, nil } // auditPageFetch 拉取游标位置的一页已映射事件,返回窗内下一页游标。 type auditPageFetch func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) // auditFetchers 汇集两条数据通道:search 为 Logging Search 倒序主路, // audit 为 Search 配额为零租户的 Audit API 回退路。 type auditFetchers struct { search auditPageFetch audit auditPageFetch } // newAuditFetchers 构造两条通道的取页闭包;客户端惰性初始化, // 各模式的续查不会白建用不到的客户端。 func (c *RealClient) newAuditFetchers(cred Credentials, region string) auditFetchers { return auditFetchers{search: c.searchFetcher(cred, region), audit: c.auditAPIFetcher(cred, region)} } // searchFetcher 构造 Logging Search 通道的取页闭包。 func (c *RealClient) searchFetcher(cred Credentials, region string) auditPageFetch { var sc *loggingsearch.LogSearchClient return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) { if sc == nil { cli, err := c.auditSearchClient(cred, region) if err != nil { return nil, "", err } sc = &cli } return searchAuditPage(ctx, *sc, cred.TenancyOCID, cur) } } // auditAPIFetcher 构造 Audit API 回退通道的取页闭包。 func (c *RealClient) auditAPIFetcher(cred Credentials, region string) auditPageFetch { var ac *audit.AuditClient return func(ctx context.Context, cur AuditCursor) ([]AuditEvent, string, error) { if ac == nil { cli, err := c.auditClient(cred, region) if err != nil { return nil, "", err } ac = &cli } return listAuditPage(ctx, *ac, cred.TenancyOCID, cur) } } // isSearchQuotaZero 识别「租户 Logging Search 配额为零」的失败:此类租户该 // 服务永久不可用(maxQueriesPerMinute/maxConcurrentQueries 均为 0),应回退 // Audit API;普通限流(配额非零)不回退,避免数据通道来回切换。 func isSearchQuotaZero(err error) bool { if err == nil { return false } msg := strings.ToLower(strings.ReplaceAll(err.Error(), " ", "")) return strings.Contains(msg, "ratelimitexceeded") && strings.Contains(msg, "maxqueriesperminute:0,") } // appendKeptAuditEvents 过滤噪声后追加一页已映射事件;窗口式与批式查询共用。 func appendKeptAuditEvents(dst []AuditEvent, items []AuditEvent) []AuditEvent { for _, ev := range items { if keepAuditEvent(ev) { dst = append(dst, ev) } } return dst } // ---- 批式懒加载查询:分窗回溯 + 游标续查 ---- // 批式查询参数:单批翻页预算沿用 maxAuditPages;首窗 24h,连续空窗倍增 // 加速跨越闲置期,上限 14 天(Logging Search 单次查询时间窗硬限); // 回溯下限为审计事件保留期 365 天。 const ( auditWindowHours = 24 auditWindowMaxHours = 336 auditRetentionDays = 365 ) // auditModeFallback 标记游标处于 Audit API 回退模式:部分租户的 // Logging Search 服务配额为零(maxQueriesPerMinute: 0),永久不可用。 const auditModeFallback = "a" // auditFallbackWindowHours 是回退模式的基准窗宽:Audit API 窗口内固定按 // 处理时间正序且无排序参数,只能小窗回溯 + 前端全局重排保住从新到旧的体验。 const auditFallbackWindowHours = 1 // AuditCursor 是批式查询的续查位置:当前时间窗、窗内翻页游标、当前窗宽 // (小时,空窗倍增的记忆)、检索关键字(随游标续查,保证跨批过滤一致) // 与数据通道模式(空为 Search 主路,"a" 为 Audit API 回退,续查沿用不再试错)。 // 序列化为不透明 cursor 由 service 层负责。 type AuditCursor struct { Start time.Time `json:"s"` End time.Time `json:"e"` Page string `json:"p,omitempty"` WindowHours int `json:"w"` Q string `json:"q,omitempty"` M string `json:"m,omitempty"` } // toFallback 把游标切到 Audit API 回退模式:Search 页游标跨通道失效须清空; // 首窗收窄到基准窗宽,避免大窗正序分页又回到「首批全是窗口内最旧事件」的老问题。 func (cur AuditCursor) toFallback() AuditCursor { cur.M = auditModeFallback cur.Page = "" cur.WindowHours = auditFallbackWindowHours if cur.End.Sub(cur.Start) > auditFallbackWindowHours*time.Hour { cur.Start = cur.End.Add(-auditFallbackWindowHours * time.Hour) } return cur } // NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。 func NewAuditCursor(now time.Time) AuditCursor { end := now.UTC().Truncate(time.Minute) return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours} } // advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增, // 否则重置为该模式基准窗宽。done 为 true 表示已越过保留期尽头。 func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) { base := auditWindowHours if cur.M == auditModeFallback { base = auditFallbackWindowHours } w := cur.WindowHours if w <= 0 { w = base } if empty { if w *= 2; w > auditWindowMaxHours { w = auditWindowMaxHours } } else { w = base } end := cur.Start if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) { return cur, true } return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w, Q: cur.Q, M: cur.M}, false } // AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true // 表示已回溯到保留期尽头,无更早数据。 type AuditBatchResult struct { Items []AuditEvent Cursor *AuditCursor Exhausted bool } // ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条 // 保留事件;单批受页预算与时间预算双重约束,不足额也返回,由前端按需续查。 // 倒序返回下,窗口不重叠 + 窗内游标续翻保证跨批不重不漏。 func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) { return listAuditBatch(ctx, c.newAuditFetchers(cred, region), cur, limit) } // listAuditBatch 是批式回溯的通道无关内核,取页函数注入便于测试。 func listAuditBatch(ctx context.Context, f auditFetchers, cur AuditCursor, limit int) (AuditBatchResult, error) { res := AuditBatchResult{Items: []AuditEvent{}} windowHasKept := false deadline := time.Now().Add(auditBatchTimeBudget) for budget := maxAuditPages; budget > 0 && len(res.Items) < limit && time.Now().Before(deadline); budget-- { items, next, nextCur, err := fetchAuditPage(ctx, f, cur) if err != nil { return AuditBatchResult{}, err } cur = nextCur before := len(res.Items) res.Items = appendKeptAuditEvents(res.Items, filterAuditTerm(items, cur)) windowHasKept = windowHasKept || len(res.Items) > before if next != "" { cur.Page = next continue } adv, done := cur.advance(time.Now(), !windowHasKept) if done { res.Exhausted = true sortAuditEvents(res.Items) return res, nil } cur, windowHasKept = adv, false } sortAuditEvents(res.Items) res.Cursor = &cur return res, nil } // fetchAuditPage 按游标模式取一页;Search 主路报「配额为零」时切到回退游标 // 并立即用 Audit API 重试,后续批次凭游标模式直达回退通道不再试错。 func fetchAuditPage(ctx context.Context, f auditFetchers, cur AuditCursor) ([]AuditEvent, string, AuditCursor, error) { if cur.M == auditModeFallback { items, next, err := f.audit(ctx, cur) return items, next, cur, err } items, next, err := f.search(ctx, cur) if err != nil && isSearchQuotaZero(err) { cur = cur.toFallback() items, next, err = f.audit(ctx, cur) } return items, next, cur, err } // filterAuditTerm 关键字精筛:只认列表可见字段(matchesAuditTerm),两条通道 // 语义一致。Search 主路的 logContent 全文条件会命中隐藏认证元数据(如 // opc-principal 头里的 ttype:login),只作粗筛减少翻页,不作为最终判定。 func filterAuditTerm(items []AuditEvent, cur AuditCursor) []AuditEvent { if cur.Q == "" { return items } out := items[:0] for _, ev := range items { if matchesAuditTerm(ev, cur.Q) { out = append(out, ev) } } return out } // matchesAuditTerm 判断事件是否命中关键字:不区分大小写的包含匹配, // * 作为通配分段、各段都出现即命中,近似 Search 通道的 logContent 语义。 func matchesAuditTerm(ev AuditEvent, q string) bool { hay := strings.ToLower(strings.Join([]string{ ev.EventName, ev.Source, ev.ResourceName, ev.CompartmentName, ev.PrincipalName, ev.IPAddress, ev.Status, ev.RequestAction, ev.RequestPath, }, "\n")) for _, part := range strings.Split(strings.ToLower(q), "*") { if part != "" && !strings.Contains(hay, part) { return false } } return true } // auditClient 构造区域化的 Audit API 客户端(回退通道)。 func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditClient, error) { ac, err := audit.NewAuditClientWithConfigurationProvider(provider(cred)) if err != nil { return ac, fmt.Errorf("new audit client: %w", err) } applyProxy(&ac.BaseClient, cred) if region != "" { ac.SetRegion(normalizeRegion(region)) } return ac, nil } // listAuditPage 拉取窗口内一页 Audit API 原始事件并压平;该 API 窗口内固定 // 正序且只接受分钟粒度(起止秒与毫秒必须为 0)。Raw 为 SDK 事件原文, // 与 Search 通道的 logContent 形态不同,详情弹窗均按任意 JSON 渲染。 func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) { req := audit.ListEventsRequest{ CompartmentId: &tenancyOCID, StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)}, EndTime: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)}, } if cur.Page != "" { req.Page = &cur.Page } resp, err := ac.ListEvents(ctx, req) if err != nil { return nil, "", fmt.Errorf("list audit events: %w", err) } items := make([]AuditEvent, 0, len(resp.Items)) for _, ev := range resp.Items { out := toAuditEvent(ev) if raw, mErr := json.Marshal(ev); mErr == nil { out.Raw = raw } items = append(items, out) } return items, deref(resp.OpcNextPage), nil } // toAuditEvent 把 Audit SDK 事件压平为列表 DTO;SDK 字段全为指针,逐层判 nil。 func toAuditEvent(ev audit.AuditEvent) AuditEvent { out := AuditEvent{EventId: deref(ev.EventId), Source: deref(ev.Source)} if ev.EventTime != nil { out.EventTime = &ev.EventTime.Time } if ev.Data == nil { return out } out.EventName = deref(ev.Data.EventName) out.ResourceName = deref(ev.Data.ResourceName) out.CompartmentName = deref(ev.Data.CompartmentName) if id := ev.Data.Identity; id != nil { out.PrincipalName = deref(id.PrincipalName) out.IPAddress = deref(id.IpAddress) } if req := ev.Data.Request; req != nil { out.RequestAction = deref(req.Action) out.RequestPath = deref(req.Path) } if resp := ev.Data.Response; resp != nil { out.Status = deref(resp.Status) } return out } // searchAuditPage 拉取游标窗口内按 datetime 倒序的一页审计事件(已映射未过滤)。 func searchAuditPage(ctx context.Context, sc loggingsearch.LogSearchClient, tenancyOCID string, cur AuditCursor) ([]AuditEvent, string, error) { req := loggingsearch.SearchLogsRequest{ SearchLogsDetails: loggingsearch.SearchLogsDetails{ TimeStart: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)}, TimeEnd: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)}, SearchQuery: common.String(auditSearchQuery(tenancyOCID, cur.Q)), }, Limit: common.Int(auditSearchPageLimit), } if cur.Page != "" { req.Page = &cur.Page } resp, err := sc.SearchLogs(ctx, req) if err != nil { return nil, "", fmt.Errorf("search audit logs: %w", err) } items := make([]AuditEvent, 0, len(resp.Results)) for _, r := range resp.Results { if ev, ok := toSearchAuditEvent(r); ok { items = append(items, ev) } } return items, deref(resp.OpcNextPage), nil } // auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。 var auditInternalCIDRs = func() []*net.IPNet { out := make([]*net.IPNet, 0, 4) for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10"} { _, block, _ := net.ParseCIDR(cidr) out = append(out, block) } return out }() // keepAuditEvent 保留有展示价值的事件:SummarizeMetricsData 已在检索语句里 // 先滤(此处兜底),内网地址发起的服务互调用 CIDR 判断(查询语言不便表达); // 无 IP 的事件(控制面内部)保留。 func keepAuditEvent(ev AuditEvent) bool { if ev.EventName == "SummarizeMetricsData" { return false } if ev.IPAddress == "" { return true } ip := net.ParseIP(ev.IPAddress) if ip == nil { return true } for _, block := range auditInternalCIDRs { if block.Contains(ip) { return false } } return true } // searchAuditContent 是 _Audit 日志 logContent 的字段投影,只取列表展示所需; // identity / request / response 可能为 null,零值即缺省。 type searchAuditContent struct { ID string `json:"id"` Time *time.Time `json:"time"` Source string `json:"source"` Data struct { EventName string `json:"eventName"` ResourceName string `json:"resourceName"` CompartmentName string `json:"compartmentName"` Identity struct { PrincipalName string `json:"principalName"` IPAddress string `json:"ipAddress"` } `json:"identity"` Request struct { Action string `json:"action"` Path string `json:"path"` } `json:"request"` Response struct { Status string `json:"status"` } `json:"response"` } `json:"data"` } // toSearchAuditEvent 把日志搜索结果压平为列表 DTO;Raw 即 logContent 原文。 // 结构不符的条目丢弃(返回 false),不因单条脏数据整页失败。 func toSearchAuditEvent(r loggingsearch.SearchResult) (AuditEvent, bool) { if r.Data == nil { return AuditEvent{}, false } b, err := json.Marshal(r.Data) if err != nil { return AuditEvent{}, false } var hit struct { LogContent json.RawMessage `json:"logContent"` } if err := json.Unmarshal(b, &hit); err != nil || len(hit.LogContent) == 0 { return AuditEvent{}, false } var content searchAuditContent if err := json.Unmarshal(hit.LogContent, &content); err != nil { return AuditEvent{}, false } ev := searchContentToEvent(content) ev.Raw = hit.LogContent return ev, true } // searchContentToEvent 把投影字段填入列表 DTO;Raw 由调用方设置。 func searchContentToEvent(c searchAuditContent) AuditEvent { return AuditEvent{ EventId: c.ID, EventTime: c.Time, EventName: c.Data.EventName, Source: c.Source, ResourceName: c.Data.ResourceName, CompartmentName: c.Data.CompartmentName, PrincipalName: c.Data.Identity.PrincipalName, IPAddress: c.Data.Identity.IPAddress, Status: c.Data.Response.Status, RequestAction: c.Data.Request.Action, RequestPath: c.Data.Request.Path, } } // sortAuditEvents 按发生时间倒序排列;服务端返回顺序不保证,nil 时间排最后。 func sortAuditEvents(items []AuditEvent) { sort.SliceStable(items, func(i, j int) bool { ti, tj := items[i].EventTime, items[j].EventTime switch { case ti == nil: return false case tj == nil: return true default: return ti.After(*tj) } }) }