444 lines
16 KiB
Go
444 lines
16 KiB
Go
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, domainID string) ([]IdentityProviderInfo, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
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, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
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 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||
count := len(adminGroupNames)
|
||
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)
|
||
}
|
||
for _, name := range adminGroupNames {
|
||
for _, g := range resp.Resources {
|
||
if deref(g.DisplayName) == name && g.Id != nil {
|
||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: g.Id}, nil
|
||
}
|
||
}
|
||
}
|
||
return nil, 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, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
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, domainID, idpID string) error {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
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, domainID string) ([]byte, error) {
|
||
url, err := c.domainURL(ctx, cred, region, domainID)
|
||
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, domainID); 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, domainID string) error {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
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
|
||
}
|