发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
@@ -19,6 +26,89 @@ type AccountProfile struct {
|
||||
PromotionExpires *time.Time
|
||||
}
|
||||
|
||||
// AccountCapabilities 是账户能力接口的关键字段。来源为 Limits 服务
|
||||
// 20230301 版 `/compartments/{tenancy}/capabilities?accountInfoOnly=true`
|
||||
// (与控制台同源;oci-go-sdk v65 未收录,自签名裸调)。
|
||||
// 该接口对暂停/已终止租户仍可访问,借此把「暂停」从「失联」中区分出来。
|
||||
type AccountCapabilities struct {
|
||||
Suspended bool `json:"suspended"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
FreeTierEnabled bool `json:"freeTierEnabled"`
|
||||
FreeTierOnly bool `json:"freeTierOnly"`
|
||||
PromotionStatus string `json:"promotionStatus"`
|
||||
ProgramType string `json:"programType"`
|
||||
HasSaasSubscription bool `json:"hasSaasSubscription"`
|
||||
Deletable bool `json:"deletable"`
|
||||
}
|
||||
|
||||
// capabilitiesEnvelope 对应接口原始响应:顶层与 accountInfo 各有一个
|
||||
// suspended,任一为 true 即视为暂停。
|
||||
type capabilitiesEnvelope struct {
|
||||
Capabilities struct {
|
||||
Suspended bool `json:"suspended"`
|
||||
AccountInfo struct {
|
||||
FreeTierEnabled bool `json:"freeTierEnabled"`
|
||||
FreeTierOnly bool `json:"freeTierOnly"`
|
||||
PromotionStatus string `json:"promotionStatus"`
|
||||
Suspended bool `json:"suspended"`
|
||||
ProgramType string `json:"programType"`
|
||||
HasSaasSubscription bool `json:"hasSaasSubscription"`
|
||||
AccountStatus string `json:"accountStatus"`
|
||||
Deletable bool `json:"deletable"`
|
||||
} `json:"accountInfo"`
|
||||
} `json:"capabilities"`
|
||||
}
|
||||
|
||||
// parseAccountCapabilities 把原始响应体压平为关键字段视图。
|
||||
func parseAccountCapabilities(body []byte) (AccountCapabilities, error) {
|
||||
var env capabilitiesEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("decode account capabilities: %w", err)
|
||||
}
|
||||
info := env.Capabilities.AccountInfo
|
||||
return AccountCapabilities{
|
||||
Suspended: env.Capabilities.Suspended || info.Suspended,
|
||||
AccountStatus: info.AccountStatus,
|
||||
FreeTierEnabled: info.FreeTierEnabled,
|
||||
FreeTierOnly: info.FreeTierOnly,
|
||||
PromotionStatus: info.PromotionStatus,
|
||||
ProgramType: info.ProgramType,
|
||||
HasSaasSubscription: info.HasSaasSubscription,
|
||||
Deletable: info.Deletable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountCapabilities 实现 Client:查询账户能力(暂停/账户状态/免费层)。
|
||||
// region 应传 home region;请求带 API Key 签名,并沿用租户出站代理。
|
||||
func (c *RealClient) GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error) {
|
||||
url := fmt.Sprintf("https://limits.%s.oci.oraclecloud.com/20230301/compartments/%s/capabilities?accountInfoOnly=true",
|
||||
normalizeRegion(region), cred.TenancyOCID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("new capabilities request: %w", err)
|
||||
}
|
||||
if err := common.DefaultRequestSigner(provider(cred)).Sign(req); err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("sign capabilities request: %w", err)
|
||||
}
|
||||
hc := proxyHTTPClient(cred.Proxy)
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: proxyClientTimeout}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("get account capabilities: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return AccountCapabilities{}, fmt.Errorf("read account capabilities: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return AccountCapabilities{}, fmt.Errorf("get account capabilities: status %d", resp.StatusCode)
|
||||
}
|
||||
return parseAccountCapabilities(body)
|
||||
}
|
||||
|
||||
// ClassifyAccount 按订阅字段判定账户类别:
|
||||
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
||||
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
||||
|
||||
@@ -36,3 +36,53 @@ func TestClassifyAccount(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAccountCapabilities 用控制台同源接口的真实响应样例驱动。
|
||||
func TestParseAccountCapabilities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSusp bool
|
||||
wantStatus string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "已终止租户(顶层与 accountInfo 双 suspended)",
|
||||
body: `{"compartmentId":"ocid1.tenancy.oc1..x","capabilities":{"suspended":true,` +
|
||||
`"accountInfo":{"freeTierEnabled":false,"freeTierOnly":false,"promotionStatus":"none",` +
|
||||
`"suspended":true,"programType":"default","limitsProvisioned":true,"intentToPay":false,` +
|
||||
`"hasSaasSubscription":true,"accountStatus":"terminated","accountFlags":0,"deletable":true,` +
|
||||
`"limitIncrease":false,"orgProperties":"0"}}}`,
|
||||
wantSusp: true,
|
||||
wantStatus: "terminated",
|
||||
},
|
||||
{
|
||||
name: "正常租户",
|
||||
body: `{"capabilities":{"suspended":false,"accountInfo":{"accountStatus":"active","freeTierEnabled":true}}}`,
|
||||
wantSusp: false, wantStatus: "active",
|
||||
},
|
||||
{
|
||||
name: "仅 accountInfo 标记 suspended 也算暂停",
|
||||
body: `{"capabilities":{"suspended":false,"accountInfo":{"suspended":true}}}`,
|
||||
wantSusp: true,
|
||||
},
|
||||
{name: "非 JSON 报错", body: `<html>`, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAccountCapabilities([]byte(tc.body))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Suspended != tc.wantSusp || got.AccountStatus != tc.wantStatus {
|
||||
t.Fatalf("got %+v, want suspended=%v status=%q", got, tc.wantSusp, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+113
-1
@@ -93,6 +93,11 @@ func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, regi
|
||||
|
||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
||||
result.Items = appendKeptAuditEvents(result.Items, items)
|
||||
}
|
||||
|
||||
// appendKeptAuditEvents 是过滤追加的通用形态,窗口式与批式查询共用。
|
||||
func appendKeptAuditEvents(dst []AuditEvent, items []audit.AuditEvent) []AuditEvent {
|
||||
for _, ev := range items {
|
||||
out := toAuditEvent(ev)
|
||||
if !keepAuditEvent(out) {
|
||||
@@ -101,8 +106,115 @@ func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||
out.Raw = raw
|
||||
}
|
||||
result.Items = append(result.Items, out)
|
||||
dst = append(dst, out)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// ---- 批式懒加载查询:分窗回溯 + 游标续查 ----
|
||||
|
||||
// 批式查询参数:单批 OCI 翻页预算沿用 maxAuditPages;首窗 24h,
|
||||
// 连续空窗倍增(上限 30 天)加速跨越闲置期;回溯下限为事件保留期 365 天。
|
||||
const (
|
||||
auditWindowHours = 24
|
||||
auditWindowMaxHours = 720
|
||||
auditRetentionDays = 365
|
||||
)
|
||||
|
||||
// AuditCursor 是批式查询的续查位置:当前时间窗、窗内 OCI 翻页游标
|
||||
// 与当前窗宽(小时,空窗倍增的记忆)。序列化为不透明 cursor 由 service 层负责。
|
||||
type AuditCursor struct {
|
||||
Start time.Time `json:"s"`
|
||||
End time.Time `json:"e"`
|
||||
Page string `json:"p,omitempty"`
|
||||
WindowHours int `json:"w"`
|
||||
}
|
||||
|
||||
// NewAuditCursor 构造首查游标:自 now 起回溯第一个 24h 窗。
|
||||
func NewAuditCursor(now time.Time) AuditCursor {
|
||||
end := now.UTC().Truncate(time.Minute)
|
||||
return AuditCursor{Start: end.Add(-auditWindowHours * time.Hour), End: end, WindowHours: auditWindowHours}
|
||||
}
|
||||
|
||||
// advance 推进到紧邻更早的窗;empty 表示刚结束的窗无保留事件,窗宽倍增,
|
||||
// 否则重置 24h。done 为 true 表示已越过保留期尽头。
|
||||
func (cur AuditCursor) advance(now time.Time, empty bool) (AuditCursor, bool) {
|
||||
w := cur.WindowHours
|
||||
if w <= 0 {
|
||||
w = auditWindowHours
|
||||
}
|
||||
if empty {
|
||||
if w *= 2; w > auditWindowMaxHours {
|
||||
w = auditWindowMaxHours
|
||||
}
|
||||
} else {
|
||||
w = auditWindowHours
|
||||
}
|
||||
end := cur.Start
|
||||
if end.Before(now.UTC().AddDate(0, 0, -auditRetentionDays)) {
|
||||
return cur, true
|
||||
}
|
||||
return AuditCursor{Start: end.Add(-time.Duration(w) * time.Hour), End: end, WindowHours: w}, false
|
||||
}
|
||||
|
||||
// AuditBatchResult 是一批懒加载结果;Cursor 为 nil 且 Exhausted 为 true
|
||||
// 表示已回溯到保留期尽头,无更早数据。
|
||||
type AuditBatchResult struct {
|
||||
Items []AuditEvent
|
||||
Cursor *AuditCursor
|
||||
Exhausted bool
|
||||
}
|
||||
|
||||
// ListAuditEventsBatch 实现 Client:从 cur 位置向更早方向收集约 limit 条
|
||||
// 保留事件;单批最多消费 maxAuditPages 页 OCI 调用,不足额也按预算返回,
|
||||
// 由前端按需续查。窗口不重叠 + 窗内游标续翻保证跨批不重不漏。
|
||||
func (c *RealClient) ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error) {
|
||||
ac, err := c.auditClient(cred, region)
|
||||
if err != nil {
|
||||
return AuditBatchResult{}, err
|
||||
}
|
||||
res := AuditBatchResult{Items: []AuditEvent{}}
|
||||
windowHasKept := false
|
||||
for budget := maxAuditPages; budget > 0 && len(res.Items) < limit; budget-- {
|
||||
items, next, err := listAuditPage(ctx, ac, cred.TenancyOCID, cur)
|
||||
if err != nil {
|
||||
return AuditBatchResult{}, err
|
||||
}
|
||||
before := len(res.Items)
|
||||
res.Items = appendKeptAuditEvents(res.Items, items)
|
||||
windowHasKept = windowHasKept || len(res.Items) > before
|
||||
if next != "" {
|
||||
cur.Page = next
|
||||
continue
|
||||
}
|
||||
nextCur, done := cur.advance(time.Now(), !windowHasKept)
|
||||
if done {
|
||||
res.Exhausted = true
|
||||
sortAuditEvents(res.Items)
|
||||
return res, nil
|
||||
}
|
||||
cur, windowHasKept = nextCur, false
|
||||
}
|
||||
sortAuditEvents(res.Items)
|
||||
res.Cursor = &cur
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// listAuditPage 拉取当前游标位置的一页原始事件。
|
||||
func listAuditPage(ctx context.Context, ac audit.AuditClient, tenancyOCID string, cur AuditCursor) ([]audit.AuditEvent, string, error) {
|
||||
req := audit.ListEventsRequest{
|
||||
CompartmentId: &tenancyOCID,
|
||||
StartTime: &common.SDKTime{Time: cur.Start.UTC().Truncate(time.Minute)},
|
||||
EndTime: &common.SDKTime{Time: cur.End.UTC().Truncate(time.Minute)},
|
||||
}
|
||||
if cur.Page != "" {
|
||||
req.Page = &cur.Page
|
||||
}
|
||||
resp, err := ac.ListEvents(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list audit events: %w", err)
|
||||
}
|
||||
return resp.Items, deref(resp.OpcNextPage), nil
|
||||
}
|
||||
|
||||
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||
|
||||
@@ -142,3 +142,48 @@ func TestKeepAuditEvent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditCursorAdvance(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
base := AuditCursor{
|
||||
Start: now.Add(-24 * time.Hour),
|
||||
End: now,
|
||||
WindowHours: 24,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cur AuditCursor
|
||||
empty bool
|
||||
wantHours int
|
||||
wantDone bool
|
||||
}{
|
||||
{"有事件重置 24h 窗", AuditCursor{Start: base.Start, End: base.End, WindowHours: 96}, false, 24, false},
|
||||
{"空窗倍增", base, true, 48, false},
|
||||
{"倍增封顶 720h", AuditCursor{Start: base.Start, End: base.End, WindowHours: 512}, true, 720, false},
|
||||
{"窗宽缺省按 24h 起算", AuditCursor{Start: base.Start, End: base.End}, true, 48, false},
|
||||
{"越过保留期即尽头", AuditCursor{Start: now.AddDate(0, 0, -366), End: now.AddDate(0, 0, -365), WindowHours: 24}, false, 0, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
next, done := tc.cur.advance(now, tc.empty)
|
||||
if done != tc.wantDone {
|
||||
t.Fatalf("done = %v, want %v", done, tc.wantDone)
|
||||
}
|
||||
if done {
|
||||
return
|
||||
}
|
||||
if next.WindowHours != tc.wantHours {
|
||||
t.Fatalf("WindowHours = %d, want %d", next.WindowHours, tc.wantHours)
|
||||
}
|
||||
if !next.End.Equal(tc.cur.Start) {
|
||||
t.Fatalf("新窗 End = %v, 应紧邻上窗 Start %v", next.End, tc.cur.Start)
|
||||
}
|
||||
if got := next.End.Sub(next.Start); got != time.Duration(tc.wantHours)*time.Hour {
|
||||
t.Fatalf("窗宽 = %v, want %dh", got, tc.wantHours)
|
||||
}
|
||||
if next.Page != "" {
|
||||
t.Fatalf("新窗应清空窗内游标, got %q", next.Page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -39,10 +39,18 @@ func ckey(cred Credentials, parts ...string) string {
|
||||
// bust 写操作成功后失效该租户全部读缓存。
|
||||
func (c *CachedClient) bust(err error, cred Credentials) {
|
||||
if err == nil {
|
||||
c.store.DeletePrefix(cred.TenancyOCID + "|")
|
||||
c.InvalidateTenancy(cred.TenancyOCID)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateTenancy 主动失效指定 tenancy 的全部 OCI 读缓存。
|
||||
func (c *CachedClient) InvalidateTenancy(tenancyOCID string) {
|
||||
if tenancyOCID == "" {
|
||||
return
|
||||
}
|
||||
c.store.DeletePrefix(tenancyOCID + "|")
|
||||
}
|
||||
|
||||
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
||||
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
|
||||
v, err := cache.Do(c.store, key, ttl, fn)
|
||||
@@ -120,6 +128,13 @@ func (c *CachedClient) ListVolumeAttachments(ctx context.Context, cred Credentia
|
||||
})
|
||||
}
|
||||
|
||||
// ListIdentityDomains 域列表准静态且是所有按域操作的 URL 解析源,长 TTL 缓存。
|
||||
func (c *CachedClient) ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error) {
|
||||
return cachedList(c, ckey(cred, "iddomains", region), cacheTTLStatic, func() ([]IdentityDomain, error) {
|
||||
return c.Client.ListIdentityDomains(ctx, cred, region)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 写:直通,成功后按租户失效 ----
|
||||
|
||||
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||
|
||||
@@ -86,3 +86,18 @@ func TestCachedClientReturnsClone(t *testing.T) {
|
||||
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedClientInvalidateTenancy(t *testing.T) {
|
||||
inner := &countingClient{}
|
||||
c := NewCachedClient(inner)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
c.InvalidateTenancy("t1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t1"), "r1")
|
||||
_, _ = c.ListInstances(ctx, testCred("t2"), "r1")
|
||||
if inner.instCalls != 3 {
|
||||
t.Fatalf("invalidate tenancy calls = %d, want 3", inner.instCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-22
@@ -122,32 +122,41 @@ type Client interface {
|
||||
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
|
||||
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
||||
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
||||
// 批式懒加载走 ListAuditEventsBatch,本方法保留给详情小窗反查。
|
||||
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
||||
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
|
||||
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
|
||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
|
||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
|
||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
|
||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
|
||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error)
|
||||
// ListAuditEventsBatch 从游标位置向更早方向收集约 limit 条保留事件,
|
||||
// 分窗回溯(空窗倍增),跨批不重不漏;到 365 天保留期尽头置 Exhausted。
|
||||
ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error)
|
||||
// 身份域:租户 ACTIVE 域枚举(多域租户选择器数据源)。
|
||||
ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error)
|
||||
// 账户能力:暂停标记/账户状态/免费层信息(对暂停租户仍可访问,测活据此判「暂停」)。
|
||||
GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error)
|
||||
// 租户用户管理:domainID 非空按域走 Identity Domains SCIM,
|
||||
// 为空保持经典 IAM 为主、新式租户自动回退的旧行为。
|
||||
ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error)
|
||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error)
|
||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error)
|
||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error
|
||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
|
||||
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||
// 域设置:通知收件人、密码策略、身份设置。
|
||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
|
||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
|
||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
|
||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
|
||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
|
||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error)
|
||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||
GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error)
|
||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
||||
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
|
||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
|
||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
|
||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
|
||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
|
||||
ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error)
|
||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
|
||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
||||
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
||||
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
||||
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
||||
|
||||
@@ -47,26 +47,82 @@ type IdentitySettingInfo struct {
|
||||
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||||
}
|
||||
|
||||
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
|
||||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
|
||||
// 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
|
||||
}
|
||||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list identity domains: %w", err)
|
||||
}
|
||||
url := pickDomainURL(resp.Items)
|
||||
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 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
|
||||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
// 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
|
||||
}
|
||||
@@ -80,13 +136,20 @@ func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||
func pickDomainURL(domains []identity.DomainSummary) string {
|
||||
// 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)
|
||||
}
|
||||
@@ -98,8 +161,8 @@ func pickDomainURL(domains []identity.DomainSummary) string {
|
||||
}
|
||||
|
||||
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -115,8 +178,8 @@ func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credent
|
||||
|
||||
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||||
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -153,8 +216,8 @@ func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDoma
|
||||
}
|
||||
|
||||
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -171,12 +234,12 @@ func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials,
|
||||
|
||||
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||||
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
||||
func (c *RealClient) UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error) {
|
||||
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)
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return PasswordPolicyInfo{}, err
|
||||
}
|
||||
@@ -233,8 +296,8 @@ func removeOp(path string) identitydomains.Operations {
|
||||
}
|
||||
|
||||
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -254,12 +317,12 @@ func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, r
|
||||
}
|
||||
|
||||
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||
current, err := c.GetIdentitySetting(ctx, cred, region)
|
||||
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)
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentitySettingInfo{}, err
|
||||
}
|
||||
|
||||
+25
-20
@@ -56,8 +56,8 @@ type CreateIdpInput struct {
|
||||
}
|
||||
|
||||
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -76,8 +76,8 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -139,22 +139,27 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
||||
return idp
|
||||
}
|
||||
|
||||
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
||||
count := 1
|
||||
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)
|
||||
return nil, fmt.Errorf("find administrators group: %w", err)
|
||||
}
|
||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
||||
return nil, nil
|
||||
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 &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||
@@ -207,8 +212,8 @@ func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.Identity
|
||||
|
||||
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -249,8 +254,8 @@ func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClie
|
||||
}
|
||||
|
||||
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -346,8 +351,8 @@ func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
||||
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
||||
url, err := c.domainURL(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
@@ -358,7 +363,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
if status == http.StatusOK {
|
||||
return body, nil
|
||||
}
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region); err != nil {
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
@@ -390,8 +395,8 @@ func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identity"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
func domainSummary(id, name, url string, state identity.DomainLifecycleStateEnum) identity.DomainSummary {
|
||||
return identity.DomainSummary{Id: &id, DisplayName: &name, Url: &url, LifecycleState: state}
|
||||
}
|
||||
|
||||
func TestPickDomainURL(t *testing.T) {
|
||||
domains := []identity.DomainSummary{
|
||||
domainSummary("ocid1.domain.idcs", "OracleIdentityCloudService", "https://idcs-a.example.com", identity.DomainLifecycleStateActive),
|
||||
domainSummary("ocid1.domain.default", "Default", "https://idcs-b.example.com", identity.DomainLifecycleStateActive),
|
||||
domainSummary("ocid1.domain.off", "Off", "https://idcs-c.example.com", identity.DomainLifecycleStateInactive),
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
domains []identity.DomainSummary
|
||||
domainID string
|
||||
want string
|
||||
}{
|
||||
{"缺省优先 Default 域", domains, "", "https://idcs-b.example.com"},
|
||||
{"按 OCID 精确匹配", domains, "ocid1.domain.idcs", "https://idcs-a.example.com"},
|
||||
{"OCID 不存在返回空", domains, "ocid1.domain.miss", ""},
|
||||
{"非 ACTIVE 域不可选", domains, "ocid1.domain.off", ""},
|
||||
{"无 Default 时取第一个 ACTIVE", domains[:1], "", "https://idcs-a.example.com"},
|
||||
{"空列表返回空", nil, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := pickDomainURL(tc.domains, tc.domainID); got != tc.want {
|
||||
t.Fatalf("pickDomainURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToDomainListUser(t *testing.T) {
|
||||
created := "2024-12-12T14:52:00Z"
|
||||
lastLogin := "2026-07-01T08:00:00.123Z"
|
||||
yes, no := true, false
|
||||
u := identitydomains.User{
|
||||
Id: common.String("scim-1"),
|
||||
Ocid: common.String("ocid1.user.oc1..a"),
|
||||
UserName: common.String("oci"),
|
||||
Active: &yes,
|
||||
Meta: &identitydomains.Meta{Created: &created},
|
||||
Emails: []identitydomains.UserEmails{{
|
||||
Value: common.String("a@b.c"), Primary: &yes, Verified: &yes,
|
||||
Type: identitydomains.UserEmailsTypeWork,
|
||||
}},
|
||||
UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser: &identitydomains.ExtensionMfaUser{
|
||||
MfaStatus: identitydomains.ExtensionMfaUserMfaStatusEnrolled,
|
||||
},
|
||||
UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser: &identitydomains.ExtensionUserStateUser{
|
||||
LastSuccessfulLoginDate: &lastLogin,
|
||||
},
|
||||
}
|
||||
got := toDomainListUser(u, "ocid1.user.oc1..a")
|
||||
if got.ID != "ocid1.user.oc1..a" || !got.IsCurrentUser {
|
||||
t.Fatalf("ID/IsCurrentUser 映射错误: %+v", got)
|
||||
}
|
||||
if !got.MfaActivated || !got.EmailVerified || got.Email != "a@b.c" {
|
||||
t.Fatalf("MFA/邮箱映射错误: %+v", got)
|
||||
}
|
||||
if got.LifecycleState != "ACTIVE" || got.TimeCreated == nil || got.LastLoginTime == nil {
|
||||
t.Fatalf("状态/时间映射错误: %+v", got)
|
||||
}
|
||||
|
||||
inactive := identitydomains.User{Id: common.String("scim-2"), Ocid: common.String("ocid1.user.oc1..b"), Active: &no}
|
||||
if s := toDomainListUser(inactive, "x").LifecycleState; s != "INACTIVE" {
|
||||
t.Fatalf("inactive 用户 LifecycleState = %q, want INACTIVE", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScimTime(t *testing.T) {
|
||||
valid := "2026-07-10T01:02:03Z"
|
||||
bad := "not-a-time"
|
||||
empty := ""
|
||||
cases := []struct {
|
||||
name string
|
||||
in *string
|
||||
want *time.Time
|
||||
}{
|
||||
{"合法 RFC3339", &valid, func() *time.Time { t0, _ := time.Parse(time.RFC3339, valid); return &t0 }()},
|
||||
{"nil 返回 nil", nil, nil},
|
||||
{"空串返回 nil", &empty, nil},
|
||||
{"非法格式返回 nil", &bad, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseScimTime(tc.in)
|
||||
if (got == nil) != (tc.want == nil) {
|
||||
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got != nil && !got.Equal(*tc.want) {
|
||||
t.Fatalf("parseScimTime() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestRealOCIFederation(t *testing.T) {
|
||||
client := NewClient()
|
||||
region := cred.Region
|
||||
|
||||
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region)
|
||||
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestRealOCIFederation(t *testing.T) {
|
||||
}
|
||||
t.Logf("domain saml metadata: %d bytes", len(metadata))
|
||||
|
||||
before, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
before, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules: %v", err)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestRealOCIFederation(t *testing.T) {
|
||||
idp := createTestIdp(ctx, t, client, cred, region)
|
||||
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
|
||||
|
||||
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, true)
|
||||
activated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("activate idp: %v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestRealOCIFederation(t *testing.T) {
|
||||
|
||||
exemptionRoundTrip(ctx, t, client, cred, region, idp.ID, len(before))
|
||||
|
||||
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, idp.ID, false)
|
||||
deactivated, err := client.SetIdentityProviderEnabled(ctx, cred, region, "", idp.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("deactivate idp: %v", err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
|
||||
t.Fatalf("read test-idp.xml: %v", err)
|
||||
}
|
||||
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
|
||||
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, CreateIdpInput{
|
||||
idp, err := client.CreateSamlIdentityProvider(ctx, cred, region, "", CreateIdpInput{
|
||||
Name: name,
|
||||
Metadata: string(metadata),
|
||||
Description: "oci-portal e2e temporary idp",
|
||||
@@ -86,7 +86,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
|
||||
t.Cleanup(func() {
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer ccancel()
|
||||
if err := client.DeleteIdentityProvider(cctx, cred, region, idp.ID); err != nil {
|
||||
if err := client.DeleteIdentityProvider(cctx, cred, region, "", idp.ID); err != nil {
|
||||
t.Errorf("cleanup: delete idp: %v", err)
|
||||
} else {
|
||||
t.Log("cleanup: idp deleted")
|
||||
@@ -98,7 +98,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
|
||||
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
|
||||
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
|
||||
t.Helper()
|
||||
dc, err := client.domainsClient(ctx, cred, region)
|
||||
dc, err := client.domainsClient(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("domainsClient: %v", err)
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.Ide
|
||||
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
|
||||
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
|
||||
t.Helper()
|
||||
dc, err := client.domainsClient(ctx, cred, region)
|
||||
dc, err := client.domainsClient(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("domainsClient: %v", err)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred
|
||||
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
|
||||
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
|
||||
t.Helper()
|
||||
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, idpID, "oci-portal-e2e-skip-mfa")
|
||||
rule, err := client.CreateMfaExemptionRule(ctx, cred, region, "", idpID, "oci-portal-e2e-skip-mfa")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMfaExemptionRule: %v", err)
|
||||
}
|
||||
@@ -185,12 +185,12 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
|
||||
if deleted {
|
||||
return
|
||||
}
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
|
||||
t.Errorf("cleanup: delete exemption rule: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
after, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
after, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules after create: %v", err)
|
||||
}
|
||||
@@ -208,11 +208,11 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
|
||||
t.Errorf("condition value %q does not reference idp", after[0].ConditionValue)
|
||||
}
|
||||
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, rule.ID); err != nil {
|
||||
if err := client.DeleteMfaExemptionRule(ctx, cred, region, "", rule.ID); err != nil {
|
||||
t.Fatalf("DeleteMfaExemptionRule: %v", err)
|
||||
}
|
||||
deleted = true
|
||||
restored, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
||||
restored, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
client := NewClient()
|
||||
homeRegion := cred.Region
|
||||
|
||||
users, err := client.ListTenantUsers(ctx, cred)
|
||||
users, err := client.ListTenantUsers(ctx, cred, homeRegion, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListTenantUsers: %v", err)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
t.Logf("tenant users: %d", len(users))
|
||||
|
||||
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
|
||||
user, err := client.CreateTenantUser(ctx, cred, homeRegion, CreateTenantUserInput{
|
||||
user, err := client.CreateTenantUser(ctx, cred, homeRegion, "", CreateTenantUserInput{
|
||||
Name: name, Description: "e2e temp user", Email: name + "@example.com",
|
||||
GivenName: "E2E", FamilyName: "Temp",
|
||||
})
|
||||
@@ -49,7 +49,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
}
|
||||
t.Logf("created user %s (%s)", user.Name, user.ID)
|
||||
defer func() {
|
||||
if err := client.DeleteTenantUser(ctx, cred, homeRegion, user.ID); err != nil {
|
||||
if err := client.DeleteTenantUser(ctx, cred, homeRegion, "", user.ID); err != nil {
|
||||
t.Errorf("cleanup: DeleteTenantUser: %v", err)
|
||||
} else {
|
||||
t.Log("cleanup: user deleted")
|
||||
@@ -57,7 +57,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
}()
|
||||
|
||||
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
|
||||
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, user.ID, UpdateTenantUserInput{
|
||||
updated, err := client.UpdateTenantUser(ctx, cred, homeRegion, "", user.ID, UpdateTenantUserInput{
|
||||
Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -68,7 +68,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
}
|
||||
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
|
||||
|
||||
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, user.ID)
|
||||
password, err := client.ResetTenantUserPassword(ctx, cred, homeRegion, "", user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetTenantUserPassword: %v", err)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
||||
}
|
||||
t.Logf("password reset ok (len=%d)", len(password))
|
||||
|
||||
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, user.ID)
|
||||
mfaDeleted, err := client.DeleteTenantUserMfaDevices(ctx, cred, homeRegion, "", user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
|
||||
}
|
||||
@@ -101,12 +101,12 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
||||
client := NewClient()
|
||||
region := cred.Region
|
||||
|
||||
original, err := client.GetNotificationRecipients(ctx, cred, region)
|
||||
original, err := client.GetNotificationRecipients(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetNotificationRecipients: %v", err)
|
||||
}
|
||||
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
|
||||
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, []string{"e2e@example.com"})
|
||||
updated, err := client.UpdateNotificationRecipients(ctx, cred, region, "", []string{"e2e@example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateNotificationRecipients: %v", err)
|
||||
}
|
||||
@@ -117,13 +117,13 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
||||
if !original.TestModeEnabled {
|
||||
restoreRecipients = nil
|
||||
}
|
||||
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, restoreRecipients); err != nil {
|
||||
if _, err := client.UpdateNotificationRecipients(ctx, cred, region, "", restoreRecipients); err != nil {
|
||||
t.Errorf("restore recipients: %v", err)
|
||||
} else {
|
||||
t.Log("recipients restored")
|
||||
}
|
||||
|
||||
policies, err := client.ListPasswordPolicies(ctx, cred, region)
|
||||
policies, err := client.ListPasswordPolicies(ctx, cred, region, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPasswordPolicies: %v", err)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
||||
}
|
||||
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
|
||||
days := 350
|
||||
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
|
||||
patched, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &days})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePasswordPolicy: %v", err)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
||||
if target.PasswordExpiresAfter != nil {
|
||||
restore = *target.PasswordExpiresAfter
|
||||
}
|
||||
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
|
||||
if _, err := client.UpdatePasswordPolicy(ctx, cred, region, "", target.ID, UpdatePasswordPolicyInput{PasswordExpiresAfter: &restore}); err != nil {
|
||||
t.Errorf("restore password policy: %v", err)
|
||||
} else {
|
||||
t.Logf("policy restored to %d", restore)
|
||||
|
||||
@@ -29,8 +29,8 @@ type SignOnRuleInfo struct {
|
||||
}
|
||||
|
||||
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
|
||||
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -99,8 +99,8 @@ func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsCl
|
||||
|
||||
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
|
||||
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
|
||||
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return SignOnRuleInfo{}, err
|
||||
}
|
||||
@@ -239,11 +239,11 @@ func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsCli
|
||||
|
||||
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
|
||||
// 拒绝删除 Oracle 预置规则。
|
||||
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error {
|
||||
func (c *RealClient) DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error {
|
||||
if strings.HasPrefix(ruleID, "OciConsole") {
|
||||
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
|
||||
}
|
||||
dc, err := c.domainsClient(ctx, cred, region)
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+147
-28
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,11 +29,18 @@ type TenantUser struct {
|
||||
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
||||
}
|
||||
|
||||
// Oracle 预置的管理员角色与管理员组显示名。
|
||||
const (
|
||||
domainAdminRoleName = "Identity Domain Administrator"
|
||||
administratorsGroupName = "Administrators"
|
||||
)
|
||||
// Oracle 预置的「身份域管理员」应用角色显示名。
|
||||
const domainAdminRoleName = "Identity Domain Administrator"
|
||||
|
||||
// adminGroupNames 是租户管理员组候选名(按优先序):原生域为 Administrators,
|
||||
// IDCS 迁移域(idcs foundation,如 OracleIdentityCloudService)为 OCI_Administrators;
|
||||
// 迁移域另有 IDCS_Administrators 属身份域管理范畴,由上面的应用角色概念覆盖,不算在内。
|
||||
var adminGroupNames = []string{"Administrators", "OCI_Administrators"}
|
||||
|
||||
// isAdminGroupName 判断组显示名是否为租户管理员组。
|
||||
func isAdminGroupName(name string) bool {
|
||||
return slices.Contains(adminGroupNames, name)
|
||||
}
|
||||
|
||||
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
|
||||
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
|
||||
@@ -48,8 +56,8 @@ type TenantUserDetail struct {
|
||||
}
|
||||
|
||||
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
||||
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return TenantUserDetail{}, err
|
||||
}
|
||||
@@ -68,7 +76,7 @@ func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
|
||||
d.FamilyName = deref(u.Name.FamilyName)
|
||||
}
|
||||
for _, g := range u.Groups {
|
||||
if deref(g.Display) == administratorsGroupName {
|
||||
if isAdminGroupName(deref(g.Display)) {
|
||||
d.InAdminGroup = true
|
||||
}
|
||||
}
|
||||
@@ -94,8 +102,13 @@ func (c *RealClient) identityClientAt(cred Credentials, region string) (identity
|
||||
return ic, nil
|
||||
}
|
||||
|
||||
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。
|
||||
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) {
|
||||
// ListTenantUsers 实现 Client:列出租户 IAM 用户。
|
||||
// domainID 非空时经 Identity Domains SCIM 只列该域用户;
|
||||
// 为空时走经典 IAM(跨域拍平视图,兼容无域老租户)。
|
||||
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
|
||||
if domainID != "" {
|
||||
return c.listDomainUsers(ctx, cred, homeRegion, domainID)
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -117,6 +130,78 @@ func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]T
|
||||
}
|
||||
}
|
||||
|
||||
// scimListAttrs 是按域列用户时请求的属性集:列表字段 + MFA 与最近登录扩展。
|
||||
const scimListAttrs = "id,ocid,userName,description,emails,active,meta.created," +
|
||||
"urn:ietf:params:scim:schemas:oracle:idcs:extension:mfa:User:mfaStatus," +
|
||||
"urn:ietf:params:scim:schemas:oracle:idcs:extension:userState:User:lastSuccessfulLoginDate"
|
||||
|
||||
// maxScimUserPages 限制按域列用户的翻页数(每页 200,上限 5000 人)。
|
||||
const maxScimUserPages = 25
|
||||
|
||||
// listDomainUsers 经 SCIM 按 startIndex 翻页列出域内全部用户。
|
||||
func (c *RealClient) listDomainUsers(ctx context.Context, cred Credentials, homeRegion, domainID string) ([]TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []TenantUser
|
||||
attrs := scimListAttrs
|
||||
count := 200
|
||||
start := 1
|
||||
for range maxScimUserPages {
|
||||
req := identitydomains.ListUsersRequest{Attributes: &attrs, Count: &count, StartIndex: &start}
|
||||
resp, err := dc.ListUsers(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list domain users: %w", err)
|
||||
}
|
||||
for _, u := range resp.Resources {
|
||||
users = append(users, toDomainListUser(u, cred.UserOCID))
|
||||
}
|
||||
start += len(resp.Resources)
|
||||
if resp.TotalResults == nil || start > *resp.TotalResults || len(resp.Resources) == 0 {
|
||||
return orEmpty(users), nil
|
||||
}
|
||||
}
|
||||
return orEmpty(users), nil
|
||||
}
|
||||
|
||||
// toDomainListUser 把 SCIM 用户映射为列表 DTO;ID 用 IAM OCID
|
||||
// 保持与经典路径同一 id 语义(IsCurrentUser 比对、后续操作路由)。
|
||||
func toDomainListUser(u identitydomains.User, currentUserOCID string) TenantUser {
|
||||
out := toDomainTenantUser(u)
|
||||
out.IsCurrentUser = out.ID == currentUserOCID
|
||||
if u.Active != nil && !*u.Active {
|
||||
out.LifecycleState = "INACTIVE"
|
||||
}
|
||||
if u.Meta != nil {
|
||||
out.TimeCreated = parseScimTime(u.Meta.Created)
|
||||
}
|
||||
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionMfaUser; ext != nil {
|
||||
out.MfaActivated = ext.MfaStatus == identitydomains.ExtensionMfaUserMfaStatusEnrolled
|
||||
}
|
||||
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserStateUser; ext != nil {
|
||||
out.LastLoginTime = parseScimTime(ext.LastSuccessfulLoginDate)
|
||||
}
|
||||
for _, e := range u.Emails {
|
||||
if e.Primary != nil && *e.Primary {
|
||||
out.EmailVerified = e.Verified != nil && *e.Verified
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseScimTime 解析 SCIM 的 RFC3339 时间串;空值或格式异常返回 nil。
|
||||
func parseScimTime(s *string) *time.Time {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, *s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
|
||||
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
|
||||
// 先授身份域管理员角色,再加入 Administrators 组。
|
||||
@@ -133,12 +218,13 @@ type CreateTenantUserInput struct {
|
||||
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
||||
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
||||
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
||||
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in)
|
||||
// domainID 非空表示用户显式选择了目标域,SCIM 失败不再回退(避免建错域)。
|
||||
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
user, err := c.createUserViaDomain(ctx, cred, homeRegion, domainID, in)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if in.GrantDomainAdmin || in.AddToAdminGroup {
|
||||
if domainID != "" || in.GrantDomainAdmin || in.AddToAdminGroup {
|
||||
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
|
||||
}
|
||||
return c.createUserClassic(ctx, cred, homeRegion, in)
|
||||
@@ -170,8 +256,8 @@ func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, ho
|
||||
}
|
||||
|
||||
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
|
||||
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
@@ -373,9 +459,9 @@ type UpdateTenantUserInput struct {
|
||||
|
||||
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
||||
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
||||
// IAM API(仅备注与邮箱,姓名变更报错)。
|
||||
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
// IAM API(仅备注与邮箱,姓名变更报错);domainID 非空时不回退。
|
||||
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return TenantUser{}, err
|
||||
}
|
||||
@@ -394,6 +480,9 @@ func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, hom
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
if domainID != "" {
|
||||
return TenantUser{}, fmt.Errorf("update user %s: user not found in identity domain", userID)
|
||||
}
|
||||
if in.GivenName != nil || in.FamilyName != nil {
|
||||
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
|
||||
}
|
||||
@@ -495,7 +584,11 @@ func emailPatchOp(u identitydomains.User, email string) identitydomains.Operatio
|
||||
}
|
||||
|
||||
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
|
||||
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||
// domainID 非空时经该域 SCIM 删除(经典 API 对非默认域用户不生效)。
|
||||
func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
|
||||
if domainID != "" {
|
||||
return c.deleteDomainUser(ctx, cred, homeRegion, domainID, userID)
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -506,9 +599,31 @@ func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, hom
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDomainUser 经 SCIM 强制删除域用户(forceDelete 连带其授权与组成员关系)。
|
||||
func (c *RealClient) deleteDomainUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scimID, err := scimUserID(ctx, dc, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
force := true
|
||||
_, err = dc.DeleteUser(ctx, identitydomains.DeleteUserRequest{UserId: &scimID, ForceDelete: &force})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete domain user %s: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
|
||||
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
|
||||
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||
// domainID 非空直走该域 SCIM;为空优先经典 IAM API,
|
||||
// 新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
|
||||
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
|
||||
if domainID != "" {
|
||||
return c.resetPasswordViaDomain(ctx, cred, homeRegion, domainID, userID)
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -520,12 +635,12 @@ func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentia
|
||||
if !isClassicUnsupported(err) {
|
||||
return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
|
||||
}
|
||||
return c.resetPasswordViaDomain(ctx, cred, homeRegion, userID)
|
||||
return c.resetPasswordViaDomain(ctx, cred, homeRegion, "", userID)
|
||||
}
|
||||
|
||||
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
|
||||
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -558,7 +673,11 @@ func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credential
|
||||
|
||||
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
||||
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
|
||||
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, error) {
|
||||
func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error) {
|
||||
// 显式指定域时用户的认证因子只在域内,经典 TOTP 清理不适用
|
||||
if domainID != "" {
|
||||
return 0, c.removeDomainFactors(ctx, cred, homeRegion, domainID, userID)
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -567,7 +686,7 @@ func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Creden
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if err := c.removeDomainFactors(ctx, cred, homeRegion, userID); err != nil {
|
||||
if err := c.removeDomainFactors(ctx, cred, homeRegion, "", userID); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
return deleted, nil
|
||||
@@ -594,8 +713,8 @@ func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, u
|
||||
|
||||
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
||||
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
||||
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
||||
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestToTenantUserDetail(t *testing.T) {
|
||||
},
|
||||
Groups: []identitydomains.UserGroups{
|
||||
{Value: common.String("g1"), Display: common.String("Readers")},
|
||||
{Value: common.String("g2"), Display: common.String(administratorsGroupName)},
|
||||
{Value: common.String("g2"), Display: common.String(adminGroupNames[0])},
|
||||
},
|
||||
UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user