368 lines
13 KiB
Go
368 lines
13 KiB
Go
package oci
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/oracle/oci-go-sdk/v65/common"
|
||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||
)
|
||
|
||
// notificationSettingID 是 Identity Domain 通知设置的固定资源 ID。
|
||
const notificationSettingID = "NotificationSettings"
|
||
|
||
// NotificationRecipients 是域通知收件人设置:test mode 开启后
|
||
// 域内全部通知只发给 recipients 列表。
|
||
type NotificationRecipients struct {
|
||
Recipients []string `json:"recipients"`
|
||
TestModeEnabled bool `json:"testModeEnabled"`
|
||
}
|
||
|
||
// PasswordPolicyInfo 是一条域密码策略的关键字段。
|
||
type PasswordPolicyInfo struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Priority int `json:"priority"`
|
||
PasswordStrength string `json:"passwordStrength"`
|
||
PasswordExpiresAfter *int `json:"passwordExpiresAfter,omitempty"`
|
||
MinLength *int `json:"minLength,omitempty"`
|
||
NumPasswordsInHistory *int `json:"numPasswordsInHistory,omitempty"`
|
||
}
|
||
|
||
// UpdatePasswordPolicyInput 是密码策略修改项;nil 字段不修改。
|
||
// PasswordExpiresAfter 为 0 表示密码永不过期;
|
||
// NumPasswordsInHistory 为 0 表示可复用以前的密码(不记历史)。
|
||
type UpdatePasswordPolicyInput struct {
|
||
PasswordExpiresAfter *int
|
||
MinLength *int
|
||
NumPasswordsInHistory *int
|
||
}
|
||
|
||
// IdentitySettingInfo 是域身份设置里本面板关心的开关。
|
||
type IdentitySettingInfo struct {
|
||
ID string `json:"id"`
|
||
// 用户需要提供主电子邮件地址:开启后创建用户 / JIT 预配必须带邮箱
|
||
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||
}
|
||
|
||
// IdentityDomain 是租户下一个 ACTIVE 身份域的列表视图;
|
||
// URL 仅供后端解析 SCIM 端点,不下发给前端(防面板被引导签名任意地址)。
|
||
type IdentityDomain struct {
|
||
ID string `json:"id"`
|
||
DisplayName string `json:"displayName"`
|
||
URL string `json:"-"`
|
||
HomeRegion string `json:"homeRegion"`
|
||
Type string `json:"type"`
|
||
LicenseType string `json:"licenseType"`
|
||
}
|
||
|
||
// ListIdentityDomains 实现 Client:列出租户全部 ACTIVE 身份域。
|
||
// 无域老租户返回空列表(非错误),调用方据此回退经典 IAM 路径。
|
||
func (c *RealClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
|
||
items, err := c.listDomainSummaries(ctx, cred, region)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]IdentityDomain, 0, len(items))
|
||
for _, d := range items {
|
||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||
continue
|
||
}
|
||
out = append(out, IdentityDomain{
|
||
ID: deref(d.Id),
|
||
DisplayName: deref(d.DisplayName),
|
||
URL: deref(d.Url),
|
||
HomeRegion: deref(d.HomeRegion),
|
||
Type: string(d.Type),
|
||
LicenseType: deref(d.LicenseType),
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// listDomainSummaries 拉取租户全部域(数量极少,仍按游标翻全以防万一)。
|
||
func (c *RealClient) listDomainSummaries(ctx context.Context, cred Credentials, region string) ([]identity.DomainSummary, error) {
|
||
ic, err := c.identityClientAt(cred, region)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var items []identity.DomainSummary
|
||
var page *string
|
||
for {
|
||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID, Page: page})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list identity domains: %w", err)
|
||
}
|
||
items = append(items, resp.Items...)
|
||
if resp.OpcNextPage == nil {
|
||
return items, nil
|
||
}
|
||
page = resp.OpcNextPage
|
||
}
|
||
}
|
||
|
||
// domainURL 解析身份域的 endpoint URL:domainID 非空按 OCID 精确匹配
|
||
// (URL 只能来自租户自己的域列表,不信任外部输入),为空回退 Default 优先。
|
||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region, domainID string) (string, error) {
|
||
items, err := c.listDomainSummaries(ctx, cred, region)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
url := pickDomainURL(items, domainID)
|
||
if url == "" {
|
||
if domainID != "" {
|
||
return "", fmt.Errorf("identity domain %s not found or inactive", domainID)
|
||
}
|
||
return "", fmt.Errorf("no active identity domain found")
|
||
}
|
||
return url, nil
|
||
}
|
||
|
||
// domainsClient 解析身份域(domainID 为空取 Default)并构造 SCIM 客户端。
|
||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region, domainID string) (identitydomains.IdentityDomainsClient, error) {
|
||
url, err := c.domainURL(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return identitydomains.IdentityDomainsClient{}, err
|
||
}
|
||
dc, err := identitydomains.NewIdentityDomainsClientWithConfigurationProvider(provider(cred), url)
|
||
if err != nil {
|
||
return dc, fmt.Errorf("new identity domains client: %w", err)
|
||
}
|
||
applyProxy(&dc.BaseClient, cred)
|
||
// IDCS 错误体是 SCIM 格式,改写后 SDK 才能解析出真实错误消息
|
||
dc.HTTPClient = scimErrorDispatcher{base: dc.HTTPClient}
|
||
return dc, nil
|
||
}
|
||
|
||
// pickDomainURL 在 ACTIVE 域中选取:domainID 非空按 OCID 匹配;
|
||
// 为空优先名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||
func pickDomainURL(domains []identity.DomainSummary, domainID string) string {
|
||
fallback := ""
|
||
for _, d := range domains {
|
||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||
continue
|
||
}
|
||
if domainID != "" {
|
||
if deref(d.Id) == domainID {
|
||
return deref(d.Url)
|
||
}
|
||
continue
|
||
}
|
||
if deref(d.DisplayName) == "Default" {
|
||
return deref(d.Url)
|
||
}
|
||
if fallback == "" {
|
||
fallback = deref(d.Url)
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return NotificationRecipients{}, err
|
||
}
|
||
setting, err := getNotificationSetting(ctx, dc)
|
||
if err != nil {
|
||
return NotificationRecipients{}, err
|
||
}
|
||
return NotificationRecipients{
|
||
Recipients: orEmpty(setting.TestRecipients),
|
||
TestModeEnabled: setting.TestModeEnabled != nil && *setting.TestModeEnabled,
|
||
}, nil
|
||
}
|
||
|
||
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return NotificationRecipients{}, err
|
||
}
|
||
setting, err := getNotificationSetting(ctx, dc)
|
||
if err != nil {
|
||
return NotificationRecipients{}, err
|
||
}
|
||
enabled := len(emails) > 0
|
||
setting.TestRecipients = emails
|
||
setting.TestModeEnabled = &enabled
|
||
id := notificationSettingID
|
||
resp, err := dc.PutNotificationSetting(ctx, identitydomains.PutNotificationSettingRequest{
|
||
NotificationSettingId: &id,
|
||
NotificationSetting: setting,
|
||
})
|
||
if err != nil {
|
||
return NotificationRecipients{}, fmt.Errorf("put notification setting: %w", err)
|
||
}
|
||
return NotificationRecipients{
|
||
Recipients: orEmpty(resp.TestRecipients),
|
||
TestModeEnabled: resp.TestModeEnabled != nil && *resp.TestModeEnabled,
|
||
}, nil
|
||
}
|
||
|
||
func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.NotificationSetting, error) {
|
||
id := notificationSettingID
|
||
resp, err := dc.GetNotificationSetting(ctx, identitydomains.GetNotificationSettingRequest{
|
||
NotificationSettingId: &id,
|
||
})
|
||
if err != nil {
|
||
return identitydomains.NotificationSetting{}, fmt.Errorf("get notification setting: %w", err)
|
||
}
|
||
return resp.NotificationSetting, nil
|
||
}
|
||
|
||
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp, err := dc.ListPasswordPolicies(ctx, identitydomains.ListPasswordPoliciesRequest{})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list password policies: %w", err)
|
||
}
|
||
policies := make([]PasswordPolicyInfo, 0, len(resp.Resources))
|
||
for _, p := range resp.Resources {
|
||
policies = append(policies, toPasswordPolicyInfo(p))
|
||
}
|
||
return policies, nil
|
||
}
|
||
|
||
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
||
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||
ops := buildPolicyPatchOps(in)
|
||
if len(ops) == 0 {
|
||
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
|
||
}
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return PasswordPolicyInfo{}, err
|
||
}
|
||
resp, err := dc.PatchPasswordPolicy(ctx, identitydomains.PatchPasswordPolicyRequest{
|
||
PasswordPolicyId: &policyID,
|
||
PatchOp: identitydomains.PatchOp{
|
||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||
Operations: ops,
|
||
},
|
||
})
|
||
if err != nil {
|
||
if isForbidden(err) {
|
||
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: only Custom-strength policies are editable: %w", policyID, err)
|
||
}
|
||
return PasswordPolicyInfo{}, fmt.Errorf("patch password policy %s: %w", policyID, err)
|
||
}
|
||
return toPasswordPolicyInfo(resp.PasswordPolicy), nil
|
||
}
|
||
|
||
// isForbidden 判断服务端返回 403(内置只读资源等场景)。
|
||
func isForbidden(err error) bool {
|
||
var svcErr common.ServiceError
|
||
return errors.As(err, &svcErr) && svcErr.GetHTTPStatusCode() == 403
|
||
}
|
||
|
||
func buildPolicyPatchOps(in UpdatePasswordPolicyInput) []identitydomains.Operations {
|
||
var ops []identitydomains.Operations
|
||
if in.PasswordExpiresAfter != nil {
|
||
ops = append(ops, intOrRemoveOp("passwordExpiresAfter", *in.PasswordExpiresAfter))
|
||
}
|
||
if in.MinLength != nil {
|
||
ops = append(ops, replaceOp("minLength", *in.MinLength))
|
||
}
|
||
if in.NumPasswordsInHistory != nil {
|
||
ops = append(ops, intOrRemoveOp("numPasswordsInHistory", *in.NumPasswordsInHistory))
|
||
}
|
||
return ops
|
||
}
|
||
|
||
// intOrRemoveOp 值为 0 时移除属性(IDCS 语义 null = 永不过期 / 可复用密码;
|
||
// numPasswordsInHistory 合法范围 1-500,replace 0 会被拒),否则 replace。
|
||
func intOrRemoveOp(path string, v int) identitydomains.Operations {
|
||
if v == 0 {
|
||
return removeOp(path)
|
||
}
|
||
return replaceOp(path, v)
|
||
}
|
||
|
||
func removeOp(path string) identitydomains.Operations {
|
||
return identitydomains.Operations{
|
||
Op: identitydomains.OperationsOpRemove,
|
||
Path: &path,
|
||
}
|
||
}
|
||
|
||
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error) {
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return IdentitySettingInfo{}, err
|
||
}
|
||
resp, err := dc.ListIdentitySettings(ctx, identitydomains.ListIdentitySettingsRequest{})
|
||
if err != nil {
|
||
return IdentitySettingInfo{}, fmt.Errorf("list identity settings: %w", err)
|
||
}
|
||
if len(resp.Resources) == 0 {
|
||
return IdentitySettingInfo{}, fmt.Errorf("identity settings resource not found")
|
||
}
|
||
s := resp.Resources[0]
|
||
info := IdentitySettingInfo{ID: deref(s.Id)}
|
||
if s.PrimaryEmailRequired != nil {
|
||
info.PrimaryEmailRequired = *s.PrimaryEmailRequired
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||
current, err := c.GetIdentitySetting(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return IdentitySettingInfo{}, err
|
||
}
|
||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||
if err != nil {
|
||
return IdentitySettingInfo{}, err
|
||
}
|
||
resp, err := dc.PatchIdentitySetting(ctx, identitydomains.PatchIdentitySettingRequest{
|
||
IdentitySettingId: ¤t.ID,
|
||
PatchOp: identitydomains.PatchOp{
|
||
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||
Operations: []identitydomains.Operations{replaceOp("primaryEmailRequired", primaryEmailRequired)},
|
||
},
|
||
})
|
||
if err != nil {
|
||
return IdentitySettingInfo{}, fmt.Errorf("patch identity settings: %w", err)
|
||
}
|
||
info := IdentitySettingInfo{ID: deref(resp.IdentitySetting.Id)}
|
||
if resp.IdentitySetting.PrimaryEmailRequired != nil {
|
||
info.PrimaryEmailRequired = *resp.IdentitySetting.PrimaryEmailRequired
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
func replaceOp(path string, value interface{}) identitydomains.Operations {
|
||
return identitydomains.Operations{
|
||
Op: identitydomains.OperationsOpReplace,
|
||
Path: &path,
|
||
Value: &value,
|
||
}
|
||
}
|
||
|
||
func toPasswordPolicyInfo(p identitydomains.PasswordPolicy) PasswordPolicyInfo {
|
||
info := PasswordPolicyInfo{
|
||
ID: deref(p.Id),
|
||
Name: deref(p.Name),
|
||
PasswordStrength: string(p.PasswordStrength),
|
||
PasswordExpiresAfter: p.PasswordExpiresAfter,
|
||
MinLength: p.MinLength,
|
||
NumPasswordsInHistory: p.NumPasswordsInHistory,
|
||
}
|
||
if p.Priority != nil {
|
||
info.Priority = *p.Priority
|
||
}
|
||
return info
|
||
}
|