Files
oci-portal/internal/oci/federation.go
T
Wang Defa dbba1f4905
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s
发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
2026-07-10 17:38:34 +08:00

444 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) 外链 URLIDCS 拒绝 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
}