371 lines
12 KiB
Go
371 lines
12 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// ---- 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)
|
||
}
|