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 字段全部可缺省:布尔用指针区分「未传」, // 缺省即控制台默认(JIT 开启建用户不更新、分配 Administrators 组、 // 名称 ID 格式无、NameID 映射用户名)。 type createIdpRequest struct { Name string `json:"name" binding:"required"` Metadata string `json:"metadata" binding:"required"` Description string `json:"description"` IconURL string `json:"iconUrl"` NameIDFormat string `json:"nameIdFormat"` AssertionAttribute string `json:"assertionAttribute"` UserStoreAttribute string `json:"userStoreAttribute"` JitEnabled *bool `json:"jitEnabled"` JitCreate *bool `json:"jitCreate"` JitUpdate *bool `json:"jitUpdate"` JitAssignAdminGroup *bool `json:"jitAssignAdminGroup"` JitMapEmail bool `json:"jitMapEmail"` } func boolOr(v *bool, def bool) bool { if v == nil { return def } return *v } // @Summary 身份提供商列表 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Success 200 {array} oci.IdentityProviderInfo // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/identity-providers [get] func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) { id, ok := pathID(c) if !ok { return } idps, err := h.svc.IdentityProviders(c.Request.Context(), id, c.Query("domainId")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, idps) } // @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} createIdpResponse // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/identity-providers [post] func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req createIdpRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } idp, err := h.svc.CreateIdentityProvider(c.Request.Context(), id, c.Query("domainId"), oci.CreateIdpInput{ Name: req.Name, Metadata: req.Metadata, Description: req.Description, IconURL: req.IconURL, NameIDFormat: req.NameIDFormat, AssertionAttribute: req.AssertionAttribute, UserStoreAttribute: req.UserStoreAttribute, JitEnabled: boolOr(req.JitEnabled, true), JitCreate: boolOr(req.JitCreate, true), JitUpdate: boolOr(req.JitUpdate, false), JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true), JitMapEmail: req.JitMapEmail, }) 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 } 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 激活身份提供商 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param idpId path string true "idpId" // @Param body body object true "请求体(见接口说明)" // @Success 200 {object} oci.IdentityProviderInfo // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/identity-providers/{idpId}/activate [post] func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req struct { Enabled *bool `json:"enabled" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } idp, err := h.svc.SetIdentityProviderEnabled(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId"), *req.Enabled) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, idp) } // @Summary 删除身份提供商 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param idpId path string true "idpId" // @Success 204 "无内容" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/identity-providers/{idpId} [delete] func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) { id, ok := pathID(c) if !ok { return } if err := h.svc.DeleteIdentityProvider(c.Request.Context(), id, c.Query("domainId"), c.Param("idpId")); err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) } // @Summary 下载 SAML 元数据 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Success 200 {file} file "SAML 元数据 XML(附件下载)" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/saml-metadata [get] func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) { id, ok := pathID(c) if !ok { return } metadata, err := h.svc.DomainSamlMetadata(c.Request.Context(), id, c.Query("domainId")) if err != nil { respondError(c, err) return } c.Header("Content-Disposition", `attachment; filename="oci-domain-saml-metadata.xml"`) c.Data(http.StatusOK, "application/xml", metadata) } // @Summary 单点登录规则列表 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Success 200 {array} oci.SignOnRuleInfo // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/sign-on-rules [get] func (h *ociConfigHandler) listSignOnRules(c *gin.Context) { id, ok := pathID(c) if !ok { return } rules, err := h.svc.ConsoleSignOnRules(c.Request.Context(), id, c.Query("domainId")) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, rules) } // @Summary 创建 MFA 豁免规则 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param body body object true "请求体(见接口说明)" // @Success 201 {object} oci.SignOnRuleInfo // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/sign-on-exemptions [post] func (h *ociConfigHandler) createMfaExemption(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req struct { IdentityProviderID string `json:"identityProviderId" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } rule, err := h.svc.CreateMfaExemption(c.Request.Context(), id, c.Query("domainId"), req.IdentityProviderID) if err != nil { respondError(c, err) return } c.JSON(http.StatusCreated, rule) } // @Summary 删除 MFA 豁免规则 // @Tags 租户 IAM // @Param id path int true "配置 ID" // @Param domainId query string false "身份域 OCID(缺省 Default 域)" // @Param ruleId path string true "ruleId" // @Success 204 "无内容" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/sign-on-exemptions/{ruleId} [delete] func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) { id, ok := pathID(c) if !ok { return } if err := h.svc.DeleteMfaExemption(c.Request.Context(), id, c.Query("domainId"), c.Param("ruleId")); err != nil { respondError(c, err) return } c.Status(http.StatusNoContent) }