From f51fb6c722adc6cc856b50e424244275f47e9386 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Wed, 22 Jul 2026 16:51:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=A8=E9=87=8F=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E9=97=AE=E9=A2=98;=E8=AE=BE=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3PATCH=E5=8C=96;=E5=9B=9E=E4=BC=A0=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/.version | 2 +- .trellis/spec/backend/concurrency.md | 1 + .trellis/spec/backend/database-guidelines.md | 77 +---- .trellis/spec/backend/error-handling.md | 4 + .trellis/spec/backend/idp-icon-upload.md | 91 +++++ .trellis/spec/backend/index.md | 6 +- .trellis/spec/backend/logging-guidelines.md | 51 --- .trellis/spec/backend/oci-audit.md | 7 + .trellis/spec/backend/quality-guidelines.md | 4 + .trellis/spec/backend/testing.md | 21 +- .../spec/guides/code-reuse-thinking-guide.md | 223 ------------ README.md | 8 +- cmd/server/main.go | 2 +- docs/OCI调用者指纹评估.md | 117 +++++++ docs/docs.go | 323 +++++++++++++++++- docs/swagger.json | 321 ++++++++++++++++- docs/swagger.yaml | 225 +++++++++++- internal/api/aiadmin.go | 4 +- internal/api/authx.go | 24 +- internal/api/federation.go | 141 +++++++- internal/api/federation_test.go | 217 ++++++++++++ internal/api/objectstorage.go | 18 +- internal/api/router.go | 7 + internal/api/router_test.go | 8 + internal/api/routes_auth.go | 2 +- internal/api/routes_oci.go | 18 +- internal/api/routes_settings.go | 2 +- internal/api/settings.go | 11 +- internal/api/task.go | 18 +- internal/oci/client.go | 6 + internal/oci/domainimage.go | 157 +++++++++ internal/oci/domainimage_test.go | 143 ++++++++ internal/oci/federation.go | 120 ++++++- internal/oci/federation_test.go | 196 +++++++++++ internal/oci/genai_responses.go | 17 +- internal/oci/genai_responses_test.go | 29 ++ internal/oci/genai_speech.go | 5 +- internal/oci/logrelay.go | 208 +++++++---- internal/oci/logrelay_names.go | 53 +++ internal/oci/logrelay_names_test.go | 115 +++++++ internal/oci/objectstorage.go | 80 ++++- internal/oci/objectstorage_test.go | 47 +++ internal/oci/signon.go | 16 +- internal/service/aigateway.go | 55 ++- internal/service/aigateway_extras_test.go | 70 ++++ internal/service/credentials.go | 49 ++- internal/service/federation.go | 173 +++++++++- internal/service/federation_test.go | 227 ++++++++++++ internal/service/logevent_test.go | 2 + internal/service/oauth.go | 57 +++- internal/service/oauthconfig.go | 75 ++-- internal/service/objectstorage.go | 81 ++++- internal/service/objectstorage_test.go | 150 +++++++- internal/service/ociconfig.go | 24 +- internal/service/overview.go | 60 +++- internal/service/overview_test.go | 23 ++ internal/service/proxy_test.go | 24 ++ internal/service/proxygeo.go | 18 +- internal/service/security.go | 66 +++- internal/service/security_test.go | 81 ++++- internal/service/setting.go | 23 +- internal/service/setting_test.go | 4 + internal/service/task.go | 82 ++++- internal/service/task_test.go | 91 +++++ internal/service/tenantdelete.go | 26 +- internal/service/totp_oauth_test.go | 78 ++++- 66 files changed, 3997 insertions(+), 687 deletions(-) create mode 100644 .trellis/spec/backend/idp-icon-upload.md delete mode 100644 .trellis/spec/backend/logging-guidelines.md delete mode 100644 .trellis/spec/guides/code-reuse-thinking-guide.md create mode 100644 docs/OCI调用者指纹评估.md create mode 100644 internal/api/federation_test.go create mode 100644 internal/oci/domainimage.go create mode 100644 internal/oci/domainimage_test.go create mode 100644 internal/oci/federation_test.go create mode 100644 internal/oci/logrelay_names.go create mode 100644 internal/oci/logrelay_names_test.go create mode 100644 internal/service/federation_test.go diff --git a/.trellis/.version b/.trellis/.version index bf21f52..8b707c6 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.6.6 \ No newline at end of file +0.6.7 \ No newline at end of file diff --git a/.trellis/spec/backend/concurrency.md b/.trellis/spec/backend/concurrency.md index 8c03410..765b828 100644 --- a/.trellis/spec/backend/concurrency.md +++ b/.trellis/spec/backend/concurrency.md @@ -47,6 +47,7 @@ go func() { - 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。 - 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。 - SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。 +- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。 - 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。 ## 出站连接复用与批量删除(2026-07 删桶超时复盘) diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index 87b7808..306afec 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -1,75 +1,32 @@ -# Database Guidelines +# 数据库规范 -> Database patterns and conventions for this project. +> GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。 ---- +## 租户级数据删除 -## Overview - - - -(To be filled by the team) - ---- - -## Query Patterns - - - -### 租户级数据删除 - -- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。 -- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。 -- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。 -- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。 +- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。 +- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。 +- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。 +- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。 - 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。 -### 大集合谓词用子查询,禁止展开 IN 列表 +## 大集合谓词用子查询,禁止展开 IN 列表 - 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。 - GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。 +- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。 - 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。 - 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。 ---- +## Common Mistake: 用 `Save` 持久化在途任务的陈旧快照 -## Migrations +**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。 - +**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。 -(To be filled by the team) +**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。 ---- - -## Naming Conventions - - - -(To be filled by the team) - ---- - -## Common Mistakes - - - -### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照 - -**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。 - -**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。 - -**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。 - -### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零 +## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零 **Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。 @@ -79,7 +36,7 @@ Questions to answer: **Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。 -### Common Mistake: 需要"条件更新多列含 NULL"时的写法 +## Common Mistake: 需要"条件更新多列含 NULL"时的写法 ```go // 正确:零写库条件 + 显式 NULL @@ -88,13 +45,13 @@ db.Model(&model.AiChannel{}). Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")}) ``` -### `serializer:json` 字段走 map Updates 时手动 marshal +## `serializer:json` 字段走 map Updates 时手动 marshal - 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化; - 但 `Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测; - 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。 -### Common Mistake: 进程内缓存键漏掉查询维度 +## Common Mistake: 进程内缓存键漏掉查询维度 **Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。 diff --git a/.trellis/spec/backend/error-handling.md b/.trellis/spec/backend/error-handling.md index 75e9aa3..71f11dd 100644 --- a/.trellis/spec/backend/error-handling.md +++ b/.trellis/spec/backend/error-handling.md @@ -41,3 +41,7 @@ func sanitizeURLError(err error) error { - `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。 **约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。 + +## 外部响应体必须限长,超限报错而非静默截断 + +读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。 diff --git a/.trellis/spec/backend/idp-icon-upload.md b/.trellis/spec/backend/idp-icon-upload.md new file mode 100644 index 0000000..b44daca --- /dev/null +++ b/.trellis/spec/backend/idp-icon-upload.md @@ -0,0 +1,91 @@ +# IdP 图标上传与清理契约 + +## 1. Scope / Trigger + +- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。 +- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**, + 孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。 + +## 2. Signatures + +```text +POST /api/v1/oci-configs/{id}/idp-icons?domainId= +DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=&fileName= +``` + +```typescript +uploadIdpIcon(id: number, file: File, domainId?: string): Promise +deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise + +interface IdpIconUpload { + url: string // 写入 IdP iconUrl 的公网地址 + fileName: string // 仅用于清理的存储标识 +} +``` + +上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调): + +```text +POST /storage/v1/Images multipart: file + fileName +DELETE /storage/v1/Images query: fileName +``` + +## 3. Contracts + +- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制; + 路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。 +- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成 + `idp-icon-<32 lowercase hex>`:既避免并发/迟到响应下同名互相 + 覆盖,又让存储名成为不可猜测的清理凭据。 +- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg), + 不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管, + 传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起 + (2026-07 曾过度实现完整解码套件后精简)。 +- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.`, + 不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。 +- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon, + 用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除, + 失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。 +- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP; + 回滚成功按普通失败,回滚失败/无法确认返回 `201` 和 + `setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`; + 内部原因只按 requestId 写服务端日志,不进响应。 +- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置, + `primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。 + +## 4. Validation & Error Matrix + +| 条件 | HTTP | +| --- | --- | +| 缺少 multipart `file`、文件为空或文件名非法 | 400 | +| 文件本体超过 1 MiB | 413 | +| 扩展名不支持或与文件头魔数不符 | 415 | +| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 | +| 上游 DELETE 返回 404 | 204 | +| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` | +| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 | + +## 5. Tests / Verification Required + +- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。 +- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游; + DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。 +- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。 +- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支; + 后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。 +- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。 + +## 6. Wrong vs Correct + +```typescript +// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。 +onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId)) + +// Correct:上传时捕获原始元组,清理只按元组走,失败静默。 +pendingIcon = { ...uploaded, cfgId, domainId } +void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {}) +``` + +反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 + +卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验—— +复杂度与威胁模型不匹配,已于 2026-07 精简移除。 diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index 6e18dff..a83f68d 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -19,9 +19,9 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE | [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 | | [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 | | [Testing](./testing.md) | table-driven 测试要求 | 已填 | -| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 部分已填 | +| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 | +| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 | | [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 | -| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 | --- @@ -32,7 +32,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE - [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`) - [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md)) - [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回 -- [ ] 复用已有封装,不重复造轮子(见 `../guides/code-reuse-thinking-guide.md`) +- [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式) ## Quality Check diff --git a/.trellis/spec/backend/logging-guidelines.md b/.trellis/spec/backend/logging-guidelines.md deleted file mode 100644 index bb930df..0000000 --- a/.trellis/spec/backend/logging-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Logging Guidelines - -> How logging is done in this project. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## Log Levels - - - -(To be filled by the team) - ---- - -## Structured Logging - - - -(To be filled by the team) - ---- - -## What to Log - - - -(To be filled by the team) - ---- - -## What NOT to Log - - - -(To be filled by the team) diff --git a/.trellis/spec/backend/oci-audit.md b/.trellis/spec/backend/oci-audit.md index 7dec824..30d0877 100644 --- a/.trellis/spec/backend/oci-audit.md +++ b/.trellis/spec/backend/oci-audit.md @@ -17,3 +17,10 @@ - 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。 - 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。 + +## 日志回传链路(logrelay)资源命名与描述纪律 + +- **命名派生**:Topic 前缀、IAM Policy 名、SCH Connector DisplayName 一律由 `relayResourceNames(tenancyOCID)`(见 `internal/oci/logrelay_names.go`)基于 `SHA-256(tenancyOCID)[:4]` 派生 `<8hex>-audit` / `<8hex>-audit-p`。**不得在 `logrelay.go` 里再引入品牌明文**(`oci-portal` / `ociportal` / `logs` 等),否则跨租户恒定字面量会成为 Oracle 内部风控识别「共用同一套 oci-portal」的强指纹;背景与证据分档见调研档案 `docs/oci-sdk-caller-fingerprint.md`。 +- **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。 +- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。 +- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。 diff --git a/.trellis/spec/backend/quality-guidelines.md b/.trellis/spec/backend/quality-guidelines.md index 2f2fd76..8d9bf89 100644 --- a/.trellis/spec/backend/quality-guidelines.md +++ b/.trellis/spec/backend/quality-guidelines.md @@ -27,3 +27,7 @@ - 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析 后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal` 对 RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。 + +## 多字段设置接口用字段级 PATCH,不用全量 PUT + +「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。 diff --git a/.trellis/spec/backend/testing.md b/.trellis/spec/backend/testing.md index 2a5b825..267fb67 100644 --- a/.trellis/spec/backend/testing.md +++ b/.trellis/spec/backend/testing.md @@ -6,6 +6,26 @@ - 断言失败时输出 `got/want` 对比,便于定位。 - 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`。 +## 上传接口边界测试 + +- “文件最大 N 字节”与 HTTP 请求体上限不是同一个契约:multipart boundary、 + part header 和文件名会额外占空间。路由级 body limit 必须为封装开销留余量, + service 再对文件字节数执行精确上限。 +- 新增或修改上传接口时,完整路由测试至少覆盖:恰好 N 字节成功、N+1 返回 + 413、空文件返回 400、伪造扩展名返回 415。 +- 内容校验深度与威胁模型匹配:管理员向自己租户上传、由上游域名托管的低价值 + 资源,扩展名白名单 + 魔数嗅探即可,不引入完整解码/结构校验 + (2026-07 IdP 图标曾过度实现后精简,见 idp-icon-upload.md)。 +- 临时远端资源使用服务端随机名,测试相同客户端文件名不会复用上游对象;清理接口 + 只接受该功能生成的不可猜测标识,不能因共享 `images/` 前缀删除其他业务资产。 + +## 多步远端写入测试 + +- `创建 A → 配置 B` 这类流程至少覆盖:创建失败、B 失败且 A 回滚成功、B 失败且 + A 回滚失败/状态未知。只有确认回滚成功才能返回普通失败。 +- 部分成功响应必须有稳定机器码和关联 ID,前端据此保留已被 A 引用的临时资源; + 底层上游错误只进服务端日志,测试断言不会泄露到 HTTP body。 + ## 常见坑:`:memory:` SQLite 每个连接是独立库 **症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。 @@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连 ``` **预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。 - diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md deleted file mode 100644 index bb789e9..0000000 --- a/.trellis/spec/guides/code-reuse-thinking-guide.md +++ /dev/null @@ -1,223 +0,0 @@ -# Code Reuse Thinking Guide - -> **Purpose**: Stop and think before creating new code - does it already exist? - ---- - -## The Problem - -**Duplicated code is the #1 source of inconsistency bugs.** - -When you copy-paste or rewrite existing logic: -- Bug fixes don't propagate -- Behavior diverges over time -- Codebase becomes harder to understand - ---- - -## Before Writing New Code - -### Step 1: Search First - -```bash -# Search for similar function names -grep -r "functionName" . - -# Search for similar logic -grep -r "keyword" . -``` - -### Step 2: Ask These Questions - -| Question | If Yes... | -|----------|-----------| -| Does a similar function exist? | Use or extend it | -| Is this pattern used elsewhere? | Follow the existing pattern | -| Could this be a shared utility? | Create it in the right place | -| Am I copying code from another file? | **STOP** - extract to shared | - ---- - -## Common Duplication Patterns - -### Pattern 1: Copy-Paste Functions - -**Bad**: Copying a validation function to another file - -**Good**: Extract to shared utilities, import where needed - -### Pattern 2: Similar Components - -**Bad**: Creating a new component that's 80% similar to existing - -**Good**: Extend existing component with props/variants - -### Pattern 3: Repeated Constants - -**Bad**: Defining the same constant in multiple files - -**Good**: Single source of truth, import everywhere - -### Pattern 4: Repeated Payload Field Extraction - -**Bad**: Multiple consumers cast the same JSON/event fields locally: - -```typescript -const description = (ev as { description?: string }).description; -const context = (ev as { context?: ContextEntry[] }).context; -``` - -This is duplicated contract logic even when the code is only two lines. Each -consumer now has its own definition of what a valid payload means. - -**Good**: Put the decoder, type guard, or projection next to the data owner: - -```typescript -if (isThreadEvent(ev)) { - renderThreadEvent(ev); -} -``` - -**Rule**: If the same untyped payload field is read in 2+ places, create a -shared type guard / normalizer / projection before adding a third reader. - ---- - -## When to Abstract - -**Abstract when**: -- Same code appears 3+ times -- Logic is complex enough to have bugs -- Multiple people might need this - -**Don't abstract when**: -- Only used once -- Trivial one-liner -- Abstraction would be more complex than duplication - ---- - -## After Batch Modifications - -When you've made similar changes to multiple files: - -1. **Review**: Did you catch all instances? -2. **Search**: Run grep to find any missed -3. **Consider**: Should this be abstracted? - -### Reducers Should Use Exhaustive Structure - -When state is derived from action-like values (`action`, `kind`, `status`, -`phase`), prefer a reducer with one `switch` over scattered `if/else` updates. - -```typescript -// BAD - action-specific state transitions are hard to audit -if (action === "opened") { ... } -else if (action === "comment") { ... } -else if (action === "status") { ... } - -// GOOD - one reducer owns the transition table -switch (event.action) { - case "opened": - ... - return; - case "comment": - ... - return; -} -``` - -This matters when the event log is the source of truth. A reducer is the -documented replay model; display code and commands should not duplicate pieces -of that replay model. - ---- - -## Checklist Before Commit - -- [ ] Searched for existing similar code -- [ ] No copy-pasted logic that should be shared -- [ ] No repeated untyped payload field extraction outside a shared decoder -- [ ] Constants defined in one place -- [ ] Similar patterns follow same structure -- [ ] Reducer/action transitions live in one reducer or command dispatcher - ---- - -## Gotcha: Python if/elif/else Exhaustive Check - -**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults. - -**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised. - -**Example** (`cli_adapter.py`): -```python -# BAD: "gemini" falls through to else, returns "claude" -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - else: - return "claude" # gemini silently gets "claude"! - -# GOOD: explicit branch for every platform -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - elif self.platform == "gemini": - return "gemini" - else: - return "claude" -``` - -**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values. - ---- - -## Gotcha: Asymmetric Mechanisms Producing Same Output - -**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. - -**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. - -**Prevention**: -- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list) -- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms -- When migrating directory structures, search for ALL code paths that reference the old structure - -**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3. - ---- - -## Template File Registration (Trellis-specific) - -When adding new files to `src/templates/trellis/scripts/`: - -**Single registration point**: `src/templates/trellis/index.ts` - -1. Add `export const xxxScript = readTemplate("scripts/path/file.py");` -2. Add to `getAllScripts()` Map - -That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed. - -**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate. - -**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth. - -### Quick Checklist for New Scripts - -```bash -# After adding a new .py file, verify it's in getAllScripts(): -grep -l "newFileName" src/templates/trellis/index.ts # Should match -``` - -### Template Sync Convention - -`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync: - -```bash -rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/ -``` - -**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running. diff --git a/README.md b/README.md index f669396..5626a57 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,10 @@ [界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发) -[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash) +[AI 网关文档](docs/AI网关.md) · [OCI 调用者指纹评估](docs/OCI调用者指纹评估.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash) -OCI Portal 将多份 OCI API Key、云资源、成本与账单、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。 - 本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。 ## 界面预览 @@ -308,6 +306,10 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc - 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash) - 版本变更:[`CHANGELOG.md`](CHANGELOG.md) +## 致谢 + +感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 项目为本项目提供思路。 + ## License [MIT](LICENSE) diff --git a/cmd/server/main.go b/cmd/server/main.go index ff9af2a..53ba82a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,7 +31,7 @@ import ( ) // @title OCI Portal API -// @version 0.0.1 +// @version 0.8.0 // @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。 // @BasePath / // @securityDefinitions.apikey BearerAuth diff --git a/docs/OCI调用者指纹评估.md b/docs/OCI调用者指纹评估.md new file mode 100644 index 0000000..7cf1194 --- /dev/null +++ b/docs/OCI调用者指纹评估.md @@ -0,0 +1,117 @@ + + +
+ +OCI Portal logo + +# OCI 调用者指纹评估 + +**OCI Portal 请求暴露面与多租户关联风险** + +![OCI SDK](https://img.shields.io/badge/OCI-Go%20SDK-F80000?logo=oracle&logoColor=white) +![Auth](https://img.shields.io/badge/Auth-API%20Key-2563EB) +![Scope](https://img.shields.io/badge/Scope-Multi--tenant-0F9D58) + +[结论速览](#summary) · [请求暴露面](#request-signals) · [项目指纹](#portal-signals) · [关联评估](#assessment) · [核验依据](#evidence) + +
+ +> [!NOTE] +> 本文面向架构与隐私评估,说明 OCI Portal 调用 OCI Public API 时 Oracle +> 可见的信息及跨租户关联风险。结论基于当前源码,不代表 Oracle 实际采用的 +> 风控规则。 + + + +## 结论速览 + +> [!IMPORTANT] +> OCI Portal 的每次 API Key 请求都会在签名的 `keyId` 中携带 +> `tenancy OCID / user OCID / fingerprint`。这能准确识别云身份,但不能单凭 +> 一次请求证明调用方是 OCI Portal。 + +| 问题 | 结论 | +| --- | --- | +| Oracle 能识别单次请求的租户和用户吗? | **能。**签名身份、源 IP、目标服务、操作与时间均可见 | +| 通用 SDK 请求头能识别 OCI Portal 吗? | **不能。**默认 User-Agent、签名算法和 Go TLS 特征被大量客户端共享 | +| Oracle 能关联多个租户共用同一实例吗? | **具备能力。**需组合出口 IP、资源元数据、固定文本和调用序列 | +| 外部第三方能完成同等关联吗? | **通常不能。**其无法取得 Oracle 网关日志和租户内部资源元数据 | + + + +## 单次请求暴露面 + +当前项目统一通过 `common.NewRawConfigurationProvider` 使用 API Key: + +```text +Authorization: Signature version="1", + keyId="//", + algorithm="rsa-sha256", ... +``` + +| 信号 | 默认形态 | 区分度 | +| --- | --- | :---: | +| 签名身份 | `tenancy / user / fingerprint` 明文位于 `keyId` | 身份强,软件弱 | +| User-Agent | 大多数 SDK 请求为 `Oracle-GoSDK/<版本> (...)` | 弱 | +| SDK 客户端信息 | SDK 生成的服务请求通常带 `opc-client-info: Oracle-GoSDK/<版本>` | 弱 | +| 请求元数据 | `Date`、`Host`、`request-target`;写请求另含 body 摘要 | 通用 | +| 网络特征 | 源 IP、TLS / ALPN、HTTP/2 行为 | 单次弱,跨租户组合后增强 | +| OCI 遥测头 | 显式启用 `OCI_INCLUDE_REQUEST_TELEMETRY_DATA=true` 时,SDK 操作可写 service / operation;直通请求未必携带 | 可选 | + +项目没有构造 Instance Principal、Resource Principal 或 Session Token +鉴权,也没有主动覆盖 User-Agent 或为 `opc-request-id` 设置固定前缀。 +`opc-client-info` 由 SDK 请求构造器写入,不含 OCI Portal 标记;手工构造的 +请求可能不带该字段。少量手工签名请求绕过 `BaseClient.prepareRequest`, +使用 Go `net/http` 的默认 User-Agent。 + + + +## OCI Portal 增量指纹 + +| 信号 | 当前行为 | 关联强度 | +| --- | --- | :---: | +| 出口 IP | 无租户级或全局代理时,走部署环境默认出口 / NAT | **强旁证(命中时)** | +| 日志回传资源名 | 按 tenancy 派生为 `<8hex>-audit*`,描述使用中性文案 | 弱 | +| GenAI Responses 请求头 | 同时写入 `CompartmentId` 与 `opc-compartment-id` | 中 / 弱 | +| API 调用序列 | 测活、账户画像、区域与资源查询存在稳定组合 | 辅助 | + +SDK 默认 User-Agent、`opc-client-info` 与固定签名格式只说明“使用 OCI Go +SDK”,不是项目专属信号。 + + + +## 多租户关联评估 + +| 观察视角 | 结论 | 主要依据 | +| --- | --- | --- | +| Oracle 内部,跨租户数据 | **中** | 固定文本、出口 IP 与调用序列可组合比对 | +| Oracle 内部,单租户数据 | **低** | 能识别实现痕迹,但难证明多个租户共用同一实例 | +| 仅看 SDK 请求头 | **低** | 可识别云身份和 SDK 类型,不能可靠识别具体应用 | +| 外部第三方 | **通常不可行** | 缺少 OCI 网关日志、Sign-on Policy 与租户资源元数据 | + +这些等级表达的是“可关联性”,不是 Oracle 已执行关联或据此采取处置。 +同一出口 IP 也可能来自 NAT、代理或共享基础设施,必须与其他信号联合判断。 + + + +## 核验依据 + +### OCI 与 SDK + +- [OCI Request Signatures](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm) +- [OCI API Signing Key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm) +- [OCI Go SDK client.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go) +- [OCI Go SDK http_signer.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go) +- [OCI Go SDK configuration.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/configuration.go) + +### 项目源码 + +- [API Key provider](../internal/oci/client.go#L237-L246) +- [手工签名请求](../internal/oci/account.go#L83-L97) +- [租户出站代理](../internal/oci/proxyhttp.go#L45-L65) +- [日志回传资源命名](../internal/oci/logrelay_names.go#L20-L55) +- [GenAI Responses 请求头](../internal/oci/genai_responses.go#L27-L38) +- [MFA justification](../internal/oci/signon.go#L223-L236) +- [审计与日志回传约束](../.trellis/spec/backend/oci-audit.md) + +

返回顶部 ↑

diff --git a/docs/docs.go b/docs/docs.go index 8f9fb50..212f8dc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -608,7 +608,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog" + "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog" } } } @@ -780,7 +780,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog" + "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog" } } } @@ -930,8 +930,14 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "已更新,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已更新,请重新登录" + "description": "已更新但新 token 签发失败,需重新登录" }, "401": { "description": "当前密码错误", @@ -984,8 +990,14 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "已解绑,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已解绑" + "description": "已解绑但新 token 签发失败,需重新登录" }, "409": { "description": "最后一个身份不可解绑", @@ -1165,8 +1177,14 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "已保存,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已保存" + "description": "已保存但新 token 签发失败,需重新登录" }, "409": { "description": "未绑定外部身份", @@ -1242,8 +1260,14 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "已启用,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已启用" + "description": "已启用但新 token 签发失败,需重新登录" } } } @@ -1271,8 +1295,14 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "已停用,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已停用" + "description": "已停用但新 token 签发失败,需重新登录" } } } @@ -2473,6 +2503,15 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/oci-portal_internal_oci.PAR" } + }, + "400": { + "description": "accessType 或 expiresHours 非法", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } }, @@ -2522,8 +2561,17 @@ const docTemplate = `{ } ], "responses": { + "200": { + "description": "all=1 时返回 {deleted: n}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + }, "204": { - "description": "删除成功,链接立即失效" + "description": "按 parId 删单条成功,链接立即失效" } } } @@ -2801,6 +2849,7 @@ const docTemplate = `{ "BearerAuth": [] } ], + "description": "创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功", "tags": [ "租户 IAM" ], @@ -2833,7 +2882,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/oci-portal_internal_oci.IdentityProviderInfo" + "$ref": "#/definitions/internal_api.createIdpResponse" } } } @@ -3010,6 +3059,128 @@ const docTemplate = `{ } } }, + "/api/v1/oci-configs/{id}/idp-icons": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址", + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "租户 IAM" + ], + "summary": "上传身份提供商图标", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "身份域 OCID(缺省 Default 域)", + "name": "domainId", + "in": "query" + }, + { + "type": "file", + "description": "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.idpIconResponse" + } + }, + "400": { + "description": "缺文件字段、文件为空或文件名非法", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "413": { + "description": "文件超过 1MB", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "415": { + "description": "扩展名不支持或与文件内容不符", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理", + "tags": [ + "租户 IAM" + ], + "summary": "删除身份提供商图标", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "身份域 OCID(缺省 Default 域)", + "name": "domainId", + "in": "query" + }, + { + "type": "string", + "description": "上传响应返回的 IdP 图标存储标识(images/idp-icon-\u003c32hex\u003e.\u003cext\u003e)", + "name": "fileName", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "无内容" + }, + "400": { + "description": "fileName 缺失或不是本服务生成的 IdP 图标标识", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/images": { "get": { "security": [ @@ -6711,7 +6882,7 @@ const docTemplate = `{ } } }, - "put": { + "patch": { "security": [ { "BearerAuth": [] @@ -6720,10 +6891,10 @@ const docTemplate = `{ "tags": [ "设置" ], - "summary": "保存 OAuth provider 配置", + "summary": "部分更新 OAuth provider 配置", "parameters": [ { - "description": "provider 配置", + "description": "出现的字段才会被更新", "name": "body", "in": "body", "required": true, @@ -6762,7 +6933,7 @@ const docTemplate = `{ } } }, - "put": { + "patch": { "security": [ { "BearerAuth": [] @@ -6771,15 +6942,15 @@ const docTemplate = `{ "tags": [ "设置" ], - "summary": "保存安全设置并返回最新值", + "summary": "部分更新安全设置并返回最新值", "parameters": [ { - "description": "请求体", + "description": "出现的字段才会被更新", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oci-portal_internal_service.SecuritySettings" + "$ref": "#/definitions/oci-portal_internal_service.SecurityPatch" } } ], @@ -7036,7 +7207,7 @@ const docTemplate = `{ "required": true }, { - "description": "可更新字段", + "description": "可更新字段;抢机 payload 的 count 语义为目标台数", "name": "body", "in": "body", "required": true, @@ -7051,6 +7222,18 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/oci-portal_internal_model.Task" } + }, + "400": { + "description": "参数非法或抢机目标不大于已完成数量", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } + }, + "409": { + "description": "任务被并发修改,须刷新重试", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } } } }, @@ -7448,6 +7631,35 @@ const docTemplate = `{ } } }, + "internal_api.createIdpResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "jitEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partnerProviderId": { + "type": "string" + }, + "setupWarning": { + "$ref": "#/definitions/internal_api.idpSetupWarning" + }, + "timeCreated": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "internal_api.createInstanceRequest": { "type": "object", "required": [ @@ -7759,6 +7971,34 @@ const docTemplate = `{ } } }, + "internal_api.idpIconResponse": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "internal_api.idpSetupWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "resourceCreated": { + "type": "boolean" + } + } + }, "internal_api.importRequest": { "type": "object", "required": [ @@ -11335,6 +11575,13 @@ const docTemplate = `{ "hasActiveTask": { "type": "boolean" }, + "series": { + "description": "Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个\n(主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。", + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.OverviewCostSeries" + } + }, "total": { "type": "number" } @@ -11351,6 +11598,23 @@ const docTemplate = `{ } } }, + "oci-portal_internal_service.OverviewCostSeries": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "days": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.OverviewCostDay" + } + }, + "total": { + "type": "number" + } + } + }, "oci-portal_internal_service.OverviewTasks": { "type": "object", "properties": { @@ -11525,6 +11789,29 @@ const docTemplate = `{ } } }, + "oci-portal_internal_service.SecurityPatch": { + "type": "object", + "properties": { + "appUrl": { + "type": "string" + }, + "ipRateBurst": { + "type": "integer" + }, + "ipRateRps": { + "type": "integer" + }, + "loginFailLimit": { + "type": "integer" + }, + "loginLockMinutes": { + "type": "integer" + }, + "realIpHeader": { + "type": "string" + } + } + }, "oci-portal_internal_service.SecuritySettings": { "type": "object", "properties": { @@ -11639,7 +11926,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "0.0.1", + Version: "0.8.0", Host: "", BasePath: "/", Schemes: []string{}, diff --git a/docs/swagger.json b/docs/swagger.json index b7d0160..be2d108 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -601,7 +601,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog" + "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog" } } } @@ -773,7 +773,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog" + "$ref": "#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog" } } } @@ -923,8 +923,14 @@ } ], "responses": { + "200": { + "description": "已更新,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已更新,请重新登录" + "description": "已更新但新 token 签发失败,需重新登录" }, "401": { "description": "当前密码错误", @@ -977,8 +983,14 @@ } ], "responses": { + "200": { + "description": "已解绑,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已解绑" + "description": "已解绑但新 token 签发失败,需重新登录" }, "409": { "description": "最后一个身份不可解绑", @@ -1158,8 +1170,14 @@ } ], "responses": { + "200": { + "description": "已保存,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已保存" + "description": "已保存但新 token 签发失败,需重新登录" }, "409": { "description": "未绑定外部身份", @@ -1235,8 +1253,14 @@ } ], "responses": { + "200": { + "description": "已启用,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已启用" + "description": "已启用但新 token 签发失败,需重新登录" } } } @@ -1264,8 +1288,14 @@ } ], "responses": { + "200": { + "description": "已停用,返回换发的新 token", + "schema": { + "$ref": "#/definitions/internal_api.tokenResponse" + } + }, "204": { - "description": "已停用" + "description": "已停用但新 token 签发失败,需重新登录" } } } @@ -2466,6 +2496,15 @@ "schema": { "$ref": "#/definitions/oci-portal_internal_oci.PAR" } + }, + "400": { + "description": "accessType 或 expiresHours 非法", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } }, @@ -2515,8 +2554,17 @@ } ], "responses": { + "200": { + "description": "all=1 时返回 {deleted: n}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + }, "204": { - "description": "删除成功,链接立即失效" + "description": "按 parId 删单条成功,链接立即失效" } } } @@ -2794,6 +2842,7 @@ "BearerAuth": [] } ], + "description": "创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功", "tags": [ "租户 IAM" ], @@ -2826,7 +2875,7 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/oci-portal_internal_oci.IdentityProviderInfo" + "$ref": "#/definitions/internal_api.createIdpResponse" } } } @@ -3003,6 +3052,128 @@ } } }, + "/api/v1/oci-configs/{id}/idp-icons": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址", + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "租户 IAM" + ], + "summary": "上传身份提供商图标", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "身份域 OCID(缺省 Default 域)", + "name": "domainId", + "in": "query" + }, + { + "type": "file", + "description": "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_api.idpIconResponse" + } + }, + "400": { + "description": "缺文件字段、文件为空或文件名非法", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "413": { + "description": "文件超过 1MB", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "415": { + "description": "扩展名不支持或与文件内容不符", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理", + "tags": [ + "租户 IAM" + ], + "summary": "删除身份提供商图标", + "parameters": [ + { + "type": "integer", + "description": "配置 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "身份域 OCID(缺省 Default 域)", + "name": "domainId", + "in": "query" + }, + { + "type": "string", + "description": "上传响应返回的 IdP 图标存储标识(images/idp-icon-\u003c32hex\u003e.\u003cext\u003e)", + "name": "fileName", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "无内容" + }, + "400": { + "description": "fileName 缺失或不是本服务生成的 IdP 图标标识", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/oci-configs/{id}/images": { "get": { "security": [ @@ -6704,7 +6875,7 @@ } } }, - "put": { + "patch": { "security": [ { "BearerAuth": [] @@ -6713,10 +6884,10 @@ "tags": [ "设置" ], - "summary": "保存 OAuth provider 配置", + "summary": "部分更新 OAuth provider 配置", "parameters": [ { - "description": "provider 配置", + "description": "出现的字段才会被更新", "name": "body", "in": "body", "required": true, @@ -6755,7 +6926,7 @@ } } }, - "put": { + "patch": { "security": [ { "BearerAuth": [] @@ -6764,15 +6935,15 @@ "tags": [ "设置" ], - "summary": "保存安全设置并返回最新值", + "summary": "部分更新安全设置并返回最新值", "parameters": [ { - "description": "请求体", + "description": "出现的字段才会被更新", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/oci-portal_internal_service.SecuritySettings" + "$ref": "#/definitions/oci-portal_internal_service.SecurityPatch" } } ], @@ -7029,7 +7200,7 @@ "required": true }, { - "description": "可更新字段", + "description": "可更新字段;抢机 payload 的 count 语义为目标台数", "name": "body", "in": "body", "required": true, @@ -7044,6 +7215,18 @@ "schema": { "$ref": "#/definitions/oci-portal_internal_model.Task" } + }, + "400": { + "description": "参数非法或抢机目标不大于已完成数量", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } + }, + "409": { + "description": "任务被并发修改,须刷新重试", + "schema": { + "$ref": "#/definitions/internal_api.errorResponse" + } } } }, @@ -7441,6 +7624,35 @@ } } }, + "internal_api.createIdpResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "jitEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "partnerProviderId": { + "type": "string" + }, + "setupWarning": { + "$ref": "#/definitions/internal_api.idpSetupWarning" + }, + "timeCreated": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "internal_api.createInstanceRequest": { "type": "object", "required": [ @@ -7752,6 +7964,34 @@ } } }, + "internal_api.idpIconResponse": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "internal_api.idpSetupWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "resourceCreated": { + "type": "boolean" + } + } + }, "internal_api.importRequest": { "type": "object", "required": [ @@ -11328,6 +11568,13 @@ "hasActiveTask": { "type": "boolean" }, + "series": { + "description": "Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个\n(主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。", + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.OverviewCostSeries" + } + }, "total": { "type": "number" } @@ -11344,6 +11591,23 @@ } } }, + "oci-portal_internal_service.OverviewCostSeries": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "days": { + "type": "array", + "items": { + "$ref": "#/definitions/oci-portal_internal_service.OverviewCostDay" + } + }, + "total": { + "type": "number" + } + } + }, "oci-portal_internal_service.OverviewTasks": { "type": "object", "properties": { @@ -11518,6 +11782,29 @@ } } }, + "oci-portal_internal_service.SecurityPatch": { + "type": "object", + "properties": { + "appUrl": { + "type": "string" + }, + "ipRateBurst": { + "type": "integer" + }, + "ipRateRps": { + "type": "integer" + }, + "loginFailLimit": { + "type": "integer" + }, + "loginLockMinutes": { + "type": "integer" + }, + "realIpHeader": { + "type": "string" + } + } + }, "oci-portal_internal_service.SecuritySettings": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8347059..ef77c7d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -176,6 +176,25 @@ definitions: - metadata - name type: object + internal_api.createIdpResponse: + properties: + enabled: + type: boolean + id: + type: string + jitEnabled: + type: boolean + name: + type: string + partnerProviderId: + type: string + setupWarning: + $ref: '#/definitions/internal_api.idpSetupWarning' + timeCreated: + type: string + type: + type: string + type: object internal_api.createInstanceRequest: properties: assignIpv6: @@ -385,6 +404,24 @@ definitions: error: type: string type: object + internal_api.idpIconResponse: + properties: + fileName: + type: string + url: + type: string + type: object + internal_api.idpSetupWarning: + properties: + code: + type: string + message: + type: string + requestId: + type: string + resourceCreated: + type: boolean + type: object internal_api.importRequest: properties: alias: @@ -2744,6 +2781,13 @@ definitions: type: array hasActiveTask: type: boolean + series: + description: |- + Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个 + (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。 + items: + $ref: '#/definitions/oci-portal_internal_service.OverviewCostSeries' + type: array total: type: number type: object @@ -2754,6 +2798,17 @@ definitions: day: type: string type: object + oci-portal_internal_service.OverviewCostSeries: + properties: + currency: + type: string + days: + items: + $ref: '#/definitions/oci-portal_internal_service.OverviewCostDay' + type: array + total: + type: number + type: object oci-portal_internal_service.OverviewTasks: properties: active: @@ -2869,6 +2924,21 @@ definitions: webhook: $ref: '#/definitions/oci-portal_internal_service.LogWebhookInfo' type: object + oci-portal_internal_service.SecurityPatch: + properties: + appUrl: + type: string + ipRateBurst: + type: integer + ipRateRps: + type: integer + loginFailLimit: + type: integer + loginLockMinutes: + type: integer + realIpHeader: + type: string + type: object oci-portal_internal_service.SecuritySettings: properties: appUrl: @@ -3308,7 +3378,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog' + $ref: '#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog' security: - BearerAuth: [] summary: 分页查询内容日志 @@ -3411,7 +3481,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiContentLog' + $ref: '#/definitions/internal_api.pagedResponse-oci-portal_internal_model_AiCallLog' security: - BearerAuth: [] summary: AI 调用日志列表 @@ -3498,8 +3568,12 @@ paths: schema: $ref: '#/definitions/oci-portal_internal_service.UpdateCredentialsInput' responses: + "200": + description: 已更新,返回换发的新 token + schema: + $ref: '#/definitions/internal_api.tokenResponse' "204": - description: 已更新,请重新登录 + description: 已更新但新 token 签发失败,需重新登录 "401": description: 当前密码错误 schema: @@ -3530,8 +3604,12 @@ paths: required: true type: integer responses: + "200": + description: 已解绑,返回换发的新 token + schema: + $ref: '#/definitions/internal_api.tokenResponse' "204": - description: 已解绑 + description: 已解绑但新 token 签发失败,需重新登录 "409": description: 最后一个身份不可解绑 schema: @@ -3644,8 +3722,12 @@ paths: schema: type: object responses: + "200": + description: 已保存,返回换发的新 token + schema: + $ref: '#/definitions/internal_api.tokenResponse' "204": - description: 已保存 + description: 已保存但新 token 签发失败,需重新登录 "409": description: 未绑定外部身份 schema: @@ -3689,8 +3771,12 @@ paths: schema: type: object responses: + "200": + description: 已启用,返回换发的新 token + schema: + $ref: '#/definitions/internal_api.tokenResponse' "204": - description: 已启用 + description: 已启用但新 token 签发失败,需重新登录 security: - BearerAuth: [] summary: 激活两步验证 @@ -3706,8 +3792,12 @@ paths: schema: type: object responses: + "200": + description: 已停用,返回换发的新 token + schema: + $ref: '#/definitions/internal_api.tokenResponse' "204": - description: 已停用 + description: 已停用但新 token 签发失败,需重新登录 security: - BearerAuth: [] summary: 停用两步验证 @@ -4428,8 +4518,14 @@ paths: name: region type: string responses: + "200": + description: 'all=1 时返回 {deleted: n}' + schema: + additionalProperties: + type: integer + type: object "204": - description: 删除成功,链接立即失效 + description: 按 parId 删单条成功,链接立即失效 security: - BearerAuth: [] summary: 删除临时链接 @@ -4493,6 +4589,12 @@ paths: description: fullUrl 仅创建响应返回,请立即保存 schema: $ref: '#/definitions/oci-portal_internal_oci.PAR' + "400": + description: accessType 或 expiresHours 非法 + schema: + additionalProperties: + type: string + type: object security: - BearerAuth: [] summary: 签发临时链接(PAR) @@ -4665,6 +4767,8 @@ paths: tags: - 租户 IAM post: + description: 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning + 中标明部分成功 parameters: - description: 配置 ID in: path @@ -4685,7 +4789,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/oci-portal_internal_oci.IdentityProviderInfo' + $ref: '#/definitions/internal_api.createIdpResponse' security: - BearerAuth: [] summary: 创建身份提供商(SAML) @@ -4798,6 +4902,85 @@ paths: summary: 更新身份域设置 tags: - 租户 IAM + /api/v1/oci-configs/{id}/idp-icons: + delete: + description: 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 身份域 OCID(缺省 Default 域) + in: query + name: domainId + type: string + - description: 上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.) + in: query + name: fileName + required: true + type: string + responses: + "204": + description: 无内容 + "400": + description: fileName 缺失或不是本服务生成的 IdP 图标标识 + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 删除身份提供商图标 + tags: + - 租户 IAM + post: + consumes: + - multipart/form-data + description: 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址 + parameters: + - description: 配置 ID + in: path + name: id + required: true + type: integer + - description: 身份域 OCID(缺省 Default 域) + in: query + name: domainId + type: string + - description: 图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB) + in: formData + name: file + required: true + type: file + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_api.idpIconResponse' + "400": + description: 缺文件字段、文件为空或文件名非法 + schema: + additionalProperties: + type: string + type: object + "413": + description: 文件超过 1MB + schema: + additionalProperties: + type: string + type: object + "415": + description: 扩展名不支持或与文件内容不符 + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 上传身份提供商图标 + tags: + - 租户 IAM /api/v1/oci-configs/{id}/images: get: parameters: @@ -7090,9 +7273,9 @@ paths: summary: OAuth provider 配置 tags: - 设置 - put: + patch: parameters: - - description: provider 配置 + - description: 出现的字段才会被更新 in: body name: body required: true @@ -7105,7 +7288,7 @@ paths: $ref: '#/definitions/oci-portal_internal_service.OAuthProvidersView' security: - BearerAuth: [] - summary: 保存 OAuth provider 配置 + summary: 部分更新 OAuth provider 配置 tags: - 设置 /api/v1/settings/security: @@ -7120,14 +7303,14 @@ paths: summary: 返回安全设置 tags: - 设置 - put: + patch: parameters: - - description: 请求体 + - description: 出现的字段才会被更新 in: body name: body required: true schema: - $ref: '#/definitions/oci-portal_internal_service.SecuritySettings' + $ref: '#/definitions/oci-portal_internal_service.SecurityPatch' responses: "200": description: OK @@ -7135,7 +7318,7 @@ paths: $ref: '#/definitions/oci-portal_internal_service.SecuritySettings' security: - BearerAuth: [] - summary: 保存安全设置并返回最新值 + summary: 部分更新安全设置并返回最新值 tags: - 设置 /api/v1/settings/task: @@ -7292,7 +7475,7 @@ paths: name: id required: true type: integer - - description: 可更新字段 + - description: 可更新字段;抢机 payload 的 count 语义为目标台数 in: body name: body required: true @@ -7303,6 +7486,14 @@ paths: description: OK schema: $ref: '#/definitions/oci-portal_internal_model.Task' + "400": + description: 参数非法或抢机目标不大于已完成数量 + schema: + $ref: '#/definitions/internal_api.errorResponse' + "409": + description: 任务被并发修改,须刷新重试 + schema: + $ref: '#/definitions/internal_api.errorResponse' security: - BearerAuth: [] summary: 更新任务 diff --git a/internal/api/aiadmin.go b/internal/api/aiadmin.go index 49d7bf9..2b7fce0 100644 --- a/internal/api/aiadmin.go +++ b/internal/api/aiadmin.go @@ -141,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) { // // @Summary 分页查询内容日志 // @Tags AI 管理 -// @Success 200 {object} pagedResponse[model.AiCallLog] +// @Success 200 {object} pagedResponse[model.AiContentLog] // @Security BearerAuth // @Router /api/v1/ai-content-logs [get] func (h *aiAdminHandler) listContentLogs(c *gin.Context) { @@ -400,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) { // @Summary AI 调用日志列表 // @Tags AI 管理 -// @Success 200 {object} pagedResponse[model.AiContentLog] +// @Success 200 {object} pagedResponse[model.AiCallLog] // @Security BearerAuth // @Router /api/v1/ai-logs [get] func (h *aiAdminHandler) listLogs(c *gin.Context) { diff --git a/internal/api/authx.go b/internal/api/authx.go index d747353..d8c359d 100644 --- a/internal/api/authx.go +++ b/internal/api/authx.go @@ -65,7 +65,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) { // @Summary 激活两步验证 // @Tags 认证 // @Param body body object true "{code: 6 位验证码}" -// @Success 204 "已启用" +// @Success 200 {object} tokenResponse "已启用,返回换发的新 token" +// @Success 204 "已启用但新 token 签发失败,需重新登录" // @Security BearerAuth // @Router /api/v1/auth/totp/activate [post] func (h *authxHandler) totpActivate(c *gin.Context) { @@ -93,7 +94,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) { // @Summary 停用两步验证 // @Tags 认证 // @Param body body object true "{password 或 code 任一确认}" -// @Success 204 "已停用" +// @Success 200 {object} tokenResponse "已停用,返回换发的新 token" +// @Success 204 "已停用但新 token 签发失败,需重新登录" // @Security BearerAuth // @Router /api/v1/auth/totp/disable [post] func (h *authxHandler) totpDisable(c *gin.Context) { @@ -170,7 +172,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) { // @Summary 修改登录凭据 // @Tags 认证 // @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)" -// @Success 204 "已更新,请重新登录" +// @Success 200 {object} tokenResponse "已更新,返回换发的新 token" +// @Success 204 "已更新但新 token 签发失败,需重新登录" // @Failure 401 {object} errorResponse "当前密码错误" // @Security BearerAuth // @Router /api/v1/auth/credentials [put] @@ -202,7 +205,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) { // @Summary 密码登录开关 // @Tags 认证 // @Param body body object true "{disabled: bool}" -// @Success 204 "已保存" +// @Success 200 {object} tokenResponse "已保存,返回换发的新 token" +// @Success 204 "已保存但新 token 签发失败,需重新登录" // @Failure 409 {object} errorResponse "未绑定外部身份" // @Security BearerAuth // @Router /api/v1/auth/password-login [put] @@ -375,7 +379,8 @@ func (h *authxHandler) identities(c *gin.Context) { // @Summary 解绑外部身份 // @Tags 认证 // @Param id path int true "身份 ID" -// @Success 204 "已解绑" +// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token" +// @Success 204 "已解绑但新 token 签发失败,需重新登录" // @Failure 409 {object} errorResponse "最后一个身份不可解绑" // @Security BearerAuth // @Router /api/v1/auth/identities/{id} [delete] @@ -414,14 +419,15 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin c.JSON(http.StatusOK, view) } -// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。 +// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值, +// secret 传空串清除、缺省沿用。 // -// @Summary 保存 OAuth provider 配置 +// @Summary 部分更新 OAuth provider 配置 // @Tags 设置 -// @Param body body service.UpdateOAuthInput true "provider 配置" +// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新" // @Success 200 {object} service.OAuthProvidersView // @Security BearerAuth -// @Router /api/v1/settings/oauth [put] +// @Router /api/v1/settings/oauth [patch] func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) { var req service.UpdateOAuthInput if err := c.ShouldBindJSON(&req); err != nil { diff --git a/internal/api/federation.go b/internal/api/federation.go index 99690bc..2ffbb74 100644 --- a/internal/api/federation.go +++ b/internal/api/federation.go @@ -1,13 +1,121 @@ package api import ( + "errors" + "io" + "log" "net/http" "github.com/gin-gonic/gin" "oci-portal/internal/oci" + "oci-portal/internal/service" ) +// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。 +type idpIconResponse struct { + URL string `json:"url"` + FileName string `json:"fileName"` +} + +type idpSetupWarning struct { + Code string `json:"code"` + Message string `json:"message"` + ResourceCreated bool `json:"resourceCreated"` + RequestID string `json:"requestId"` +} + +type createIdpResponse struct { + oci.IdentityProviderInfo + SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"` +} + +// @Summary 上传身份提供商图标 +// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址 +// @Tags 租户 IAM +// @Accept multipart/form-data +// @Param id path int true "配置 ID" +// @Param domainId query string false "身份域 OCID(缺省 Default 域)" +// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)" +// @Success 200 {object} idpIconResponse +// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法" +// @Failure 413 {object} map[string]string "文件超过 1MB" +// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符" +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/idp-icons [post] +func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + status, msg := iconFormStatus(err) + c.JSON(status, gin.H{"error": msg}) + return + } + defer file.Close() + data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底 + if err != nil { + status, msg := iconFormStatus(err) + c.JSON(status, gin.H{"error": msg}) + return + } + url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data) + if err != nil { + respondIdpIconErr(c, err) + return + } + c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName}) +} + +// @Summary 删除身份提供商图标 +// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理 +// @Tags 租户 IAM +// @Param id path int true "配置 ID" +// @Param domainId query string false "身份域 OCID(缺省 Default 域)" +// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.)" +// @Success 204 "无内容" +// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识" +// @Security BearerAuth +// @Router /api/v1/oci-configs/{id}/idp-icons [delete] +func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil { + respondIdpIconErr(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。 +func iconFormStatus(err error) (int, string) { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)" + } + return http.StatusBadRequest, "缺少文件字段 file" +} + +// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。 +func respondIdpIconErr(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrIdpIconTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"}) + case errors.Is(err, service.ErrIdpIconBadType): + c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"}) + case errors.Is(err, service.ErrIdpIconEmpty): + c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"}) + case errors.Is(err, service.ErrIdpIconBadName): + c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"}) + default: + respondError(c, err) + } +} + // ---- Federation:SAML IdP 与 sign-on 免 MFA ---- // createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」, @@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) { } // @Summary 创建身份提供商(SAML) +// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param body body createIdpRequest true "请求体" -// @Success 201 {object} oci.IdentityProviderInfo +// @Success 201 {object} createIdpResponse // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/identity-providers [post] func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) { @@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) { JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true), JitMapEmail: req.JitMapEmail, }) - if err != nil { + respondCreateIdpResult(c, idp, err) +} + +func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) { + if err == nil { + c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp}) + return + } + var partial *oci.PartialIdentityProviderCreateError + if !errors.As(err, &partial) { respondError(c, err) return } - c.JSON(http.StatusCreated, idp) + requestID := logPartialIdpCreate(c, partial) + c.JSON(http.StatusCreated, createIdpResponse{ + IdentityProviderInfo: partial.IdentityProvider, + SetupWarning: &idpSetupWarning{ + Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查", + ResourceCreated: true, RequestID: requestID, + }, + }) +} + +func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string { + requestID := newRequestID() + detail := errors.Unwrap(partial) + if detail == nil { + detail = partial + } + log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail) + return requestID } // @Summary 激活身份提供商 diff --git a/internal/api/federation_test.go b/internal/api/federation_test.go new file mode 100644 index 0000000..a55ec80 --- /dev/null +++ b/internal/api/federation_test.go @@ -0,0 +1,217 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "oci-portal/internal/crypto" + "oci-portal/internal/model" + "oci-portal/internal/oci" + "oci-portal/internal/service" +) + +func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) { + t.Helper() + router, auth, logs, db := newTestRouterDB(t) + id := seedIconConfig(t, db) + token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "") + if err != nil { + t.Fatalf("login: %v", err) + } + return router, token, id, logs +} + +func seedIconConfig(t *testing.T, db *gorm.DB) uint { + t.Helper() + cipher, err := crypto.NewCipher("test-key") + if err != nil { + t.Fatalf("new cipher: %v", err) + } + key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----") + if err != nil { + t.Fatalf("encrypt key: %v", err) + } + cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u", + Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key} + if err := db.Create(&cfg).Error; err != nil { + t.Fatalf("create config: %v", err) + } + return cfg.ID +} + +func iconSVG(t *testing.T, size int) []byte { + t.Helper() + data := []byte(``) + if len(data) > size { + t.Fatalf("svg size %d exceeds target %d", len(data), size) + } + return append(data, bytes.Repeat([]byte(" "), size-len(data))...) +} + +func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", fileName) + if err != nil { + t.Fatalf("create form file: %v", err) + } + _, _ = part.Write(data) + _ = writer.Close() + req := httptest.NewRequest(http.MethodPost, path, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestIdpIconMultipartSizeBoundary(t *testing.T) { + router, token, id, logs := newIconTestRouter(t) + t.Cleanup(logs.Wait) + path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons" + cases := []struct { + name, fileName string + data []byte + want int + }{ + {"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK}, + {"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge}, + {"empty", "icon.png", nil, http.StatusBadRequest}, + {"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType}, + } + for _, tc := range cases { + rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data) + if rec.Code != tc.want { + t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String()) + } + } +} + +func TestDeleteIdpIconRoute(t *testing.T) { + router, token, id, logs := newIconTestRouter(t) + t.Cleanup(logs.Wait) + base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons" + fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png" + query := url.Values{"fileName": {fileName}} + rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "") + if rec.Code != http.StatusNoContent { + t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String()) + } + rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "") + if rec.Code != http.StatusBadRequest { + t.Errorf("invalid delete status = %d, want 400", rec.Code) + } +} + +func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) { + t.Helper() + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil) + responder(ctx, err) + if rec.Code != want { + t.Errorf("%s status = %d, want %d", name, rec.Code, want) + } +} + +func TestIdpIconErrorResponder(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"empty icon", service.ErrIdpIconEmpty, 400}, + {"bad icon name", service.ErrIdpIconBadName, 400}, + {"large icon", service.ErrIdpIconTooLarge, 413}, + {"bad icon type", service.ErrIdpIconBadType, 415}, + } + for _, tc := range cases { + assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr) + } +} + +func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) { + rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String()) + } + if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) { + t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String()) + } +} + +func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) { + partial := &oci.PartialIdentityProviderCreateError{ + IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"}, + } + rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial)) + warning, ok := body["setupWarning"].(map[string]interface{}) + if rec.Code != http.StatusCreated || !ok { + t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String()) + } + requestID, _ := warning["requestId"].(string) + if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" { + t.Errorf("warning = %#v, want stable partial-create contract", warning) + } + if bytes.Contains(rec.Body.Bytes(), []byte("secret")) { + t.Errorf("partial response leaks internal cause: %s", rec.Body.String()) + } +} + +func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) { + t.Helper() + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil) + respondCreateIdpResult(ctx, idp, err) + body := map[string]interface{}{} + if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil { + t.Fatalf("decode response: %v", decodeErr) + } + return rec, body +} + +func TestPARErrorResponder(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"bad PAR type", service.ErrPARInvalidAccessType}, + {"bad PAR expiry", service.ErrPARInvalidExpiration}, + } + for _, tc := range cases { + assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError) + } +} + +func TestCreatePARValidationReturns400(t *testing.T) { + router, token, id, logs := newIconTestRouter(t) + t.Cleanup(logs.Wait) + path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars" + cases := []struct { + name, body string + }{ + {"bad access type", `{"accessType":"BucketRead","expiresHours":24}`}, + {"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`}, + {"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`}, + } + for _, tc := range cases { + rec := doRequest(t, router, http.MethodPost, path, token, tc.body) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String()) + } + } +} diff --git a/internal/api/objectstorage.go b/internal/api/objectstorage.go index 0e963a7..3d7ad3c 100644 --- a/internal/api/objectstorage.go +++ b/internal/api/objectstorage.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "oci-portal/internal/oci" + "oci-portal/internal/service" ) // ---- 对象存储 ---- @@ -363,6 +364,7 @@ type createPARRequest struct { // @Param bucket path string true "桶名" // @Param body body createPARRequest true "请求体" // @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存" +// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post] func (h *ociConfigHandler) createPAR(c *gin.Context) { @@ -379,12 +381,23 @@ func (h *ociConfigHandler) createPAR(c *gin.Context) { Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours, }) if err != nil { - respondError(c, err) + respondPARError(c, err) return } c.JSON(http.StatusCreated, par) } +func respondPARError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrPARInvalidAccessType): + c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"}) + case errors.Is(err, service.ErrPARInvalidExpiration): + c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"}) + default: + respondError(c, err) + } +} + // parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。 type parPage struct { Items []oci.PAR `json:"items"` @@ -424,7 +437,8 @@ func (h *ociConfigHandler) listPARs(c *gin.Context) { // @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)" // @Param all query string false "为 1 时删除全部" // @Param region query string false "区域" -// @Success 204 "删除成功,链接立即失效" +// @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}" +// @Success 204 "按 parId 删单条成功,链接立即失效" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete] func (h *ociConfigHandler) deletePAR(c *gin.Context) { diff --git a/internal/api/router.go b/internal/api/router.go index 3744f7e..d275cc4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -34,6 +34,13 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig registerSettings(secured, settings, notifier, systemLogs, proxies) registerTasksAndLogs(secured, tasks, logEvents) registerOci(secured, ociConfigs) + // 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间, + // 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413 + uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs)) + registerOciUploads(uploads, ociConfigs) + // 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行 + contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs)) + registerOciContentUploads(contentUploads, ociConfigs) registerAiAdmin(secured, aiGateway) registerSwagger(r) diff --git a/internal/api/router_test.go b/internal/api/router_test.go index 049e15e..13f453a 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci return oci.SubscriptionDetail{}, nil } +func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) { + return "https://images.example/" + fileName, "images/generated/" + fileName, nil +} + +func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error { + return nil +} + func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) { t.Helper() r, auth, systemLogs, _ := newTestRouterDB(t) diff --git a/internal/api/routes_auth.go b/internal/api/routes_auth.go index 20c9203..ff42170 100644 --- a/internal/api/routes_auth.go +++ b/internal/api/routes_auth.go @@ -35,5 +35,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa secured.DELETE("/auth/identities/:id", ax.unbindIdentity) secured.POST("/auth/revoke-sessions", ax.revokeSessions) secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) }) - secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) }) + secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) }) } diff --git a/internal/api/routes_oci.go b/internal/api/routes_oci.go index 6291e4a..b3fc587 100644 --- a/internal/api/routes_oci.go +++ b/internal/api/routes_oci.go @@ -127,13 +127,21 @@ func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) { g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail) // 小文件中转:预览/编辑直读直写,不签发 PAR g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent) - g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent) g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs) g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR) // parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配 g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR) } +// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开 +// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。 +// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。 +func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) { + h := &ociConfigHandler{svc: ociConfigs} + g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon) + g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon) +} + // registerOciCost 成本快照。 func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) { g.GET("/oci-configs/:id/costs", h.costs) @@ -167,3 +175,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) { g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption) g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption) } + +// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限); +// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB +// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。 +func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) { + h := &ociConfigHandler{svc: ociConfigs} + g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent) +} diff --git a/internal/api/routes_settings.go b/internal/api/routes_settings.go index 70ed177..dbe92d3 100644 --- a/internal/api/routes_settings.go +++ b/internal/api/routes_settings.go @@ -26,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService secured.GET("/settings/task", st.getTaskSettings) secured.PUT("/settings/task", st.updateTaskSettings) secured.GET("/settings/security", st.getSecurity) - secured.PUT("/settings/security", st.updateSecurity) + secured.PATCH("/settings/security", st.updateSecurity) px := &proxyHandler{svc: proxies} secured.GET("/proxies", px.list) diff --git a/internal/api/settings.go b/internal/api/settings.go index 1452fae..5d5e398 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -316,16 +316,17 @@ func (h *settingsHandler) getSecurity(c *gin.Context) { c.JSON(http.StatusOK, view) } -// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。 +// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段, +// 越界或非法返回 400,保存后立即生效。 // -// @Summary 保存安全设置并返回最新值 +// @Summary 部分更新安全设置并返回最新值 // @Tags 设置 -// @Param body body service.SecuritySettings true "请求体" +// @Param body body service.SecurityPatch true "出现的字段才会被更新" // @Success 200 {object} service.SecuritySettings // @Security BearerAuth -// @Router /api/v1/settings/security [put] +// @Router /api/v1/settings/security [patch] func (h *settingsHandler) updateSecurity(c *gin.Context) { - var req service.SecuritySettings + var req service.SecurityPatch if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/internal/api/task.go b/internal/api/task.go index f74e138..fae44d8 100644 --- a/internal/api/task.go +++ b/internal/api/task.go @@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) { // @Summary 更新任务 // @Tags 任务与日志回传 // @Param id path int true "任务 ID" -// @Param body body updateTaskRequest true "可更新字段" +// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数" // @Success 200 {object} model.Task +// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量" +// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试" // @Security BearerAuth // @Router /api/v1/tasks/{id} [put] func (h *taskHandler) update(c *gin.Context) { @@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) { Status: req.Status, }) if err != nil { - respondError(c, err) + respondUpdateTaskErr(c, err) return } c.JSON(http.StatusOK, task) } +// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。 +func respondUpdateTaskErr(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrTaskConflict): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrSnatchTargetTooLow): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + respondError(c, err) + } +} + // @Summary 删除任务 // @Tags 任务与日志回传 // @Param id path int true "任务 ID" diff --git a/internal/oci/client.go b/internal/oci/client.go index dea17b9..5caf4d1 100644 --- a/internal/oci/client.go +++ b/internal/oci/client.go @@ -105,6 +105,8 @@ type Client interface { CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error) UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error) DeleteBucket(ctx context.Context, cred Credentials, region, name string) error + // AbortAllMultipartUploads 中止桶内全部未完成分片上传(清空删桶前置步骤,404 幂等) + AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error) ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error) DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error @@ -200,6 +202,10 @@ type Client interface { SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) + // UploadDomainImage 上传公开图片到身份域存储(IdP 图标用),返回公网 URL 与存储内文件名。 + UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) + // DeleteDomainImage 按存储内文件名删除身份域公开图片。 + DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error diff --git a/internal/oci/domainimage.go b/internal/oci/domainimage.go new file mode 100644 index 0000000..fd3f346 --- /dev/null +++ b/internal/oci/domainimage.go @@ -0,0 +1,157 @@ +package oci + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" +) + +// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。 +const domainImagesPath = "/storage/v1/Images" + +// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl +// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient +// 发裸 multipart 请求,OCI 签名与代理配置直接复用。 +func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) { + dc, err := c.domainsClient(ctx, cred, region, domainID) + if err != nil { + return "", "", err + } + req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data) + if err != nil { + return "", "", err + } + resp, callErr := dc.BaseClient.Call(ctx, req) + return parseImageUploadResponse(resp, callErr) +} + +// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。 +func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error { + dc, err := c.domainsClient(ctx, cred, region, domainID) + if err != nil { + return err + } + req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName) + if err != nil { + return err + } + resp, callErr := dc.BaseClient.Call(ctx, req) + return finishDomainImageDelete(resp, callErr) +} + +func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) { + body, contentType, err := imageMultipart(fileName, data) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build image upload request: %w", err) + } + req.Header.Set("Content-Type", contentType) + return req, nil +} + +func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) { + u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath) + if err != nil { + return nil, fmt.Errorf("build image delete URL: %w", err) + } + query := u.Query() + query.Set("fileName", fileName) + u.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("build image delete request: %w", err) + } + return req, nil +} + +// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。 +func imageMultipart(fileName string, data []byte) ([]byte, string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile("file", fileName) + if err != nil { + return nil, "", fmt.Errorf("build image multipart: %w", err) + } + if _, err := fw.Write(data); err != nil { + return nil, "", fmt.Errorf("build image multipart: %w", err) + } + if err := w.WriteField("fileName", fileName); err != nil { + return nil, "", fmt.Errorf("build image multipart: %w", err) + } + if err := w.Close(); err != nil { + return nil, "", fmt.Errorf("build image multipart: %w", err) + } + return buf.Bytes(), w.FormDataContentType(), nil +} + +// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。 +func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) { + if resp != nil && resp.Body != nil { + defer drainAndClose(resp.Body) + } + if callErr != nil { + return "", "", fmt.Errorf("upload domain image: %w", callErr) + } + if resp == nil || resp.Body == nil { + return "", "", fmt.Errorf("upload domain image: empty response") + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1)) + if err != nil { + return "", "", fmt.Errorf("read image upload response: %w", err) + } + if len(b) > 1<<20 { + return "", "", fmt.Errorf("upload domain image: response exceeds 1MB") + } + return decodeImageUploadResponse(b) +} + +func decodeImageUploadResponse(data []byte) (string, string, error) { + var out struct { + FileURL string `json:"fileUrl"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal(data, &out); err != nil { + return "", "", fmt.Errorf("parse image upload response: %w", err) + } + if out.FileURL == "" || out.FileName == "" { + return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName") + } + return out.FileURL, out.FileName, nil +} + +func finishDomainImageDelete(resp *http.Response, callErr error) error { + if resp != nil && resp.Body != nil { + defer drainAndClose(resp.Body) + } + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + if callErr != nil { + return fmt.Errorf("delete domain image: %w", callErr) + } + if resp == nil { + return fmt.Errorf("delete domain image: empty response") + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode) + } + return nil +} + +// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。 +func drainAndClose(body io.ReadCloser) { + _, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10)) + _ = body.Close() +} diff --git a/internal/oci/domainimage_test.go b/internal/oci/domainimage_test.go new file mode 100644 index 0000000..9c858eb --- /dev/null +++ b/internal/oci/domainimage_test.go @@ -0,0 +1,143 @@ +package oci + +import ( + "context" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" +) + +type trackingReadCloser struct { + io.Reader + closed bool +} + +type testMultipartFile struct { + name string + data []byte +} + +func (r *trackingReadCloser) Close() error { + r.closed = true + return nil +} + +func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) { + reader := &trackingReadCloser{Reader: strings.NewReader(body)} + return &http.Response{StatusCode: status, Body: reader}, reader +} + +func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) { + t.Helper() + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content type: %v", err) + } + reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"]) + fields, files := map[string]string{}, map[string]testMultipartFile{} + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("next part: %v", err) + } + data, _ := io.ReadAll(part) + if part.FileName() == "" { + fields[part.FormName()] = string(data) + } else { + files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data} + } + } + return fields, files +} + +func TestImageMultipartIncludesFileAndName(t *testing.T) { + fileName := "idp-icon-00112233445566778899aabbccddeeff.png" + body, contentType, err := imageMultipart(fileName, []byte("image-data")) + if err != nil { + t.Fatalf("imageMultipart: %v", err) + } + fields, files := readMultipartFields(t, body, contentType) + if got := fields["fileName"]; got != fileName { + t.Errorf("fileName = %q, want %q", got, fileName) + } + file := files["file"] + if file.name != fileName || string(file.data) != "image-data" { + t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName) + } +} + +func TestParseImageUploadResponse(t *testing.T) { + cases := []struct { + name, body, wantURL, wantName string + status int + callErr error + wantErr bool + }{ + {"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false}, + {"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true}, + {"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true}, + {"bad json", `{`, "", "", 200, nil, true}, + {"http error hides body", `secret-body`, "", "", 400, nil, true}, + {"call error", `{}`, "", "", 500, errors.New("upstream"), true}, + } + for _, tc := range cases { + resp, body := testImageResponse(tc.status, tc.body) + gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr) + if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr { + t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr) + } + if !body.closed { + t.Error("response body was not closed") + } + if err != nil && strings.Contains(err.Error(), "secret-body") { + t.Errorf("error leaked response body: %v", err) + } + } +} + +func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) { + fileName := "images/folder/a b+%.png" + req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName) + if err != nil { + t.Fatalf("newDomainImageDeleteRequest: %v", err) + } + if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath { + t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath) + } + if got := req.URL.Query().Get("fileName"); got != fileName { + t.Errorf("decoded fileName = %q, want %q", got, fileName) + } + if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") { + t.Errorf("raw query is not encoded: %q", req.URL.RawQuery) + } +} + +func TestFinishDomainImageDelete(t *testing.T) { + cases := []struct { + name string + status int + callErr error + wantErr bool + }{ + {"deleted", http.StatusNoContent, nil, false}, + {"already gone", http.StatusNotFound, errors.New("not found"), false}, + {"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true}, + } + for _, tc := range cases { + resp, body := testImageResponse(tc.status, "response") + err := finishDomainImageDelete(resp, tc.callErr) + if (err != nil) != tc.wantErr { + t.Errorf("err = %v, wantErr %v", err, tc.wantErr) + } + if !body.closed { + t.Error("response body was not closed") + } + } +} diff --git a/internal/oci/federation.go b/internal/oci/federation.go index 3d18e29..da0b7b7 100644 --- a/internal/oci/federation.go +++ b/internal/oci/federation.go @@ -3,6 +3,7 @@ package oci import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata" // idpSchema 是 IdentityProvider 资源的 SCIM schema。 const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider" +const idpCreateRollbackWait = 15 * time.Second + +// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。 +const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE" + // IdentityProviderInfo 是一个外部身份提供者的关键字段。 type IdentityProviderInfo struct { ID string `json:"id"` @@ -33,6 +39,27 @@ type IdentityProviderInfo struct { TimeCreated *time.Time `json:"timeCreated,omitempty"` } +// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。 +// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。 +type PartialIdentityProviderCreateError struct { + IdentityProvider IdentityProviderInfo + cause error +} + +func (e *PartialIdentityProviderCreateError) Error() string { + return "identity provider was created but JIT setup is incomplete" +} + +func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause } + +type federationDomainsClient interface { + ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) + CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) + GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) + PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) + DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) +} + // CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为: // 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、 // 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。 @@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials return idps, nil } -// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。 +// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT; +// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。 func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) { dc, err := c.domainsClient(ctx, cred, region, domainID) if err != nil { return IdentityProviderInfo{}, err } + return createSamlIdentityProvider(ctx, dc, in) +} + +func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) { var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups + var err error if in.JitEnabled && in.JitAssignAdminGroup { if adminGroup, err = adminGroupRef(ctx, dc); err != nil { return IdentityProviderInfo{}, err @@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden if err != nil { return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err) } - if in.JitEnabled { - if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil { - return toIdpInfo(resp.IdentityProvider), err - } + info := toIdpInfo(resp.IdentityProvider) + if !in.JitEnabled { + return info, nil } - return toIdpInfo(resp.IdentityProvider), nil + if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil { + return rollbackIncompleteIdp(ctx, dc, info, err) + } + return info, nil +} + +func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) { + if info.ID == "" { + cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable")) + return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause} + } + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait) + defer cancel() + _, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{ + IdentityProviderId: &info.ID, + }) + if rollbackErr == nil { + return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr) + } + cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr)) + return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause} } // buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。 @@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide // adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标; // 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。 -func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) { +func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) { filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1]) count := len(adminGroupNames) resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{ @@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} { } // ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。 -func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error { +func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error { ref := idp.JitUserProvAttributes if ref == nil || ref.Value == nil { got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id}) @@ -302,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains } // rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。 +// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建, +// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。 func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) { - returns := make([]interface{}, 0, len(items)) - changed := false + returns := make([]interface{}, 0, len(items)+1) + changed, seen := false, false for _, item := range items { name, value := deref(item.Name), deref(item.Value) if name == "SamlIDPs" { + seen = true next, ok, err := toggleJSONList(value, idpID, show) if err != nil { return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err) @@ -316,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho } returns = append(returns, map[string]string{"name": name, "value": value}) } + if !seen && show { + returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)}) + changed = true + } return returns, changed, nil } @@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden if err != nil { return nil, err } - body, status, err := fetchSamlMetadata(ctx, url) + body, status, err := fetchSamlMetadata(ctx, cred, url) if err != nil { return nil, err } @@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil { return nil, err } - body, status, err = fetchSamlMetadata(ctx, url) + body, status, err = fetchSamlMetadata(ctx, cred, url) if err != nil { return nil, err } @@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden return body, nil } -// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。 -func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) { +// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量, +// 防异常缓慢或超大响应长期占住请求与内存。 +const ( + samlMetadataTimeout = 30 * time.Second + samlMetadataMaxBytes = 4 << 20 +) + +// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名, +// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。 +func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) { + client, err := metadataHTTPClient(cred) + if err != nil { + return nil, 0, err + } + ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout) + defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil) if err != nil { return nil, 0, fmt.Errorf("build metadata request: %w", err) } - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, 0, fmt.Errorf("download saml metadata: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1)) if err != nil { return nil, 0, fmt.Errorf("read saml metadata: %w", err) } + if len(body) > samlMetadataMaxBytes { + return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes) + } return body, resp.StatusCode, nil } +// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时 +// 报错而非静默直连(失败关闭)。 +func metadataHTTPClient(cred Credentials) (*http.Client, error) { + if cred.Proxy == nil { + return http.DefaultClient, nil + } + if hc := HTTPClientFor(cred.Proxy); hc != nil { + return hc, nil + } + return nil, errors.New("download saml metadata: invalid proxy config") +} + // enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。 func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error { dc, err := c.domainsClient(ctx, cred, region, domainID) diff --git a/internal/oci/federation_test.go b/internal/oci/federation_test.go new file mode 100644 index 0000000..eecb63a --- /dev/null +++ b/internal/oci/federation_test.go @@ -0,0 +1,196 @@ +package oci + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/identitydomains" +) + +type federationCreateStub struct { + created identitydomains.IdentityProvider + createErr error + patchErr error + deleteErr error + patchCalls int + deletedID string + deleteCtxErr error +} + +func (s *federationCreateStub) ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) { + return identitydomains.ListGroupsResponse{}, nil +} + +func (s *federationCreateStub) CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) { + return identitydomains.CreateIdentityProviderResponse{IdentityProvider: s.created}, s.createErr +} + +func (s *federationCreateStub) GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) { + return identitydomains.GetIdentityProviderResponse{IdentityProvider: s.created}, nil +} + +func (s *federationCreateStub) PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) { + s.patchCalls++ + return identitydomains.PatchMappedAttributeResponse{}, s.patchErr +} + +func (s *federationCreateStub) DeleteIdentityProvider(ctx context.Context, request identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) { + s.deletedID = deref(request.IdentityProviderId) + s.deleteCtxErr = ctx.Err() + return identitydomains.DeleteIdentityProviderResponse{}, s.deleteErr +} + +func createdJitIdp(id string) identitydomains.IdentityProvider { + return identitydomains.IdentityProvider{ + Id: idPtr(id), PartnerName: common.String("test-idp"), Type: identitydomains.IdentityProviderTypeSaml, + JitUserProvEnabled: common.Bool(true), JitUserProvAttributes: &identitydomains.IdentityProviderJitUserProvAttributes{Value: common.String("mapping-1")}, + } +} + +func idPtr(value string) *string { + if value == "" { + return nil + } + return common.String(value) +} + +func testJitInput() CreateIdpInput { + return CreateIdpInput{Name: "test-idp", JitEnabled: true, IconURL: "https://img.example/icon.png"} +} + +func TestCreateSamlIdentityProviderCreateFailure(t *testing.T) { + createErr := errors.New("create rejected") + stub := &federationCreateStub{createErr: createErr} + got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput()) + if !errors.Is(err, createErr) { + t.Fatalf("err = %v, want create error", err) + } + if got.ID != "" || stub.patchCalls != 0 || stub.deletedID != "" { + t.Errorf("got = %+v, patchCalls = %d, deletedID = %q", got, stub.patchCalls, stub.deletedID) + } +} + +func TestCreateSamlIdentityProviderJitFailureRollbackSuccess(t *testing.T) { + patchErr := errors.New("patch rejected") + stub := &federationCreateStub{created: createdJitIdp("idp-1"), patchErr: patchErr} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got, err := createSamlIdentityProvider(ctx, stub, testJitInput()) + var partial *PartialIdentityProviderCreateError + if !errors.Is(err, patchErr) || errors.As(err, &partial) { + t.Fatalf("err = %v, want ordinary wrapped patch error", err) + } + if got.ID != "" || stub.deletedID != "idp-1" || stub.deleteCtxErr != nil { + t.Errorf("got.ID = %q, deletedID = %q, deleteCtxErr = %v", got.ID, stub.deletedID, stub.deleteCtxErr) + } +} + +func TestCreateSamlIdentityProviderPartialCreateContract(t *testing.T) { + cases := []struct { + name, id string + deleteErr error + wantDelete bool + }{ + {"rollback fails", "idp-1", errors.New("rollback secret detail"), true}, + {"created id missing", "", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { assertPartialCreate(t, tc.id, tc.deleteErr, tc.wantDelete) }) + } +} + +func assertPartialCreate(t *testing.T, id string, deleteErr error, wantDelete bool) { + t.Helper() + stub := &federationCreateStub{created: createdJitIdp(id), patchErr: errors.New("patch secret detail"), deleteErr: deleteErr} + got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput()) + var partial *PartialIdentityProviderCreateError + if !errors.As(err, &partial) || partial.IdentityProvider.ID != id { + t.Fatalf("got = %+v, err = %v, partial = %+v", got, err, partial) + } + if strings.Contains(err.Error(), "secret") || (stub.deletedID != "") != wantDelete { + t.Errorf("unsafe err = %q or deletedID = %q, wantDelete = %v", err, stub.deletedID, wantDelete) + } +} + +func ruleReturn(name, value string) identitydomains.RuleReturn { + return identitydomains.RuleReturn{Name: common.String(name), Value: common.String(value)} +} + +type rebuildIdpReturnCase struct { + name string + items []identitydomains.RuleReturn + show bool + changed bool + want string +} + +func TestRebuildSamlIdpsReturn(t *testing.T) { + local := ruleReturn("LocalIDPs", `["UserNamePassword"]`) + cases := []rebuildIdpReturnCase{ + {"无SamlIDPs项时添加须补建", []identitydomains.RuleReturn{local}, true, true, `["idp-1"]`}, + {"无SamlIDPs项时移除无变化", []identitydomains.RuleReturn{local}, false, false, ""}, + {"已有其他IdP时追加", []identitydomains.RuleReturn{local, ruleReturn("SamlIDPs", `["other"]`)}, true, true, `["other","idp-1"]`}, + {"已在列表中再添加无变化", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1"]`)}, true, false, `["idp-1"]`}, + {"移除目标IdP", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1","other"]`)}, false, true, `["other"]`}, + {"空值项添加", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "")}, true, true, `["idp-1"]`}, + } + assertRebuildIdpReturns(t, cases) +} + +func assertRebuildIdpReturns(t *testing.T, cases []rebuildIdpReturnCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + returns, changed, err := rebuildSamlIdpsReturn(tc.items, "idp-1", tc.show) + if err != nil { + t.Fatalf("rebuildSamlIdpsReturn: %v", err) + } + if changed != tc.changed { + t.Errorf("changed = %v, want %v", changed, tc.changed) + } + got := "" + for _, r := range returns { + m := r.(map[string]string) + if m["name"] == "SamlIDPs" { + got = m["value"] + } + } + if got != tc.want { + t.Errorf("SamlIDPs = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRebuildSamlIdpsReturnBadJSON(t *testing.T) { + items := []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "not-json")} + if _, _, err := rebuildSamlIdpsReturn(items, "idp-1", true); err == nil { + t.Fatal("坏 JSON 应报错而非静默覆盖") + } +} + +// TestFetchSamlMetadataLimitsBody 锁定匿名元数据请求的响应体上限:超限报错而非吞下。 +func TestFetchSamlMetadataLimitsBody(t *testing.T) { + huge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(bytes.Repeat([]byte("x"), samlMetadataMaxBytes+1)) + })) + defer huge.Close() + if _, _, err := fetchSamlMetadata(context.Background(), Credentials{}, huge.URL); err == nil || + !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("err = %v, want 超限错误", err) + } + ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("")) + })) + defer ok.Close() + body, status, err := fetchSamlMetadata(context.Background(), Credentials{}, ok.URL) + if err != nil || status != http.StatusOK || string(body) != "" { + t.Fatalf("fetch = %q, %d, %v; want 正常返回", body, status, err) + } +} diff --git a/internal/oci/genai_responses.go b/internal/oci/genai_responses.go index 7136c12..0356eee 100644 --- a/internal/oci/genai_responses.go +++ b/internal/oci/genai_responses.go @@ -60,9 +60,9 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials, return nil, err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit)) + payload, err := readCompatBody(response.Body, compatResponsesLimit, "compat responses") if err != nil { - return nil, fmt.Errorf("read compat responses body: %w", err) + return nil, err } return payload, nil } @@ -121,3 +121,16 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden } return callWithHeaderBudget(ctx, client, request, wait) } + +// readCompatBody 读取上游响应体并施加上限;读 limit+1 判超报错—— +// 静默截断的 JSON/音频配 200 会被下游当完整成功记账。 +func readCompatBody(body io.Reader, limit int64, tag string) ([]byte, error) { + payload, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return nil, fmt.Errorf("read %s body: %w", tag, err) + } + if int64(len(payload)) > limit { + return nil, fmt.Errorf("%s body exceeds %d bytes", tag, limit) + } + return payload, nil +} diff --git a/internal/oci/genai_responses_test.go b/internal/oci/genai_responses_test.go index 2ac28b8..10d3db8 100644 --- a/internal/oci/genai_responses_test.go +++ b/internal/oci/genai_responses_test.go @@ -1,6 +1,7 @@ package oci import ( + "bytes" "context" "errors" "io" @@ -139,3 +140,31 @@ func TestCallWithHeaderBudget(t *testing.T) { } }) } + +// TestReadCompatBodyRejectsOversize 锁定上游响应上限:恰好达限通过,超限报错, +// 不允许静默截断配 200 让下游当完整成功。 +func TestReadCompatBodyRejectsOversize(t *testing.T) { + cases := []struct { + name string + size int64 + wantErr bool + }{ + {"under limit", 15, false}, + {"exactly limit", 16, false}, + {"over limit", 17, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, err := readCompatBody(bytes.NewReader(make([]byte, tc.size)), 16, "test") + if tc.wantErr { + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("err = %v, want 超限错误", err) + } + return + } + if err != nil || int64(len(payload)) != tc.size { + t.Fatalf("payload = %d bytes, %v; want %d", len(payload), err, tc.size) + } + }) + } +} diff --git a/internal/oci/genai_speech.go b/internal/oci/genai_speech.go index 8d258d7..19c2d3f 100644 --- a/internal/oci/genai_speech.go +++ b/internal/oci/genai_speech.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "io" "net/http" "github.com/oracle/oci-go-sdk/v65/common" @@ -36,9 +35,9 @@ func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, re return nil, "", err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit)) + payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech") if err != nil { - return nil, "", fmt.Errorf("read compat speech body: %w", err) + return nil, "", err } return payload, response.Header.Get("Content-Type"), nil } diff --git a/internal/oci/logrelay.go b/internal/oci/logrelay.go index 68350eb..d673997 100644 --- a/internal/oci/logrelay.go +++ b/internal/oci/logrelay.go @@ -2,8 +2,6 @@ package oci import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "strings" "time" @@ -14,14 +12,11 @@ import ( "github.com/oracle/oci-go-sdk/v65/sch" ) -// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建; +// 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生; // 资源建在凭据默认区域,Audit 日志组含子 compartment。 // Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用 // 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。 const ( - relayTopicPrefix = "ociportal-logs" - relayPolicyName = "ociportal-logs-sch" - relayConnectorName = "ociportal-logs" relayAuditLogGroup = "_Audit_Include_Subcompartment" relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限 ) @@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er return sc, nil } -// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。 +// EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀; +// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。 func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) { cp, err := c.onsControlClient(cred) if err != nil { return RelayResource{}, err } - if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok { - return res, err + names := relayResourceNames(cred.TenancyOCID) + res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix) + if err != nil { + return RelayResource{}, err } - name, err := relayTopicNewName() + if ok { + refreshRelayTopicDesc(ctx, cp, res.ID) + return res, nil + } + return createRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix) +} + +// createRelayTopic 以指定前缀 + 4 字节随机后缀新建 ONS Topic,描述使用中性文案。 +func createRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy, prefix string) (RelayResource, error) { + name, err := relayTopicNewName(prefix) if err != nil { return RelayResource{}, err } created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{ CreateTopicDetails: ons.CreateTopicDetails{ Name: &name, - CompartmentId: &cred.TenancyOCID, - Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"), + CompartmentId: &tenancy, + Description: common.String(relayTopicDescNew), }, }) if err != nil { @@ -109,28 +116,17 @@ func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (Re return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil } -// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。 -func relayTopicNewName() (string, error) { - buf := make([]byte, 4) - if _, err := rand.Read(buf); err != nil { - return "", fmt.Errorf("topic name suffix: %w", err) - } - return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil -} - -// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。 -func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) { +// findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回。 +// 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。 +func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) { req := ons.ListTopicsRequest{CompartmentId: &tenancy} for page := 0; page < relayTopicPages; page++ { list, err := cp.ListTopics(ctx, req) if err != nil { return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err) } - for _, t := range list.Items { - if strings.HasPrefix(deref(t.Name), relayTopicPrefix) && - t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive { - return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil - } + if res, ok := matchRelayTopic(list.Items, prefixes); ok { + return res, true, nil } if list.OpcNextPage == nil { break @@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, return RelayResource{}, false, nil } +// matchRelayTopic 在一页 Topic 中挑出第一个命名前缀命中且处于 ACTIVE 的资源。 +func matchRelayTopic(items []ons.NotificationTopicSummary, prefixes []string) (RelayResource, bool) { + for _, t := range items { + if t.LifecycleState != ons.NotificationTopicSummaryLifecycleStateActive { + continue + } + name := deref(t.Name) + for _, p := range prefixes { + if strings.HasPrefix(name, p) { + return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true + } + } + } + return RelayResource{}, false +} + +// refreshRelayTopicDesc 尽力把 Topic 描述改为中性文案;失败不阻塞主流程(权限不足等场景直接忽略)。 +func refreshRelayTopicDesc(ctx context.Context, cp ons.NotificationControlPlaneClient, topicID string) { + if topicID == "" { + return + } + _, _ = cp.UpdateTopic(ctx, ons.UpdateTopicRequest{ + TopicId: &topicID, + TopicAttributesDetails: ons.TopicAttributesDetails{ + Description: common.String(relayTopicDescNew), + }, + }) +} + // EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建; // 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。 func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) { @@ -196,28 +221,50 @@ func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials, return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil } -// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建; -// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。 +// EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名; +// 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。 func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) { ic, err := c.identityClientAt(cred, homeRegion) if err != nil { return RelayResource{}, err } - list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{ - CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName), - }) + names := relayResourceNames(cred.TenancyOCID) + res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName) if err != nil { - return RelayResource{}, fmt.Errorf("list policies: %w", err) + return RelayResource{}, err } - if len(list.Items) > 0 { - return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil + if ok { + refreshRelayPolicyDesc(ctx, ic, res.ID) + return res, nil } - stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID) + return createRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName) +} + +// findRelayPolicy 依次按传入的名称列表在租户范围内查找 IAM Policy;第一个命中即返回。 +func findRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy string, names ...string) (RelayResource, bool, error) { + for _, name := range names { + list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{ + CompartmentId: &tenancy, Name: common.String(name), + }) + if err != nil { + return RelayResource{}, false, fmt.Errorf("list policies: %w", err) + } + if len(list.Items) > 0 { + it := list.Items[0] + return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, nil + } + } + return RelayResource{}, false, nil +} + +// createRelayPolicy 以中性描述与派生名称新建 Policy,statement 允许 Service Connector 发布消息到租户内 Topic。 +func createRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy, name string) (RelayResource, error) { + stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", tenancy) created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{ CreatePolicyDetails: identity.CreatePolicyDetails{ - CompartmentId: &cred.TenancyOCID, - Name: common.String(relayPolicyName), - Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"), + CompartmentId: &tenancy, + Name: common.String(name), + Description: common.String(relayPolicyDescNew), Statements: []string{stmt}, }, }) @@ -227,25 +274,45 @@ func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, ho return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil } -// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic, -// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。 +// refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。 +func refreshRelayPolicyDesc(ctx context.Context, ic identity.IdentityClient, id string) { + if id == "" { + return + } + _, _ = ic.UpdatePolicy(ctx, identity.UpdatePolicyRequest{ + PolicyId: &id, + UpdatePolicyDetails: identity.UpdatePolicyDetails{ + Description: common.String(relayPolicyDescNew), + }, + }) +} + +// EnsureRelayConnector 实现 Client:按新命名返回既有 Connector,fallback 到 legacy DisplayName; +// 命中时对齐 Log Filter 条件,未命中则以新命名新建并轮询至 ACTIVE。 func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) { sc, err := c.schClient(cred) if err != nil { return RelayResource{}, err } - res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID) + names := relayResourceNames(cred.TenancyOCID) + res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName) if err != nil { return RelayResource{}, err } if ok { return res, reconcileRelayCondition(ctx, sc, res.ID, condition) } + return createRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, topicID, condition) +} + +// createRelayConnector 以派生名称新建 Service Connector(_Audit 含子区间 → Topic,按 condition 过滤), +// 新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。Connector 不设 Description,避免恒定文案指纹。 +func createRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name, topicID, condition string) (RelayResource, error) { details := sch.CreateServiceConnectorDetails{ - DisplayName: common.String(relayConnectorName), - CompartmentId: &cred.TenancyOCID, + DisplayName: common.String(name), + CompartmentId: &tenancy, Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{ - CompartmentId: &cred.TenancyOCID, + CompartmentId: &tenancy, LogGroupId: common.String(relayAuditLogGroup), }}}, Target: sch.NotificationsTargetDetails{TopicId: &topicID}, @@ -258,7 +325,7 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, }); err != nil { return RelayResource{}, fmt.Errorf("create service connector: %w", err) } - return waitRelayConnector(ctx, sc, cred.TenancyOCID) + return waitRelayConnector(ctx, sc, tenancy, name) } // reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更 @@ -300,24 +367,27 @@ func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool { return *rule.Condition != want } -// findRelayConnector 按名查找存活 Connector。 -func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) { - list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{ - CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName), - }) - if err != nil { - return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err) - } - for _, item := range list.Items { - if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting { - return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil +// findRelayConnector 依次按传入的 DisplayName 列表在租户范围内查找存活 Connector;第一个命中即返回。 +func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string, names ...string) (RelayResource, bool, error) { + for _, name := range names { + list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{ + CompartmentId: &tenancy, DisplayName: common.String(name), + }) + if err != nil { + return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err) + } + for _, item := range list.Items { + if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting { + return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil + } } } return RelayResource{}, false, nil } // waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。 -func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) { +// 只按新命名查找(新建资源用的就是新命名)。 +func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name string) (RelayResource, error) { last := RelayResource{Created: true} for i := 0; i < relayConnectorPollLimit; i++ { select { @@ -325,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena return last, ctx.Err() case <-time.After(relayConnectorPollTick): } - res, ok, err := findRelayConnector(ctx, sc, tenancy) + res, ok, err := findRelayConnector(ctx, sc, tenancy, name) if err != nil { return last, err } @@ -346,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint if err != nil { return st, err } - if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil { + names := relayResourceNames(cred.TenancyOCID) + if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix); err != nil { return st, err } if st.Topic.ID != "" { @@ -361,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint return c.relayControlState(ctx, cred, st) } -// relayControlState 补齐 Connector 与 Policy 两项状态。 +// relayControlState 补齐 Connector 与 Policy 两项状态,双路径兼容 legacy 命名。 func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) { sc, err := c.schClient(cred) if err != nil { return st, err } - if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil { + names := relayResourceNames(cred.TenancyOCID) + if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName); err != nil { return st, err } ic, err := c.identityClientAt(cred, "") if err != nil { return st, err } - list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{ - CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName), - }) - if err != nil { - return st, fmt.Errorf("list policies: %w", err) - } - if len(list.Items) > 0 { - st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)} + if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil { + return st, err } return st, nil } diff --git a/internal/oci/logrelay_names.go b/internal/oci/logrelay_names.go new file mode 100644 index 0000000..b8a4e91 --- /dev/null +++ b/internal/oci/logrelay_names.go @@ -0,0 +1,53 @@ +package oci + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// 日志回传链路资源命名与描述文案的集中定义。 +// 目标:让每租户派生互异的中性资源名,避免跨租户恒定字面量成为「共用同一套 oci-portal」的指纹。 + +// relayNames 是按 tenancy 派生的一组资源名。 +type relayNames struct { + TopicPrefix string // 例: "c3f2a1b0-audit",Topic 最终名再加短随机后缀 + PolicyName string // 例: "c3f2a1b0-audit-p" + ConnectorName string // 例: "c3f2a1b0-audit" +} + +// relayResourceNames 由 tenancyOCID 派生一组稳定的中性资源名。 +// SHA-256 前 4 字节做前缀,同 tenancy 每次调用结果一致(幂等查找依赖此性质)。 +func relayResourceNames(tenancyOCID string) relayNames { + sum := sha256.Sum256([]byte(tenancyOCID)) + prefix := hex.EncodeToString(sum[:4]) + return relayNames{ + TopicPrefix: prefix + "-audit", + PolicyName: prefix + "-audit-p", + ConnectorName: prefix + "-audit", + } +} + +// relayTopicNewName 按给定前缀 + 4 字节随机后缀生成 Topic 名,规避 ONS 删除保留期。 +func relayTopicNewName(prefix string) (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("topic name suffix: %w", err) + } + return prefix + "-" + hex.EncodeToString(buf), nil +} + +// 描述文案:中性英文,不含品牌明文;集中于此便于后续再调整。 +const ( + relayTopicDescNew = "Audit event relay endpoint" + relayPolicyDescNew = "Allow Service Connector to publish audit events to notification topic" +) + +// legacyRelay* 仅用于识别旧版本(硬编码 ociportal-logs*)创建的存量资源,不用于新建。 +// 新租户完全走 relayResourceNames 派生;存量租户 fallback 命中后描述会一次性刷新为中性文案。 +const ( + legacyRelayTopicPrefix = "ociportal-logs" + legacyRelayPolicyName = "ociportal-logs-sch" + legacyRelayConnectorName = "ociportal-logs" +) diff --git a/internal/oci/logrelay_names_test.go b/internal/oci/logrelay_names_test.go new file mode 100644 index 0000000..d662f50 --- /dev/null +++ b/internal/oci/logrelay_names_test.go @@ -0,0 +1,115 @@ +package oci + +import ( + "regexp" + "strings" + "testing" +) + +func TestRelayResourceNames_Deterministic(t *testing.T) { + tenancy := "ocid1.tenancy.oc1..aaaaexampletenant" + a := relayResourceNames(tenancy) + b := relayResourceNames(tenancy) + if a != b { + t.Fatalf("同 tenancy 派生不一致: %+v vs %+v", a, b) + } +} + +func TestRelayResourceNames_DistinctTenancies(t *testing.T) { + cases := []string{ + "ocid1.tenancy.oc1..aaaaaaaaaa", + "ocid1.tenancy.oc1..bbbbbbbbbb", + "ocid1.tenancy.oc1..cccccccccc", + } + seen := map[string]string{} + for _, tenancy := range cases { + n := relayResourceNames(tenancy) + if got, ok := seen[n.TopicPrefix]; ok { + t.Fatalf("前缀冲突: %s 与 %s 同为 %s", tenancy, got, n.TopicPrefix) + } + seen[n.TopicPrefix] = tenancy + assertNoBrandLeak(t, tenancy, n) + } +} + +// assertNoBrandLeak 断言派生结果不含品牌明文,以及格式符合预期。 +func assertNoBrandLeak(t *testing.T, tenancy string, n relayNames) { + t.Helper() + banned := []string{"oci-portal", "ociportal", "logs", "portal"} + fields := []struct { + name string + value string + }{ + {"TopicPrefix", n.TopicPrefix}, + {"PolicyName", n.PolicyName}, + {"ConnectorName", n.ConnectorName}, + } + for _, f := range fields { + lower := strings.ToLower(f.value) + for _, b := range banned { + if strings.Contains(lower, b) { + t.Errorf("tenancy %s: %s=%q 含品牌明文 %q", tenancy, f.name, f.value, b) + } + } + if !regexp.MustCompile(`^[0-9a-f]{8}-audit(-p)?$`).MatchString(f.value) { + t.Errorf("tenancy %s: %s=%q 不符合 <8hex>-audit(-p)? 格式", tenancy, f.name, f.value) + } + } +} + +func TestRelayTopicNewName_Suffix(t *testing.T) { + tests := []struct { + name string + prefix string + }{ + {name: "标准前缀", prefix: "c3f2a1b0-audit"}, + {name: "legacy 前缀兼容", prefix: legacyRelayTopicPrefix}, + } + pat := regexp.MustCompile(`^[0-9a-f]{8}$`) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := relayTopicNewName(tt.prefix) + if err != nil { + t.Fatalf("生成失败: %v", err) + } + if !strings.HasPrefix(got, tt.prefix+"-") { + t.Errorf("前缀不符: got=%q, prefix=%q", got, tt.prefix) + } + suffix := strings.TrimPrefix(got, tt.prefix+"-") + if !pat.MatchString(suffix) { + t.Errorf("后缀非 8 hex: got=%q, suffix=%q", got, suffix) + } + }) + } +} + +func TestLegacyPrefixes_Unchanged(t *testing.T) { + cases := []struct { + name string + got string + want string + }{ + {name: "Topic legacy", got: legacyRelayTopicPrefix, want: "ociportal-logs"}, + {name: "Policy legacy", got: legacyRelayPolicyName, want: "ociportal-logs-sch"}, + {name: "Connector legacy", got: legacyRelayConnectorName, want: "ociportal-logs"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("legacy 常量被误改: got=%q, want=%q", tt.got, tt.want) + } + }) + } +} + +func TestRelayDescriptions_Neutral(t *testing.T) { + banned := []string{"oci-portal", "ociportal", "日志回传", "面板"} + for _, desc := range []string{relayTopicDescNew, relayPolicyDescNew} { + lower := strings.ToLower(desc) + for _, b := range banned { + if strings.Contains(lower, strings.ToLower(b)) { + t.Errorf("描述含品牌明文 %q: %q", b, desc) + } + } + } +} diff --git a/internal/oci/objectstorage.go b/internal/oci/objectstorage.go index 0f04115..49530b2 100644 --- a/internal/oci/objectstorage.go +++ b/internal/oci/objectstorage.go @@ -97,7 +97,7 @@ type UpdateBucketInput struct { type CreatePARInput struct { Name string ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法) - AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite + AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectRead / AnyObjectWrite / AnyObjectReadWrite ExpiresHours int } @@ -288,6 +288,48 @@ func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region, return nil } +// AbortAllMultipartUploads 实现 Client:中止桶内全部未完成分片上传并删除已传分片。 +// 未完成分片会让「已清空」的桶仍以非空拒绝删除;404 视为桶已不存在,幂等成功。 +func (c *RealClient) AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error { + oc, err := c.osClient(cred, region) + if err != nil { + return err + } + ns, err := c.GetObjectStorageNamespace(ctx, cred, region) + if err != nil { + return err + } + for page := (*string)(nil); ; { + resp, err := oc.ListMultipartUploads(ctx, objectstorage.ListMultipartUploadsRequest{ + NamespaceName: &ns, BucketName: &bucket, Page: page, + }) + if err != nil { + return ignoreNotFound(fmt.Errorf("list multipart uploads: %w", err)) + } + for _, up := range resp.Items { + if _, err := oc.AbortMultipartUpload(ctx, objectstorage.AbortMultipartUploadRequest{ + NamespaceName: &ns, BucketName: &bucket, ObjectName: up.Object, UploadId: up.UploadId, + }); err != nil { + if err := ignoreNotFound(fmt.Errorf("abort multipart upload: %w", err)); err != nil { + return err + } + } + } + if page = resp.OpcNextPage; page == nil { + return nil + } + } +} + +// ignoreNotFound 把上游 404 归一为 nil(资源已不存在,操作目的已达成)。 +func ignoreNotFound(err error) error { + var se common.ServiceError + if errors.As(err, &se) && se.GetHTTPStatusCode() == 404 { + return nil + } + return err +} + // isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty, // 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist, // 两者都可经后台清空(对象版本+PAR)后重删。 @@ -582,21 +624,7 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu if err != nil { return PAR{}, err } - if in.ExpiresHours <= 0 { - in.ExpiresHours = 24 - } - details := objectstorage.CreatePreauthenticatedRequestDetails{ - Name: &in.Name, - AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType), - TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)}, - } - if in.ObjectName != "" { - details.ObjectName = &in.ObjectName - } - // 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览 - if strings.HasPrefix(in.AccessType, "AnyObject") { - details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects - } + details := createPARDetails(in, time.Now()) resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{ NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details, }) @@ -606,6 +634,26 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil } +// createPARDetails 集中组装 SDK 请求,避免 accessType 与桶列举权限组合回归。 +func createPARDetails(in CreatePARInput, now time.Time) objectstorage.CreatePreauthenticatedRequestDetails { + hours := in.ExpiresHours + if hours <= 0 { + hours = 24 + } + details := objectstorage.CreatePreauthenticatedRequestDetails{ + Name: &in.Name, + AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType), + TimeExpires: &common.SDKTime{Time: now.Add(time.Duration(hours) * time.Hour)}, + } + if in.ObjectName != "" { + details.ObjectName = &in.ObjectName + } + if in.AccessType == "AnyObjectRead" || in.AccessType == "AnyObjectReadWrite" { + details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects + } + return details +} + func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR { out := PAR{ ID: deref(p.Id), diff --git a/internal/oci/objectstorage_test.go b/internal/oci/objectstorage_test.go index 642ce43..3092aed 100644 --- a/internal/oci/objectstorage_test.go +++ b/internal/oci/objectstorage_test.go @@ -2,7 +2,10 @@ package oci import ( "context" + "encoding/json" + "strings" "testing" + "time" ) // namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用 @@ -27,3 +30,47 @@ func TestGetObjectStorageNamespaceCached(t *testing.T) { }) } } + +func TestCreatePARDetailsBucketListingAction(t *testing.T) { + cases := []struct { + accessType string + wantList bool + }{ + {"AnyObjectRead", true}, + {"AnyObjectReadWrite", true}, + {"AnyObjectWrite", false}, + {"ObjectRead", false}, + } + for _, tc := range cases { + details := createPARDetails(CreatePARInput{AccessType: tc.accessType, ExpiresHours: 1}, time.Unix(100, 0)) + gotList := string(details.BucketListingAction) == "ListObjects" + if gotList != tc.wantList { + t.Errorf("%s listing = %q, wantList %v", tc.accessType, details.BucketListingAction, tc.wantList) + } + wire, err := json.Marshal(details) + if err != nil { + t.Fatalf("marshal %s details: %v", tc.accessType, err) + } + gotWireList := strings.Contains(string(wire), `"bucketListingAction":"ListObjects"`) + if gotWireList != tc.wantList { + t.Errorf("%s wire = %s, wantList %v", tc.accessType, wire, tc.wantList) + } + } +} + +func TestCreatePARDetailsExpirationAndObject(t *testing.T) { + now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + details := createPARDetails(CreatePARInput{ + Name: "share", ObjectName: "folder/a.txt", AccessType: "ObjectRead", ExpiresHours: 48, + }, now) + if got, want := details.TimeExpires.Time, now.Add(48*time.Hour); !got.Equal(want) { + t.Errorf("TimeExpires = %v, want %v", got, want) + } + if details.ObjectName == nil || *details.ObjectName != "folder/a.txt" { + t.Errorf("ObjectName = %v, want folder/a.txt", details.ObjectName) + } + defaults := createPARDetails(CreatePARInput{AccessType: "ObjectRead"}, now) + if got, want := defaults.TimeExpires.Time, now.Add(24*time.Hour); !got.Equal(want) { + t.Errorf("default TimeExpires = %v, want %v", got, want) + } +} diff --git a/internal/oci/signon.go b/internal/oci/signon.go index 2bf4a3d..81b057c 100644 --- a/internal/oci/signon.go +++ b/internal/oci/signon.go @@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy" // scimPatchSchema 是 SCIM PatchOp 的 schema。 const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +// signOnConsentSchema 是修改 Oracle 预置 OCI Console sign-on 策略的知情同意扩展 schema。 +const signOnConsentSchema = "urn:ietf:params:scim:schemas:oracle:idcs:extension:ociconsolesignonpolicyconsent:Policy" + // SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。 type SignOnRuleInfo struct { ID string `json:"id"` @@ -220,15 +223,18 @@ func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRu func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error { var value interface{} = rules + var consent interface{} = true + var justification interface{} = "MFA Configured in External IDP" _, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{ PolicyId: common.String(ociConsolePolicyID), PatchOp: identitydomains.PatchOp{ Schemas: []string{scimPatchSchema}, - Operations: []identitydomains.Operations{{ - Op: identitydomains.OperationsOpReplace, - Path: common.String("rules"), - Value: &value, - }}, + // 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent + Operations: []identitydomains.Operations{ + {Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value}, + {Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":consent"), Value: &consent}, + {Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":justification"), Value: &justification}, + }, }, }) if err != nil { diff --git a/internal/service/aigateway.go b/internal/service/aigateway.go index 5f75955..9720958 100644 --- a/internal/service/aigateway.go +++ b/internal/service/aigateway.go @@ -1048,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) { if err != nil || !ok { return err } + on, err := contentLogStillOn(tx, entry.KeyID) + if err != nil || !on { + return err + } return tx.Create(&entry).Error }) if err != nil { @@ -1055,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) { } } +// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入, +// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。 +func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) { + var key model.AiKey + err := tx.First(&key, keyID).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil +} + func truncateBody(s string) string { if len(s) > aiContentBodyLimit { return s[:aiContentBodyLimit] @@ -1110,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) { s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log") } -// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。 +// cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限 +// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。 +var cleanupBatch = 10000 + +// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环, +// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。 func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) { cutoff := time.Now().Add(-retention) if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil { @@ -1119,16 +1142,36 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti } var total int64 if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil { + log.Printf("%s cleanup count: %v", tag, err) return } - if overflow := int(total) - maxRows; overflow > 0 { - var ids []uint - s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids) - if len(ids) > 0 { - s.db.WithContext(ctx).Delete(m, ids) + for overflow := int(total) - maxRows; overflow > 0; { + n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch)) + if err != nil { + log.Printf("%s cleanup overflow: %v", tag, err) + return } + if n == 0 { + return + } + overflow -= n } } +// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。 +func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) { + var ids []uint + if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil { + return 0, fmt.Errorf("pluck oldest: %w", err) + } + if len(ids) == 0 { + return 0, nil + } + if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil { + return 0, fmt.Errorf("delete batch: %w", err) + } + return len(ids), nil +} + // Wait 等待后台清理 goroutine 退出。 func (s *AiGatewayService) Wait() { s.wg.Wait() } diff --git a/internal/service/aigateway_extras_test.go b/internal/service/aigateway_extras_test.go index 7cee53c..97b9350 100644 --- a/internal/service/aigateway_extras_test.go +++ b/internal/service/aigateway_extras_test.go @@ -11,6 +11,8 @@ import ( "oci-portal/internal/aiwire" "oci-portal/internal/model" "oci-portal/internal/oci" + + "gorm.io/gorm" ) // TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。 @@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) { t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err) } } + +// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为: +// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。 +func TestCleanupTableBatchesOverflow(t *testing.T) { + gw, _ := newTestGateway(t, &fakeClient{}) + old := cleanupBatch + cleanupBatch = 3 + t.Cleanup(func() { cleanupBatch = old }) + for i := 0; i < 10; i++ { + if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil { + t.Fatalf("seed %d: %v", i, err) + } + } + // maxRows=2:溢出 8 行,需 3 轮批次(3+3+2) + gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test") + var rest []model.AiCallLog + if err := gw.db.Order("id").Find(&rest).Error; err != nil { + t.Fatalf("load rest: %v", err) + } + if len(rest) != 2 { + t.Fatalf("remaining = %d, want 2", len(rest)) + } + for _, row := range rest { + if row.ID <= 8 { + t.Errorf("row %d survived, want oldest deleted first", row.ID) + } + } +} + +// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥, +// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。 +func TestLogContentRechecksKeyInsideTx(t *testing.T) { + gw, _ := newTestGateway(t, &fakeClient{}) + if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil { + t.Fatalf("migrate content log: %v", err) + } + future := time.Now().Add(time.Hour) + key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future} + call := model.AiCallLog{Endpoint: "chat"} + if err := gw.db.Create(&key).Error; err != nil { + t.Fatalf("seed key: %v", err) + } + if err := gw.db.Create(&call).Error; err != nil { + t.Fatalf("seed call: %v", err) + } + countIs := func(want int64, note string) { + t.Helper() + var n int64 + if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want { + t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want) + } + } + gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"}) + countIs(1, "窗口开启时应写入") + // 管理员立即关闭窗口:在途请求携带的旧快照不得再写 + if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID). + Update("content_log_until", gorm.Expr("NULL")).Error; err != nil { + t.Fatalf("close window: %v", err) + } + gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"}) + countIs(1, "窗口关闭后不得写入") + // 密钥删除后同样拒写 + if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil { + t.Fatalf("delete key: %v", err) + } + gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"}) + countIs(1, "密钥已删后不得写入") +} diff --git a/internal/service/credentials.go b/internal/service/credentials.go index 0b1d810..5552063 100644 --- a/internal/service/credentials.go +++ b/internal/service/credentials.go @@ -8,6 +8,7 @@ import ( "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" "oci-portal/internal/model" ) @@ -119,33 +120,53 @@ func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) { } // SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。 +// 检查与写入在同一事务内并锁定用户行,防与解绑身份并发绕过「至少一种登录方式」。 func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error { if s.settings == nil { return errors.New("settings unavailable") } + value := "" if disabled { - user, err := s.findUser(ctx, username) - if err != nil { - return err - } - n, err := s.identityCount(ctx, user.ID) - if err != nil { - return err - } - if n == 0 { - return ErrNeedIdentity - } + value = "1" } - if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + user, err := lockUserForAuthChange(tx, username) + if err != nil { + return err + } + if disabled { + n, err := identityCountTx(tx, user.ID) + if err != nil { + return err + } + if n == 0 { + return ErrNeedIdentity + } + } + return saveSettingTx(tx, settingSecPasswordLoginOff, value) + }) + if err != nil { return err } // 登录策略属敏感变更:递增令牌版本,已签发会话全部失效 return s.bumpTokenVersion(ctx, username) } -func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) { +// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁), +// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。 +func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) { + var user model.User + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&user, "username = ?", username).Error + if err != nil { + return nil, fmt.Errorf("find user %s: %w", username, err) + } + return &user, nil +} + +func identityCountTx(tx *gorm.DB, userID uint) (int64, error) { var count int64 - err := s.db.WithContext(ctx).Model(&model.UserIdentity{}). + err := tx.Model(&model.UserIdentity{}). Where("user_id = ?", userID).Count(&count).Error if err != nil { return 0, fmt.Errorf("count identities: %w", err) diff --git a/internal/service/federation.go b/internal/service/federation.go index df61916..5616fda 100644 --- a/internal/service/federation.go +++ b/internal/service/federation.go @@ -2,12 +2,36 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" + "errors" "fmt" + "io" + "path" "strings" + "unicode" "oci-portal/internal/oci" ) +// idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。 +const ( + idpIconMaxBytes = 1 << 20 + idpIconRandomBytes = 16 +) + +var idpIconExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true, +} + +// 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。 +var ( + ErrIdpIconEmpty = errors.New("icon file is empty") + ErrIdpIconTooLarge = errors.New("icon exceeds 1MB") + ErrIdpIconBadType = errors.New("icon file type not allowed") + ErrIdpIconBadName = errors.New("icon file name is invalid") +) + // IdentityProviders 列出域内 SAML 身份提供者。 func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) { cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) @@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint, if err != nil { return oci.IdentityProviderInfo{}, err } - return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in)) + in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in)) + if err != nil { + return oci.IdentityProviderInfo{}, err + } + return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in) +} + +func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) { + if !in.JitEnabled { + return in, nil + } + setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID) + if err != nil { + return in, fmt.Errorf("get identity setting before create idp: %w", err) + } + if setting.PrimaryEmailRequired { + in.JitMapEmail = true + } + return in, nil } // normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、 @@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID) } +// UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。 +func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) { + return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader) +} + +func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) { + if len(data) == 0 { + return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty) + } + if len(data) > idpIconMaxBytes { + return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge) + } + fileName, err := validateIdpIcon(fileName, data) + if err != nil { + return "", "", fmt.Errorf("upload idp icon: %w", err) + } + fileName, err = newIdpIconStorageName(fileName, random) + if err != nil { + return "", "", fmt.Errorf("upload idp icon: %w", err) + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return "", "", err + } + url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data) + if err != nil { + return "", "", fmt.Errorf("upload idp icon: %w", err) + } + return url, storedName, nil +} + +func newIdpIconStorageName(fileName string, random io.Reader) (string, error) { + token := make([]byte, idpIconRandomBytes) + if _, err := io.ReadFull(random, token); err != nil { + return "", fmt.Errorf("generate storage name: %w", err) + } + ext := strings.ToLower(path.Ext(fileName)) + return "idp-icon-" + hex.EncodeToString(token) + ext, nil +} + +// DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。 +func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error { + if err := validateDomainImageFileName(fileName); err != nil { + return fmt.Errorf("delete idp icon: %w", err) + } + cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id) + if err != nil { + return err + } + if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil { + return fmt.Errorf("delete idp icon: %w", err) + } + return nil +} + +func validateIdpIcon(input string, data []byte) (string, error) { + fileName := path.Base(strings.ReplaceAll(input, "\\", "/")) + if invalidIconName(fileName, 255) { + return "", ErrIdpIconBadName + } + ext := strings.ToLower(path.Ext(fileName)) + if !idpIconExts[ext] || !iconMagicOK(ext, data) { + return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext) + } + return fileName, nil +} + +func validateDomainImageFileName(fileName string) error { + if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") { + return ErrIdpIconBadName + } + clean := path.Clean(fileName) + if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) { + return ErrIdpIconBadName + } + return nil +} + +func validIdpIconStorageBase(fileName string) bool { + ext := path.Ext(fileName) + if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] { + return false + } + stem := strings.TrimSuffix(fileName, ext) + if !strings.HasPrefix(stem, "idp-icon-") { + return false + } + token := strings.TrimPrefix(stem, "idp-icon-") + if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) { + return false + } + _, err := hex.DecodeString(token) + return err == nil +} + +func invalidIconName(fileName string, maxBytes int) bool { + return fileName == "" || fileName == "." || fileName == ".." || + len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName || + strings.IndexFunc(fileName, unicode.IsControl) >= 0 +} + +// iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码: +// 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会 +// 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。 +func iconMagicOK(ext string, data []byte) bool { + has := func(prefix string) bool { + return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix + } + switch ext { + case ".png": + return has("\x89PNG\r\n\x1a\n") + case ".jpg", ".jpeg": + return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff + case ".gif": + return has("GIF87a") || has("GIF89a") + case ".webp": + return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" + case ".ico": + return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 + case ".svg": + head := data + if len(head) > 512 { + head = head[:512] + } + return strings.Contains(strings.ToLower(string(head)), "", JitEnabled: jitEnabled, + } +} + +// iconFixture 生成带正确文件头魔数的最小样本;精简校验只核对魔数,不做深度解码。 +func iconFixture(ext string) []byte { + switch ext { + case ".png": + return append([]byte("\x89PNG\r\n\x1a\n"), 0, 0, 0, 0) + case ".jpg", ".jpeg": + return []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0} + case ".gif": + return []byte("GIF89a\x00\x00") + case ".webp": + return []byte("RIFF\x00\x00\x00\x00WEBPVP8 ") + case ".ico": + return []byte{0, 0, 1, 0, 1, 0} + case ".svg": + return []byte(``) + } + return nil +} + +func TestValidateIdpIconAcceptsMatchingContent(t *testing.T) { + for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg"} { + t.Run(ext, func(t *testing.T) { + fileName := "icon" + ext + got, err := validateIdpIcon(fileName, iconFixture(ext)) + if err != nil || got != fileName { + t.Errorf("validateIdpIcon = %q, %v; want %q, nil", got, err, fileName) + } + }) + } +} + +func TestCreateIdentityProviderForcesEmailMapping(t *testing.T) { + service, client, configID := newIdpCreateTestService(t) + client.setting.PrimaryEmailRequired = true + _, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true)) + if err != nil { + t.Fatalf("CreateIdentityProvider: %v", err) + } + if client.settingCalls != 1 || client.createCalls != 1 { + t.Fatalf("calls = setting:%d create:%d; want 1, 1", client.settingCalls, client.createCalls) + } + if !client.createdInput.JitMapEmail { + t.Error("JitMapEmail = false, want true for primary-email-required domain") + } +} + +func TestCreateIdentityProviderStopsWhenSettingFails(t *testing.T) { + service, client, configID := newIdpCreateTestService(t) + client.settingErr = errors.New("setting unavailable") + _, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true)) + if err == nil || !strings.Contains(err.Error(), "get identity setting") { + t.Fatalf("CreateIdentityProvider error = %v, want setting failure", err) + } + if client.settingCalls != 1 || client.createCalls != 0 { + t.Errorf("calls = setting:%d create:%d; want 1, 0", client.settingCalls, client.createCalls) + } +} + +func TestCreateIdentityProviderSkipsSettingWithoutJIT(t *testing.T) { + service, client, configID := newIdpCreateTestService(t) + client.settingErr = errors.New("must not be called") + _, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(false)) + if err != nil { + t.Fatalf("CreateIdentityProvider: %v", err) + } + if client.settingCalls != 0 || client.createCalls != 1 { + t.Errorf("calls = setting:%d create:%d; want 0, 1", client.settingCalls, client.createCalls) + } +} + +func TestUploadIdpIconUsesUniqueServerNames(t *testing.T) { + service, client, configID := newIdpIconTestService(t) + data := iconFixture(".png") + randomData := append(bytes.Repeat([]byte{0x11}, idpIconRandomBytes), bytes.Repeat([]byte{0x22}, idpIconRandomBytes)...) + random := bytes.NewReader(randomData) + storedNames := make([]string, 2) + for index := range storedNames { + _, storedName, err := service.uploadIdpIcon(context.Background(), configID, "domain", "Logo.PNG", data, random) + if err != nil { + t.Fatalf("upload %d: %v", index, err) + } + storedNames[index] = storedName + } + wants := []string{"idp-icon-" + strings.Repeat("11", idpIconRandomBytes) + ".png", "idp-icon-" + strings.Repeat("22", idpIconRandomBytes) + ".png"} + for index, want := range wants { + if client.uploadedNames[index] != want || storedNames[index] != "images/"+want { + t.Errorf("upload %d = %q, %q; want %q, %q", index, client.uploadedNames[index], storedNames[index], want, "images/"+want) + } + } +} + +func TestUploadIdpIconRandomFailureSkipsUpstream(t *testing.T) { + service, client, configID := newIdpIconTestService(t) + _, _, err := service.uploadIdpIcon(context.Background(), configID, "domain", "logo.png", iconFixture(".png"), failingIconRandom{}) + if err == nil || !strings.Contains(err.Error(), "generate storage name") { + t.Fatalf("UploadIdpIcon error = %v, want random source failure", err) + } + if len(client.uploadedNames) != 0 { + t.Errorf("upstream upload calls = %d, want 0", len(client.uploadedNames)) + } +} + +type iconRejectCase struct { + name, fileName string + data []byte + want error +} + +func assertIconRejected(t *testing.T, cases []iconRejectCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := validateIdpIcon(tc.fileName, tc.data) + if !errors.Is(err, tc.want) { + t.Errorf("err = %v, want errors.Is(%v)", err, tc.want) + } + }) + } +} + +func TestValidateIdpIconRejectsSpoofedContent(t *testing.T) { + assertIconRejected(t, []iconRejectCase{ + {"extension mismatch", "icon.jpg", iconFixture(".png"), ErrIdpIconBadType}, + {"random bytes as png", "icon.png", []byte("not an image"), ErrIdpIconBadType}, + {"unsupported extension", "icon.bmp", iconFixture(".png"), ErrIdpIconBadType}, + }) +} + +func TestValidateIdpIconRejectsBadNames(t *testing.T) { + assertIconRejected(t, []iconRejectCase{ + {"control name", "bad\r\n.png", iconFixture(".png"), ErrIdpIconBadName}, + {"long name", strings.Repeat("a", 252) + ".png", iconFixture(".png"), ErrIdpIconBadName}, + }) +} + +func TestValidateDomainImageFileName(t *testing.T) { + token := strings.Repeat("ab", idpIconRandomBytes) + cases := []struct { + name, fileName string + valid bool + }{ + {"generated image", "images/idp-icon-" + token + ".png", true}, + {"empty", "", false}, {"wrong prefix", "icons/a.png", false}, + {"traversal", "images/a/../secret", false}, {"backslash", `images\a.png`, false}, + {"control", "images/a\x00.png", false}, {"directory", "images/", false}, + {"unrelated domain image", "images/company-brand.png", false}, + {"nested image", "images/generated/idp-icon-" + token + ".png", false}, + {"uppercase token", "images/idp-icon-" + strings.ToUpper(token) + ".png", false}, + {"wrong token length", "images/idp-icon-ab.png", false}, + } + for _, tc := range cases { + err := validateDomainImageFileName(tc.fileName) + if (err == nil) != tc.valid { + t.Errorf("%s: err = %v, valid = %v", tc.name, err, tc.valid) + } + } +} diff --git a/internal/service/logevent_test.go b/internal/service/logevent_test.go index 5b14300..9d719c5 100644 --- a/internal/service/logevent_test.go +++ b/internal/service/logevent_test.go @@ -286,6 +286,8 @@ func TestParseLogEvent(t *testing.T) { wantType: "x", }, { + // message 中的 ociportal-logs-sch 是历史 Policy 命名 fixture,反映升级前存量租户的审计事件形态; + // 新版命名已按 tenancy 派生,见 internal/oci/logrelay_names.go。 name: "Audit v2 提取操作者成败与策略描述", payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{ "identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"}, diff --git a/internal/service/oauth.go b/internal/service/oauth.go index 39a7531..ffe3493 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -329,27 +329,48 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model } // Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。 +// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。 func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error { - user, err := o.auth.findUser(ctx, username) - if err != nil { - return err - } - n, err := o.auth.identityCount(ctx, user.ID) - if err != nil { - return err - } - if n <= 1 { - if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off { - return ErrLastIdentity + err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + user, err := lockUserForAuthChange(tx, username) + if err != nil { + return err } - } - res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{}) - if res.Error != nil { - return fmt.Errorf("unbind identity: %w", res.Error) - } - if res.RowsAffected == 0 { - return gorm.ErrRecordNotFound + if err := ensureNotLastLogin(tx, user.ID); err != nil { + return err + } + res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{}) + if res.Error != nil { + return fmt.Errorf("unbind identity: %w", res.Error) + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) + if err != nil { + return err } // 解绑属敏感变更:递增令牌版本,已签发会话全部失效 return o.auth.bumpTokenVersion(ctx, username) } + +// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑; +// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。 +func ensureNotLastLogin(tx *gorm.DB, userID uint) error { + n, err := identityCountTx(tx, userID) + if err != nil { + return err + } + if n > 1 { + return nil + } + off, err := settingValueTx(tx, settingSecPasswordLoginOff) + if err != nil { + return err + } + if off == "1" { + return ErrLastIdentity + } + return nil +} diff --git a/internal/service/oauthconfig.go b/internal/service/oauthconfig.go index 75cdb4f..06299e8 100644 --- a/internal/service/oauthconfig.go +++ b/internal/service/oauthconfig.go @@ -32,18 +32,19 @@ type OAuthProvidersView struct { GithubDisabled bool `json:"githubDisabled"` } -// UpdateOAuthInput 是保存 provider 配置的输入; -// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。 +// UpdateOAuthInput 是 provider 配置的部分更新:所有字段 nil=沿用已存值, +// 只落库出现的字段;并发编辑不同 provider 因此互不回滚(2026-07-22 审查 #18)。 +// secret 非 nil 覆盖(空串清除),绝不回读。 type UpdateOAuthInput struct { - OidcIssuer string `json:"oidcIssuer"` - OidcClientID string `json:"oidcClientId"` + OidcIssuer *string `json:"oidcIssuer"` + OidcClientID *string `json:"oidcClientId"` OidcClientSecret *string `json:"oidcClientSecret"` - OidcDisplayName string `json:"oidcDisplayName"` - OidcDisabled bool `json:"oidcDisabled"` - GithubClientID string `json:"githubClientId"` + OidcDisplayName *string `json:"oidcDisplayName"` + OidcDisabled *bool `json:"oidcDisabled"` + GithubClientID *string `json:"githubClientId"` GithubClientSecret *string `json:"githubClientSecret"` - GithubDisplayName string `json:"githubDisplayName"` - GithubDisabled bool `json:"githubDisabled"` + GithubDisplayName *string `json:"githubDisplayName"` + GithubDisabled *bool `json:"githubDisabled"` } // OAuthView 返回脱敏后的 provider 配置。 @@ -77,19 +78,51 @@ func boolFlag(on bool) string { return "" } -// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库。 -func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error { - plain := map[string]string{ - settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"), - settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID), - settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName), - settingOauthOidcDisabled: boolFlag(in.OidcDisabled), - settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID), - settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName), - settingOauthGithubDisabled: boolFlag(in.GithubDisabled), +// trimPtr / issuerPtr / flagPtr 把补丁字段规范化为存储值;nil 表示未出现不写。 +func trimPtr(p *string) *string { + if p == nil { + return nil } - for key, value := range plain { - if err := s.set(ctx, key, value); err != nil { + v := strings.TrimSpace(*p) + return &v +} + +func issuerPtr(p *string) *string { + if p == nil { + return nil + } + v := strings.TrimRight(strings.TrimSpace(*p), "/") + return &v +} + +func flagPtr(p *bool) *string { + if p == nil { + return nil + } + v := boolFlag(*p) + return &v +} + +// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段; +// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。 +func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error { + writes := []struct { + key string + val *string + }{ + {settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)}, + {settingOauthOidcClientID, trimPtr(in.OidcClientID)}, + {settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)}, + {settingOauthOidcDisabled, flagPtr(in.OidcDisabled)}, + {settingOauthGithubClientID, trimPtr(in.GithubClientID)}, + {settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)}, + {settingOauthGithubDisabled, flagPtr(in.GithubDisabled)}, + } + for _, w := range writes { + if w.val == nil { + continue + } + if err := s.set(ctx, w.key, *w.val); err != nil { return err } } diff --git a/internal/service/objectstorage.go b/internal/service/objectstorage.go index 8bd5ca9..2bf7e34 100644 --- a/internal/service/objectstorage.go +++ b/internal/service/objectstorage.go @@ -66,8 +66,9 @@ func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, na return s.client.UpdateBucket(ctx, cred, region, name, in) } -// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除, -// 返回 queued=true 表示已排队。 +// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR + +// 未完成分片)后删除,返回 queued=true 表示已排队。同一桶的清空任务全局单飞, +// 重复删除请求不再并发起新任务;执行权在触发方同步抢占,避免双触发窗口。 func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) { cred, err := s.credentialsByID(ctx, id) if err != nil { @@ -80,16 +81,43 @@ func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, na if !errors.Is(err, oci.ErrBucketNotEmpty) { return false, err } - go s.purgeAndDeleteBucket(cred, region, name) + key := fmt.Sprintf("%d/%s/%s", id, region, name) + if !s.beginPurge(key) { + return true, nil // 已有清空任务在途,视为已排队 + } + s.bindWG.Add(1) + go func() { + defer s.bindWG.Done() + defer s.endPurge(key) + s.purgeAndDeleteBucket(cred, region, name) + }() return true, nil } +// beginPurge / endPurge 维护桶清空在途注册表(键 cfgID/region/bucket)。 +func (s *OciConfigService) beginPurge(key string) bool { + s.purgeMu.Lock() + defer s.purgeMu.Unlock() + if s.purgeInFlight[key] { + return false + } + s.purgeInFlight[key] = true + return true +} + +func (s *OciConfigService) endPurge(key string) { + s.purgeMu.Lock() + defer s.purgeMu.Unlock() + delete(s.purgeInFlight, key) +} + // purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。 const purgeBucketTimeout = 30 * time.Minute // purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。 +// 顺序:对象版本 → PAR → 未完成分片 → 删桶;分片不清会让空桶仍以非空拒绝删除。 func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) { - ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout) + ctx, cancel := s.purgeContext() defer cancel() start := time.Now() if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil { @@ -99,6 +127,9 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil { log.Printf("[bucket-purge] %s purge pars failed: %v", name, err) } + if err := s.client.AbortAllMultipartUploads(ctx, cred, region, name); err != nil { + log.Printf("[bucket-purge] %s abort multipart uploads failed: %v", name, err) + } if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil { log.Printf("[bucket-purge] %s delete failed: %v", name, err) return @@ -106,6 +137,21 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second)) } +// purgeContext 组装清空任务的 ctx:总超时之外叠加关服信号, +// 避免 Stop 等待在途清空长达整个超时窗口。 +func (s *OciConfigService) purgeContext() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout) + done := make(chan struct{}) + go func() { + select { + case <-s.bindStop: + cancel() + case <-done: + } + }() + return ctx, func() { close(done); cancel() } +} + // purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象); // 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。 func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error { @@ -238,19 +284,34 @@ func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region // parAccessTypes 是允许签发的 PAR 访问类型。 var parAccessTypes = map[string]bool{ - "ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true, + "ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, + "AnyObjectRead": true, "AnyObjectWrite": true, "AnyObjectReadWrite": true, } -// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天。 +// PAR 输入校验错误供 API 层稳定映射 400。 +var ( + ErrPARInvalidAccessType = errors.New("invalid PAR access type") + ErrPARInvalidExpiration = errors.New("invalid PAR expiration") +) + +// maxParHours 是 PAR 有效期安全上限(100 年):time.Duration 按小时乘会在 +// ≈292 年(2562047h)溢出回绕为负,收在远低于溢出点又远超实际需求的位置。 +const maxParHours = 100 * 365 * 24 + +// CreatePAR 签发预签名请求;过期时长 1 小时到 100 年(放开旧 30 天上限)。 func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) { if !parAccessTypes[in.AccessType] { - return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType) + return oci.PAR{}, fmt.Errorf("create par: %w: unsupported access type %q", ErrPARInvalidAccessType, in.AccessType) } - if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 { - return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720") + if in.ExpiresHours < 1 || in.ExpiresHours > maxParHours { + return oci.PAR{}, fmt.Errorf("create par: %w: expiresHours must be within 1-%d", ErrPARInvalidExpiration, maxParHours) } if strings.TrimSpace(in.Name) == "" { - in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-") + base := in.ObjectName + if base == "" { + base = "bucket-" + bucket + } + in.Name = "par-" + strings.ReplaceAll(base, "/", "-") } cred, err := s.credentialsByID(ctx, id) if err != nil { diff --git a/internal/service/objectstorage_test.go b/internal/service/objectstorage_test.go index a4d811f..ca818d5 100644 --- a/internal/service/objectstorage_test.go +++ b/internal/service/objectstorage_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "oci-portal/internal/model" "oci-portal/internal/oci" ) @@ -27,10 +28,19 @@ type osStubClient struct { putIfMatch string // 后台清空删除桶的编排状态,goroutine 并发访问需加锁 - mu sync.Mutex - versions []oci.ObjectVersion - pars []oci.PAR - bucketDeleted bool + mu sync.Mutex + versions []oci.ObjectVersion + pars []oci.PAR + bucketDeleted bool + multipartAborted bool + listCalls int +} + +func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.multipartAborted = true + return nil } func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) { @@ -62,6 +72,7 @@ func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, r func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) { f.mu.Lock() defer f.mu.Unlock() + f.listCalls++ return append([]oci.ObjectVersion(nil), f.versions...), "", nil } @@ -170,10 +181,20 @@ func TestObjectStorageValidation(t *testing.T) { _, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2}) return err }, wantErr: "unsupported access type"}, - {name: "PAR 时长越界", run: func() error { - _, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800}) + {name: "PAR 时长过短", run: func() error { + _, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}) return err - }, wantErr: "1-720"}, + }, wantErr: "1-876000"}, + // 旧 30 天上限已放开:10 年应放行 + {name: "PAR 超长有效期放行", run: func() error { + _, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650}) + return err + }}, + // 防 time.Duration 溢出的安全上限(100 年)仍要拦截 + {name: "PAR 超安全上限拒绝", run: func() error { + _, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}) + return err + }, wantErr: "1-876000"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -403,3 +424,118 @@ func TestCreatePARDefaultsName(t *testing.T) { t.Errorf("FullURL empty, want populated") } } + +// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。 +func TestCreatePARAccessTypes(t *testing.T) { + client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}} + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + + cases := []struct { + accessType string + wantErr bool + }{ + {"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false}, + {"BucketRead", true}, {"", true}, + } + for _, tc := range cases { + _, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b", + oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24}) + if (err != nil) != tc.wantErr { + t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr) + } + } + if client.createdPAR.Name != "par-bucket-b" { + t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name) + } +} + +func TestCreatePARValidationSentinels(t *testing.T) { + svc := &OciConfigService{} + cases := []struct { + name string + in oci.CreatePARInput + want error + }{ + {"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType}, + {"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration}, + {"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration}, + } + for _, tc := range cases { + _, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in) + if !errors.Is(err, tc.want) { + t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want) + } + } +} + +// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求 +// 按已排队返回,不并发起第二个清空任务。 +func TestDeleteBucketPurgeSingleflight(t *testing.T) { + client := &osStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + key := fmt.Sprintf("%d/r/b", cfg.ID) + if !svc.beginPurge(key) { + t.Fatal("beginPurge 首次抢占失败") + } + queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b") + if err != nil || !queued { + t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err) + } + time.Sleep(50 * time.Millisecond) + client.mu.Lock() + calls := client.listCalls + client.mu.Unlock() + if calls != 0 { + t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls) + } + svc.endPurge(key) + if !svc.beginPurge(key) { + t.Error("endPurge 后应可重新抢占") + } + svc.endPurge(key) +} + +// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。 +func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) { + client := &osStubClient{ + fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}, + versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}}, + } + svc := newTestService(t, client) + cfg := importAliveConfig(t, svc) + if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); err != nil || !queued { + t.Fatalf("DeleteBucket = queued %v, %v, want queued", queued, err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + client.mu.Lock() + done := client.bucketDeleted && client.multipartAborted + client.mu.Unlock() + if done { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("purge 未在期限内完成分片中止与删桶") +} + +// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝, +// 不落库也不发起绕过代理的直连测活。 +func TestImportRejectsMissingProxy(t *testing.T) { + svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) + in := trialImportInput() + missing := uint(999) + in.ProxyID = &missing + if _, err := svc.Import(context.Background(), in); err == nil { + t.Fatal("Import 关联不存在的代理应报错") + } + var n int64 + if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 { + t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err) + } +} diff --git a/internal/service/ociconfig.go b/internal/service/ociconfig.go index 812c642..b059d4e 100644 --- a/internal/service/ociconfig.go +++ b/internal/service/ociconfig.go @@ -24,9 +24,12 @@ type OciConfigService struct { // auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟), // 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。 auditRaw *cache.Cache - // bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。 + // bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。 bindWG sync.WaitGroup bindStop chan struct{} + // purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。 + purgeMu sync.Mutex + purgeInFlight map[string]bool } // NewOciConfigService 组装依赖。 @@ -34,6 +37,7 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client) return &OciConfigService{ db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}), + purgeInFlight: map[string]bool{}, } } @@ -71,6 +75,12 @@ func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.O if err != nil { return nil, err } + // 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入, + // 避免首次测活绕过代理直连,泄露真实出口 + cred.Proxy, err = s.proxySpecOf(in.ProxyID) + if err != nil { + return nil, err + } cfg, err := s.storeConfig(in.Alias, cred) if err != nil { return nil, err @@ -427,7 +437,7 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err) } } - spec, err := s.proxySpecOf(cfg) + spec, err := s.proxySpecOf(cfg.ProxyID) if err != nil { return oci.Credentials{}, err } @@ -442,15 +452,15 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials, }, nil } -// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。 +// proxySpecOf 加载关联的出站代理;未关联返回 nil(直连)。 // 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。 -func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) { - if cfg.ProxyID == nil { +func (s *OciConfigService) proxySpecOf(proxyID *uint) (*oci.ProxySpec, error) { + if proxyID == nil { return nil, nil } var row model.Proxy - if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil { - return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err) + if err := s.db.First(&row, *proxyID).Error; err != nil { + return nil, fmt.Errorf("find proxy %d: %w", *proxyID, err) } password := "" if row.PasswordEnc != "" { diff --git a/internal/service/overview.go b/internal/service/overview.go index 867f187..e10df38 100644 --- a/internal/service/overview.go +++ b/internal/service/overview.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "sort" "time" "oci-portal/internal/model" @@ -32,14 +33,23 @@ type OverviewCostDay struct { Amount float64 `json:"amount"` } -// OverviewCost 是近 7 日成本快照聚合;金额跨配置直接相加, -// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。 +// OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。 type OverviewCost struct { HasActiveTask bool `json:"hasActiveTask"` CoveredConfigs int `json:"coveredConfigs"` Currency string `json:"currency"` Total float64 `json:"total"` Days []OverviewCostDay `json:"days"` + // Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个 + // (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。 + Series []OverviewCostSeries `json:"series"` +} + +// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。 +type OverviewCostSeries struct { + Currency string `json:"currency"` + Total float64 `json:"total"` + Days []OverviewCostDay `json:"days"` } // OverviewTasks 是后台任务数量统计。 @@ -150,29 +160,53 @@ func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) erro return nil } -// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序)。 +// aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)。 +// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的 +// 主币种,其余币种只出现在 Series 里。 func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) { - byDay := map[string]float64{} + perCur := map[string]map[string]float64{} + dayOrder := map[string][]string{} covered := map[uint]bool{} - var order []string for _, snap := range snaps { + byDay := perCur[snap.Currency] + if byDay == nil { + byDay = map[string]float64{} + perCur[snap.Currency] = byDay + } if _, ok := byDay[snap.Day]; !ok { - order = append(order, snap.Day) + dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day) } byDay[snap.Day] += snap.Amount covered[snap.OciConfigID] = true - if cost.Currency == "" { - cost.Currency = snap.Currency - } } cost.CoveredConfigs = len(covered) - cost.Days = make([]OverviewCostDay, 0, len(order)) - for _, day := range order { - cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]}) - cost.Total += byDay[day] + cost.Days = []OverviewCostDay{} + cost.Series = costSeries(perCur, dayOrder) + if len(cost.Series) > 0 { + cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days } } +// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。 +func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries { + series := make([]OverviewCostSeries, 0, len(perCur)) + for currency, byDay := range perCur { + item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))} + for _, day := range dayOrder[currency] { + item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]}) + item.Total += byDay[day] + } + series = append(series, item) + } + sort.Slice(series, func(i, j int) bool { + if series[i].Total != series[j].Total { + return series[i].Total > series[j].Total + } + return series[i].Currency < series[j].Currency + }) + return series +} + func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error { var tasks []model.Task if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil { diff --git a/internal/service/overview_test.go b/internal/service/overview_test.go index 0c14392..645c094 100644 --- a/internal/service/overview_test.go +++ b/internal/service/overview_test.go @@ -170,3 +170,26 @@ func TestOverviewAggregates(t *testing.T) { t.Errorf("cost days = %d, want 1", len(out.Cost.Days)) } } + +// TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加, +// 顶层字段恒为合计最大的主币种,其余币种进 Series。 +func TestAggregateCostDaysSplitsCurrencies(t *testing.T) { + snaps := []model.CostSnapshot{ + {OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"}, + {OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"}, + {OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"}, + {OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"}, + } + var cost OverviewCost + aggregateCostDays(&cost, snaps) + if cost.CoveredConfigs != 3 || len(cost.Series) != 2 { + t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series)) + } + if cost.Currency != "EUR" || cost.Total != 9.0 { + t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total) + } + usd := cost.Series[1] + if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 { + t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd) + } +} diff --git a/internal/service/proxy_test.go b/internal/service/proxy_test.go index 9eb763b..10e3f97 100644 --- a/internal/service/proxy_test.go +++ b/internal/service/proxy_test.go @@ -324,3 +324,27 @@ func TestProxySetTenantsRebind(t *testing.T) { } } } + +// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃, +// 不得经 Save 回退 Create 复活已删代理。 +func TestPersistGeoSkipsDeletedProxy(t *testing.T) { + svc, db := newProxyEnv(t) + ctx := context.Background() + row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed proxy: %v", err) + } + svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"}) + var fresh model.Proxy + if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil { + t.Fatalf("正常落库 = %+v, %v", fresh, err) + } + if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil { + t.Fatalf("delete proxy: %v", err) + } + svc.persistGeo(ctx, row.ID, geoResult{Country: "US"}) + var n int64 + if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 { + t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err) + } +} diff --git a/internal/service/proxygeo.go b/internal/service/proxygeo.go index b9d8c6c..ca2339c 100644 --- a/internal/service/proxygeo.go +++ b/internal/service/proxygeo.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "time" @@ -108,13 +109,18 @@ func (s *ProxyService) probeGeo(ctx context.Context, id uint) { break } } - var row model.Proxy - if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { - return - } + s.persistGeo(ctx, id, res) +} + +// persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会 +// 回退 Create 把删掉的代理复活;错误也不能静默吞掉。 +func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) { now := time.Now() - row.Country, row.City, row.GeoAt = res.Country, res.City, &now - s.db.WithContext(ctx).Save(&row) + tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id). + Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now}) + if tx.Error != nil { + log.Printf("proxy geo persist %d: %v", id, tx.Error) + } } // Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。 diff --git a/internal/service/security.go b/internal/service/security.go index a5a0a1b..f4c16e0 100644 --- a/internal/service/security.go +++ b/internal/service/security.go @@ -112,26 +112,66 @@ func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error) return out, nil } -// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。 -func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error { - if err := validateSecurity(&in); err != nil { +// SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段, +// 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。 +type SecurityPatch struct { + LoginFailLimit *int `json:"loginFailLimit"` + LoginLockMinutes *int `json:"loginLockMinutes"` + IPRateRPS *int `json:"ipRateRps"` + IPRateBurst *int `json:"ipRateBurst"` + RealIPHeader *string `json:"realIpHeader"` + AppURL *string `json:"appUrl"` +} + +// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。 +func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool { + touched := map[string]bool{} + fields := []struct { + key string + set func() + on bool + }{ + {settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil}, + {settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil}, + {settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil}, + {settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil}, + {settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil}, + {settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil}, + } + for _, f := range fields { + if f.on { + f.set() + touched[f.key] = true + } + } + return touched +} + +// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。 +func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error { + cur, err := s.Security(ctx) + if err != nil { + return err + } + touched := p.merge(&cur) + if err := validateSecurity(&cur); err != nil { return err } kv := map[string]string{ - settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit), - settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes), - settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS), - settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst), - settingSecRealIPHeader: in.RealIPHeader, - settingSecAppURL: in.AppURL, + settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit), + settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes), + settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS), + settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst), + settingSecRealIPHeader: cur.RealIPHeader, + settingSecAppURL: cur.AppURL, } - for key, value := range kv { - if err := s.set(ctx, key, value); err != nil { + for key := range touched { + if err := s.set(ctx, key, kv[key]); err != nil { return err } } - s.security.Store(in) - return nil + // 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值 + return s.ReloadSecurity(ctx) } // ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。 diff --git a/internal/service/security_test.go b/internal/service/security_test.go index 2b58834..e4aeae7 100644 --- a/internal/service/security_test.go +++ b/internal/service/security_test.go @@ -6,6 +6,18 @@ import ( "testing" ) +// patchOf 把完整设置转为全字段补丁,等价旧全量保存,供既有用例复用。 +func patchOf(in SecuritySettings) SecurityPatch { + return SecurityPatch{ + LoginFailLimit: &in.LoginFailLimit, + LoginLockMinutes: &in.LoginLockMinutes, + IPRateRPS: &in.IPRateRPS, + IPRateBurst: &in.IPRateBurst, + RealIPHeader: &in.RealIPHeader, + AppURL: &in.AppURL, + } +} + func TestSecurityReadWrite(t *testing.T) { tests := []struct { name string @@ -21,7 +33,7 @@ func TestSecurityReadWrite(t *testing.T) { name: "写入后读回", setup: func(t *testing.T, svc *SettingService) { in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"} - if err := svc.UpdateSecurity(context.Background(), in); err != nil { + if err := svc.UpdateSecurity(context.Background(), patchOf(in)); err != nil { t.Fatalf("UpdateSecurity: %v", err) } }, @@ -76,7 +88,7 @@ func TestUpdateSecurityRejectsInvalid(t *testing.T) { svc, _ := newSettingEnv(t) in := base tt.mutate(&in) - if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) { + if err := svc.UpdateSecurity(context.Background(), patchOf(in)); !errors.Is(err, ErrInvalidSecurity) { t.Fatalf("err = %v, want ErrInvalidSecurity", err) } }) @@ -89,9 +101,7 @@ func TestSecurityCachedSnapshot(t *testing.T) { if got := svc.SecurityCached(); got != securityDefaults { t.Errorf("cold cache = %+v, want defaults", got) } - in := securityDefaults - in.LoginFailLimit = 8 - if err := svc.UpdateSecurity(context.Background(), in); err != nil { + if err := svc.UpdateSecurity(context.Background(), SecurityPatch{LoginFailLimit: intPtr(8)}); err != nil { t.Fatalf("UpdateSecurity: %v", err) } // Update 即刷新快照 @@ -110,12 +120,67 @@ func TestEffectiveAppURL(t *testing.T) { if got := svc.EffectiveAppURL(); got != "https://env.example.com" { t.Errorf("env fallback = %q", got) } - in := securityDefaults - in.AppURL = "https://app.example.com" - if err := svc.UpdateSecurity(context.Background(), in); err != nil { + if err := svc.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil { t.Fatalf("UpdateSecurity: %v", err) } if got := svc.EffectiveAppURL(); got != "https://app.example.com" { t.Errorf("app_url 应优先, got %q", got) } } + +// TestUpdateSecurityPartialPatch 锁定 PATCH 语义:只写出现字段,其余不回滚。 +func TestUpdateSecurityPartialPatch(t *testing.T) { + seeded := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://a.example.com"} + tests := []struct { + name string + patch SecurityPatch + want SecuritySettings + wantErr bool + }{ + { + name: "单字段更新不动其余", + patch: SecurityPatch{LoginFailLimit: intPtr(3)}, + want: func() SecuritySettings { + w := seeded + w.LoginFailLimit = 3 + return w + }(), + }, + {name: "空补丁不改任何值", patch: SecurityPatch{}, want: seeded}, + { + name: "补丁字段规范化(AppURL 去尾斜杠)", + patch: SecurityPatch{AppURL: strPtr("https://b.example.com/")}, + want: func() SecuritySettings { + w := seeded + w.AppURL = "https://b.example.com" + return w + }(), + }, + {name: "补丁字段越界拒绝", patch: SecurityPatch{IPRateRPS: intPtr(0)}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, _ := newSettingEnv(t) + if err := svc.UpdateSecurity(context.Background(), patchOf(seeded)); err != nil { + t.Fatalf("seed: %v", err) + } + err := svc.UpdateSecurity(context.Background(), tt.patch) + if tt.wantErr { + if !errors.Is(err, ErrInvalidSecurity) { + t.Fatalf("err = %v, want ErrInvalidSecurity", err) + } + return + } + if err != nil { + t.Fatalf("UpdateSecurity: %v", err) + } + got, err := svc.Security(context.Background()) + if err != nil || got != tt.want { + t.Errorf("settings = %+v (%v), want %+v", got, err, tt.want) + } + if cached := svc.SecurityCached(); cached != tt.want { + t.Errorf("cached = %+v, want %+v", cached, tt.want) + } + }) + } +} diff --git a/internal/service/setting.go b/internal/service/setting.go index 4329e75..cb67328 100644 --- a/internal/service/setting.go +++ b/internal/service/setting.go @@ -81,8 +81,29 @@ func (s *SettingService) get(ctx context.Context, key string) (string, error) { // set 写入键值(key 为主键,Save 即 upsert)。 func (s *SettingService) set(ctx context.Context, key, value string) error { + if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil { + return err + } + return nil +} + +// settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种 +// 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。 +func settingValueTx(tx *gorm.DB, key string) (string, error) { + var st model.Setting + err := tx.First(&st, "key = ?", key).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("get setting %s: %w", key, err) + } + return st.Value, nil +} + +func saveSettingTx(tx *gorm.DB, key, value string) error { st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()} - if err := s.db.WithContext(ctx).Save(&st).Error; err != nil { + if err := tx.Save(&st).Error; err != nil { return fmt.Errorf("set setting %s: %w", key, err) } return nil diff --git a/internal/service/setting_test.go b/internal/service/setting_test.go index 5d43df9..496a25e 100644 --- a/internal/service/setting_test.go +++ b/internal/service/setting_test.go @@ -41,6 +41,10 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) { func strPtr(s string) *string { return &s } +func intPtr(n int) *int { return &n } + +func boolPtr(b bool) *bool { return &b } + func TestUpdateTelegramTokenHandling(t *testing.T) { tests := []struct { name string diff --git a/internal/service/task.go b/internal/service/task.go index 69e542a..0f12331 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -202,6 +202,38 @@ func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMe return out } +// mergeSnatchPayload 把编辑提交的 count 视为**新目标台数**:按旧 payload 的 +// 已完成数换算剩余、保留连续鉴权失败计数,目标不大于已完成数时拒绝。 +// 非抢机任务原样返回;旧 payload 不可解析时按新建任务归一化。 +func mergeSnatchPayload(task *model.Task, incoming json.RawMessage) (json.RawMessage, error) { + if task.Type != model.TaskTypeSnatch { + return incoming, nil + } + var old, next snatchPayload + if err := json.Unmarshal([]byte(task.Payload), &old); err != nil { + return normalizeSnatchPayload(task.Type, incoming), nil + } + if err := json.Unmarshal(incoming, &next); err != nil { + return nil, fmt.Errorf("update task: invalid payload: %w", err) + } + oldTotal := old.TotalCount + if oldTotal <= 0 { + oldTotal = old.Count + } + done := oldTotal - old.Count + if next.Count <= done { + return nil, fmt.Errorf("%w(已完成 %d 台)", ErrSnatchTargetTooLow, done) + } + next.TotalCount = next.Count + next.Count -= done + next.AuthFailCount = old.AuthFailCount + out, err := json.Marshal(next) + if err != nil { + return nil, fmt.Errorf("update task: marshal payload: %w", err) + } + return out, nil +} + // validateTaskPayload 按任务类型校验参数 JSON。 func validateTaskPayload(taskType string, payload json.RawMessage) error { switch taskType { @@ -245,7 +277,8 @@ type UpdateTaskInput struct { Status *string } -// UpdateTask 修改任务并重新调度。 +// UpdateTask 修改任务并重新调度;写入用 updated_at 条件更新, +// 防止陈旧快照整行覆盖并发执行刚落库的状态与进度。 func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) { task, err := s.GetTask(ctx, id) if err != nil { @@ -254,16 +287,39 @@ func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInpu if err := applyTaskUpdate(task, in); err != nil { return nil, err } - if err := s.db.WithContext(ctx).Save(task).Error; err != nil { - return nil, fmt.Errorf("update task %d: %w", id, err) + fresh, err := s.persistTaskUpdate(ctx, task) + if err != nil { + return nil, err } - s.unschedule(task.ID) - if task.Status == model.TaskStatusActive { - if err := s.schedule(task); err != nil { + s.unschedule(fresh.ID) + if fresh.Status == model.TaskStatusActive { + if err := s.schedule(fresh); err != nil { return nil, err } } - return task, nil + return fresh, nil +} + +// persistTaskUpdate 只写用户可编辑列;零命中说明执行侧已并发落库,返回冲突。 +func (s *TaskService) persistTaskUpdate(ctx context.Context, task *model.Task) (*model.Task, error) { + res := s.db.WithContext(ctx).Model(&model.Task{}). + Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt). + Updates(map[string]any{ + "name": task.Name, "cron_expr": task.CronExpr, + "payload": task.Payload, "status": task.Status, + }) + if res.Error != nil { + return nil, fmt.Errorf("update task %d: %w", task.ID, res.Error) + } + if res.RowsAffected == 0 { + return nil, ErrTaskConflict + } + // 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值 + var fresh model.Task + if err := s.db.WithContext(ctx).First(&fresh, task.ID).Error; err != nil { + return nil, fmt.Errorf("reload task %d: %w", task.ID, err) + } + return &fresh, nil } func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error { @@ -280,7 +336,11 @@ func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error { if err := validateTaskPayload(task.Type, in.Payload); err != nil { return err } - task.Payload = string(in.Payload) + merged, err := mergeSnatchPayload(task, in.Payload) + if err != nil { + return err + } + task.Payload = string(merged) } if in.Status != nil { if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused { @@ -376,6 +436,12 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog, // ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。 var ErrTaskRunning = errors.New("任务正在执行中,请稍候") +// ErrTaskConflict 表示任务在编辑期间被并发修改(如执行结果落库),须刷新重试。 +var ErrTaskConflict = errors.New("任务状态已变化,请刷新后重试") + +// ErrSnatchTargetTooLow 表示编辑抢机任务时新目标台数不大于已完成数量。 +var ErrSnatchTargetTooLow = errors.New("目标台数须大于已完成数量") + // TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知, // 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。 func (s *TaskService) TriggerTask(ctx context.Context, id uint) error { diff --git a/internal/service/task_test.go b/internal/service/task_test.go index 4fa04d9..3f3cc02 100644 --- a/internal/service/task_test.go +++ b/internal/service/task_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -835,3 +836,93 @@ func TestTriggerTaskAsyncAndDedup(t *testing.T) { t.Error("不存在的任务应报错") } } + +// snatchInstanceJSON 是编辑测试用的合法实例参数片段。 +const snatchInstanceJSON = `"instance":{"displayName":"vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}` + +// TestUpdateSnatchTaskCountIsTarget 锁定编辑语义:提交的 count 是目标台数, +// 按旧 payload 已完成数换算剩余、保留鉴权失败计数;目标不大于已完成数拒绝。 +func TestUpdateSnatchTaskCountIsTarget(t *testing.T) { + tests := []struct { + name string + oldPayload string + newCount int + wantErr error + want snatchPayload + }{ + {"部分成功后提高目标", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`, + 6, nil, snatchPayload{Count: 4, TotalCount: 6, AuthFailCount: 2}}, + {"原样保存不丢进度", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`, + 5, nil, snatchPayload{Count: 3, TotalCount: 5, AuthFailCount: 2}}, + {"目标不大于已完成拒绝", `{"ociConfigId":1,"count":3,"totalCount":5,` + snatchInstanceJSON + `}`, + 2, ErrSnatchTargetTooLow, snatchPayload{}}, + {"旧任务缺 totalCount 视为零完成", `{"ociConfigId":1,"count":3,` + snatchInstanceJSON + `}`, + 5, nil, snatchPayload{Count: 5, TotalCount: 5}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tasks, _, db := newTaskEnv(t, &fakeClient{}) + ctx := context.Background() + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *", + Payload: json.RawMessage(tt.oldPayload), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + // 直写旧 payload,绕过创建期归一化,构造「执行过若干轮」的现场 + if err := db.Model(&model.Task{}).Where("id = ?", task.ID). + Update("payload", tt.oldPayload).Error; err != nil { + t.Fatalf("seed payload: %v", err) + } + incoming := fmt.Sprintf(`{"ociConfigId":1,"count":%d,`+snatchInstanceJSON+`}`, tt.newCount) + updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Payload: json.RawMessage(incoming)}) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("UpdateTask: %v", err) + } + var got snatchPayload + if err := json.Unmarshal([]byte(updated.Payload), &got); err != nil { + t.Fatalf("payload: %v", err) + } + if got.Count != tt.want.Count || got.TotalCount != tt.want.TotalCount || got.AuthFailCount != tt.want.AuthFailCount { + t.Errorf("payload = {count:%d total:%d authFail:%d}, want {count:%d total:%d authFail:%d}", + got.Count, got.TotalCount, got.AuthFailCount, tt.want.Count, tt.want.TotalCount, tt.want.AuthFailCount) + } + }) + } +} + +// TestPersistTaskUpdateConflict 锁定编辑竞态防护:陈旧快照零命中返回冲突且不落库。 +func TestPersistTaskUpdateConflict(t *testing.T) { + tasks, _, db := newTaskEnv(t, &fakeClient{}) + ctx := context.Background() + task, err := tasks.CreateTask(ctx, CreateTaskInput{ + Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + stale, err := tasks.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + // 模拟执行侧并发落库:行的 updated_at 前移,编辑快照过期 + time.Sleep(5 * time.Millisecond) + if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("run_count", 1).Error; err != nil { + t.Fatalf("simulate run persist: %v", err) + } + stale.Name = "renamed" + if _, err := tasks.persistTaskUpdate(ctx, stale); !errors.Is(err, ErrTaskConflict) { + t.Fatalf("err = %v, want ErrTaskConflict", err) + } + fresh, _ := tasks.GetTask(ctx, task.ID) + if fresh.Name != "测活" || fresh.RunCount != 1 { + t.Errorf("row = {name:%s runCount:%d}, want 执行结果保留且未被改名", fresh.Name, fresh.RunCount) + } +} diff --git a/internal/service/tenantdelete.go b/internal/service/tenantdelete.go index 1e3be20..3e20635 100644 --- a/internal/service/tenantdelete.go +++ b/internal/service/tenantdelete.go @@ -231,11 +231,10 @@ func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error { if len(channelIDs) == 0 { return nil } - callIDs, err := lockedAiCallIDs(tx, channelIDs) - if err != nil { + if err := lockAiCallRows(tx, channelIDs); err != nil { return fmt.Errorf("load tenant AI calls: %w", err) } - if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil { + if err := deleteTenantAIRows(tx, channelIDs); err != nil { return err } result.channelsGone = true @@ -253,22 +252,19 @@ func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) { return ids, err } -func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) { +// lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略, +// MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL, +// 内容日志删除用子查询,避免绑定变量上限。 +func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error { var rows []model.AiCallLog - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). + return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id"). Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error - ids := make([]uint, 0, len(rows)) - for _, row := range rows { - ids = append(ids, row.ID) - } - return ids, err } -func deleteTenantAIRows(tx *gorm.DB, channelIDs, callIDs []uint) error { - if len(callIDs) > 0 { - if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil { - return fmt.Errorf("delete tenant AI content logs: %w", err) - } +func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error { + callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs) + if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil { + return fmt.Errorf("delete tenant AI content logs: %w", err) } steps := []struct { name string diff --git a/internal/service/totp_oauth_test.go b/internal/service/totp_oauth_test.go index 8f7e8e8..4a7d719 100644 --- a/internal/service/totp_oauth_test.go +++ b/internal/service/totp_oauth_test.go @@ -224,7 +224,7 @@ func TestOAuthProvidersListsConfigured(t *testing.T) { t.Fatalf("未配置时 providers = %v", got) } secret := "gh-secret" - err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret}) + err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret}) if err != nil { t.Fatalf("UpdateOAuth: %v", err) } @@ -249,7 +249,7 @@ func TestOAuthAuthorizeConfigErrors(t *testing.T) { t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err) } // clientID 已配但面板地址未设置 - if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil { + if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}); err != nil { t.Fatalf("UpdateOAuth: %v", err) } if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) { @@ -273,7 +273,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) { t.Fatalf("Providers = %v, want empty", got) } // 配置 github(无显示名称)→ 默认名 GitHub - in := UpdateOAuthInput{GithubClientID: "Iv1.test"} + in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")} if err := o.settings.UpdateOAuth(ctx, in); err != nil { t.Fatalf("UpdateOAuth: %v", err) } @@ -282,7 +282,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) { t.Fatalf("Providers = %+v, want [github/GitHub]", got) } // 自定义显示名称 - in.GithubDisplayName = "公司账号" + in.GithubDisplayName = strPtr("公司账号") if err := o.settings.UpdateOAuth(ctx, in); err != nil { t.Fatalf("UpdateOAuth: %v", err) } @@ -290,7 +290,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) { t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName) } // 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起 - in.GithubDisabled = true + in.GithubDisabled = boolPtr(true) if err := o.settings.UpdateOAuth(ctx, in); err != nil { t.Fatalf("UpdateOAuth: %v", err) } @@ -309,3 +309,71 @@ func TestOAuthProvidersAndDisabled(t *testing.T) { t.Fatalf("OAuthView = %+v, %v", view, err) } } + +// TestUpdateOAuthPartialPatch 锁定 provider 配置的部分更新语义: +// 只写出现字段,另一 provider 与未出现字段不回滚,secret nil 沿用空串清除。 +func TestUpdateOAuthPartialPatch(t *testing.T) { + o, _ := newOAuthEnv(t) + ctx := context.Background() + secret := "gh-secret" + seed := UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret, GithubDisplayName: strPtr("公司账号")} + if err := o.settings.UpdateOAuth(ctx, seed); err != nil { + t.Fatalf("seed: %v", err) + } + steps := []struct { + name string + patch UpdateOAuthInput + check func(v OAuthProvidersView) string + }{ + { + name: "只更新 oidc 不动 github", + patch: UpdateOAuthInput{OidcIssuer: strPtr("https://sso.example.com/"), OidcClientID: strPtr("oidc-cid")}, + check: func(v OAuthProvidersView) string { + if v.OidcIssuer != "https://sso.example.com" || v.OidcClientID != "oidc-cid" { + return "oidc 字段未生效或未规范化" + } + if v.GithubClientID != "cid" || !v.GithubSecretSet || v.GithubDisplayName != "公司账号" { + return "github 字段被回滚" + } + return "" + }, + }, + { + name: "单开关启停,secret nil 沿用", + patch: UpdateOAuthInput{GithubDisabled: boolPtr(true)}, + check: func(v OAuthProvidersView) string { + if !v.GithubDisabled || v.GithubClientID != "cid" || !v.GithubSecretSet { + return "启停外字段被动到或 secret 丢失" + } + return "" + }, + }, + { + name: "secret 空串清除", + patch: UpdateOAuthInput{GithubClientSecret: strPtr("")}, + check: func(v OAuthProvidersView) string { + if v.GithubSecretSet { + return "空串未清除 secret" + } + if v.GithubClientID != "cid" { + return "clientID 被动到" + } + return "" + }, + }, + } + for _, st := range steps { + t.Run(st.name, func(t *testing.T) { + if err := o.settings.UpdateOAuth(ctx, st.patch); err != nil { + t.Fatalf("UpdateOAuth: %v", err) + } + view, err := o.settings.OAuthView(ctx) + if err != nil { + t.Fatalf("OAuthView: %v", err) + } + if msg := st.check(view); msg != "" { + t.Error(msg) + } + }) + } +}