+104
-16
@@ -3,6 +3,7 @@ package oci
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
|
||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||
|
||||
const idpCreateRollbackWait = 15 * time.Second
|
||||
|
||||
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
|
||||
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
|
||||
|
||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||
type IdentityProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
|
||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||
}
|
||||
|
||||
// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。
|
||||
// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。
|
||||
type PartialIdentityProviderCreateError struct {
|
||||
IdentityProvider IdentityProviderInfo
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Error() string {
|
||||
return "identity provider was created but JIT setup is incomplete"
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause }
|
||||
|
||||
type federationDomainsClient interface {
|
||||
ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error)
|
||||
CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error)
|
||||
GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error)
|
||||
PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error)
|
||||
DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error)
|
||||
}
|
||||
|
||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
||||
return idps, nil
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT;
|
||||
// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。
|
||||
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
|
||||
}
|
||||
return createSamlIdentityProvider(ctx, dc, in)
|
||||
}
|
||||
|
||||
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||
var err error
|
||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
|
||||
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
|
||||
}
|
||||
info := toIdpInfo(resp.IdentityProvider)
|
||||
if !in.JitEnabled {
|
||||
return info, nil
|
||||
}
|
||||
return toIdpInfo(resp.IdentityProvider), nil
|
||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||
return rollbackIncompleteIdp(ctx, dc, info, err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
|
||||
if info.ID == "" {
|
||||
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
|
||||
defer cancel()
|
||||
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
|
||||
IdentityProviderId: &info.ID,
|
||||
})
|
||||
if rollbackErr == nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
|
||||
}
|
||||
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
|
||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
||||
|
||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*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{
|
||||
@@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
|
||||
}
|
||||
|
||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, 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})
|
||||
@@ -302,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains
|
||||
}
|
||||
|
||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||
// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建,
|
||||
// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。
|
||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||
returns := make([]interface{}, 0, len(items))
|
||||
changed := false
|
||||
returns := make([]interface{}, 0, len(items)+1)
|
||||
changed, seen := false, false
|
||||
for _, item := range items {
|
||||
name, value := deref(item.Name), deref(item.Value)
|
||||
if name == "SamlIDPs" {
|
||||
seen = true
|
||||
next, ok, err := toggleJSONList(value, idpID, show)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||
@@ -316,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho
|
||||
}
|
||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
if !seen && show {
|
||||
returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)})
|
||||
changed = true
|
||||
}
|
||||
return returns, changed, nil
|
||||
}
|
||||
|
||||
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := fetchSamlMetadata(ctx, url)
|
||||
body, status, err := fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
body, status, err = fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
|
||||
// 防异常缓慢或超大响应长期占住请求与内存。
|
||||
const (
|
||||
samlMetadataTimeout = 30 * time.Second
|
||||
samlMetadataMaxBytes = 4 << 20
|
||||
)
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名,
|
||||
// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。
|
||||
func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) {
|
||||
client, err := metadataHTTPClient(cred)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout)
|
||||
defer cancel()
|
||||
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)
|
||||
resp, err := client.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)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||
}
|
||||
if len(body) > samlMetadataMaxBytes {
|
||||
return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes)
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时
|
||||
// 报错而非静默直连(失败关闭)。
|
||||
func metadataHTTPClient(cred Credentials) (*http.Client, error) {
|
||||
if cred.Proxy == nil {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
if hc := HTTPClientFor(cred.Proxy); hc != nil {
|
||||
return hc, nil
|
||||
}
|
||||
return nil, errors.New("download saml metadata: invalid proxy config")
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
|
||||
Reference in New Issue
Block a user