@@ -105,6 +105,8 @@ type Client interface {
|
||||
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
|
||||
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
|
||||
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
|
||||
// AbortAllMultipartUploads 中止桶内全部未完成分片上传(清空删桶前置步骤,404 幂等)
|
||||
AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error
|
||||
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
|
||||
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
|
||||
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
|
||||
@@ -200,6 +202,10 @@ type Client interface {
|
||||
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)
|
||||
// UploadDomainImage 上传公开图片到身份域存储(IdP 图标用),返回公网 URL 与存储内文件名。
|
||||
UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error)
|
||||
// DeleteDomainImage 按存储内文件名删除身份域公开图片。
|
||||
DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) 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
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。
|
||||
const domainImagesPath = "/storage/v1/Images"
|
||||
|
||||
// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl
|
||||
// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient
|
||||
// 发裸 multipart 请求,OCI 签名与代理配置直接复用。
|
||||
func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||
return parseImageUploadResponse(resp, callErr)
|
||||
}
|
||||
|
||||
// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。
|
||||
func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||
return finishDomainImageDelete(resp, callErr)
|
||||
}
|
||||
|
||||
func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) {
|
||||
body, contentType, err := imageMultipart(fileName, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image upload request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) {
|
||||
u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image delete URL: %w", err)
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("fileName", fileName)
|
||||
u.RawQuery = query.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build image delete request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。
|
||||
func imageMultipart(fileName string, data []byte) ([]byte, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if err := w.WriteField("fileName", fileName); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。
|
||||
func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer drainAndClose(resp.Body)
|
||||
}
|
||||
if callErr != nil {
|
||||
return "", "", fmt.Errorf("upload domain image: %w", callErr)
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return "", "", fmt.Errorf("upload domain image: empty response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read image upload response: %w", err)
|
||||
}
|
||||
if len(b) > 1<<20 {
|
||||
return "", "", fmt.Errorf("upload domain image: response exceeds 1MB")
|
||||
}
|
||||
return decodeImageUploadResponse(b)
|
||||
}
|
||||
|
||||
func decodeImageUploadResponse(data []byte) (string, string, error) {
|
||||
var out struct {
|
||||
FileURL string `json:"fileUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", "", fmt.Errorf("parse image upload response: %w", err)
|
||||
}
|
||||
if out.FileURL == "" || out.FileName == "" {
|
||||
return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName")
|
||||
}
|
||||
return out.FileURL, out.FileName, nil
|
||||
}
|
||||
|
||||
func finishDomainImageDelete(resp *http.Response, callErr error) error {
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer drainAndClose(resp.Body)
|
||||
}
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
return fmt.Errorf("delete domain image: %w", callErr)
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("delete domain image: empty response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
|
||||
_ = body.Close()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type trackingReadCloser struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
type testMultipartFile struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) {
|
||||
reader := &trackingReadCloser{Reader: strings.NewReader(body)}
|
||||
return &http.Response{StatusCode: status, Body: reader}, reader
|
||||
}
|
||||
|
||||
func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) {
|
||||
t.Helper()
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content type: %v", err)
|
||||
}
|
||||
reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
|
||||
fields, files := map[string]string{}, map[string]testMultipartFile{}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("next part: %v", err)
|
||||
}
|
||||
data, _ := io.ReadAll(part)
|
||||
if part.FileName() == "" {
|
||||
fields[part.FormName()] = string(data)
|
||||
} else {
|
||||
files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data}
|
||||
}
|
||||
}
|
||||
return fields, files
|
||||
}
|
||||
|
||||
func TestImageMultipartIncludesFileAndName(t *testing.T) {
|
||||
fileName := "idp-icon-00112233445566778899aabbccddeeff.png"
|
||||
body, contentType, err := imageMultipart(fileName, []byte("image-data"))
|
||||
if err != nil {
|
||||
t.Fatalf("imageMultipart: %v", err)
|
||||
}
|
||||
fields, files := readMultipartFields(t, body, contentType)
|
||||
if got := fields["fileName"]; got != fileName {
|
||||
t.Errorf("fileName = %q, want %q", got, fileName)
|
||||
}
|
||||
file := files["file"]
|
||||
if file.name != fileName || string(file.data) != "image-data" {
|
||||
t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageUploadResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, body, wantURL, wantName string
|
||||
status int
|
||||
callErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false},
|
||||
{"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true},
|
||||
{"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true},
|
||||
{"bad json", `{`, "", "", 200, nil, true},
|
||||
{"http error hides body", `secret-body`, "", "", 400, nil, true},
|
||||
{"call error", `{}`, "", "", 500, errors.New("upstream"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, body := testImageResponse(tc.status, tc.body)
|
||||
gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr)
|
||||
if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr {
|
||||
t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr)
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("response body was not closed")
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "secret-body") {
|
||||
t.Errorf("error leaked response body: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) {
|
||||
fileName := "images/folder/a b+%.png"
|
||||
req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("newDomainImageDeleteRequest: %v", err)
|
||||
}
|
||||
if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath {
|
||||
t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath)
|
||||
}
|
||||
if got := req.URL.Query().Get("fileName"); got != fileName {
|
||||
t.Errorf("decoded fileName = %q, want %q", got, fileName)
|
||||
}
|
||||
if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") {
|
||||
t.Errorf("raw query is not encoded: %q", req.URL.RawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishDomainImageDelete(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
callErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{"deleted", http.StatusNoContent, nil, false},
|
||||
{"already gone", http.StatusNotFound, errors.New("not found"), false},
|
||||
{"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resp, body := testImageResponse(tc.status, "response")
|
||||
err := finishDomainImageDelete(resp, tc.callErr)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("err = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("response body was not closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-16
@@ -3,6 +3,7 @@ package oci
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
|
||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||
|
||||
const idpCreateRollbackWait = 15 * time.Second
|
||||
|
||||
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
|
||||
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
|
||||
|
||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||
type IdentityProviderInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
|
||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||
}
|
||||
|
||||
// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。
|
||||
// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。
|
||||
type PartialIdentityProviderCreateError struct {
|
||||
IdentityProvider IdentityProviderInfo
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Error() string {
|
||||
return "identity provider was created but JIT setup is incomplete"
|
||||
}
|
||||
|
||||
func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause }
|
||||
|
||||
type federationDomainsClient interface {
|
||||
ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error)
|
||||
CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error)
|
||||
GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error)
|
||||
PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error)
|
||||
DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error)
|
||||
}
|
||||
|
||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
||||
return idps, nil
|
||||
}
|
||||
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT;
|
||||
// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。
|
||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
}
|
||||
return createSamlIdentityProvider(ctx, dc, in)
|
||||
}
|
||||
|
||||
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||
var err error
|
||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||
return IdentityProviderInfo{}, err
|
||||
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
|
||||
if err != nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||
}
|
||||
if in.JitEnabled {
|
||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||
return toIdpInfo(resp.IdentityProvider), err
|
||||
}
|
||||
info := toIdpInfo(resp.IdentityProvider)
|
||||
if !in.JitEnabled {
|
||||
return info, nil
|
||||
}
|
||||
return toIdpInfo(resp.IdentityProvider), nil
|
||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||
return rollbackIncompleteIdp(ctx, dc, info, err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
|
||||
if info.ID == "" {
|
||||
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
|
||||
defer cancel()
|
||||
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
|
||||
IdentityProviderId: &info.ID,
|
||||
})
|
||||
if rollbackErr == nil {
|
||||
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
|
||||
}
|
||||
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
|
||||
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||
}
|
||||
|
||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
||||
|
||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||||
count := len(adminGroupNames)
|
||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||
@@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
|
||||
}
|
||||
|
||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||
ref := idp.JitUserProvAttributes
|
||||
if ref == nil || ref.Value == nil {
|
||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
||||
@@ -302,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains
|
||||
}
|
||||
|
||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||
// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建,
|
||||
// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。
|
||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||
returns := make([]interface{}, 0, len(items))
|
||||
changed := false
|
||||
returns := make([]interface{}, 0, len(items)+1)
|
||||
changed, seen := false, false
|
||||
for _, item := range items {
|
||||
name, value := deref(item.Name), deref(item.Value)
|
||||
if name == "SamlIDPs" {
|
||||
seen = true
|
||||
next, ok, err := toggleJSONList(value, idpID, show)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||
@@ -316,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho
|
||||
}
|
||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||
}
|
||||
if !seen && show {
|
||||
returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)})
|
||||
changed = true
|
||||
}
|
||||
return returns, changed, nil
|
||||
}
|
||||
|
||||
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := fetchSamlMetadata(ctx, url)
|
||||
body, status, err := fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err = fetchSamlMetadata(ctx, url)
|
||||
body, status, err = fetchSamlMetadata(ctx, cred, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
||||
// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
|
||||
// 防异常缓慢或超大响应长期占住请求与内存。
|
||||
const (
|
||||
samlMetadataTimeout = 30 * time.Second
|
||||
samlMetadataMaxBytes = 4 << 20
|
||||
)
|
||||
|
||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名,
|
||||
// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。
|
||||
func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) {
|
||||
client, err := metadataHTTPClient(cred)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||
}
|
||||
if len(body) > samlMetadataMaxBytes {
|
||||
return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes)
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时
|
||||
// 报错而非静默直连(失败关闭)。
|
||||
func metadataHTTPClient(cred Credentials) (*http.Client, error) {
|
||||
if cred.Proxy == nil {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
if hc := HTTPClientFor(cred.Proxy); hc != nil {
|
||||
return hc, nil
|
||||
}
|
||||
return nil, errors.New("download saml metadata: invalid proxy config")
|
||||
}
|
||||
|
||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||
)
|
||||
|
||||
type federationCreateStub struct {
|
||||
created identitydomains.IdentityProvider
|
||||
createErr error
|
||||
patchErr error
|
||||
deleteErr error
|
||||
patchCalls int
|
||||
deletedID string
|
||||
deleteCtxErr error
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) {
|
||||
return identitydomains.ListGroupsResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) {
|
||||
return identitydomains.CreateIdentityProviderResponse{IdentityProvider: s.created}, s.createErr
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) {
|
||||
return identitydomains.GetIdentityProviderResponse{IdentityProvider: s.created}, nil
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) {
|
||||
s.patchCalls++
|
||||
return identitydomains.PatchMappedAttributeResponse{}, s.patchErr
|
||||
}
|
||||
|
||||
func (s *federationCreateStub) DeleteIdentityProvider(ctx context.Context, request identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) {
|
||||
s.deletedID = deref(request.IdentityProviderId)
|
||||
s.deleteCtxErr = ctx.Err()
|
||||
return identitydomains.DeleteIdentityProviderResponse{}, s.deleteErr
|
||||
}
|
||||
|
||||
func createdJitIdp(id string) identitydomains.IdentityProvider {
|
||||
return identitydomains.IdentityProvider{
|
||||
Id: idPtr(id), PartnerName: common.String("test-idp"), Type: identitydomains.IdentityProviderTypeSaml,
|
||||
JitUserProvEnabled: common.Bool(true), JitUserProvAttributes: &identitydomains.IdentityProviderJitUserProvAttributes{Value: common.String("mapping-1")},
|
||||
}
|
||||
}
|
||||
|
||||
func idPtr(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return common.String(value)
|
||||
}
|
||||
|
||||
func testJitInput() CreateIdpInput {
|
||||
return CreateIdpInput{Name: "test-idp", JitEnabled: true, IconURL: "https://img.example/icon.png"}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderCreateFailure(t *testing.T) {
|
||||
createErr := errors.New("create rejected")
|
||||
stub := &federationCreateStub{createErr: createErr}
|
||||
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||
if !errors.Is(err, createErr) {
|
||||
t.Fatalf("err = %v, want create error", err)
|
||||
}
|
||||
if got.ID != "" || stub.patchCalls != 0 || stub.deletedID != "" {
|
||||
t.Errorf("got = %+v, patchCalls = %d, deletedID = %q", got, stub.patchCalls, stub.deletedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderJitFailureRollbackSuccess(t *testing.T) {
|
||||
patchErr := errors.New("patch rejected")
|
||||
stub := &federationCreateStub{created: createdJitIdp("idp-1"), patchErr: patchErr}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got, err := createSamlIdentityProvider(ctx, stub, testJitInput())
|
||||
var partial *PartialIdentityProviderCreateError
|
||||
if !errors.Is(err, patchErr) || errors.As(err, &partial) {
|
||||
t.Fatalf("err = %v, want ordinary wrapped patch error", err)
|
||||
}
|
||||
if got.ID != "" || stub.deletedID != "idp-1" || stub.deleteCtxErr != nil {
|
||||
t.Errorf("got.ID = %q, deletedID = %q, deleteCtxErr = %v", got.ID, stub.deletedID, stub.deleteCtxErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSamlIdentityProviderPartialCreateContract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, id string
|
||||
deleteErr error
|
||||
wantDelete bool
|
||||
}{
|
||||
{"rollback fails", "idp-1", errors.New("rollback secret detail"), true},
|
||||
{"created id missing", "", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) { assertPartialCreate(t, tc.id, tc.deleteErr, tc.wantDelete) })
|
||||
}
|
||||
}
|
||||
|
||||
func assertPartialCreate(t *testing.T, id string, deleteErr error, wantDelete bool) {
|
||||
t.Helper()
|
||||
stub := &federationCreateStub{created: createdJitIdp(id), patchErr: errors.New("patch secret detail"), deleteErr: deleteErr}
|
||||
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||
var partial *PartialIdentityProviderCreateError
|
||||
if !errors.As(err, &partial) || partial.IdentityProvider.ID != id {
|
||||
t.Fatalf("got = %+v, err = %v, partial = %+v", got, err, partial)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret") || (stub.deletedID != "") != wantDelete {
|
||||
t.Errorf("unsafe err = %q or deletedID = %q, wantDelete = %v", err, stub.deletedID, wantDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func ruleReturn(name, value string) identitydomains.RuleReturn {
|
||||
return identitydomains.RuleReturn{Name: common.String(name), Value: common.String(value)}
|
||||
}
|
||||
|
||||
type rebuildIdpReturnCase struct {
|
||||
name string
|
||||
items []identitydomains.RuleReturn
|
||||
show bool
|
||||
changed bool
|
||||
want string
|
||||
}
|
||||
|
||||
func TestRebuildSamlIdpsReturn(t *testing.T) {
|
||||
local := ruleReturn("LocalIDPs", `["UserNamePassword"]`)
|
||||
cases := []rebuildIdpReturnCase{
|
||||
{"无SamlIDPs项时添加须补建", []identitydomains.RuleReturn{local}, true, true, `["idp-1"]`},
|
||||
{"无SamlIDPs项时移除无变化", []identitydomains.RuleReturn{local}, false, false, ""},
|
||||
{"已有其他IdP时追加", []identitydomains.RuleReturn{local, ruleReturn("SamlIDPs", `["other"]`)}, true, true, `["other","idp-1"]`},
|
||||
{"已在列表中再添加无变化", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1"]`)}, true, false, `["idp-1"]`},
|
||||
{"移除目标IdP", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1","other"]`)}, false, true, `["other"]`},
|
||||
{"空值项添加", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "")}, true, true, `["idp-1"]`},
|
||||
}
|
||||
assertRebuildIdpReturns(t, cases)
|
||||
}
|
||||
|
||||
func assertRebuildIdpReturns(t *testing.T, cases []rebuildIdpReturnCase) {
|
||||
t.Helper()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
returns, changed, err := rebuildSamlIdpsReturn(tc.items, "idp-1", tc.show)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuildSamlIdpsReturn: %v", err)
|
||||
}
|
||||
if changed != tc.changed {
|
||||
t.Errorf("changed = %v, want %v", changed, tc.changed)
|
||||
}
|
||||
got := ""
|
||||
for _, r := range returns {
|
||||
m := r.(map[string]string)
|
||||
if m["name"] == "SamlIDPs" {
|
||||
got = m["value"]
|
||||
}
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("SamlIDPs = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildSamlIdpsReturnBadJSON(t *testing.T) {
|
||||
items := []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "not-json")}
|
||||
if _, _, err := rebuildSamlIdpsReturn(items, "idp-1", true); err == nil {
|
||||
t.Fatal("坏 JSON 应报错而非静默覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchSamlMetadataLimitsBody 锁定匿名元数据请求的响应体上限:超限报错而非吞下。
|
||||
func TestFetchSamlMetadataLimitsBody(t *testing.T) {
|
||||
huge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(bytes.Repeat([]byte("x"), samlMetadataMaxBytes+1))
|
||||
}))
|
||||
defer huge.Close()
|
||||
if _, _, err := fetchSamlMetadata(context.Background(), Credentials{}, huge.URL); err == nil ||
|
||||
!strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("err = %v, want 超限错误", err)
|
||||
}
|
||||
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<EntityDescriptor/>"))
|
||||
}))
|
||||
defer ok.Close()
|
||||
body, status, err := fetchSamlMetadata(context.Background(), Credentials{}, ok.URL)
|
||||
if err != nil || status != http.StatusOK || string(body) != "<EntityDescriptor/>" {
|
||||
t.Fatalf("fetch = %q, %d, %v; want 正常返回", body, status, err)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
|
||||
payload, err := readCompatBody(response.Body, compatResponsesLimit, "compat responses")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read compat responses body: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -121,3 +121,16 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
|
||||
}
|
||||
return callWithHeaderBudget(ctx, client, request, wait)
|
||||
}
|
||||
|
||||
// readCompatBody 读取上游响应体并施加上限;读 limit+1 判超报错——
|
||||
// 静默截断的 JSON/音频配 200 会被下游当完整成功记账。
|
||||
func readCompatBody(body io.Reader, limit int64, tag string) ([]byte, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s body: %w", tag, err)
|
||||
}
|
||||
if int64(len(payload)) > limit {
|
||||
return nil, fmt.Errorf("%s body exceeds %d bytes", tag, limit)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -139,3 +140,31 @@ func TestCallWithHeaderBudget(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestReadCompatBodyRejectsOversize 锁定上游响应上限:恰好达限通过,超限报错,
|
||||
// 不允许静默截断配 200 让下游当完整成功。
|
||||
func TestReadCompatBodyRejectsOversize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
size int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"under limit", 15, false},
|
||||
{"exactly limit", 16, false},
|
||||
{"over limit", 17, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := readCompatBody(bytes.NewReader(make([]byte, tc.size)), 16, "test")
|
||||
if tc.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("err = %v, want 超限错误", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || int64(len(payload)) != tc.size {
|
||||
t.Fatalf("payload = %d bytes, %v; want %d", len(payload), err, tc.size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/oracle/oci-go-sdk/v65/common"
|
||||
@@ -36,9 +35,9 @@ func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, re
|
||||
return nil, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
|
||||
payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read compat speech body: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
return payload, response.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
+137
-71
@@ -2,8 +2,6 @@ package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,14 +12,11 @@ import (
|
||||
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||
)
|
||||
|
||||
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
|
||||
// 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生;
|
||||
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
||||
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
||||
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
||||
const (
|
||||
relayTopicPrefix = "ociportal-logs"
|
||||
relayPolicyName = "ociportal-logs-sch"
|
||||
relayConnectorName = "ociportal-logs"
|
||||
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
||||
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
||||
)
|
||||
@@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
|
||||
// EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀;
|
||||
// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。
|
||||
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
||||
cp, err := c.onsControlClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
|
||||
return res, err
|
||||
names := relayResourceNames(cred.TenancyOCID)
|
||||
res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
name, err := relayTopicNewName()
|
||||
if ok {
|
||||
refreshRelayTopicDesc(ctx, cp, res.ID)
|
||||
return res, nil
|
||||
}
|
||||
return createRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix)
|
||||
}
|
||||
|
||||
// createRelayTopic 以指定前缀 + 4 字节随机后缀新建 ONS Topic,描述使用中性文案。
|
||||
func createRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy, prefix string) (RelayResource, error) {
|
||||
name, err := relayTopicNewName(prefix)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
||||
CreateTopicDetails: ons.CreateTopicDetails{
|
||||
Name: &name,
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
|
||||
CompartmentId: &tenancy,
|
||||
Description: common.String(relayTopicDescNew),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -109,28 +116,17 @@ func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (Re
|
||||
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
||||
}
|
||||
|
||||
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。
|
||||
func relayTopicNewName() (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("topic name suffix: %w", err)
|
||||
}
|
||||
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
|
||||
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
|
||||
// findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回。
|
||||
// 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。
|
||||
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) {
|
||||
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
||||
for page := 0; page < relayTopicPages; page++ {
|
||||
list, err := cp.ListTopics(ctx, req)
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
||||
}
|
||||
for _, t := range list.Items {
|
||||
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
|
||||
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
|
||||
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
|
||||
}
|
||||
if res, ok := matchRelayTopic(list.Items, prefixes); ok {
|
||||
return res, true, nil
|
||||
}
|
||||
if list.OpcNextPage == nil {
|
||||
break
|
||||
@@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient,
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// matchRelayTopic 在一页 Topic 中挑出第一个命名前缀命中且处于 ACTIVE 的资源。
|
||||
func matchRelayTopic(items []ons.NotificationTopicSummary, prefixes []string) (RelayResource, bool) {
|
||||
for _, t := range items {
|
||||
if t.LifecycleState != ons.NotificationTopicSummaryLifecycleStateActive {
|
||||
continue
|
||||
}
|
||||
name := deref(t.Name)
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return RelayResource{}, false
|
||||
}
|
||||
|
||||
// refreshRelayTopicDesc 尽力把 Topic 描述改为中性文案;失败不阻塞主流程(权限不足等场景直接忽略)。
|
||||
func refreshRelayTopicDesc(ctx context.Context, cp ons.NotificationControlPlaneClient, topicID string) {
|
||||
if topicID == "" {
|
||||
return
|
||||
}
|
||||
_, _ = cp.UpdateTopic(ctx, ons.UpdateTopicRequest{
|
||||
TopicId: &topicID,
|
||||
TopicAttributesDetails: ons.TopicAttributesDetails{
|
||||
Description: common.String(relayTopicDescNew),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
||||
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
||||
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
||||
@@ -196,28 +221,50 @@ func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials,
|
||||
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
||||
}
|
||||
|
||||
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
|
||||
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||
// EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名;
|
||||
// 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
||||
ic, err := c.identityClientAt(cred, homeRegion)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||
})
|
||||
names := relayResourceNames(cred.TenancyOCID)
|
||||
res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName)
|
||||
if err != nil {
|
||||
return RelayResource{}, fmt.Errorf("list policies: %w", err)
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if len(list.Items) > 0 {
|
||||
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
|
||||
if ok {
|
||||
refreshRelayPolicyDesc(ctx, ic, res.ID)
|
||||
return res, nil
|
||||
}
|
||||
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID)
|
||||
return createRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName)
|
||||
}
|
||||
|
||||
// findRelayPolicy 依次按传入的名称列表在租户范围内查找 IAM Policy;第一个命中即返回。
|
||||
func findRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy string, names ...string) (RelayResource, bool, error) {
|
||||
for _, name := range names {
|
||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||
CompartmentId: &tenancy, Name: common.String(name),
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list policies: %w", err)
|
||||
}
|
||||
if len(list.Items) > 0 {
|
||||
it := list.Items[0]
|
||||
return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, nil
|
||||
}
|
||||
}
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// createRelayPolicy 以中性描述与派生名称新建 Policy,statement 允许 Service Connector 发布消息到租户内 Topic。
|
||||
func createRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy, name string) (RelayResource, error) {
|
||||
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", tenancy)
|
||||
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
||||
CreatePolicyDetails: identity.CreatePolicyDetails{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
Name: common.String(relayPolicyName),
|
||||
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
|
||||
CompartmentId: &tenancy,
|
||||
Name: common.String(name),
|
||||
Description: common.String(relayPolicyDescNew),
|
||||
Statements: []string{stmt},
|
||||
},
|
||||
})
|
||||
@@ -227,25 +274,45 @@ func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, ho
|
||||
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||
}
|
||||
|
||||
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
|
||||
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
|
||||
// refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。
|
||||
func refreshRelayPolicyDesc(ctx context.Context, ic identity.IdentityClient, id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
_, _ = ic.UpdatePolicy(ctx, identity.UpdatePolicyRequest{
|
||||
PolicyId: &id,
|
||||
UpdatePolicyDetails: identity.UpdatePolicyDetails{
|
||||
Description: common.String(relayPolicyDescNew),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureRelayConnector 实现 Client:按新命名返回既有 Connector,fallback 到 legacy DisplayName;
|
||||
// 命中时对齐 Log Filter 条件,未命中则以新命名新建并轮询至 ACTIVE。
|
||||
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
||||
sc, err := c.schClient(cred)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||
names := relayResourceNames(cred.TenancyOCID)
|
||||
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName)
|
||||
if err != nil {
|
||||
return RelayResource{}, err
|
||||
}
|
||||
if ok {
|
||||
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
|
||||
}
|
||||
return createRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, topicID, condition)
|
||||
}
|
||||
|
||||
// createRelayConnector 以派生名称新建 Service Connector(_Audit 含子区间 → Topic,按 condition 过滤),
|
||||
// 新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。Connector 不设 Description,避免恒定文案指纹。
|
||||
func createRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name, topicID, condition string) (RelayResource, error) {
|
||||
details := sch.CreateServiceConnectorDetails{
|
||||
DisplayName: common.String(relayConnectorName),
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
DisplayName: common.String(name),
|
||||
CompartmentId: &tenancy,
|
||||
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
||||
CompartmentId: &cred.TenancyOCID,
|
||||
CompartmentId: &tenancy,
|
||||
LogGroupId: common.String(relayAuditLogGroup),
|
||||
}}},
|
||||
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
||||
@@ -258,7 +325,7 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
|
||||
}); err != nil {
|
||||
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
||||
}
|
||||
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
||||
return waitRelayConnector(ctx, sc, tenancy, name)
|
||||
}
|
||||
|
||||
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
|
||||
@@ -300,24 +367,27 @@ func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
|
||||
return *rule.Condition != want
|
||||
}
|
||||
|
||||
// findRelayConnector 按名查找存活 Connector。
|
||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
||||
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
||||
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||
// findRelayConnector 依次按传入的 DisplayName 列表在租户范围内查找存活 Connector;第一个命中即返回。
|
||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string, names ...string) (RelayResource, bool, error) {
|
||||
for _, name := range names {
|
||||
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||
CompartmentId: &tenancy, DisplayName: common.String(name),
|
||||
})
|
||||
if err != nil {
|
||||
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
||||
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return RelayResource{}, false, nil
|
||||
}
|
||||
|
||||
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
||||
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) {
|
||||
// 只按新命名查找(新建资源用的就是新命名)。
|
||||
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name string) (RelayResource, error) {
|
||||
last := RelayResource{Created: true}
|
||||
for i := 0; i < relayConnectorPollLimit; i++ {
|
||||
select {
|
||||
@@ -325,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
|
||||
return last, ctx.Err()
|
||||
case <-time.After(relayConnectorPollTick):
|
||||
}
|
||||
res, ok, err := findRelayConnector(ctx, sc, tenancy)
|
||||
res, ok, err := findRelayConnector(ctx, sc, tenancy, name)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
@@ -346,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil {
|
||||
names := relayResourceNames(cred.TenancyOCID)
|
||||
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Topic.ID != "" {
|
||||
@@ -361,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
||||
return c.relayControlState(ctx, cred, st)
|
||||
}
|
||||
|
||||
// relayControlState 补齐 Connector 与 Policy 两项状态。
|
||||
// relayControlState 补齐 Connector 与 Policy 两项状态,双路径兼容 legacy 命名。
|
||||
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
||||
sc, err := c.schClient(cred)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil {
|
||||
names := relayResourceNames(cred.TenancyOCID)
|
||||
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName); err != nil {
|
||||
return st, err
|
||||
}
|
||||
ic, err := c.identityClientAt(cred, "")
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
||||
})
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("list policies: %w", err)
|
||||
}
|
||||
if len(list.Items) > 0 {
|
||||
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
|
||||
if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil {
|
||||
return st, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 日志回传链路资源命名与描述文案的集中定义。
|
||||
// 目标:让每租户派生互异的中性资源名,避免跨租户恒定字面量成为「共用同一套 oci-portal」的指纹。
|
||||
|
||||
// relayNames 是按 tenancy 派生的一组资源名。
|
||||
type relayNames struct {
|
||||
TopicPrefix string // 例: "c3f2a1b0-audit",Topic 最终名再加短随机后缀
|
||||
PolicyName string // 例: "c3f2a1b0-audit-p"
|
||||
ConnectorName string // 例: "c3f2a1b0-audit"
|
||||
}
|
||||
|
||||
// relayResourceNames 由 tenancyOCID 派生一组稳定的中性资源名。
|
||||
// SHA-256 前 4 字节做前缀,同 tenancy 每次调用结果一致(幂等查找依赖此性质)。
|
||||
func relayResourceNames(tenancyOCID string) relayNames {
|
||||
sum := sha256.Sum256([]byte(tenancyOCID))
|
||||
prefix := hex.EncodeToString(sum[:4])
|
||||
return relayNames{
|
||||
TopicPrefix: prefix + "-audit",
|
||||
PolicyName: prefix + "-audit-p",
|
||||
ConnectorName: prefix + "-audit",
|
||||
}
|
||||
}
|
||||
|
||||
// relayTopicNewName 按给定前缀 + 4 字节随机后缀生成 Topic 名,规避 ONS 删除保留期。
|
||||
func relayTopicNewName(prefix string) (string, error) {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("topic name suffix: %w", err)
|
||||
}
|
||||
return prefix + "-" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// 描述文案:中性英文,不含品牌明文;集中于此便于后续再调整。
|
||||
const (
|
||||
relayTopicDescNew = "Audit event relay endpoint"
|
||||
relayPolicyDescNew = "Allow Service Connector to publish audit events to notification topic"
|
||||
)
|
||||
|
||||
// legacyRelay* 仅用于识别旧版本(硬编码 ociportal-logs*)创建的存量资源,不用于新建。
|
||||
// 新租户完全走 relayResourceNames 派生;存量租户 fallback 命中后描述会一次性刷新为中性文案。
|
||||
const (
|
||||
legacyRelayTopicPrefix = "ociportal-logs"
|
||||
legacyRelayPolicyName = "ociportal-logs-sch"
|
||||
legacyRelayConnectorName = "ociportal-logs"
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRelayResourceNames_Deterministic(t *testing.T) {
|
||||
tenancy := "ocid1.tenancy.oc1..aaaaexampletenant"
|
||||
a := relayResourceNames(tenancy)
|
||||
b := relayResourceNames(tenancy)
|
||||
if a != b {
|
||||
t.Fatalf("同 tenancy 派生不一致: %+v vs %+v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayResourceNames_DistinctTenancies(t *testing.T) {
|
||||
cases := []string{
|
||||
"ocid1.tenancy.oc1..aaaaaaaaaa",
|
||||
"ocid1.tenancy.oc1..bbbbbbbbbb",
|
||||
"ocid1.tenancy.oc1..cccccccccc",
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, tenancy := range cases {
|
||||
n := relayResourceNames(tenancy)
|
||||
if got, ok := seen[n.TopicPrefix]; ok {
|
||||
t.Fatalf("前缀冲突: %s 与 %s 同为 %s", tenancy, got, n.TopicPrefix)
|
||||
}
|
||||
seen[n.TopicPrefix] = tenancy
|
||||
assertNoBrandLeak(t, tenancy, n)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoBrandLeak 断言派生结果不含品牌明文,以及格式符合预期。
|
||||
func assertNoBrandLeak(t *testing.T, tenancy string, n relayNames) {
|
||||
t.Helper()
|
||||
banned := []string{"oci-portal", "ociportal", "logs", "portal"}
|
||||
fields := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"TopicPrefix", n.TopicPrefix},
|
||||
{"PolicyName", n.PolicyName},
|
||||
{"ConnectorName", n.ConnectorName},
|
||||
}
|
||||
for _, f := range fields {
|
||||
lower := strings.ToLower(f.value)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, b) {
|
||||
t.Errorf("tenancy %s: %s=%q 含品牌明文 %q", tenancy, f.name, f.value, b)
|
||||
}
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{8}-audit(-p)?$`).MatchString(f.value) {
|
||||
t.Errorf("tenancy %s: %s=%q 不符合 <8hex>-audit(-p)? 格式", tenancy, f.name, f.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayTopicNewName_Suffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
}{
|
||||
{name: "标准前缀", prefix: "c3f2a1b0-audit"},
|
||||
{name: "legacy 前缀兼容", prefix: legacyRelayTopicPrefix},
|
||||
}
|
||||
pat := regexp.MustCompile(`^[0-9a-f]{8}$`)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := relayTopicNewName(tt.prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, tt.prefix+"-") {
|
||||
t.Errorf("前缀不符: got=%q, prefix=%q", got, tt.prefix)
|
||||
}
|
||||
suffix := strings.TrimPrefix(got, tt.prefix+"-")
|
||||
if !pat.MatchString(suffix) {
|
||||
t.Errorf("后缀非 8 hex: got=%q, suffix=%q", got, suffix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPrefixes_Unchanged(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{name: "Topic legacy", got: legacyRelayTopicPrefix, want: "ociportal-logs"},
|
||||
{name: "Policy legacy", got: legacyRelayPolicyName, want: "ociportal-logs-sch"},
|
||||
{name: "Connector legacy", got: legacyRelayConnectorName, want: "ociportal-logs"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("legacy 常量被误改: got=%q, want=%q", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayDescriptions_Neutral(t *testing.T) {
|
||||
banned := []string{"oci-portal", "ociportal", "日志回传", "面板"}
|
||||
for _, desc := range []string{relayTopicDescNew, relayPolicyDescNew} {
|
||||
lower := strings.ToLower(desc)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, strings.ToLower(b)) {
|
||||
t.Errorf("描述含品牌明文 %q: %q", b, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ type UpdateBucketInput struct {
|
||||
type CreatePARInput struct {
|
||||
Name string
|
||||
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
|
||||
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite
|
||||
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectRead / AnyObjectWrite / AnyObjectReadWrite
|
||||
ExpiresHours int
|
||||
}
|
||||
|
||||
@@ -288,6 +288,48 @@ func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region,
|
||||
return nil
|
||||
}
|
||||
|
||||
// AbortAllMultipartUploads 实现 Client:中止桶内全部未完成分片上传并删除已传分片。
|
||||
// 未完成分片会让「已清空」的桶仍以非空拒绝删除;404 视为桶已不存在,幂等成功。
|
||||
func (c *RealClient) AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error {
|
||||
oc, err := c.osClient(cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for page := (*string)(nil); ; {
|
||||
resp, err := oc.ListMultipartUploads(ctx, objectstorage.ListMultipartUploadsRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, Page: page,
|
||||
})
|
||||
if err != nil {
|
||||
return ignoreNotFound(fmt.Errorf("list multipart uploads: %w", err))
|
||||
}
|
||||
for _, up := range resp.Items {
|
||||
if _, err := oc.AbortMultipartUpload(ctx, objectstorage.AbortMultipartUploadRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, ObjectName: up.Object, UploadId: up.UploadId,
|
||||
}); err != nil {
|
||||
if err := ignoreNotFound(fmt.Errorf("abort multipart upload: %w", err)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if page = resp.OpcNextPage; page == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ignoreNotFound 把上游 404 归一为 nil(资源已不存在,操作目的已达成)。
|
||||
func ignoreNotFound(err error) error {
|
||||
var se common.ServiceError
|
||||
if errors.As(err, &se) && se.GetHTTPStatusCode() == 404 {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
|
||||
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
|
||||
// 两者都可经后台清空(对象版本+PAR)后重删。
|
||||
@@ -582,21 +624,7 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu
|
||||
if err != nil {
|
||||
return PAR{}, err
|
||||
}
|
||||
if in.ExpiresHours <= 0 {
|
||||
in.ExpiresHours = 24
|
||||
}
|
||||
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
||||
Name: &in.Name,
|
||||
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
||||
TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)},
|
||||
}
|
||||
if in.ObjectName != "" {
|
||||
details.ObjectName = &in.ObjectName
|
||||
}
|
||||
// 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览
|
||||
if strings.HasPrefix(in.AccessType, "AnyObject") {
|
||||
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
||||
}
|
||||
details := createPARDetails(in, time.Now())
|
||||
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
|
||||
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
|
||||
})
|
||||
@@ -606,6 +634,26 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu
|
||||
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
|
||||
}
|
||||
|
||||
// createPARDetails 集中组装 SDK 请求,避免 accessType 与桶列举权限组合回归。
|
||||
func createPARDetails(in CreatePARInput, now time.Time) objectstorage.CreatePreauthenticatedRequestDetails {
|
||||
hours := in.ExpiresHours
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
||||
Name: &in.Name,
|
||||
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
||||
TimeExpires: &common.SDKTime{Time: now.Add(time.Duration(hours) * time.Hour)},
|
||||
}
|
||||
if in.ObjectName != "" {
|
||||
details.ObjectName = &in.ObjectName
|
||||
}
|
||||
if in.AccessType == "AnyObjectRead" || in.AccessType == "AnyObjectReadWrite" {
|
||||
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
|
||||
out := PAR{
|
||||
ID: deref(p.Id),
|
||||
|
||||
@@ -2,7 +2,10 @@ package oci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
|
||||
@@ -27,3 +30,47 @@ func TestGetObjectStorageNamespaceCached(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARDetailsBucketListingAction(t *testing.T) {
|
||||
cases := []struct {
|
||||
accessType string
|
||||
wantList bool
|
||||
}{
|
||||
{"AnyObjectRead", true},
|
||||
{"AnyObjectReadWrite", true},
|
||||
{"AnyObjectWrite", false},
|
||||
{"ObjectRead", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
details := createPARDetails(CreatePARInput{AccessType: tc.accessType, ExpiresHours: 1}, time.Unix(100, 0))
|
||||
gotList := string(details.BucketListingAction) == "ListObjects"
|
||||
if gotList != tc.wantList {
|
||||
t.Errorf("%s listing = %q, wantList %v", tc.accessType, details.BucketListingAction, tc.wantList)
|
||||
}
|
||||
wire, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s details: %v", tc.accessType, err)
|
||||
}
|
||||
gotWireList := strings.Contains(string(wire), `"bucketListingAction":"ListObjects"`)
|
||||
if gotWireList != tc.wantList {
|
||||
t.Errorf("%s wire = %s, wantList %v", tc.accessType, wire, tc.wantList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARDetailsExpirationAndObject(t *testing.T) {
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
details := createPARDetails(CreatePARInput{
|
||||
Name: "share", ObjectName: "folder/a.txt", AccessType: "ObjectRead", ExpiresHours: 48,
|
||||
}, now)
|
||||
if got, want := details.TimeExpires.Time, now.Add(48*time.Hour); !got.Equal(want) {
|
||||
t.Errorf("TimeExpires = %v, want %v", got, want)
|
||||
}
|
||||
if details.ObjectName == nil || *details.ObjectName != "folder/a.txt" {
|
||||
t.Errorf("ObjectName = %v, want folder/a.txt", details.ObjectName)
|
||||
}
|
||||
defaults := createPARDetails(CreatePARInput{AccessType: "ObjectRead"}, now)
|
||||
if got, want := defaults.TimeExpires.Time, now.Add(24*time.Hour); !got.Equal(want) {
|
||||
t.Errorf("default TimeExpires = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy"
|
||||
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
||||
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
|
||||
// signOnConsentSchema 是修改 Oracle 预置 OCI Console sign-on 策略的知情同意扩展 schema。
|
||||
const signOnConsentSchema = "urn:ietf:params:scim:schemas:oracle:idcs:extension:ociconsolesignonpolicyconsent:Policy"
|
||||
|
||||
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
||||
type SignOnRuleInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -220,15 +223,18 @@ func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRu
|
||||
|
||||
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
||||
var value interface{} = rules
|
||||
var consent interface{} = true
|
||||
var justification interface{} = "MFA Configured in External IDP"
|
||||
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
||||
PolicyId: common.String(ociConsolePolicyID),
|
||||
PatchOp: identitydomains.PatchOp{
|
||||
Schemas: []string{scimPatchSchema},
|
||||
Operations: []identitydomains.Operations{{
|
||||
Op: identitydomains.OperationsOpReplace,
|
||||
Path: common.String("rules"),
|
||||
Value: &value,
|
||||
}},
|
||||
// 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent
|
||||
Operations: []identitydomains.Operations{
|
||||
{Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value},
|
||||
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":consent"), Value: &consent},
|
||||
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":justification"), Value: &justification},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user