修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s

This commit is contained in:
2026-07-22 16:51:23 +08:00
parent 0614ef22af
commit f51fb6c722
66 changed files with 3997 additions and 687 deletions
+2 -2
View File
@@ -141,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
//
// @Summary 分页查询内容日志
// @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiCallLog]
// @Success 200 {object} pagedResponse[model.AiContentLog]
// @Security BearerAuth
// @Router /api/v1/ai-content-logs [get]
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
@@ -400,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
// @Summary AI 调用日志列表
// @Tags AI 管理
// @Success 200 {object} pagedResponse[model.AiContentLog]
// @Success 200 {object} pagedResponse[model.AiCallLog]
// @Security BearerAuth
// @Router /api/v1/ai-logs [get]
func (h *aiAdminHandler) listLogs(c *gin.Context) {
+15 -9
View File
@@ -65,7 +65,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
// @Summary 激活两步验证
// @Tags 认证
// @Param body body object true "{code: 6 位验证码}"
// @Success 204 "已启用"
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
// @Success 204 "已启用但新 token 签发失败,需重新登录"
// @Security BearerAuth
// @Router /api/v1/auth/totp/activate [post]
func (h *authxHandler) totpActivate(c *gin.Context) {
@@ -93,7 +94,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
// @Summary 停用两步验证
// @Tags 认证
// @Param body body object true "{password 或 code 任一确认}"
// @Success 204 "已停用"
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
// @Success 204 "已停用但新 token 签发失败,需重新登录"
// @Security BearerAuth
// @Router /api/v1/auth/totp/disable [post]
func (h *authxHandler) totpDisable(c *gin.Context) {
@@ -170,7 +172,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
// @Summary 修改登录凭据
// @Tags 认证
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
// @Success 204 "已更新,请重新登录"
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
// @Success 204 "已更新但新 token 签发失败,需重新登录"
// @Failure 401 {object} errorResponse "当前密码错误"
// @Security BearerAuth
// @Router /api/v1/auth/credentials [put]
@@ -202,7 +205,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
// @Summary 密码登录开关
// @Tags 认证
// @Param body body object true "{disabled: bool}"
// @Success 204 "已保存"
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
// @Success 204 "已保存但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "未绑定外部身份"
// @Security BearerAuth
// @Router /api/v1/auth/password-login [put]
@@ -375,7 +379,8 @@ func (h *authxHandler) identities(c *gin.Context) {
// @Summary 解绑外部身份
// @Tags 认证
// @Param id path int true "身份 ID"
// @Success 204 "已解绑"
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
// @Security BearerAuth
// @Router /api/v1/auth/identities/{id} [delete]
@@ -414,14 +419,15 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
c.JSON(http.StatusOK, view)
}
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
// secret 传空串清除、缺省沿用。
//
// @Summary 保存 OAuth provider 配置
// @Summary 部分更新 OAuth provider 配置
// @Tags 设置
// @Param body body service.UpdateOAuthInput true "provider 配置"
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
// @Success 200 {object} service.OAuthProvidersView
// @Security BearerAuth
// @Router /api/v1/settings/oauth [put]
// @Router /api/v1/settings/oauth [patch]
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
var req service.UpdateOAuthInput
if err := c.ShouldBindJSON(&req); err != nil {
+138 -3
View File
@@ -1,13 +1,121 @@
package api
import (
"errors"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
type idpIconResponse struct {
URL string `json:"url"`
FileName string `json:"fileName"`
}
type idpSetupWarning struct {
Code string `json:"code"`
Message string `json:"message"`
ResourceCreated bool `json:"resourceCreated"`
RequestID string `json:"requestId"`
}
type createIdpResponse struct {
oci.IdentityProviderInfo
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
}
// @Summary 上传身份提供商图标
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
// @Tags 租户 IAM
// @Accept multipart/form-data
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
// @Success 200 {object} idpIconResponse
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
// @Failure 413 {object} map[string]string "文件超过 1MB"
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
status, msg := iconFormStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
defer file.Close()
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
if err != nil {
status, msg := iconFormStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
if err != nil {
respondIdpIconErr(c, err)
return
}
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
}
// @Summary 删除身份提供商图标
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
// @Success 204 "无内容"
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
respondIdpIconErr(c, err)
return
}
c.Status(http.StatusNoContent)
}
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
func iconFormStatus(err error) (int, string) {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
}
return http.StatusBadRequest, "缺少文件字段 file"
}
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
func respondIdpIconErr(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrIdpIconTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
case errors.Is(err, service.ErrIdpIconBadType):
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
case errors.Is(err, service.ErrIdpIconEmpty):
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
case errors.Is(err, service.ErrIdpIconBadName):
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
default:
respondError(c, err)
}
}
// ---- FederationSAML IdP 与 sign-on 免 MFA ----
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
@@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
}
// @Summary 创建身份提供商(SAML)
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
// @Param body body createIdpRequest true "请求体"
// @Success 201 {object} oci.IdentityProviderInfo
// @Success 201 {object} createIdpResponse
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
@@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
JitMapEmail: req.JitMapEmail,
})
if err != nil {
respondCreateIdpResult(c, idp, err)
}
func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) {
if err == nil {
c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp})
return
}
var partial *oci.PartialIdentityProviderCreateError
if !errors.As(err, &partial) {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, idp)
requestID := logPartialIdpCreate(c, partial)
c.JSON(http.StatusCreated, createIdpResponse{
IdentityProviderInfo: partial.IdentityProvider,
SetupWarning: &idpSetupWarning{
Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查",
ResourceCreated: true, RequestID: requestID,
},
})
}
func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string {
requestID := newRequestID()
detail := errors.Unwrap(partial)
if detail == nil {
detail = partial
}
log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail)
return requestID
}
// @Summary 激活身份提供商
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
t.Helper()
router, auth, logs, db := newTestRouterDB(t)
id := seedIconConfig(t, db)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
if err != nil {
t.Fatalf("login: %v", err)
}
return router, token, id, logs
}
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
t.Helper()
cipher, err := crypto.NewCipher("test-key")
if err != nil {
t.Fatalf("new cipher: %v", err)
}
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
if err != nil {
t.Fatalf("encrypt key: %v", err)
}
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
if err := db.Create(&cfg).Error; err != nil {
t.Fatalf("create config: %v", err)
}
return cfg.ID
}
func iconSVG(t *testing.T, size int) []byte {
t.Helper()
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
if len(data) > size {
t.Fatalf("svg size %d exceeds target %d", len(data), size)
}
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
}
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatalf("create form file: %v", err)
}
_, _ = part.Write(data)
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
cases := []struct {
name, fileName string
data []byte
want int
}{
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
{"empty", "icon.png", nil, http.StatusBadRequest},
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
}
for _, tc := range cases {
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
if rec.Code != tc.want {
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
}
}
}
func TestDeleteIdpIconRoute(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
query := url.Values{"fileName": {fileName}}
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
if rec.Code != http.StatusNoContent {
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
}
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid delete status = %d, want 400", rec.Code)
}
}
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
responder(ctx, err)
if rec.Code != want {
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
}
}
func TestIdpIconErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"empty icon", service.ErrIdpIconEmpty, 400},
{"bad icon name", service.ErrIdpIconBadName, 400},
{"large icon", service.ErrIdpIconTooLarge, 413},
{"bad icon type", service.ErrIdpIconBadType, 415},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
}
}
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
}
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
}
}
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
partial := &oci.PartialIdentityProviderCreateError{
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
}
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
warning, ok := body["setupWarning"].(map[string]interface{})
if rec.Code != http.StatusCreated || !ok {
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
}
requestID, _ := warning["requestId"].(string)
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
t.Errorf("warning = %#v, want stable partial-create contract", warning)
}
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
}
}
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
t.Helper()
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
respondCreateIdpResult(ctx, idp, err)
body := map[string]interface{}{}
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
return rec, body
}
func TestPARErrorResponder(t *testing.T) {
cases := []struct {
name string
err error
}{
{"bad PAR type", service.ErrPARInvalidAccessType},
{"bad PAR expiry", service.ErrPARInvalidExpiration},
}
for _, tc := range cases {
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
}
}
func TestCreatePARValidationReturns400(t *testing.T) {
router, token, id, logs := newIconTestRouter(t)
t.Cleanup(logs.Wait)
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
cases := []struct {
name, body string
}{
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
}
for _, tc := range cases {
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
}
}
}
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"oci-portal/internal/oci"
"oci-portal/internal/service"
)
// ---- 对象存储 ----
@@ -363,6 +364,7 @@ type createPARRequest struct {
// @Param bucket path string true "桶名"
// @Param body body createPARRequest true "请求体"
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
func (h *ociConfigHandler) createPAR(c *gin.Context) {
@@ -379,12 +381,23 @@ func (h *ociConfigHandler) createPAR(c *gin.Context) {
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
})
if err != nil {
respondError(c, err)
respondPARError(c, err)
return
}
c.JSON(http.StatusCreated, par)
}
func respondPARError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrPARInvalidAccessType):
c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"})
case errors.Is(err, service.ErrPARInvalidExpiration):
c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"})
default:
respondError(c, err)
}
}
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
type parPage struct {
Items []oci.PAR `json:"items"`
@@ -424,7 +437,8 @@ func (h *ociConfigHandler) listPARs(c *gin.Context) {
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
// @Param all query string false "为 1 时删除全部"
// @Param region query string false "区域"
// @Success 204 "删除成功,链接立即失效"
// @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}"
// @Success 204 "按 parId 删单条成功,链接立即失效"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
+7
View File
@@ -34,6 +34,13 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
registerSettings(secured, settings, notifier, systemLogs, proxies)
registerTasksAndLogs(secured, tasks, logEvents)
registerOci(secured, ociConfigs)
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
// 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413
uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
registerOciUploads(uploads, ociConfigs)
// 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行
contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
registerOciContentUploads(contentUploads, ociConfigs)
registerAiAdmin(secured, aiGateway)
registerSwagger(r)
+8
View File
@@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
return oci.SubscriptionDetail{}, nil
}
func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
return "https://images.example/" + fileName, "images/generated/" + fileName, nil
}
func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error {
return nil
}
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
t.Helper()
r, auth, systemLogs, _ := newTestRouterDB(t)
+1 -1
View File
@@ -35,5 +35,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
}
+17 -1
View File
@@ -127,13 +127,21 @@ func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
// 小文件中转:预览/编辑直读直写,不签发 PAR
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
}
// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开
// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。
// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。
func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
h := &ociConfigHandler{svc: ociConfigs}
g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon)
g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon)
}
// registerOciCost 成本快照。
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
g.GET("/oci-configs/:id/costs", h.costs)
@@ -167,3 +175,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
}
// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限);
// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB
// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。
func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
h := &ociConfigHandler{svc: ociConfigs}
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
}
+1 -1
View File
@@ -26,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.GET("/settings/task", st.getTaskSettings)
secured.PUT("/settings/task", st.updateTaskSettings)
secured.GET("/settings/security", st.getSecurity)
secured.PUT("/settings/security", st.updateSecurity)
secured.PATCH("/settings/security", st.updateSecurity)
px := &proxyHandler{svc: proxies}
secured.GET("/proxies", px.list)
+6 -5
View File
@@ -316,16 +316,17 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
c.JSON(http.StatusOK, view)
}
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
// 越界或非法返回 400,保存后立即生效。
//
// @Summary 保存安全设置并返回最新值
// @Summary 部分更新安全设置并返回最新值
// @Tags 设置
// @Param body body service.SecuritySettings true "请求体"
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
// @Success 200 {object} service.SecuritySettings
// @Security BearerAuth
// @Router /api/v1/settings/security [put]
// @Router /api/v1/settings/security [patch]
func (h *settingsHandler) updateSecurity(c *gin.Context) {
var req service.SecuritySettings
var req service.SecurityPatch
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+16 -2
View File
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
// @Summary 更新任务
// @Tags 任务与日志回传
// @Param id path int true "任务 ID"
// @Param body body updateTaskRequest true "可更新字段"
// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
// @Success 200 {object} model.Task
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
// @Security BearerAuth
// @Router /api/v1/tasks/{id} [put]
func (h *taskHandler) update(c *gin.Context) {
@@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
Status: req.Status,
})
if err != nil {
respondError(c, err)
respondUpdateTaskErr(c, err)
return
}
c.JSON(http.StatusOK, task)
}
// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。
func respondUpdateTaskErr(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrTaskConflict):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrSnatchTargetTooLow):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
respondError(c, err)
}
}
// @Summary 删除任务
// @Tags 任务与日志回传
// @Param id path int true "任务 ID"
+6
View File
@@ -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
+157
View File
@@ -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()
}
+143
View File
@@ -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
View File
@@ -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)
+196
View File
@@ -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)
}
}
+15 -2
View File
@@ -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
}
+29
View File
@@ -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)
}
})
}
}
+2 -3
View File
@@ -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
View File
@@ -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
}
+53
View File
@@ -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"
)
+115
View File
@@ -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)
}
}
}
}
+64 -16
View File
@@ -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),
+47
View File
@@ -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
View File
@@ -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 {
+49 -6
View File
@@ -1048,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
if err != nil || !ok {
return err
}
on, err := contentLogStillOn(tx, entry.KeyID)
if err != nil || !on {
return err
}
return tx.Create(&entry).Error
})
if err != nil {
@@ -1055,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
}
}
// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入,
// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。
func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) {
var key model.AiKey
err := tx.First(&key, keyID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil
}
func truncateBody(s string) string {
if len(s) > aiContentBodyLimit {
return s[:aiContentBodyLimit]
@@ -1110,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
}
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
// cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限
// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。
var cleanupBatch = 10000
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环,
// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
cutoff := time.Now().Add(-retention)
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
@@ -1119,16 +1142,36 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
}
var total int64
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
log.Printf("%s cleanup count: %v", tag, err)
return
}
if overflow := int(total) - maxRows; overflow > 0 {
var ids []uint
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
if len(ids) > 0 {
s.db.WithContext(ctx).Delete(m, ids)
for overflow := int(total) - maxRows; overflow > 0; {
n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch))
if err != nil {
log.Printf("%s cleanup overflow: %v", tag, err)
return
}
if n == 0 {
return
}
overflow -= n
}
}
// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。
func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) {
var ids []uint
if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
return 0, fmt.Errorf("pluck oldest: %w", err)
}
if len(ids) == 0 {
return 0, nil
}
if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil {
return 0, fmt.Errorf("delete batch: %w", err)
}
return len(ids), nil
}
// Wait 等待后台清理 goroutine 退出。
func (s *AiGatewayService) Wait() { s.wg.Wait() }
+70
View File
@@ -11,6 +11,8 @@ import (
"oci-portal/internal/aiwire"
"oci-portal/internal/model"
"oci-portal/internal/oci"
"gorm.io/gorm"
)
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
}
}
// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为:
// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。
func TestCleanupTableBatchesOverflow(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
old := cleanupBatch
cleanupBatch = 3
t.Cleanup(func() { cleanupBatch = old })
for i := 0; i < 10; i++ {
if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// maxRows=2:溢出 8 行,需 3 轮批次(3+3+2)
gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test")
var rest []model.AiCallLog
if err := gw.db.Order("id").Find(&rest).Error; err != nil {
t.Fatalf("load rest: %v", err)
}
if len(rest) != 2 {
t.Fatalf("remaining = %d, want 2", len(rest))
}
for _, row := range rest {
if row.ID <= 8 {
t.Errorf("row %d survived, want oldest deleted first", row.ID)
}
}
}
// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥,
// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。
func TestLogContentRechecksKeyInsideTx(t *testing.T) {
gw, _ := newTestGateway(t, &fakeClient{})
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
t.Fatalf("migrate content log: %v", err)
}
future := time.Now().Add(time.Hour)
key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future}
call := model.AiCallLog{Endpoint: "chat"}
if err := gw.db.Create(&key).Error; err != nil {
t.Fatalf("seed key: %v", err)
}
if err := gw.db.Create(&call).Error; err != nil {
t.Fatalf("seed call: %v", err)
}
countIs := func(want int64, note string) {
t.Helper()
var n int64
if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want {
t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want)
}
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"})
countIs(1, "窗口开启时应写入")
// 管理员立即关闭窗口:在途请求携带的旧快照不得再写
if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID).
Update("content_log_until", gorm.Expr("NULL")).Error; err != nil {
t.Fatalf("close window: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"})
countIs(1, "窗口关闭后不得写入")
// 密钥删除后同样拒写
if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil {
t.Fatalf("delete key: %v", err)
}
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"})
countIs(1, "密钥已删后不得写入")
}
+35 -14
View File
@@ -8,6 +8,7 @@ import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"oci-portal/internal/model"
)
@@ -119,33 +120,53 @@ func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
}
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
// 检查与写入在同一事务内并锁定用户行,防与解绑身份并发绕过「至少一种登录方式」。
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
if s.settings == nil {
return errors.New("settings unavailable")
}
value := ""
if disabled {
user, err := s.findUser(ctx, username)
if err != nil {
return err
}
n, err := s.identityCount(ctx, user.ID)
if err != nil {
return err
}
if n == 0 {
return ErrNeedIdentity
}
value = "1"
}
if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil {
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil {
return err
}
if disabled {
n, err := identityCountTx(tx, user.ID)
if err != nil {
return err
}
if n == 0 {
return ErrNeedIdentity
}
}
return saveSettingTx(tx, settingSecPasswordLoginOff, value)
})
if err != nil {
return err
}
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
return s.bumpTokenVersion(ctx, username)
}
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。
func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
var user model.User
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user, "username = ?", username).Error
if err != nil {
return nil, fmt.Errorf("find user %s: %w", username, err)
}
return &user, nil
}
func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.UserIdentity{}).
err := tx.Model(&model.UserIdentity{}).
Where("user_id = ?", userID).Count(&count).Error
if err != nil {
return 0, fmt.Errorf("count identities: %w", err)
+172 -1
View File
@@ -2,12 +2,36 @@ package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"path"
"strings"
"unicode"
"oci-portal/internal/oci"
)
// idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。
const (
idpIconMaxBytes = 1 << 20
idpIconRandomBytes = 16
)
var idpIconExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true,
}
// 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。
var (
ErrIdpIconEmpty = errors.New("icon file is empty")
ErrIdpIconTooLarge = errors.New("icon exceeds 1MB")
ErrIdpIconBadType = errors.New("icon file type not allowed")
ErrIdpIconBadName = errors.New("icon file name is invalid")
)
// IdentityProviders 列出域内 SAML 身份提供者。
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
@@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
if err != nil {
return oci.IdentityProviderInfo{}, err
}
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in)
}
func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) {
if !in.JitEnabled {
return in, nil
}
setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID)
if err != nil {
return in, fmt.Errorf("get identity setting before create idp: %w", err)
}
if setting.PrimaryEmailRequired {
in.JitMapEmail = true
}
return in, nil
}
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
@@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
}
// UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。
func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) {
return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader)
}
func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) {
if len(data) == 0 {
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty)
}
if len(data) > idpIconMaxBytes {
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge)
}
fileName, err := validateIdpIcon(fileName, data)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
fileName, err = newIdpIconStorageName(fileName, random)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return "", "", err
}
url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data)
if err != nil {
return "", "", fmt.Errorf("upload idp icon: %w", err)
}
return url, storedName, nil
}
func newIdpIconStorageName(fileName string, random io.Reader) (string, error) {
token := make([]byte, idpIconRandomBytes)
if _, err := io.ReadFull(random, token); err != nil {
return "", fmt.Errorf("generate storage name: %w", err)
}
ext := strings.ToLower(path.Ext(fileName))
return "idp-icon-" + hex.EncodeToString(token) + ext, nil
}
// DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。
func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error {
if err := validateDomainImageFileName(fileName); err != nil {
return fmt.Errorf("delete idp icon: %w", err)
}
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
if err != nil {
return err
}
if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil {
return fmt.Errorf("delete idp icon: %w", err)
}
return nil
}
func validateIdpIcon(input string, data []byte) (string, error) {
fileName := path.Base(strings.ReplaceAll(input, "\\", "/"))
if invalidIconName(fileName, 255) {
return "", ErrIdpIconBadName
}
ext := strings.ToLower(path.Ext(fileName))
if !idpIconExts[ext] || !iconMagicOK(ext, data) {
return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext)
}
return fileName, nil
}
func validateDomainImageFileName(fileName string) error {
if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") {
return ErrIdpIconBadName
}
clean := path.Clean(fileName)
if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) {
return ErrIdpIconBadName
}
return nil
}
func validIdpIconStorageBase(fileName string) bool {
ext := path.Ext(fileName)
if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] {
return false
}
stem := strings.TrimSuffix(fileName, ext)
if !strings.HasPrefix(stem, "idp-icon-") {
return false
}
token := strings.TrimPrefix(stem, "idp-icon-")
if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) {
return false
}
_, err := hex.DecodeString(token)
return err == nil
}
func invalidIconName(fileName string, maxBytes int) bool {
return fileName == "" || fileName == "." || fileName == ".." ||
len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName ||
strings.IndexFunc(fileName, unicode.IsControl) >= 0
}
// iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码:
// 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会
// 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。
func iconMagicOK(ext string, data []byte) bool {
has := func(prefix string) bool {
return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix
}
switch ext {
case ".png":
return has("\x89PNG\r\n\x1a\n")
case ".jpg", ".jpeg":
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
case ".gif":
return has("GIF87a") || has("GIF89a")
case ".webp":
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP"
case ".ico":
return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0
case ".svg":
head := data
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(strings.ToLower(string(head)), "<svg")
}
return false
}
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
+227
View File
@@ -0,0 +1,227 @@
package service
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"oci-portal/internal/oci"
)
type idpIconStubClient struct {
*fakeClient
uploadedNames []string
}
type idpCreateStubClient struct {
*fakeClient
setting oci.IdentitySettingInfo
settingErr error
settingCalls int
createCalls int
createdInput oci.CreateIdpInput
}
func (c *idpIconStubClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
c.uploadedNames = append(c.uploadedNames, fileName)
return "https://images.example/" + fileName, "images/" + fileName, nil
}
func (c *idpCreateStubClient) GetIdentitySetting(_ context.Context, _ oci.Credentials, _, _ string) (oci.IdentitySettingInfo, error) {
c.settingCalls++
return c.setting, c.settingErr
}
func (c *idpCreateStubClient) CreateSamlIdentityProvider(_ context.Context, _ oci.Credentials, _, _ string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
c.createCalls++
c.createdInput = in
return oci.IdentityProviderInfo{Name: in.Name}, nil
}
type failingIconRandom struct{}
func (failingIconRandom) Read([]byte) (int, error) {
return 0, errors.New("entropy unavailable")
}
func newIdpIconTestService(t *testing.T) (*OciConfigService, *idpIconStubClient, uint) {
t.Helper()
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
client := &idpIconStubClient{fakeClient: base}
service := newTestService(t, client)
return service, client, importAliveConfig(t, service).ID
}
func newIdpCreateTestService(t *testing.T) (*OciConfigService, *idpCreateStubClient, uint) {
t.Helper()
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
client := &idpCreateStubClient{fakeClient: base}
service := newTestService(t, client)
return service, client, importAliveConfig(t, service).ID
}
func testCreateIdpInput(jitEnabled bool) oci.CreateIdpInput {
return oci.CreateIdpInput{
Name: "test-idp", Metadata: "<EntityDescriptor/>", JitEnabled: jitEnabled,
}
}
// iconFixture 生成带正确文件头魔数的最小样本;精简校验只核对魔数,不做深度解码。
func iconFixture(ext string) []byte {
switch ext {
case ".png":
return append([]byte("\x89PNG\r\n\x1a\n"), 0, 0, 0, 0)
case ".jpg", ".jpeg":
return []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0}
case ".gif":
return []byte("GIF89a\x00\x00")
case ".webp":
return []byte("RIFF\x00\x00\x00\x00WEBPVP8 ")
case ".ico":
return []byte{0, 0, 1, 0, 1, 0}
case ".svg":
return []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
}
return nil
}
func TestValidateIdpIconAcceptsMatchingContent(t *testing.T) {
for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg"} {
t.Run(ext, func(t *testing.T) {
fileName := "icon" + ext
got, err := validateIdpIcon(fileName, iconFixture(ext))
if err != nil || got != fileName {
t.Errorf("validateIdpIcon = %q, %v; want %q, nil", got, err, fileName)
}
})
}
}
func TestCreateIdentityProviderForcesEmailMapping(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.setting.PrimaryEmailRequired = true
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
if err != nil {
t.Fatalf("CreateIdentityProvider: %v", err)
}
if client.settingCalls != 1 || client.createCalls != 1 {
t.Fatalf("calls = setting:%d create:%d; want 1, 1", client.settingCalls, client.createCalls)
}
if !client.createdInput.JitMapEmail {
t.Error("JitMapEmail = false, want true for primary-email-required domain")
}
}
func TestCreateIdentityProviderStopsWhenSettingFails(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.settingErr = errors.New("setting unavailable")
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
if err == nil || !strings.Contains(err.Error(), "get identity setting") {
t.Fatalf("CreateIdentityProvider error = %v, want setting failure", err)
}
if client.settingCalls != 1 || client.createCalls != 0 {
t.Errorf("calls = setting:%d create:%d; want 1, 0", client.settingCalls, client.createCalls)
}
}
func TestCreateIdentityProviderSkipsSettingWithoutJIT(t *testing.T) {
service, client, configID := newIdpCreateTestService(t)
client.settingErr = errors.New("must not be called")
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(false))
if err != nil {
t.Fatalf("CreateIdentityProvider: %v", err)
}
if client.settingCalls != 0 || client.createCalls != 1 {
t.Errorf("calls = setting:%d create:%d; want 0, 1", client.settingCalls, client.createCalls)
}
}
func TestUploadIdpIconUsesUniqueServerNames(t *testing.T) {
service, client, configID := newIdpIconTestService(t)
data := iconFixture(".png")
randomData := append(bytes.Repeat([]byte{0x11}, idpIconRandomBytes), bytes.Repeat([]byte{0x22}, idpIconRandomBytes)...)
random := bytes.NewReader(randomData)
storedNames := make([]string, 2)
for index := range storedNames {
_, storedName, err := service.uploadIdpIcon(context.Background(), configID, "domain", "Logo.PNG", data, random)
if err != nil {
t.Fatalf("upload %d: %v", index, err)
}
storedNames[index] = storedName
}
wants := []string{"idp-icon-" + strings.Repeat("11", idpIconRandomBytes) + ".png", "idp-icon-" + strings.Repeat("22", idpIconRandomBytes) + ".png"}
for index, want := range wants {
if client.uploadedNames[index] != want || storedNames[index] != "images/"+want {
t.Errorf("upload %d = %q, %q; want %q, %q", index, client.uploadedNames[index], storedNames[index], want, "images/"+want)
}
}
}
func TestUploadIdpIconRandomFailureSkipsUpstream(t *testing.T) {
service, client, configID := newIdpIconTestService(t)
_, _, err := service.uploadIdpIcon(context.Background(), configID, "domain", "logo.png", iconFixture(".png"), failingIconRandom{})
if err == nil || !strings.Contains(err.Error(), "generate storage name") {
t.Fatalf("UploadIdpIcon error = %v, want random source failure", err)
}
if len(client.uploadedNames) != 0 {
t.Errorf("upstream upload calls = %d, want 0", len(client.uploadedNames))
}
}
type iconRejectCase struct {
name, fileName string
data []byte
want error
}
func assertIconRejected(t *testing.T, cases []iconRejectCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateIdpIcon(tc.fileName, tc.data)
if !errors.Is(err, tc.want) {
t.Errorf("err = %v, want errors.Is(%v)", err, tc.want)
}
})
}
}
func TestValidateIdpIconRejectsSpoofedContent(t *testing.T) {
assertIconRejected(t, []iconRejectCase{
{"extension mismatch", "icon.jpg", iconFixture(".png"), ErrIdpIconBadType},
{"random bytes as png", "icon.png", []byte("not an image"), ErrIdpIconBadType},
{"unsupported extension", "icon.bmp", iconFixture(".png"), ErrIdpIconBadType},
})
}
func TestValidateIdpIconRejectsBadNames(t *testing.T) {
assertIconRejected(t, []iconRejectCase{
{"control name", "bad\r\n.png", iconFixture(".png"), ErrIdpIconBadName},
{"long name", strings.Repeat("a", 252) + ".png", iconFixture(".png"), ErrIdpIconBadName},
})
}
func TestValidateDomainImageFileName(t *testing.T) {
token := strings.Repeat("ab", idpIconRandomBytes)
cases := []struct {
name, fileName string
valid bool
}{
{"generated image", "images/idp-icon-" + token + ".png", true},
{"empty", "", false}, {"wrong prefix", "icons/a.png", false},
{"traversal", "images/a/../secret", false}, {"backslash", `images\a.png`, false},
{"control", "images/a\x00.png", false}, {"directory", "images/", false},
{"unrelated domain image", "images/company-brand.png", false},
{"nested image", "images/generated/idp-icon-" + token + ".png", false},
{"uppercase token", "images/idp-icon-" + strings.ToUpper(token) + ".png", false},
{"wrong token length", "images/idp-icon-ab.png", false},
}
for _, tc := range cases {
err := validateDomainImageFileName(tc.fileName)
if (err == nil) != tc.valid {
t.Errorf("%s: err = %v, valid = %v", tc.name, err, tc.valid)
}
}
}
+2
View File
@@ -286,6 +286,8 @@ func TestParseLogEvent(t *testing.T) {
wantType: "x",
},
{
// message 中的 ociportal-logs-sch 是历史 Policy 命名 fixture,反映升级前存量租户的审计事件形态;
// 新版命名已按 tenancy 派生,见 internal/oci/logrelay_names.go。
name: "Audit v2 提取操作者成败与策略描述",
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
+39 -18
View File
@@ -329,27 +329,48 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
}
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
user, err := o.auth.findUser(ctx, username)
if err != nil {
return err
}
n, err := o.auth.identityCount(ctx, user.ID)
if err != nil {
return err
}
if n <= 1 {
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
return ErrLastIdentity
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username)
if err != nil {
return err
}
}
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
if res.Error != nil {
return fmt.Errorf("unbind identity: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
if err := ensureNotLastLogin(tx, user.ID); err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
if res.Error != nil {
return fmt.Errorf("unbind identity: %w", res.Error)
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
return err
}
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
return o.auth.bumpTokenVersion(ctx, username)
}
// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑;
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
func ensureNotLastLogin(tx *gorm.DB, userID uint) error {
n, err := identityCountTx(tx, userID)
if err != nil {
return err
}
if n > 1 {
return nil
}
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
if err != nil {
return err
}
if off == "1" {
return ErrLastIdentity
}
return nil
}
+54 -21
View File
@@ -32,18 +32,19 @@ type OAuthProvidersView struct {
GithubDisabled bool `json:"githubDisabled"`
}
// UpdateOAuthInput 是保存 provider 配置的输入;
// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。
// UpdateOAuthInput 是 provider 配置的部分更新:所有字段 nil=沿用已存值,
// 只落库出现的字段;并发编辑不同 provider 因此互不回滚(2026-07-22 审查 #18)。
// secret 非 nil 覆盖(空串清除),绝不回读。
type UpdateOAuthInput struct {
OidcIssuer string `json:"oidcIssuer"`
OidcClientID string `json:"oidcClientId"`
OidcIssuer *string `json:"oidcIssuer"`
OidcClientID *string `json:"oidcClientId"`
OidcClientSecret *string `json:"oidcClientSecret"`
OidcDisplayName string `json:"oidcDisplayName"`
OidcDisabled bool `json:"oidcDisabled"`
GithubClientID string `json:"githubClientId"`
OidcDisplayName *string `json:"oidcDisplayName"`
OidcDisabled *bool `json:"oidcDisabled"`
GithubClientID *string `json:"githubClientId"`
GithubClientSecret *string `json:"githubClientSecret"`
GithubDisplayName string `json:"githubDisplayName"`
GithubDisabled bool `json:"githubDisabled"`
GithubDisplayName *string `json:"githubDisplayName"`
GithubDisabled *bool `json:"githubDisabled"`
}
// OAuthView 返回脱敏后的 provider 配置。
@@ -77,19 +78,51 @@ func boolFlag(on bool) string {
return ""
}
// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
plain := map[string]string{
settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"),
settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID),
settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName),
settingOauthOidcDisabled: boolFlag(in.OidcDisabled),
settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID),
settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName),
settingOauthGithubDisabled: boolFlag(in.GithubDisabled),
// trimPtr / issuerPtr / flagPtr 把补丁字段规范化为存储值;nil 表示未出现不写
func trimPtr(p *string) *string {
if p == nil {
return nil
}
for key, value := range plain {
if err := s.set(ctx, key, value); err != nil {
v := strings.TrimSpace(*p)
return &v
}
func issuerPtr(p *string) *string {
if p == nil {
return nil
}
v := strings.TrimRight(strings.TrimSpace(*p), "/")
return &v
}
func flagPtr(p *bool) *string {
if p == nil {
return nil
}
v := boolFlag(*p)
return &v
}
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;
// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
writes := []struct {
key string
val *string
}{
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
{settingOauthOidcClientID, trimPtr(in.OidcClientID)},
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
{settingOauthGithubClientID, trimPtr(in.GithubClientID)},
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
}
for _, w := range writes {
if w.val == nil {
continue
}
if err := s.set(ctx, w.key, *w.val); err != nil {
return err
}
}
+71 -10
View File
@@ -66,8 +66,9 @@ func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, na
return s.client.UpdateBucket(ctx, cred, region, name, in)
}
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除,
// 返回 queued=true 表示已排队。
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR +
// 未完成分片)后删除,返回 queued=true 表示已排队。同一桶的清空任务全局单飞,
// 重复删除请求不再并发起新任务;执行权在触发方同步抢占,避免双触发窗口。
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
cred, err := s.credentialsByID(ctx, id)
if err != nil {
@@ -80,16 +81,43 @@ func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, na
if !errors.Is(err, oci.ErrBucketNotEmpty) {
return false, err
}
go s.purgeAndDeleteBucket(cred, region, name)
key := fmt.Sprintf("%d/%s/%s", id, region, name)
if !s.beginPurge(key) {
return true, nil // 已有清空任务在途,视为已排队
}
s.bindWG.Add(1)
go func() {
defer s.bindWG.Done()
defer s.endPurge(key)
s.purgeAndDeleteBucket(cred, region, name)
}()
return true, nil
}
// beginPurge / endPurge 维护桶清空在途注册表(键 cfgID/region/bucket)。
func (s *OciConfigService) beginPurge(key string) bool {
s.purgeMu.Lock()
defer s.purgeMu.Unlock()
if s.purgeInFlight[key] {
return false
}
s.purgeInFlight[key] = true
return true
}
func (s *OciConfigService) endPurge(key string) {
s.purgeMu.Lock()
defer s.purgeMu.Unlock()
delete(s.purgeInFlight, key)
}
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
const purgeBucketTimeout = 30 * time.Minute
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
// 顺序:对象版本 → PAR → 未完成分片 → 删桶;分片不清会让空桶仍以非空拒绝删除。
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
ctx, cancel := s.purgeContext()
defer cancel()
start := time.Now()
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
@@ -99,6 +127,9 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
}
if err := s.client.AbortAllMultipartUploads(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s abort multipart uploads failed: %v", name, err)
}
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
return
@@ -106,6 +137,21 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
}
// purgeContext 组装清空任务的 ctx:总超时之外叠加关服信号,
// 避免 Stop 等待在途清空长达整个超时窗口。
func (s *OciConfigService) purgeContext() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
done := make(chan struct{})
go func() {
select {
case <-s.bindStop:
cancel()
case <-done:
}
}()
return ctx, func() { close(done); cancel() }
}
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
@@ -238,19 +284,34 @@ func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region
// parAccessTypes 是允许签发的 PAR 访问类型。
var parAccessTypes = map[string]bool{
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true,
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true,
"AnyObjectRead": true, "AnyObjectWrite": true, "AnyObjectReadWrite": true,
}
// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天
// PAR 输入校验错误供 API 层稳定映射 400
var (
ErrPARInvalidAccessType = errors.New("invalid PAR access type")
ErrPARInvalidExpiration = errors.New("invalid PAR expiration")
)
// maxParHours 是 PAR 有效期安全上限(100 年):time.Duration 按小时乘会在
// ≈292 年(2562047h)溢出回绕为负,收在远低于溢出点又远超实际需求的位置。
const maxParHours = 100 * 365 * 24
// CreatePAR 签发预签名请求;过期时长 1 小时到 100 年(放开旧 30 天上限)。
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
if !parAccessTypes[in.AccessType] {
return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType)
return oci.PAR{}, fmt.Errorf("create par: %w: unsupported access type %q", ErrPARInvalidAccessType, in.AccessType)
}
if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 {
return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720")
if in.ExpiresHours < 1 || in.ExpiresHours > maxParHours {
return oci.PAR{}, fmt.Errorf("create par: %w: expiresHours must be within 1-%d", ErrPARInvalidExpiration, maxParHours)
}
if strings.TrimSpace(in.Name) == "" {
in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-")
base := in.ObjectName
if base == "" {
base = "bucket-" + bucket
}
in.Name = "par-" + strings.ReplaceAll(base, "/", "-")
}
cred, err := s.credentialsByID(ctx, id)
if err != nil {
+143 -7
View File
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"oci-portal/internal/model"
"oci-portal/internal/oci"
)
@@ -27,10 +28,19 @@ type osStubClient struct {
putIfMatch string
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
mu sync.Mutex
versions []oci.ObjectVersion
pars []oci.PAR
bucketDeleted bool
mu sync.Mutex
versions []oci.ObjectVersion
pars []oci.PAR
bucketDeleted bool
multipartAborted bool
listCalls int
}
func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.multipartAborted = true
return nil
}
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
@@ -62,6 +72,7 @@ func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, r
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.listCalls++
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
}
@@ -170,10 +181,20 @@ func TestObjectStorageValidation(t *testing.T) {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
return err
}, wantErr: "unsupported access type"},
{name: "PAR 时长越界", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
{name: "PAR 时长过短", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0})
return err
}, wantErr: "1-720"},
}, wantErr: "1-876000"},
// 旧 30 天上限已放开:10 年应放行
{name: "PAR 超长有效期放行", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650})
return err
}},
// 防 time.Duration 溢出的安全上限(100 年)仍要拦截
{name: "PAR 超安全上限拒绝", run: func() error {
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1})
return err
}, wantErr: "1-876000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -403,3 +424,118 @@ func TestCreatePARDefaultsName(t *testing.T) {
t.Errorf("FullURL empty, want populated")
}
}
// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。
func TestCreatePARAccessTypes(t *testing.T) {
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
cases := []struct {
accessType string
wantErr bool
}{
{"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false},
{"BucketRead", true}, {"", true},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24})
if (err != nil) != tc.wantErr {
t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr)
}
}
if client.createdPAR.Name != "par-bucket-b" {
t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name)
}
}
func TestCreatePARValidationSentinels(t *testing.T) {
svc := &OciConfigService{}
cases := []struct {
name string
in oci.CreatePARInput
want error
}{
{"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType},
{"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration},
{"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration},
}
for _, tc := range cases {
_, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in)
if !errors.Is(err, tc.want) {
t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want)
}
}
}
// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求
// 按已排队返回,不并发起第二个清空任务。
func TestDeleteBucketPurgeSingleflight(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
key := fmt.Sprintf("%d/r/b", cfg.ID)
if !svc.beginPurge(key) {
t.Fatal("beginPurge 首次抢占失败")
}
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
if err != nil || !queued {
t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err)
}
time.Sleep(50 * time.Millisecond)
client.mu.Lock()
calls := client.listCalls
client.mu.Unlock()
if calls != 0 {
t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls)
}
svc.endPurge(key)
if !svc.beginPurge(key) {
t.Error("endPurge 后应可重新抢占")
}
svc.endPurge(key)
}
// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。
func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) {
client := &osStubClient{
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
}
svc := newTestService(t, client)
cfg := importAliveConfig(t, svc)
if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); err != nil || !queued {
t.Fatalf("DeleteBucket = queued %v, %v, want queued", queued, err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
client.mu.Lock()
done := client.bucketDeleted && client.multipartAborted
client.mu.Unlock()
if done {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("purge 未在期限内完成分片中止与删桶")
}
// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝,
// 不落库也不发起绕过代理的直连测活。
func TestImportRejectsMissingProxy(t *testing.T) {
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
in := trialImportInput()
missing := uint(999)
in.ProxyID = &missing
if _, err := svc.Import(context.Background(), in); err == nil {
t.Fatal("Import 关联不存在的代理应报错")
}
var n int64
if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err)
}
}
+17 -7
View File
@@ -24,9 +24,12 @@ type OciConfigService struct {
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
auditRaw *cache.Cache
// bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。
// bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。
bindWG sync.WaitGroup
bindStop chan struct{}
// purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。
purgeMu sync.Mutex
purgeInFlight map[string]bool
}
// NewOciConfigService 组装依赖。
@@ -34,6 +37,7 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
return &OciConfigService{
db: db, cipher: cipher, client: client,
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
purgeInFlight: map[string]bool{},
}
}
@@ -71,6 +75,12 @@ func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.O
if err != nil {
return nil, err
}
// 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入,
// 避免首次测活绕过代理直连,泄露真实出口
cred.Proxy, err = s.proxySpecOf(in.ProxyID)
if err != nil {
return nil, err
}
cfg, err := s.storeConfig(in.Alias, cred)
if err != nil {
return nil, err
@@ -427,7 +437,7 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
}
}
spec, err := s.proxySpecOf(cfg)
spec, err := s.proxySpecOf(cfg.ProxyID)
if err != nil {
return oci.Credentials{}, err
}
@@ -442,15 +452,15 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
}, nil
}
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。
// proxySpecOf 加载关联的出站代理;未关联返回 nil(直连)。
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) {
if cfg.ProxyID == nil {
func (s *OciConfigService) proxySpecOf(proxyID *uint) (*oci.ProxySpec, error) {
if proxyID == nil {
return nil, nil
}
var row model.Proxy
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil {
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err)
if err := s.db.First(&row, *proxyID).Error; err != nil {
return nil, fmt.Errorf("find proxy %d: %w", *proxyID, err)
}
password := ""
if row.PasswordEnc != "" {
+47 -13
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"sort"
"time"
"oci-portal/internal/model"
@@ -32,14 +33,23 @@ type OverviewCostDay struct {
Amount float64 `json:"amount"`
}
// OverviewCost 是近 7 日成本快照聚合金额跨配置直接相加,
// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。
// OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。
type OverviewCost struct {
HasActiveTask bool `json:"hasActiveTask"`
CoveredConfigs int `json:"coveredConfigs"`
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
// Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个
// (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。
Series []OverviewCostSeries `json:"series"`
}
// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。
type OverviewCostSeries struct {
Currency string `json:"currency"`
Total float64 `json:"total"`
Days []OverviewCostDay `json:"days"`
}
// OverviewTasks 是后台任务数量统计。
@@ -150,29 +160,53 @@ func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) erro
return nil
}
// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序
// aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)
// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的
// 主币种,其余币种只出现在 Series 里。
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
byDay := map[string]float64{}
perCur := map[string]map[string]float64{}
dayOrder := map[string][]string{}
covered := map[uint]bool{}
var order []string
for _, snap := range snaps {
byDay := perCur[snap.Currency]
if byDay == nil {
byDay = map[string]float64{}
perCur[snap.Currency] = byDay
}
if _, ok := byDay[snap.Day]; !ok {
order = append(order, snap.Day)
dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day)
}
byDay[snap.Day] += snap.Amount
covered[snap.OciConfigID] = true
if cost.Currency == "" {
cost.Currency = snap.Currency
}
}
cost.CoveredConfigs = len(covered)
cost.Days = make([]OverviewCostDay, 0, len(order))
for _, day := range order {
cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
cost.Total += byDay[day]
cost.Days = []OverviewCostDay{}
cost.Series = costSeries(perCur, dayOrder)
if len(cost.Series) > 0 {
cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days
}
}
// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。
func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries {
series := make([]OverviewCostSeries, 0, len(perCur))
for currency, byDay := range perCur {
item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))}
for _, day := range dayOrder[currency] {
item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
item.Total += byDay[day]
}
series = append(series, item)
}
sort.Slice(series, func(i, j int) bool {
if series[i].Total != series[j].Total {
return series[i].Total > series[j].Total
}
return series[i].Currency < series[j].Currency
})
return series
}
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
var tasks []model.Task
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
+23
View File
@@ -170,3 +170,26 @@ func TestOverviewAggregates(t *testing.T) {
t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
}
}
// TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加,
// 顶层字段恒为合计最大的主币种,其余币种进 Series。
func TestAggregateCostDaysSplitsCurrencies(t *testing.T) {
snaps := []model.CostSnapshot{
{OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"},
{OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"},
{OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"},
{OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"},
}
var cost OverviewCost
aggregateCostDays(&cost, snaps)
if cost.CoveredConfigs != 3 || len(cost.Series) != 2 {
t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series))
}
if cost.Currency != "EUR" || cost.Total != 9.0 {
t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total)
}
usd := cost.Series[1]
if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 {
t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd)
}
}
+24
View File
@@ -324,3 +324,27 @@ func TestProxySetTenantsRebind(t *testing.T) {
}
}
}
// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃,
// 不得经 Save 回退 Create 复活已删代理。
func TestPersistGeoSkipsDeletedProxy(t *testing.T) {
svc, db := newProxyEnv(t)
ctx := context.Background()
row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed proxy: %v", err)
}
svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"})
var fresh model.Proxy
if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil {
t.Fatalf("正常落库 = %+v, %v", fresh, err)
}
if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil {
t.Fatalf("delete proxy: %v", err)
}
svc.persistGeo(ctx, row.ID, geoResult{Country: "US"})
var n int64
if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err)
}
}
+12 -6
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
@@ -108,13 +109,18 @@ func (s *ProxyService) probeGeo(ctx context.Context, id uint) {
break
}
}
var row model.Proxy
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
return
}
s.persistGeo(ctx, id, res)
}
// persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会
// 回退 Create 把删掉的代理复活;错误也不能静默吞掉。
func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) {
now := time.Now()
row.Country, row.City, row.GeoAt = res.Country, res.City, &now
s.db.WithContext(ctx).Save(&row)
tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id).
Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now})
if tx.Error != nil {
log.Printf("proxy geo persist %d: %v", id, tx.Error)
}
}
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
+53 -13
View File
@@ -112,26 +112,66 @@ func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error)
return out, nil
}
// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。
func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error {
if err := validateSecurity(&in); err != nil {
// SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段,
// 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。
type SecurityPatch struct {
LoginFailLimit *int `json:"loginFailLimit"`
LoginLockMinutes *int `json:"loginLockMinutes"`
IPRateRPS *int `json:"ipRateRps"`
IPRateBurst *int `json:"ipRateBurst"`
RealIPHeader *string `json:"realIpHeader"`
AppURL *string `json:"appUrl"`
}
// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。
func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
touched := map[string]bool{}
fields := []struct {
key string
set func()
on bool
}{
{settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil},
{settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil},
{settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil},
{settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil},
{settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil},
{settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil},
}
for _, f := range fields {
if f.on {
f.set()
touched[f.key] = true
}
}
return touched
}
// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。
func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
cur, err := s.Security(ctx)
if err != nil {
return err
}
touched := p.merge(&cur)
if err := validateSecurity(&cur); err != nil {
return err
}
kv := map[string]string{
settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit),
settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes),
settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS),
settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst),
settingSecRealIPHeader: in.RealIPHeader,
settingSecAppURL: in.AppURL,
settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst),
settingSecRealIPHeader: cur.RealIPHeader,
settingSecAppURL: cur.AppURL,
}
for key, value := range kv {
if err := s.set(ctx, key, value); err != nil {
for key := range touched {
if err := s.set(ctx, key, kv[key]); err != nil {
return err
}
}
s.security.Store(in)
return nil
// 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值
return s.ReloadSecurity(ctx)
}
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
+73 -8
View File
@@ -6,6 +6,18 @@ import (
"testing"
)
// patchOf 把完整设置转为全字段补丁,等价旧全量保存,供既有用例复用。
func patchOf(in SecuritySettings) SecurityPatch {
return SecurityPatch{
LoginFailLimit: &in.LoginFailLimit,
LoginLockMinutes: &in.LoginLockMinutes,
IPRateRPS: &in.IPRateRPS,
IPRateBurst: &in.IPRateBurst,
RealIPHeader: &in.RealIPHeader,
AppURL: &in.AppURL,
}
}
func TestSecurityReadWrite(t *testing.T) {
tests := []struct {
name string
@@ -21,7 +33,7 @@ func TestSecurityReadWrite(t *testing.T) {
name: "写入后读回",
setup: func(t *testing.T, svc *SettingService) {
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
},
@@ -76,7 +88,7 @@ func TestUpdateSecurityRejectsInvalid(t *testing.T) {
svc, _ := newSettingEnv(t)
in := base
tt.mutate(&in)
if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) {
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); !errors.Is(err, ErrInvalidSecurity) {
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
}
})
@@ -89,9 +101,7 @@ func TestSecurityCachedSnapshot(t *testing.T) {
if got := svc.SecurityCached(); got != securityDefaults {
t.Errorf("cold cache = %+v, want defaults", got)
}
in := securityDefaults
in.LoginFailLimit = 8
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{LoginFailLimit: intPtr(8)}); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
// Update 即刷新快照
@@ -110,12 +120,67 @@ func TestEffectiveAppURL(t *testing.T) {
if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
t.Errorf("env fallback = %q", got)
}
in := securityDefaults
in.AppURL = "https://app.example.com"
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
t.Errorf("app_url 应优先, got %q", got)
}
}
// TestUpdateSecurityPartialPatch 锁定 PATCH 语义:只写出现字段,其余不回滚。
func TestUpdateSecurityPartialPatch(t *testing.T) {
seeded := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://a.example.com"}
tests := []struct {
name string
patch SecurityPatch
want SecuritySettings
wantErr bool
}{
{
name: "单字段更新不动其余",
patch: SecurityPatch{LoginFailLimit: intPtr(3)},
want: func() SecuritySettings {
w := seeded
w.LoginFailLimit = 3
return w
}(),
},
{name: "空补丁不改任何值", patch: SecurityPatch{}, want: seeded},
{
name: "补丁字段规范化(AppURL 去尾斜杠)",
patch: SecurityPatch{AppURL: strPtr("https://b.example.com/")},
want: func() SecuritySettings {
w := seeded
w.AppURL = "https://b.example.com"
return w
}(),
},
{name: "补丁字段越界拒绝", patch: SecurityPatch{IPRateRPS: intPtr(0)}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _ := newSettingEnv(t)
if err := svc.UpdateSecurity(context.Background(), patchOf(seeded)); err != nil {
t.Fatalf("seed: %v", err)
}
err := svc.UpdateSecurity(context.Background(), tt.patch)
if tt.wantErr {
if !errors.Is(err, ErrInvalidSecurity) {
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
}
return
}
if err != nil {
t.Fatalf("UpdateSecurity: %v", err)
}
got, err := svc.Security(context.Background())
if err != nil || got != tt.want {
t.Errorf("settings = %+v (%v), want %+v", got, err, tt.want)
}
if cached := svc.SecurityCached(); cached != tt.want {
t.Errorf("cached = %+v, want %+v", cached, tt.want)
}
})
}
}
+22 -1
View File
@@ -81,8 +81,29 @@ func (s *SettingService) get(ctx context.Context, key string) (string, error) {
// set 写入键值(key 为主键,Save 即 upsert)。
func (s *SettingService) set(ctx context.Context, key, value string) error {
if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil {
return err
}
return nil
}
// settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种
// 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。
func settingValueTx(tx *gorm.DB, key string) (string, error) {
var st model.Setting
err := tx.First(&st, "key = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get setting %s: %w", key, err)
}
return st.Value, nil
}
func saveSettingTx(tx *gorm.DB, key, value string) error {
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
if err := tx.Save(&st).Error; err != nil {
return fmt.Errorf("set setting %s: %w", key, err)
}
return nil
+4
View File
@@ -41,6 +41,10 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
func strPtr(s string) *string { return &s }
func intPtr(n int) *int { return &n }
func boolPtr(b bool) *bool { return &b }
func TestUpdateTelegramTokenHandling(t *testing.T) {
tests := []struct {
name string
+74 -8
View File
@@ -202,6 +202,38 @@ func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMe
return out
}
// mergeSnatchPayload 把编辑提交的 count 视为**新目标台数**:按旧 payload 的
// 已完成数换算剩余、保留连续鉴权失败计数,目标不大于已完成数时拒绝。
// 非抢机任务原样返回;旧 payload 不可解析时按新建任务归一化。
func mergeSnatchPayload(task *model.Task, incoming json.RawMessage) (json.RawMessage, error) {
if task.Type != model.TaskTypeSnatch {
return incoming, nil
}
var old, next snatchPayload
if err := json.Unmarshal([]byte(task.Payload), &old); err != nil {
return normalizeSnatchPayload(task.Type, incoming), nil
}
if err := json.Unmarshal(incoming, &next); err != nil {
return nil, fmt.Errorf("update task: invalid payload: %w", err)
}
oldTotal := old.TotalCount
if oldTotal <= 0 {
oldTotal = old.Count
}
done := oldTotal - old.Count
if next.Count <= done {
return nil, fmt.Errorf("%w(已完成 %d 台)", ErrSnatchTargetTooLow, done)
}
next.TotalCount = next.Count
next.Count -= done
next.AuthFailCount = old.AuthFailCount
out, err := json.Marshal(next)
if err != nil {
return nil, fmt.Errorf("update task: marshal payload: %w", err)
}
return out, nil
}
// validateTaskPayload 按任务类型校验参数 JSON。
func validateTaskPayload(taskType string, payload json.RawMessage) error {
switch taskType {
@@ -245,7 +277,8 @@ type UpdateTaskInput struct {
Status *string
}
// UpdateTask 修改任务并重新调度
// UpdateTask 修改任务并重新调度;写入用 updated_at 条件更新,
// 防止陈旧快照整行覆盖并发执行刚落库的状态与进度。
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
task, err := s.GetTask(ctx, id)
if err != nil {
@@ -254,16 +287,39 @@ func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInpu
if err := applyTaskUpdate(task, in); err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Save(task).Error; err != nil {
return nil, fmt.Errorf("update task %d: %w", id, err)
fresh, err := s.persistTaskUpdate(ctx, task)
if err != nil {
return nil, err
}
s.unschedule(task.ID)
if task.Status == model.TaskStatusActive {
if err := s.schedule(task); err != nil {
s.unschedule(fresh.ID)
if fresh.Status == model.TaskStatusActive {
if err := s.schedule(fresh); err != nil {
return nil, err
}
}
return task, nil
return fresh, nil
}
// persistTaskUpdate 只写用户可编辑列;零命中说明执行侧已并发落库,返回冲突。
func (s *TaskService) persistTaskUpdate(ctx context.Context, task *model.Task) (*model.Task, error) {
res := s.db.WithContext(ctx).Model(&model.Task{}).
Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).
Updates(map[string]any{
"name": task.Name, "cron_expr": task.CronExpr,
"payload": task.Payload, "status": task.Status,
})
if res.Error != nil {
return nil, fmt.Errorf("update task %d: %w", task.ID, res.Error)
}
if res.RowsAffected == 0 {
return nil, ErrTaskConflict
}
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
var fresh model.Task
if err := s.db.WithContext(ctx).First(&fresh, task.ID).Error; err != nil {
return nil, fmt.Errorf("reload task %d: %w", task.ID, err)
}
return &fresh, nil
}
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
@@ -280,7 +336,11 @@ func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
if err := validateTaskPayload(task.Type, in.Payload); err != nil {
return err
}
task.Payload = string(in.Payload)
merged, err := mergeSnatchPayload(task, in.Payload)
if err != nil {
return err
}
task.Payload = string(merged)
}
if in.Status != nil {
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
@@ -376,6 +436,12 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog,
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
// ErrTaskConflict 表示任务在编辑期间被并发修改(如执行结果落库),须刷新重试。
var ErrTaskConflict = errors.New("任务状态已变化,请刷新后重试")
// ErrSnatchTargetTooLow 表示编辑抢机任务时新目标台数不大于已完成数量。
var ErrSnatchTargetTooLow = errors.New("目标台数须大于已完成数量")
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
+91
View File
@@ -8,6 +8,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
@@ -835,3 +836,93 @@ func TestTriggerTaskAsyncAndDedup(t *testing.T) {
t.Error("不存在的任务应报错")
}
}
// snatchInstanceJSON 是编辑测试用的合法实例参数片段。
const snatchInstanceJSON = `"instance":{"displayName":"vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}`
// TestUpdateSnatchTaskCountIsTarget 锁定编辑语义:提交的 count 是目标台数,
// 按旧 payload 已完成数换算剩余、保留鉴权失败计数;目标不大于已完成数拒绝。
func TestUpdateSnatchTaskCountIsTarget(t *testing.T) {
tests := []struct {
name string
oldPayload string
newCount int
wantErr error
want snatchPayload
}{
{"部分成功后提高目标", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
6, nil, snatchPayload{Count: 4, TotalCount: 6, AuthFailCount: 2}},
{"原样保存不丢进度", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
5, nil, snatchPayload{Count: 3, TotalCount: 5, AuthFailCount: 2}},
{"目标不大于已完成拒绝", `{"ociConfigId":1,"count":3,"totalCount":5,` + snatchInstanceJSON + `}`,
2, ErrSnatchTargetTooLow, snatchPayload{}},
{"旧任务缺 totalCount 视为零完成", `{"ociConfigId":1,"count":3,` + snatchInstanceJSON + `}`,
5, nil, snatchPayload{Count: 5, TotalCount: 5}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tasks, _, db := newTaskEnv(t, &fakeClient{})
ctx := context.Background()
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
Payload: json.RawMessage(tt.oldPayload),
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
// 直写旧 payload,绕过创建期归一化,构造「执行过若干轮」的现场
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).
Update("payload", tt.oldPayload).Error; err != nil {
t.Fatalf("seed payload: %v", err)
}
incoming := fmt.Sprintf(`{"ociConfigId":1,"count":%d,`+snatchInstanceJSON+`}`, tt.newCount)
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Payload: json.RawMessage(incoming)})
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("UpdateTask: %v", err)
}
var got snatchPayload
if err := json.Unmarshal([]byte(updated.Payload), &got); err != nil {
t.Fatalf("payload: %v", err)
}
if got.Count != tt.want.Count || got.TotalCount != tt.want.TotalCount || got.AuthFailCount != tt.want.AuthFailCount {
t.Errorf("payload = {count:%d total:%d authFail:%d}, want {count:%d total:%d authFail:%d}",
got.Count, got.TotalCount, got.AuthFailCount, tt.want.Count, tt.want.TotalCount, tt.want.AuthFailCount)
}
})
}
}
// TestPersistTaskUpdateConflict 锁定编辑竞态防护:陈旧快照零命中返回冲突且不落库。
func TestPersistTaskUpdateConflict(t *testing.T) {
tasks, _, db := newTaskEnv(t, &fakeClient{})
ctx := context.Background()
task, err := tasks.CreateTask(ctx, CreateTaskInput{
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *",
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
stale, err := tasks.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
// 模拟执行侧并发落库:行的 updated_at 前移,编辑快照过期
time.Sleep(5 * time.Millisecond)
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("run_count", 1).Error; err != nil {
t.Fatalf("simulate run persist: %v", err)
}
stale.Name = "renamed"
if _, err := tasks.persistTaskUpdate(ctx, stale); !errors.Is(err, ErrTaskConflict) {
t.Fatalf("err = %v, want ErrTaskConflict", err)
}
fresh, _ := tasks.GetTask(ctx, task.ID)
if fresh.Name != "测活" || fresh.RunCount != 1 {
t.Errorf("row = {name:%s runCount:%d}, want 执行结果保留且未被改名", fresh.Name, fresh.RunCount)
}
}
+11 -15
View File
@@ -231,11 +231,10 @@ func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
if len(channelIDs) == 0 {
return nil
}
callIDs, err := lockedAiCallIDs(tx, channelIDs)
if err != nil {
if err := lockAiCallRows(tx, channelIDs); err != nil {
return fmt.Errorf("load tenant AI calls: %w", err)
}
if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil {
if err := deleteTenantAIRows(tx, channelIDs); err != nil {
return err
}
result.channelsGone = true
@@ -253,22 +252,19 @@ func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) {
return ids, err
}
func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) {
// lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略,
// MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL,
// 内容日志删除用子查询,避免绑定变量上限。
func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error {
var rows []model.AiCallLog
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
return ids, err
}
func deleteTenantAIRows(tx *gorm.DB, channelIDs, callIDs []uint) error {
if len(callIDs) > 0 {
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil {
return fmt.Errorf("delete tenant AI content logs: %w", err)
}
func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error {
callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs)
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil {
return fmt.Errorf("delete tenant AI content logs: %w", err)
}
steps := []struct {
name string
+73 -5
View File
@@ -224,7 +224,7 @@ func TestOAuthProvidersListsConfigured(t *testing.T) {
t.Fatalf("未配置时 providers = %v", got)
}
secret := "gh-secret"
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret})
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret})
if err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
@@ -249,7 +249,7 @@ func TestOAuthAuthorizeConfigErrors(t *testing.T) {
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
}
// clientID 已配但面板地址未设置
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil {
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) {
@@ -273,7 +273,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("Providers = %v, want empty", got)
}
// 配置 github(无显示名称)→ 默认名 GitHub
in := UpdateOAuthInput{GithubClientID: "Iv1.test"}
in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
@@ -282,7 +282,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("Providers = %+v, want [github/GitHub]", got)
}
// 自定义显示名称
in.GithubDisplayName = "公司账号"
in.GithubDisplayName = strPtr("公司账号")
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
@@ -290,7 +290,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
}
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
in.GithubDisabled = true
in.GithubDisabled = boolPtr(true)
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
@@ -309,3 +309,71 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
t.Fatalf("OAuthView = %+v, %v", view, err)
}
}
// TestUpdateOAuthPartialPatch 锁定 provider 配置的部分更新语义:
// 只写出现字段,另一 provider 与未出现字段不回滚,secret nil 沿用空串清除。
func TestUpdateOAuthPartialPatch(t *testing.T) {
o, _ := newOAuthEnv(t)
ctx := context.Background()
secret := "gh-secret"
seed := UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret, GithubDisplayName: strPtr("公司账号")}
if err := o.settings.UpdateOAuth(ctx, seed); err != nil {
t.Fatalf("seed: %v", err)
}
steps := []struct {
name string
patch UpdateOAuthInput
check func(v OAuthProvidersView) string
}{
{
name: "只更新 oidc 不动 github",
patch: UpdateOAuthInput{OidcIssuer: strPtr("https://sso.example.com/"), OidcClientID: strPtr("oidc-cid")},
check: func(v OAuthProvidersView) string {
if v.OidcIssuer != "https://sso.example.com" || v.OidcClientID != "oidc-cid" {
return "oidc 字段未生效或未规范化"
}
if v.GithubClientID != "cid" || !v.GithubSecretSet || v.GithubDisplayName != "公司账号" {
return "github 字段被回滚"
}
return ""
},
},
{
name: "单开关启停,secret nil 沿用",
patch: UpdateOAuthInput{GithubDisabled: boolPtr(true)},
check: func(v OAuthProvidersView) string {
if !v.GithubDisabled || v.GithubClientID != "cid" || !v.GithubSecretSet {
return "启停外字段被动到或 secret 丢失"
}
return ""
},
},
{
name: "secret 空串清除",
patch: UpdateOAuthInput{GithubClientSecret: strPtr("")},
check: func(v OAuthProvidersView) string {
if v.GithubSecretSet {
return "空串未清除 secret"
}
if v.GithubClientID != "cid" {
return "clientID 被动到"
}
return ""
},
},
}
for _, st := range steps {
t.Run(st.name, func(t *testing.T) {
if err := o.settings.UpdateOAuth(ctx, st.patch); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
view, err := o.settings.OAuthView(ctx)
if err != nil {
t.Fatalf("OAuthView: %v", err)
}
if msg := st.check(view); msg != "" {
t.Error(msg)
}
})
}
}