diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index ec00321..9a1396d 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -20,6 +20,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE | [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 | | [Testing](./testing.md) | table-driven 测试要求 | 已填 | | [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 待填 | +| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 | | [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 | --- diff --git a/.trellis/spec/backend/oci-audit.md b/.trellis/spec/backend/oci-audit.md new file mode 100644 index 0000000..7dec824 --- /dev/null +++ b/.trellis/spec/backend/oci-audit.md @@ -0,0 +1,19 @@ +# OCI 审计事件集成约定 + +> 2026-07 审计日志重构(数据源切换 + 检索 + 配额回退)沉淀;实现见 `internal/oci/audit.go`。 + +## 数据源:双通道,Search 主路 + Audit API 回退 + +- **Audit API(`audit.ListEvents`)无排序参数,窗口内固定按处理时间正序分页**。任何"从最新往更早"的列表需求禁止直接用它凑批——首批会拿到窗口内最旧的一段(2026-07-16 曾以此形态上线出 bug)。 +- 倒序列表一律走 **Logging Search**(`loggingsearch.SearchLogs`,`search "/_Audit" | ... | sort by datetime desc`)。硬约束:单次查询时间窗 ≤ 14 天、limit ≤ 1000、时间过滤基于**处理时间**而非发生时间。 +- **部分免费租户 Logging Search 服务配额为零**(报错含 `Rate limit exceeded` + `maxQueriesPerMinute: 0`,SDK 解析该错误体还会失败),属永久不可用,须自动回退 Audit API(小窗正序 + 前端全局重排);普通限流(配额非零)不回退。游标携带通道模式,续查不再试错。 + +## 检索语义 + +- `logContent = '*词*'` 是对整条日志 JSON **所有字段值**的包含匹配,会命中隐藏认证元数据(如 `opc-principal` 头里的 `ttype: login`),只可作服务端粗筛;**用户可见语义必须再做客户端精筛**(只匹配列表可见字段,不区分大小写,`*` 通配分段)。 +- 用户输入进检索语句前必须消毒(去引号/反斜杠/控制字符、截断),见 `SanitizeAuditTerm`。 + +## 批式回溯的预算纪律 + +- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。 +- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。 diff --git a/docs/docs.go b/docs/docs.go index de90216..65feb5d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1572,6 +1572,12 @@ const docTemplate = `{ "description": "单批目标条数,缺省 100,上限 200", "name": "limit", "in": "query" + }, + { + "type": "string", + "description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)", + "name": "q", + "in": "query" } ], "responses": { @@ -9439,6 +9445,9 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/oci-portal_internal_oci.AuditEvent" } + }, + "scannedThrough": { + "type": "string" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 340e725..3245420 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1565,6 +1565,12 @@ "description": "单批目标条数,缺省 100,上限 200", "name": "limit", "in": "query" + }, + { + "type": "string", + "description": "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)", + "name": "q", + "in": "query" } ], "responses": { @@ -9432,6 +9438,9 @@ "items": { "$ref": "#/definitions/oci-portal_internal_oci.AuditEvent" } + }, + "scannedThrough": { + "type": "string" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ac49a10..385a7ea 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2271,6 +2271,8 @@ definitions: items: $ref: '#/definitions/oci-portal_internal_oci.AuditEvent' type: array + scannedThrough: + type: string type: object oci-portal_internal_service.Changes: additionalProperties: @@ -3637,6 +3639,10 @@ paths: in: query name: limit type: integer + - description: 检索关键字(服务端全文匹配,支持 * 通配;仅首查生效) + in: query + name: q + type: string responses: "200": description: OK diff --git a/internal/api/tenant.go b/internal/api/tenant.go index 9d2c9f7..78b89d9 100644 --- a/internal/api/tenant.go +++ b/internal/api/tenant.go @@ -68,13 +68,15 @@ func (h *ociConfigHandler) costs(c *gin.Context) { // ---- 租户审计日志 ---- // getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查, -// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。 +// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200); +// q 为服务端全文检索关键字,仅首查生效,续查沿用游标内嵌关键字。 // // @Summary 批式懒加载查询租户 OCI 审计事件 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param cursor query string false "续查游标(上次响应原样带回)" // @Param limit query int false "单批目标条数,缺省 100,上限 200" +// @Param q query string false "检索关键字(服务端全文匹配,支持 * 通配;仅首查生效)" // @Success 200 {object} service.AuditEventsView // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/audit-events [get] @@ -84,7 +86,7 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) { return } limit, _ := strconv.Atoi(c.Query("limit")) - q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit} + q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit, Q: c.Query("q")} result, err := h.svc.AuditEvents(c.Request.Context(), id, q) if errors.Is(err, service.ErrInvalidAuditCursor) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/oci/audit.go b/internal/oci/audit.go index 266f4bd..ba2fd5b 100644 --- a/internal/oci/audit.go +++ b/internal/oci/audit.go @@ -6,19 +6,29 @@ import ( "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 限制单次查询的翻页数:繁忙租户单日事件可上千, -// 到限即返回 Truncated=true,由调用方收窄时间窗。 -// 默认过滤(噪声事件/内网发起)后有效结果变少,页数放宽到 10 缓解截断。 +// 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 为 SDK 原始事件的 JSON 序列化,由 service 层剥离进缓存, +// 详情反查的键。Raw 为 _Audit 日志 logContent 原文,由 service 层剥离进缓存, // 列表响应不再携带(详情接口按 eventId 取回)。 type AuditEvent struct { EventId string `json:"eventId"` @@ -35,7 +45,7 @@ type AuditEvent struct { Raw json.RawMessage `json:"raw,omitempty"` } -// AuditEventsResult 是一次审计查询的结果;Truncated 表示翻页到限被截断, +// AuditEventsResult 是一次窗口式审计查询的结果;Truncated 表示翻页到限被截断, // 此时 NextPage 携带 opc-next-page 游标,同一时间窗回传可断点续翻。 type AuditEventsResult struct { Items []AuditEvent `json:"items"` @@ -43,6 +53,324 @@ type AuditEventsResult struct { 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 { @@ -55,153 +383,10 @@ func (c *RealClient) auditClient(cred Credentials, region string) (audit.AuditCl return ac, nil } -// ListAuditEvents 实现 Client:实时查询租户根 compartment 在 [start, end) 内的 -// 审计事件,最多翻 maxAuditPages 页,结果按发生时间倒序;纯读不落库。 -// page 非空时从该游标断点续翻(必须配同一时间窗);到限截断时回传 NextPage。 -func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error) { - ac, err := c.auditClient(cred, region) - if err != nil { - return AuditEventsResult{}, err - } - // Audit API 只接受分钟粒度:起止时间的秒与毫秒必须为 0 - req := audit.ListEventsRequest{ - CompartmentId: &cred.TenancyOCID, - StartTime: &common.SDKTime{Time: start.UTC().Truncate(time.Minute)}, - EndTime: &common.SDKTime{Time: end.UTC().Truncate(time.Minute)}, - } - if page != "" { - req.Page = &page - } - result := AuditEventsResult{Items: []AuditEvent{}} - for i := 0; i < maxAuditPages; i++ { - resp, err := ac.ListEvents(ctx, req) - if err != nil { - return AuditEventsResult{}, fmt.Errorf("list audit events: %w", err) - } - appendAuditEvents(&result, resp.Items) - if resp.OpcNextPage == nil { - sortAuditEvents(result.Items) - return result, nil - } - req.Page = resp.OpcNextPage - } - result.Truncated = true - result.NextPage = deref(req.Page) - sortAuditEvents(result.Items) - return result, nil -} - -// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。 -func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) { - result.Items = appendKeptAuditEvents(result.Items, items) -} - -// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。 -func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent { - for _, ev := range items { - out := toAuditEvent(ev) - if !keepAuditEvent(out) { - continue - } - if raw, mErr := json.Marshal(ev); mErr == nil { - out.Raw = raw - } - dst = append(dst, out) - } - return dst -} - -// ---- 批式懒加载查询:分窗回溯 + 游标续查 ---- - -// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h, -// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。 -const ( - auditWindowHours = 24 - auditWindowMaxHours = 720 - auditRetentionDays = 365 -) - -// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标 -// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。 -type AuditCursor struct { - Start time.Time `json:"s"` - End time.Time `json:"e"` - Page string `json:"p,omitempty"` - WindowHours int `json:"w"` -} - -// 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 表示刚结束的窗无保留事件,窗宽倍增, -// 否则重置 24h。done 为 true 表示已越过保留期尽头。 -func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) { - w := cur.WindowHours - if w <= 0 { - w = auditWindowHours - } - if empty { - if w *= 2; w > auditWindowMaxHours { - w = auditWindowMaxHours - } - } else { - w = auditWindowHours - } - 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}, false -} - -// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true -// 表示已回溯到保留期尽头,无更早数据。 -type AuditBatchResult struct { - Items []AuditEvent - Cursor *AuditCursor - Exhausted bool -} - -// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条 -// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回, -// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。 -func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) { - ac, err := c.auditClient(cred, region) - if err != nil { - return AuditBatchResult{}, err - } - res := AuditBatchResult{Items: []AuditEvent{}} - windowHasKept := false - for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- { - items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur) - if err != nil { - return AuditBatchResult{}, err - } - before := len(res.Items) - res.Items = appendKeptAuditEvents(res.Items, items) - windowHasKept = windowHasKept || len(res.Items) > before - if next != "" { - cur.Page = next - continue - } - nextCur, done := cur.advance(time.Now(), !windowHasKept) - if done { - res.Exhausted = true - sortAuditEvents(res.Items) - return res, nil - } - cur, windowHasKept = nextCur, false - } - sortAuditEvents(res.Items) - res.Cursor = &cur - return res, nil -} - -// listAuditPage 拉取当前游标位置的一页原始事件。 -func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) { +// 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)}, @@ -214,42 +399,18 @@ func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string if err != nil { return nil, "", fmt.Errorf("list audit events: %w", err) } - return resp.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 保留有展示价值的事件:Audit API 无服务端过滤参数(仅时间窗), -// 在翻页循环内排除高频遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调; -// 无 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 + 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 true + return items, deref(resp.OpcNextPage), nil } -// toAuditEvent 把 SDK 审计事件压平为列表 DTO;SDK 字段全为指针,逐层判 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 { @@ -275,6 +436,130 @@ func toAuditEvent(ev audit.AuditEvent) AuditEvent { 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 { diff --git a/internal/oci/audit_test.go b/internal/oci/audit_test.go index 3b23a93..b563811 100644 --- a/internal/oci/audit_test.go +++ b/internal/oci/audit_test.go @@ -1,14 +1,262 @@ package oci import ( + "context" + "encoding/json" + "errors" + "fmt" "reflect" + "strings" "testing" "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" ) +// quotaZeroErr 复刻 Search 配额为零租户的真实报错(SDK 解析错误体失败后带原文)。 +var quotaZeroErr = errors.New(`search audit logs: Failed to parse json from response body due to: json: cannot unmarshal number into Go struct field servicefailure.code of type string. With response body { "code" : 500, "message" : "Rate limit exceeded for ocid: ocid1.tenancy..x, maxQueriesPerMinute: 0, maxConcurrentQueries: 0" }.`) + +func TestIsSearchQuotaZero(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"配额为零真实报错", quotaZeroErr, true}, + {"普通限流不回退", errors.New(`Rate limit exceeded for ocid: x, maxQueriesPerMinute: 60, maxConcurrentQueries: 2`), false}, + {"其他错误", errors.New("service unavailable"), false}, + {"nil", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isSearchQuotaZero(tc.err); got != tc.want { + t.Fatalf("isSearchQuotaZero() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestListAuditBatchFallback(t *testing.T) { + et := time.Now().UTC().Add(-10 * time.Minute) + searchCalls, auditCalls := 0, 0 + f := auditFetchers{ + search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + searchCalls++ + return nil, "", quotaZeroErr + }, + audit: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) { + auditCalls++ + if cur.M != auditModeFallback || cur.Page != "" { + t.Fatalf("回退通道应携带模式标记且清空页游标, got %+v", cur) + } + ev := AuditEvent{EventId: fmt.Sprint(auditCalls), EventName: "GetInstance", EventTime: &et} + return []AuditEvent{ev}, "", nil + }, + } + res, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 3) + if err != nil { + t.Fatalf("配额为零应回退成功, got %v", err) + } + if searchCalls != 1 { + t.Fatalf("Search 只应试错一次, got %d", searchCalls) + } + if len(res.Items) < 3 || auditCalls < 3 { + t.Fatalf("回退后应继续凑批, items=%d auditCalls=%d", len(res.Items), auditCalls) + } + if res.Cursor == nil || res.Cursor.M != auditModeFallback || res.Cursor.WindowHours != auditFallbackWindowHours { + t.Fatalf("续查游标应保持回退模式与基准窗宽, got %+v", res.Cursor) + } +} + +func TestListAuditBatchFallbackCursorSkipsSearch(t *testing.T) { + et := time.Now().UTC().Add(-10 * time.Minute) + f := auditFetchers{ + search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + t.Fatal("回退模式游标不应再调用 Search 通道") + return nil, "", nil + }, + audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + return []AuditEvent{{EventId: "e1", EventName: "GetVcn", EventTime: &et}}, "", nil + }, + } + cur := NewAuditCursor(time.Now()).toFallback() + if _, err := listAuditBatch(context.Background(), f, cur, 1); err != nil { + t.Fatalf("回退模式续查失败: %v", err) + } +} + +func TestListAuditBatchSearchErrorNoFallback(t *testing.T) { + f := auditFetchers{ + search: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + return nil, "", errors.New("search audit logs: timeout") + }, + audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + t.Fatal("普通错误不应触发回退") + return nil, "", nil + }, + } + if _, err := listAuditBatch(context.Background(), f, NewAuditCursor(time.Now()), 1); err == nil { + t.Fatal("普通错误应原样上抛") + } +} + +func TestFilterAuditTerm(t *testing.T) { + login := AuditEvent{EventId: "e1", EventName: "InteractiveLogin"} + noise := AuditEvent{EventId: "e2", EventName: "ListRecommendations"} + items := []AuditEvent{login, noise} + cases := []struct { + name string + cur AuditCursor + want int + }{ + {"无关键字原样放行", AuditCursor{}, 2}, + {"Search 主路也精筛可见字段", AuditCursor{Q: "login"}, 1}, + {"回退模式精筛", AuditCursor{Q: "login", M: auditModeFallback}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := filterAuditTerm(append([]AuditEvent{}, items...), tc.cur); len(got) != tc.want { + t.Fatalf("filterAuditTerm() 保留 %d 条, want %d", len(got), tc.want) + } + }) + } +} + +func TestListAuditBatchSearchTermPrecision(t *testing.T) { + et := time.Now().UTC().Add(-10 * time.Minute) + // 模拟 Search 主路粗筛后仍混入的隐藏元数据误命中(如 ttype:login) + f := auditFetchers{ + search: func(_ context.Context, cur AuditCursor) ([]AuditEvent, string, error) { + return []AuditEvent{ + {EventId: "hit", EventName: "InteractiveLogin", EventTime: &et}, + {EventId: "noise1", EventName: "ListRecommendations", EventTime: &et}, + {EventId: "noise2", EventName: "SearchLogs", EventTime: &et}, + }, "", nil + }, + audit: func(context.Context, AuditCursor) ([]AuditEvent, string, error) { + t.Fatal("Search 正常时不应走回退") + return nil, "", nil + }, + } + cur := NewAuditCursor(time.Now()) + cur.Q = "login" + res, err := listAuditBatch(context.Background(), f, cur, 1) + if err != nil { + t.Fatalf("listAuditBatch() err = %v", err) + } + if len(res.Items) != 1 || res.Items[0].EventId != "hit" { + t.Fatalf("应只保留可见字段命中的事件, got %+v", res.Items) + } +} + +func TestMatchesAuditTerm(t *testing.T) { + ev := AuditEvent{EventName: "ListVnicAttachments", ResourceName: "web-1", PrincipalName: "Vivien", IPAddress: "1.2.3.4"} + cases := []struct { + name string + q string + want bool + }{ + {"不区分大小写", "listvnic", true}, + {"通配分段都出现", "List*Attachments", true}, + {"资源名命中", "WEB-1", true}, + {"未命中", "TerminateInstance", false}, + {"通配缺段不命中", "List*Volume", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := matchesAuditTerm(ev, tc.q); got != tc.want { + t.Fatalf("matchesAuditTerm(%q) = %v, want %v", tc.q, got, tc.want) + } + }) + } +} + +// searchResultFromJSON 把 JSON 文本构造成 SearchLogs 单条结果(Data 为 interface{})。 +func searchResultFromJSON(t *testing.T, s string) loggingsearch.SearchResult { + t.Helper() + var v interface{} + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("fixture 不是合法 JSON: %v", err) + } + return loggingsearch.SearchResult{Data: &v} +} + +func TestToSearchAuditEvent(t *testing.T) { + eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC) + tests := []struct { + name string + data string + wantOK bool + want AuditEvent + }{ + { + name: "全字段齐全", + data: `{"datetime":1783074600000,"logContent":{ + "id":"evt-abc","time":"2026-07-06T10:30:00Z","source":"ComputeApi", + "data":{"eventName":"TerminateInstance","resourceName":"web-1","compartmentName":"prod", + "identity":{"principalName":"api-admin","ipAddress":"1.2.3.4"}, + "request":{"action":"DELETE","path":"/20160918/instances/ocid1..."}, + "response":{"status":"204"}}}}`, + wantOK: true, + want: AuditEvent{ + EventId: "evt-abc", + EventTime: &eventTime, + EventName: "TerminateInstance", + Source: "ComputeApi", + ResourceName: "web-1", + CompartmentName: "prod", + PrincipalName: "api-admin", + IPAddress: "1.2.3.4", + Status: "204", + RequestAction: "DELETE", + RequestPath: "/20160918/instances/ocid1...", + }, + }, + { + name: "identity/request/response 为 null 时只保留信封字段", + data: `{"logContent":{"id":"evt-x","time":"2026-07-06T10:30:00Z","source":"VcnApi", + "data":{"eventName":"GetVcn","identity":null,"request":null,"response":null}}}`, + wantOK: true, + want: AuditEvent{EventId: "evt-x", EventTime: &eventTime, Source: "VcnApi", EventName: "GetVcn"}, + }, + { + name: "缺 logContent 丢弃", + data: `{"datetime":1783074600000}`, + wantOK: false, + }, + { + name: "logContent 结构不符丢弃", + data: `{"logContent":"plain-text"}`, + wantOK: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := toSearchAuditEvent(searchResultFromJSON(t, tt.data)) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if len(got.Raw) == 0 { + t.Fatalf("Raw 应携带 logContent 原文") + } + if !auditEventEqual(got, tt.want) { + t.Errorf("toSearchAuditEvent() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestToSearchAuditEventNilData(t *testing.T) { + if _, ok := toSearchAuditEvent(loggingsearch.SearchResult{}); ok { + t.Fatal("Data 为 nil 应丢弃") + } +} + func TestToAuditEvent(t *testing.T) { eventTime := time.Date(2026, 7, 6, 10, 30, 0, 0, time.UTC) tests := []struct { @@ -38,44 +286,23 @@ func TestToAuditEvent(t *testing.T) { }, }, want: AuditEvent{ - EventId: "evt-abc", - EventTime: &eventTime, - EventName: "TerminateInstance", - Source: "ComputeApi", - ResourceName: "web-1", - CompartmentName: "prod", - PrincipalName: "api-admin", - IPAddress: "1.2.3.4", - Status: "204", - RequestAction: "DELETE", - RequestPath: "/20160918/instances/ocid1...", + EventId: "evt-abc", EventTime: &eventTime, EventName: "TerminateInstance", + Source: "ComputeApi", ResourceName: "web-1", CompartmentName: "prod", + PrincipalName: "api-admin", IPAddress: "1.2.3.4", Status: "204", + RequestAction: "DELETE", RequestPath: "/20160918/instances/ocid1...", }, }, - { - name: "Data 为 nil 时只保留信封字段", - ev: audit.AuditEvent{ - Source: common.String("VcnApi"), - EventTime: &common.SDKTime{Time: eventTime}, - }, - want: AuditEvent{EventTime: &eventTime, Source: "VcnApi"}, - }, { name: "嵌套局部 nil 各自安全跳过", ev: audit.AuditEvent{ Data: &audit.Data{ EventName: common.String("GetInstance"), - Identity: nil, Request: &audit.Request{Path: common.String("/instances")}, - Response: nil, }, }, want: AuditEvent{EventName: "GetInstance", RequestPath: "/instances"}, }, - { - name: "空事件全部零值", - ev: audit.AuditEvent{}, - want: AuditEvent{}, - }, + {name: "空事件全部零值", ev: audit.AuditEvent{}, want: AuditEvent{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -86,6 +313,37 @@ func TestToAuditEvent(t *testing.T) { } } +func TestAuditSearchQuery(t *testing.T) { + const prefix = `search "ocid1.tenancy.oc1..aaa/_Audit" | where data.eventName != 'SummarizeMetricsData'` + cases := []struct { + name string + term string + want string + }{ + {"无关键字", "", prefix + ` | sort by datetime desc`}, + {"带关键字追加全文匹配", "TerminateInstance", prefix + ` and logContent = '*TerminateInstance*' | sort by datetime desc`}, + {"引号与反斜杠被消毒", `O'Brien\"x`, prefix + ` and logContent = '*OBrienx*' | sort by datetime desc`}, + {"纯引号消毒后为空不追加", `'"`, prefix + ` | sort by datetime desc`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := auditSearchQuery("ocid1.tenancy.oc1..aaa", tc.term); got != tc.want { + t.Fatalf("auditSearchQuery() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSanitizeAuditTerm(t *testing.T) { + if got := SanitizeAuditTerm(" Get*Instance\t "); got != "Get*Instance" { + t.Fatalf("应保留 * 并去除首尾空白与控制字符, got %q", got) + } + long := strings.Repeat("a", 300) + if got := SanitizeAuditTerm(long); len(got) != auditTermMaxLen { + t.Fatalf("超长应截断到 %d, got %d", auditTermMaxLen, len(got)) + } +} + // auditEventEqual 比较两个 DTO:EventTime 按值比较,Raw 不参与,其余反射比较。 func auditEventEqual(a, b AuditEvent) bool { if (a.EventTime == nil) != (b.EventTime == nil) { @@ -149,6 +407,7 @@ func TestAuditCursorAdvance(t *testing.T) { Start: now.Add(-24 * time.Hour), End: now, WindowHours: 24, + Q: "kw", } cases := []struct { name string @@ -159,8 +418,10 @@ func TestAuditCursorAdvance(t *testing.T) { }{ {"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false}, {"空窗倍增", base, true, 48, false}, - {"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false}, + {"倍增封顶 336h(14 天查询窗硬限)", AuditCursor{Start: base.Start, End: base.End, WindowHours: 256}, true, 336, false}, {"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false}, + {"回退模式有事件重置 1h 基准窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 8, M: auditModeFallback}, false, 1, false}, + {"回退模式空窗照常倍增", AuditCursor{Start: base.Start, End: base.End, WindowHours: 1, M: auditModeFallback}, true, 2, false}, {"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true}, } for _, tc := range cases { @@ -184,6 +445,12 @@ func TestAuditCursorAdvance(t *testing.T) { if next.Page != "" { t.Fatalf("新窗应清空窗内游标, got %q", next.Page) } + if next.Q != tc.cur.Q { + t.Fatalf("新窗应继承检索关键字, got %q want %q", next.Q, tc.cur.Q) + } + if next.M != tc.cur.M { + t.Fatalf("新窗应继承通道模式, got %q want %q", next.M, tc.cur.M) + } }) } } diff --git a/internal/service/audit.go b/internal/service/audit.go index 7f16b45..057f787 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -30,19 +30,23 @@ var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试") // AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查, -// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。 +// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数; +// Q 为检索关键字,仅首查生效(续查沿用游标内嵌的关键字,保证跨批一致)。 type AuditQuery struct { Region string Cursor string Limit int + Q string } // AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回); -// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。 +// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头; +// ScannedThrough 为已完整回溯到的时刻(比它更新的时段已扫完),供前端展示进度。 type AuditEventsView struct { - Items []oci.AuditEvent `json:"items"` - Cursor string `json:"cursor,omitempty"` - Exhausted bool `json:"exhausted"` + Items []oci.AuditEvent `json:"items"` + Cursor string `json:"cursor,omitempty"` + Exhausted bool `json:"exhausted"` + ScannedThrough *time.Time `json:"scannedThrough,omitempty"` } // AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置 @@ -52,6 +56,9 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer if err != nil { return AuditEventsView{}, err } + if q.Cursor == "" { + cur.Q = oci.SanitizeAuditTerm(q.Q) + } cred, err := s.credentialsByID(ctx, id) if err != nil { return AuditEventsView{}, err @@ -63,6 +70,7 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted} if res.Cursor != nil { view.Cursor = encodeAuditCursor(*res.Cursor) + view.ScannedThrough = &res.Cursor.End } return view, nil }