Files
oci-portal/internal/api/federation.go
T
2026-07-22 16:51:23 +08:00

371 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>.<ext>)"
// @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)
}
}
// ---- FederationSAML 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)
}