916 lines
32 KiB
Go
916 lines
32 KiB
Go
package oci
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"slices"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
// TenantUser 是租户下的一个 IAM 用户。
|
|
type TenantUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"emailVerified"`
|
|
MfaActivated bool `json:"mfaActivated"`
|
|
LifecycleState string `json:"lifecycleState"`
|
|
IsCurrentUser bool `json:"isCurrentUser"`
|
|
TimeCreated *time.Time `json:"timeCreated"`
|
|
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
|
}
|
|
|
|
// 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"
|
|
|
|
// TenantUserDetail 是编辑表单需要的域档案与管理员状态;
|
|
// InDomain 为 false 表示经典 IAM 用户,其余字段无意义。
|
|
type TenantUserDetail struct {
|
|
InDomain bool `json:"inDomain"`
|
|
GivenName string `json:"givenName"`
|
|
FamilyName string `json:"familyName"`
|
|
IsDomainAdmin bool `json:"isDomainAdmin"`
|
|
InAdminGroup bool `json:"inAdminGroup"`
|
|
}
|
|
|
|
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
|
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
|
|
}
|
|
u, found, err := domainUserByOcid(ctx, dc, userID, "groups", scimAppRolesAttr)
|
|
if err != nil || !found {
|
|
return TenantUserDetail{}, err
|
|
}
|
|
return toTenantUserDetail(u), nil
|
|
}
|
|
|
|
// toTenantUserDetail 提取姓名、管理员角色与管理员组成员状态。
|
|
func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
|
|
d := TenantUserDetail{InDomain: true}
|
|
if u.Name != nil {
|
|
d.GivenName = deref(u.Name.GivenName)
|
|
d.FamilyName = deref(u.Name.FamilyName)
|
|
}
|
|
for _, g := range u.Groups {
|
|
if isAdminGroupName(deref(g.Display)) {
|
|
d.InAdminGroup = true
|
|
}
|
|
}
|
|
if ext := u.UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser; ext != nil {
|
|
for _, r := range ext.AppRoles {
|
|
if deref(r.Display) == domainAdminRoleName {
|
|
d.IsDomainAdmin = true
|
|
}
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (c *RealClient) identityClientAt(cred Credentials, region string) (identity.IdentityClient, error) {
|
|
ic, err := identity.NewIdentityClientWithConfigurationProvider(provider(cred))
|
|
if err != nil {
|
|
return ic, fmt.Errorf("new identity client: %w", err)
|
|
}
|
|
applyProxy(&ic.BaseClient, cred)
|
|
if region != "" {
|
|
ic.SetRegion(normalizeRegion(region))
|
|
}
|
|
return ic, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
var users []TenantUser
|
|
var page *string
|
|
for {
|
|
resp, err := ic.ListUsers(ctx, identity.ListUsersRequest{CompartmentId: &cred.TenancyOCID, Page: page})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list users: %w", err)
|
|
}
|
|
for _, item := range resp.Items {
|
|
users = append(users, toTenantUser(item, cred.UserOCID))
|
|
}
|
|
if resp.OpcNextPage == nil {
|
|
return orEmpty(users), nil
|
|
}
|
|
page = resp.OpcNextPage
|
|
}
|
|
}
|
|
|
|
// 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 组。
|
|
type CreateTenantUserInput struct {
|
|
Name string // 登录用户名
|
|
Description string
|
|
Email string
|
|
GivenName string
|
|
FamilyName string
|
|
GrantDomainAdmin bool // 授予「身份域管理员」应用角色
|
|
AddToAdminGroup bool // 加入 Administrators 组
|
|
}
|
|
|
|
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
|
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
|
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
|
// 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 domainID != "" || in.GrantDomainAdmin || in.AddToAdminGroup {
|
|
return TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
|
|
}
|
|
return c.createUserClassic(ctx, cred, homeRegion, in)
|
|
}
|
|
|
|
// createUserClassic 走经典 IAM API 创建用户(姓名并入描述)。
|
|
func (c *RealClient) createUserClassic(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return TenantUser{}, err
|
|
}
|
|
description := in.Description
|
|
if description == "" {
|
|
description = strings.TrimSpace(in.GivenName + " " + in.FamilyName)
|
|
}
|
|
details := identity.CreateUserDetails{
|
|
CompartmentId: &cred.TenancyOCID,
|
|
Name: &in.Name,
|
|
Description: &description,
|
|
}
|
|
if in.Email != "" {
|
|
details.Email = &in.Email
|
|
}
|
|
resp, err := ic.CreateUser(ctx, identity.CreateUserRequest{CreateUserDetails: details})
|
|
if err != nil {
|
|
return TenantUser{}, fmt.Errorf("create user %s: %w", in.Name, err)
|
|
}
|
|
return toTenantUser(resp.User, cred.UserOCID), nil
|
|
}
|
|
|
|
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
|
|
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
|
|
}
|
|
resp, err := dc.CreateUser(ctx, identitydomains.CreateUserRequest{User: buildScimUser(in)})
|
|
if err != nil {
|
|
return TenantUser{}, fmt.Errorf("create domain user %s: %w", in.Name, err)
|
|
}
|
|
scimID := deref(resp.User.Id)
|
|
// 同时勾选时约定先授身份域管理员,再加管理员组
|
|
if in.GrantDomainAdmin {
|
|
if err := grantDomainAdminRole(ctx, dc, scimID); err != nil {
|
|
return toDomainTenantUser(resp.User), err
|
|
}
|
|
}
|
|
if in.AddToAdminGroup {
|
|
if err := addUserToAdminGroup(ctx, dc, scimID); err != nil {
|
|
return toDomainTenantUser(resp.User), err
|
|
}
|
|
}
|
|
return toDomainTenantUser(resp.User), nil
|
|
}
|
|
|
|
func buildScimUser(in CreateTenantUserInput) identitydomains.User {
|
|
u := identitydomains.User{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
|
|
UserName: &in.Name,
|
|
Name: &identitydomains.UserName{
|
|
GivenName: &in.GivenName,
|
|
FamilyName: &in.FamilyName,
|
|
},
|
|
}
|
|
if in.Description != "" {
|
|
u.Description = &in.Description
|
|
}
|
|
if in.Email != "" {
|
|
primary := true
|
|
u.Emails = []identitydomains.UserEmails{{
|
|
Value: &in.Email,
|
|
Type: identitydomains.UserEmailsTypeWork,
|
|
Primary: &primary,
|
|
}}
|
|
}
|
|
return u
|
|
}
|
|
|
|
// findDomainAdminRole 查找「Identity Domain Administrator」应用角色。
|
|
func findDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient) (identitydomains.AppRole, error) {
|
|
filter := fmt.Sprintf("displayName eq %q", domainAdminRoleName)
|
|
count := 1
|
|
roles, err := dc.ListAppRoles(ctx, identitydomains.ListAppRolesRequest{
|
|
Filter: &filter,
|
|
Attributes: common.String("id,displayName,app"),
|
|
Count: &count,
|
|
})
|
|
if err != nil {
|
|
return identitydomains.AppRole{}, fmt.Errorf("find domain administrator role: %w", err)
|
|
}
|
|
if len(roles.Resources) == 0 || roles.Resources[0].Id == nil || roles.Resources[0].App == nil {
|
|
return identitydomains.AppRole{}, fmt.Errorf("identity domain administrator role not found")
|
|
}
|
|
return roles.Resources[0], nil
|
|
}
|
|
|
|
// grantDomainAdminRole 授予「Identity Domain Administrator」应用角色。
|
|
func grantDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
|
role, err := findDomainAdminRole(ctx, dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = dc.CreateGrant(ctx, identitydomains.CreateGrantRequest{
|
|
Grant: identitydomains.Grant{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:Grant"},
|
|
GrantMechanism: identitydomains.GrantGrantMechanismAdministratorToUser,
|
|
Grantee: &identitydomains.GrantGrantee{
|
|
Type: identitydomains.GrantGranteeTypeUser,
|
|
Value: &userSCIMID,
|
|
},
|
|
App: &identitydomains.GrantApp{Value: role.App.Value},
|
|
Entitlement: &identitydomains.GrantEntitlement{
|
|
AttributeName: common.String("appRoles"),
|
|
AttributeValue: role.Id,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("grant domain administrator role: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// revokeDomainAdminRole 撤销直接授予用户的「身份域管理员」角色;
|
|
// 只删 ADMINISTRATOR_TO_USER 机制的授权,经组继承的不动。
|
|
func revokeDomainAdminRole(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
|
role, err := findDomainAdminRole(ctx, dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filter := fmt.Sprintf("grantee.value eq %q and entitlement.attributeValue eq %q", userSCIMID, deref(role.Id))
|
|
grants, err := dc.ListGrants(ctx, identitydomains.ListGrantsRequest{
|
|
Filter: &filter,
|
|
Attributes: common.String("id,grantMechanism"),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("find domain administrator grants: %w", err)
|
|
}
|
|
for _, g := range grants.Resources {
|
|
if g.GrantMechanism != identitydomains.GrantGrantMechanismAdministratorToUser {
|
|
continue
|
|
}
|
|
if _, err := dc.DeleteGrant(ctx, identitydomains.DeleteGrantRequest{GrantId: g.Id}); err != nil {
|
|
return fmt.Errorf("revoke domain administrator role: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addUserToAdminGroup 把用户加入 Administrators 组。
|
|
func addUserToAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
|
group, err := adminGroupRef(ctx, dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if group == nil {
|
|
return fmt.Errorf("administrators group not found")
|
|
}
|
|
var members interface{} = []map[string]string{{"value": userSCIMID, "type": "User"}}
|
|
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
|
GroupId: group.Value,
|
|
PatchOp: identitydomains.PatchOp{
|
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
|
Operations: []identitydomains.Operations{{
|
|
Op: identitydomains.OperationsOpAdd,
|
|
Path: common.String("members"),
|
|
Value: &members,
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("add user to administrators group: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// removeUserFromAdminGroup 把用户移出 Administrators 组。
|
|
func removeUserFromAdminGroup(ctx context.Context, dc identitydomains.IdentityDomainsClient, userSCIMID string) error {
|
|
group, err := adminGroupRef(ctx, dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if group == nil {
|
|
return fmt.Errorf("administrators group not found")
|
|
}
|
|
path := fmt.Sprintf("members[value eq %q]", userSCIMID)
|
|
_, err = dc.PatchGroup(ctx, identitydomains.PatchGroupRequest{
|
|
GroupId: group.Value,
|
|
PatchOp: identitydomains.PatchOp{
|
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
|
Operations: []identitydomains.Operations{{
|
|
Op: identitydomains.OperationsOpRemove,
|
|
Path: &path,
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("remove user from administrators group: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toDomainTenantUser 把 SCIM 用户转为列表通用形状(ID 用对应的 IAM OCID)。
|
|
func toDomainTenantUser(u identitydomains.User) TenantUser {
|
|
out := TenantUser{
|
|
ID: deref(u.Ocid),
|
|
Name: deref(u.UserName),
|
|
Description: deref(u.Description),
|
|
LifecycleState: "ACTIVE",
|
|
}
|
|
if out.ID == "" {
|
|
out.ID = deref(u.Id)
|
|
}
|
|
for _, e := range u.Emails {
|
|
if e.Primary != nil && *e.Primary {
|
|
out.Email = deref(e.Value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UpdateTenantUserInput 是编辑用户资料的输入;nil 字段不修改。
|
|
// 管理员字段为三态:nil 不动,true 授予/加入,false 撤销/移出。
|
|
type UpdateTenantUserInput struct {
|
|
Description *string
|
|
Email *string
|
|
GivenName *string
|
|
FamilyName *string
|
|
GrantDomainAdmin *bool // 「身份域管理员」应用角色
|
|
AddToAdminGroup *bool // Administrators 组成员
|
|
}
|
|
|
|
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
|
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
|
// 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
|
|
}
|
|
domainUser, found, err := domainUserByOcid(ctx, dc, userID)
|
|
if err != nil {
|
|
return TenantUser{}, err
|
|
}
|
|
if found {
|
|
user, err := patchDomainUser(ctx, dc, domainUser, in)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
scimID := deref(domainUser.Id)
|
|
if err := applyAdminGrants(ctx, dc, scimID, in); err != nil {
|
|
return user, err
|
|
}
|
|
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)
|
|
}
|
|
if in.GrantDomainAdmin != nil || in.AddToAdminGroup != nil {
|
|
return TenantUser{}, fmt.Errorf("update user %s: 管理员授权仅身份域用户可操作(该用户不在身份域中)", userID)
|
|
}
|
|
return c.updateUserClassic(ctx, cred, homeRegion, userID, in)
|
|
}
|
|
|
|
// updateUserClassic 走经典 IAM API 更新备注与邮箱。
|
|
func (c *RealClient) updateUserClassic(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return TenantUser{}, err
|
|
}
|
|
resp, err := ic.UpdateUser(ctx, identity.UpdateUserRequest{
|
|
UserId: &userID,
|
|
UpdateUserDetails: identity.UpdateUserDetails{Description: in.Description, Email: in.Email},
|
|
})
|
|
if err != nil {
|
|
return TenantUser{}, fmt.Errorf("update user %s: %w", userID, err)
|
|
}
|
|
return toTenantUser(resp.User, cred.UserOCID), nil
|
|
}
|
|
|
|
// patchDomainUser 以 SCIM Patch 修改域用户的指定字段。
|
|
func patchDomainUser(ctx context.Context, dc identitydomains.IdentityDomainsClient, u identitydomains.User, in UpdateTenantUserInput) (TenantUser, error) {
|
|
ops := buildUserPatchOps(u, in)
|
|
if len(ops) == 0 {
|
|
return toDomainTenantUser(u), nil
|
|
}
|
|
resp, err := dc.PatchUser(ctx, identitydomains.PatchUserRequest{
|
|
UserId: u.Id,
|
|
PatchOp: identitydomains.PatchOp{
|
|
Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
|
Operations: ops,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return TenantUser{}, fmt.Errorf("patch domain user %s: %w", deref(u.UserName), err)
|
|
}
|
|
return toDomainTenantUser(resp.User), nil
|
|
}
|
|
|
|
// applyAdminGrants 同步管理员状态:nil 不动,true 授予/加入,false 撤销/移出。
|
|
func applyAdminGrants(ctx context.Context, dc identitydomains.IdentityDomainsClient, scimID string, in UpdateTenantUserInput) error {
|
|
if in.GrantDomainAdmin != nil {
|
|
apply := revokeDomainAdminRole
|
|
if *in.GrantDomainAdmin {
|
|
apply = grantDomainAdminRole
|
|
}
|
|
if err := apply(ctx, dc, scimID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if in.AddToAdminGroup != nil {
|
|
apply := removeUserFromAdminGroup
|
|
if *in.AddToAdminGroup {
|
|
apply = addUserToAdminGroup
|
|
}
|
|
return apply(ctx, dc, scimID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildUserPatchOps(u identitydomains.User, in UpdateTenantUserInput) []identitydomains.Operations {
|
|
var ops []identitydomains.Operations
|
|
if in.GivenName != nil {
|
|
ops = append(ops, replaceOp("name.givenName", *in.GivenName))
|
|
}
|
|
if in.FamilyName != nil {
|
|
ops = append(ops, replaceOp("name.familyName", *in.FamilyName))
|
|
}
|
|
if in.Description != nil {
|
|
ops = append(ops, replaceOp("description", *in.Description))
|
|
}
|
|
if in.Email != nil {
|
|
ops = append(ops, emailPatchOp(u, *in.Email))
|
|
}
|
|
return ops
|
|
}
|
|
|
|
// emailPatchOp 生成主邮箱修改操作:已有 work 邮箱按 filter 路径替换,
|
|
// 没有则整体添加一条 primary work 邮箱(避免整组 replace 冲掉 recovery 邮箱)。
|
|
func emailPatchOp(u identitydomains.User, email string) identitydomains.Operations {
|
|
for _, e := range u.Emails {
|
|
if e.Type == identitydomains.UserEmailsTypeWork {
|
|
return replaceOp(`emails[type eq "work"].value`, email)
|
|
}
|
|
}
|
|
var value interface{} = []map[string]interface{}{
|
|
{"value": email, "type": "work", "primary": true},
|
|
}
|
|
return identitydomains.Operations{
|
|
Op: identitydomains.OperationsOpAdd,
|
|
Path: common.String("emails"),
|
|
Value: &value,
|
|
}
|
|
}
|
|
|
|
// DeleteTenantUser 实现 Client:删除 IAM 用户(须发往 home region)。
|
|
// 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
|
|
}
|
|
if _, err := ic.DeleteUser(ctx, identity.DeleteUserRequest{UserId: &userID}); err != nil {
|
|
return fmt.Errorf("delete user %s: %w", userID, err)
|
|
}
|
|
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:重置用户控制台密码并返回一次性新密码。
|
|
// 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
|
|
}
|
|
resp, err := ic.CreateOrResetUIPassword(ctx, identity.CreateOrResetUIPasswordRequest{UserId: &userID})
|
|
if err == nil {
|
|
return deref(resp.Password), nil
|
|
}
|
|
if !isClassicUnsupported(err) {
|
|
return "", fmt.Errorf("reset ui password of %s: %w", userID, err)
|
|
}
|
|
return c.resetPasswordViaDomain(ctx, cred, homeRegion, "", userID)
|
|
}
|
|
|
|
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
|
|
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
|
|
}
|
|
scimID, err := scimUserID(ctx, dc, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
password, err := generatePassword()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
bypass := true
|
|
_, err = dc.PutUserPasswordChanger(ctx, identitydomains.PutUserPasswordChangerRequest{
|
|
UserPasswordChangerId: &scimID,
|
|
UserPasswordChanger: identitydomains.UserPasswordChanger{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:UserPasswordChanger"},
|
|
Password: &password,
|
|
BypassNotification: &bypass,
|
|
},
|
|
})
|
|
if err != nil {
|
|
// 身份域通常不允许凭据本人经管理通道改自己的密码
|
|
if userID == cred.UserOCID {
|
|
return "", fmt.Errorf("change password via identity domain(当前签名用户请在 OCI 控制台修改密码): %w", err)
|
|
}
|
|
return "", fmt.Errorf("change password via identity domain: %w", err)
|
|
}
|
|
return password, nil
|
|
}
|
|
|
|
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
|
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
|
|
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
|
|
}
|
|
deleted, err := deleteClassicTotpDevices(ctx, ic, userID)
|
|
if err != nil {
|
|
return deleted, err
|
|
}
|
|
if err := c.removeDomainFactors(ctx, cred, homeRegion, "", userID); err != nil {
|
|
return deleted, err
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, userID string) (int, error) {
|
|
resp, err := ic.ListMfaTotpDevices(ctx, identity.ListMfaTotpDevicesRequest{UserId: &userID})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list mfa totp devices: %w", err)
|
|
}
|
|
deleted := 0
|
|
for _, item := range resp.Items {
|
|
_, err := ic.DeleteMfaTotpDevice(ctx, identity.DeleteMfaTotpDeviceRequest{
|
|
UserId: &userID,
|
|
MfaTotpDeviceId: item.Id,
|
|
})
|
|
if err != nil {
|
|
return deleted, fmt.Errorf("delete mfa totp device: %w", err)
|
|
}
|
|
deleted++
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
|
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
|
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
|
|
}
|
|
scimID, err := scimUserID(ctx, dc, userID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
_, err = dc.CreateAuthenticationFactorsRemover(ctx, identitydomains.CreateAuthenticationFactorsRemoverRequest{
|
|
AuthenticationFactorsRemover: identitydomains.AuthenticationFactorsRemover{
|
|
Schemas: []string{"urn:ietf:params:scim:schemas:oracle:idcs:AuthenticationFactorsRemover"},
|
|
User: &identitydomains.AuthenticationFactorsRemoverUser{Value: &scimID},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("remove authentication factors: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTenantUserApiKeys 实现 Client:删除用户的 API Key。
|
|
// 默认跳过当前配置正在使用的指纹以免面板失联,includeCurrent 为 true 时一并删除。
|
|
func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error) {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list api keys: %w", err)
|
|
}
|
|
deleted := 0
|
|
for _, key := range resp.Items {
|
|
fingerprint := deref(key.Fingerprint)
|
|
if !includeCurrent && userID == cred.UserOCID && fingerprint == cred.Fingerprint {
|
|
continue
|
|
}
|
|
_, err := ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
|
|
if err != nil {
|
|
return deleted, fmt.Errorf("delete api key %s: %w", fingerprint, err)
|
|
}
|
|
deleted++
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// TenantUserApiKey 是用户名下的一把 API 签名 key。
|
|
type TenantUserApiKey struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
TimeCreated *time.Time `json:"timeCreated"`
|
|
// IsCurrent 表示该 key 正被当前配置用于签名请求
|
|
IsCurrent bool `json:"isCurrent"`
|
|
}
|
|
|
|
// ListTenantUserApiKeys 实现 Client:列出用户的 API 签名 key。
|
|
func (c *RealClient) ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list api keys: %w", err)
|
|
}
|
|
keys := make([]TenantUserApiKey, 0, len(resp.Items))
|
|
for _, item := range resp.Items {
|
|
k := TenantUserApiKey{Fingerprint: deref(item.Fingerprint)}
|
|
if item.TimeCreated != nil {
|
|
t := item.TimeCreated.Time
|
|
k.TimeCreated = &t
|
|
}
|
|
k.IsCurrent = userID == cred.UserOCID && k.Fingerprint == cred.Fingerprint
|
|
keys = append(keys, k)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// UploadTenantUserApiKey 实现 Client:为用户上传 RSA 公钥,返回 OCI 回填的指纹。
|
|
func (c *RealClient) UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := ic.UploadApiKey(ctx, identity.UploadApiKeyRequest{
|
|
UserId: &userID,
|
|
CreateApiKeyDetails: identity.CreateApiKeyDetails{Key: &publicKeyPEM},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("upload api key: %w", err)
|
|
}
|
|
return deref(resp.Fingerprint), nil
|
|
}
|
|
|
|
// DeleteTenantUserApiKey 实现 Client:按指纹删除用户的单把 API key。
|
|
func (c *RealClient) DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error {
|
|
ic, err := c.identityClientAt(cred, homeRegion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
|
|
if err != nil {
|
|
return fmt.Errorf("delete api key %s: %w", fingerprint, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
|
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
|
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !found {
|
|
return "", fmt.Errorf("user %s not found in identity domain", classicOCID)
|
|
}
|
|
return *u.Id, nil
|
|
}
|
|
|
|
// domainUserByOcid 用经典 OCID 查域用户;不存在时 found 为 false 而非报错。
|
|
// extraAttrs 用于按需追加 groups、appRoles 等重量级属性。
|
|
func domainUserByOcid(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string, extraAttrs ...string) (identitydomains.User, bool, error) {
|
|
filter := fmt.Sprintf("ocid eq %q", classicOCID)
|
|
attrs := strings.Join(append([]string{"id,ocid,userName,description,name,emails"}, extraAttrs...), ",")
|
|
count := 1
|
|
resp, err := dc.ListUsers(ctx, identitydomains.ListUsersRequest{
|
|
Filter: &filter,
|
|
Attributes: &attrs,
|
|
Count: &count,
|
|
})
|
|
if err != nil {
|
|
return identitydomains.User{}, false, fmt.Errorf("find domain user by ocid: %w", err)
|
|
}
|
|
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
|
return identitydomains.User{}, false, nil
|
|
}
|
|
return resp.Resources[0], true, nil
|
|
}
|
|
|
|
// isClassicUnsupported 判断经典 IAM API 对该用户不适用(仅存在于 Identity Domain)。
|
|
func isClassicUnsupported(err error) bool {
|
|
var svcErr common.ServiceError
|
|
if errors.As(err, &svcErr) {
|
|
return svcErr.GetHTTPStatusCode() == 404 || svcErr.GetHTTPStatusCode() == 400
|
|
}
|
|
return false
|
|
}
|
|
|
|
// passwordCharsets 保证生成的密码覆盖四类字符,满足常见复杂度策略。
|
|
var passwordCharsets = []string{
|
|
"ABCDEFGHJKLMNPQRSTUVWXYZ",
|
|
"abcdefghijkmnpqrstuvwxyz",
|
|
"23456789",
|
|
"#-_+!",
|
|
}
|
|
|
|
// generatePassword 生成 16 位随机密码,每类字符至少出现一次。
|
|
func generatePassword() (string, error) {
|
|
const length = 16
|
|
all := strings.Join(passwordCharsets, "")
|
|
out := make([]byte, 0, length)
|
|
for _, set := range passwordCharsets {
|
|
ch, err := randomChar(set)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out = append(out, ch)
|
|
}
|
|
for len(out) < length {
|
|
ch, err := randomChar(all)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out = append(out, ch)
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
func randomChar(set string) (byte, error) {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(set))))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("generate password: %w", err)
|
|
}
|
|
return set[n.Int64()], nil
|
|
}
|
|
|
|
func toTenantUser(u identity.User, currentUserOCID string) TenantUser {
|
|
return TenantUser{
|
|
ID: deref(u.Id),
|
|
Name: deref(u.Name),
|
|
Description: deref(u.Description),
|
|
Email: deref(u.Email),
|
|
EmailVerified: u.EmailVerified != nil && *u.EmailVerified,
|
|
MfaActivated: u.IsMfaActivated != nil && *u.IsMfaActivated,
|
|
LifecycleState: string(u.LifecycleState),
|
|
IsCurrentUser: deref(u.Id) == currentUserOCID,
|
|
TimeCreated: sdkTime(u.TimeCreated),
|
|
LastLoginTime: sdkTime(u.LastSuccessfulLoginTime),
|
|
}
|
|
}
|