package oci import ( "context" "encoding/json" "errors" "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" const idpCreateRollbackWait = 15 * time.Second // IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。 const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE" // 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"` } // 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 层负责把缺省字段填成这些默认值)。 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; // 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 } } 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) } info := toIdpInfo(resp.IdentityProvider) if !in.JitEnabled { return info, 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 接口)。 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 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{ 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 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}) 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。 // 从未分配过 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)+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) } value, changed = next, ok } 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 } // 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, cred, 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, cred, 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 } // 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 := client.Do(req) if err != nil { return nil, 0, fmt.Errorf("download saml metadata: %w", err) } defer resp.Body.Close() 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) 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 }