初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
// defaultIdpRuleID 是 Default Identity Provider Policy 的预置规则固定 ID,
|
||||
// 其 return 中 SamlIDPs 数组决定登录页显示哪些 SAML IdP。
|
||||
const defaultIdpRuleID = "DefaultIDPRule"
|
||||
|
||||
// samlMetadataPath 是 Identity Domain 的 SP SAML 元数据端点。
|
||||
const samlMetadataPath = "/fed/v1/metadata"
|
||||
|
||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||
|
||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||
type IdentityProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PartnerProviderID string `json:"partnerProviderId"`
|
||||
JitEnabled bool `json:"jitEnabled"`
|
||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||
}
|
||||
|
||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||
type CreateIdpInput struct {
|
||||
Name string // partnerName,必填
|
||||
Metadata string // IdP metadata XML,必填
|
||||
Description string
|
||||
IconURL string // 登录页 logo,仅接受 http(s) 外链 URL(IDCS 拒绝 data URI),空则不设置
|
||||
|
||||
// 映射用户身份
|
||||
NameIDFormat string // 请求的名称 ID 格式,如 saml-none / saml-emailaddress
|
||||
AssertionAttribute string // 非空时改用断言属性映射,值为断言中的属性名;空用 NameID
|
||||
UserStoreAttribute string // 目标域用户属性:userName 或 emails.primary(主邮箱)
|
||||
|
||||
// JIT 预配
|
||||
JitEnabled bool
|
||||
JitCreate bool // JIT 时创建新用户
|
||||
JitUpdate bool // JIT 时更新既有用户属性
|
||||
JitAssignAdminGroup bool // 静态分配 Administrators 组
|
||||
JitMapEmail bool // 属性映射额外映射主邮箱(域要求邮箱必填时需开启)
|
||||
}
|
||||
|
||||
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := dc.ListIdentityProviders(ctx, identitydomains.ListIdentityProvidersRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identity providers: %w", err)
|
||||
}
|
||||
idps := make([]IdentityProviderInfo, 0, len(resp.Resources))
|
||||
for _, idp := range resp.Resources {
|
||||
if idp.Type != identitydomains.IdentityProviderTypeSaml {
|
||||
continue
|
||||
}
|
||||
idps = append(idps, toIdpInfo(idp))
|
||||
}
|
||||
return idps, nil
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
}
|
||||
resp, err := dc.CreateIdentityProvider(ctx, identitydomains.CreateIdentityProviderRequest{
|
||||
IdentityProvider: buildSamlIdp(in, adminGroup),
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
return toIdpInfo(resp.IdentityProvider), nil
|
||||
}
|
||||
|
||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||
func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups) identitydomains.IdentityProvider {
|
||||
enabled := false
|
||||
boolFalse := false
|
||||
idp := identitydomains.IdentityProvider{
|
||||
Schemas: []string{idpSchema},
|
||||
PartnerName: &in.Name,
|
||||
Enabled: &enabled,
|
||||
Type: identitydomains.IdentityProviderTypeSaml,
|
||||
Metadata: &in.Metadata,
|
||||
NameIdFormat: &in.NameIDFormat,
|
||||
SignatureHashAlgorithm: identitydomains.IdentityProviderSignatureHashAlgorithm256,
|
||||
IncludeSigningCertInSignature: &boolFalse,
|
||||
UserMappingMethod: identitydomains.IdentityProviderUserMappingMethodNameidtouserattribute,
|
||||
UserMappingStoreAttribute: &in.UserStoreAttribute,
|
||||
JitUserProvEnabled: &in.JitEnabled,
|
||||
JitUserProvCreateUserEnabled: &in.JitCreate,
|
||||
JitUserProvAttributeUpdateEnabled: &in.JitUpdate,
|
||||
JitUserProvGroupAssertionAttributeEnabled: &boolFalse,
|
||||
JitUserProvGroupStaticListEnabled: &in.JitAssignAdminGroup,
|
||||
JitUserProvGroupMappingMode: identitydomains.IdentityProviderJitUserProvGroupMappingModeImplicit,
|
||||
}
|
||||
if in.AssertionAttribute != "" {
|
||||
idp.UserMappingMethod = identitydomains.IdentityProviderUserMappingMethodAssertionattributetouserattribute
|
||||
idp.AssertionAttribute = &in.AssertionAttribute
|
||||
}
|
||||
if in.IconURL != "" {
|
||||
idp.IconUrl = &in.IconURL
|
||||
}
|
||||
if in.Description != "" {
|
||||
idp.Description = &in.Description
|
||||
}
|
||||
if adminGroup != nil {
|
||||
idp.JitUserProvAssignedGroups = []identitydomains.IdentityProviderJitUserProvAssignedGroups{*adminGroup}
|
||||
}
|
||||
return idp
|
||||
}
|
||||
|
||||
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
||||
count := 1
|
||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||
Filter: &filter,
|
||||
Attributes: common.String("id,displayName"),
|
||||
Count: &count,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find Administrators group: %w", err)
|
||||
}
|
||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
||||
}
|
||||
|
||||
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||
// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。
|
||||
func jitMappings(in CreateIdpInput) []interface{} {
|
||||
src := "$(assertion.fed.nameidvalue)"
|
||||
if in.AssertionAttribute != "" {
|
||||
src = "$(assertion." + in.AssertionAttribute + ")"
|
||||
}
|
||||
ms := []interface{}{
|
||||
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "userName"},
|
||||
map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "name.familyName"},
|
||||
}
|
||||
if in.JitMapEmail {
|
||||
ms = append(ms, map[string]string{"managedObjectAttributeName": src, "idcsAttributeName": "emails[work].value"})
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, 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})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload idp for jit attributes: %w", err)
|
||||
}
|
||||
ref = got.JitUserProvAttributes
|
||||
}
|
||||
if ref == nil || ref.Value == nil {
|
||||
return fmt.Errorf("idp %s has no jit attribute mapping resource", deref(idp.Id))
|
||||
}
|
||||
var value interface{} = mappings
|
||||
_, err := dc.PatchMappedAttribute(ctx, identitydomains.PatchMappedAttributeRequest{
|
||||
MappedAttributeId: ref.Value,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("attributeMappings"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch jit attribute mappings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
if !enabled {
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
}
|
||||
idp, err := patchIdpEnabled(ctx, dc, idpID, enabled)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
if enabled {
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, true); err != nil {
|
||||
return toIdpInfo(idp), err
|
||||
}
|
||||
}
|
||||
return toIdpInfo(idp), nil
|
||||
}
|
||||
|
||||
func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, enabled bool) (identitydomains.IdentityProvider, error) {
|
||||
var value interface{} = enabled
|
||||
resp, err := dc.PatchIdentityProvider(ctx, identitydomains.PatchIdentityProviderRequest{
|
||||
IdentityProviderId: &idpID,
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("enabled"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return identitydomains.IdentityProvider{}, fmt.Errorf("patch idp %s enabled=%v: %w", idpID, enabled, err)
|
||||
}
|
||||
return resp.IdentityProvider, nil
|
||||
}
|
||||
|
||||
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateLoginPageIdps(ctx, dc, idpID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := patchIdpEnabled(ctx, dc, idpID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dc.DeleteIdentityProvider(ctx, identitydomains.DeleteIdentityProviderRequest{
|
||||
IdentityProviderId: &idpID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete identity provider %s: %w", idpID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLoginPageIdps 修改预置 DefaultIDPRule 的 SamlIDPs 数组控制登录页显示。
|
||||
func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomainsClient, idpID string, show bool) error {
|
||||
rule, err := dc.GetRule(ctx, identitydomains.GetRuleRequest{RuleId: common.String(defaultIdpRuleID)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get default idp rule: %w", err)
|
||||
}
|
||||
returns, changed, err := rebuildSamlIdpsReturn(rule.Return, idpID, show)
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
var value interface{} = returns
|
||||
_, err = dc.PatchRule(ctx, identitydomains.PatchRuleRequest{
|
||||
RuleId: common.String(defaultIdpRuleID),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("return"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("patch default idp rule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||
returns := make([]interface{}, 0, len(items))
|
||||
changed := false
|
||||
for _, item := range items {
|
||||
name, value := deref(item.Name), deref(item.Value)
|
||||
if name == "SamlIDPs" {
|
||||
next, ok, err := toggleJSONList(value, idpID, show)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||
}
|
||||
value, changed = next, ok
|
||||
}
|
||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
return returns, changed, nil
|
||||
}
|
||||
|
||||
// toggleJSONList 在 JSON 数组字符串中添加或移除一个元素,返回是否有变化。
|
||||
func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
||||
var items []string
|
||||
if list != "" {
|
||||
if err := json.Unmarshal([]byte(list), &items); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
next := make([]string, 0, len(items)+1)
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it == item {
|
||||
found = true
|
||||
if !add {
|
||||
continue
|
||||
}
|
||||
}
|
||||
next = append(next, it)
|
||||
}
|
||||
if add && !found {
|
||||
next = append(next, item)
|
||||
}
|
||||
if add == found {
|
||||
return list, false, nil
|
||||
}
|
||||
b, err := json.Marshal(next)
|
||||
return string(b), true, err
|
||||
}
|
||||
|
||||
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := fetchSamlMetadata(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
return body, nil
|
||||
}
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("download saml metadata: HTTP %d: %s", status, truncate(string(body), 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var value interface{} = true
|
||||
_, err = dc.PatchSetting(ctx, identitydomains.PatchSettingRequest{
|
||||
SettingId: common.String("Settings"),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("signingCertPublicAccess"),
|
||||
Value: &value,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable signing cert public access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func toIdpInfo(idp identitydomains.IdentityProvider) IdentityProviderInfo {
|
||||
info := IdentityProviderInfo{
|
||||
ID: deref(idp.Id),
|
||||
Name: deref(idp.PartnerName),
|
||||
Type: string(idp.Type),
|
||||
Enabled: idp.Enabled != nil && *idp.Enabled,
|
||||
PartnerProviderID: deref(idp.PartnerProviderId),
|
||||
JitEnabled: idp.JitUserProvEnabled != nil && *idp.JitUserProvEnabled,
|
||||
}
|
||||
if idp.Meta != nil && idp.Meta.Created != nil {
|
||||
if t, err := time.Parse(time.RFC3339, *idp.Meta.Created); err == nil {
|
||||
info.TimeCreated = &t
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
Reference in New Issue
Block a user