Files
oci-portal/.trellis/spec/backend/idp-icon-upload.md
T
2026-07-22 16:51:23 +08:00

4.6 KiB

IdP 图标上传与清理契约

1. Scope / Trigger

  • 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
  • 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做尽力清理, 孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。

2. Signatures

POST   /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>

interface IdpIconUpload {
  url: string      // 写入 IdP iconUrl 的公网地址
  fileName: string // 仅用于清理的存储标识
}

上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):

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><lowercase ext>:既避免并发/迟到响应下同名互相 覆盖,又让存储名成为不可猜测的清理凭据。
  • 内容校验只做扩展名白名单 + 文件头魔数嗅探(png/jpg/jpeg/gif/webp/ico/svg), 不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管, 传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起 (2026-07 曾过度实现完整解码套件后精简)。
  • DELETE 只接受单层 images/idp-icon-<32 lowercase hex>.<allowed ext>, 不得因共享 images/ 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
  • 前端上传成功时保存 {cfgId, domainId, url, fileName} 原始元组作 pendingIcon, 用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除, 失败静默。创建请求引用了它且返回 2xx(含 201 + setupWarning)即视为已采用。
  • IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP; 回滚成功按普通失败,回滚失败/无法确认返回 201setupWarning{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

// 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 精简移除。