修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s

This commit is contained in:
2026-07-22 16:51:23 +08:00
parent 0614ef22af
commit f51fb6c722
66 changed files with 3997 additions and 687 deletions
+104 -16
View File
@@ -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)