From 16611a8ee0b459ee002d50efb5a90d9d06d92ff0 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 14 Oct 2026 18:34:52 +0800 Subject: [PATCH] fix(openai): isolate Codex imports by workspace --- .../handler/admin/account_codex_import.go | 12 ++- .../admin/account_codex_import_test.go | 80 ++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 0de8078669..8337488931 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -908,14 +908,16 @@ func buildCodexAgentIdentityKeys(accountID string) []string { return []string{"account:" + accountID} } -// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, -// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 +// buildCodexStoredIdentityKeys 生成存量账号索引键,按 workspace/account +// 和 user 组合隔离身份,避免同一用户在不同 workspace 的账号误合并。 func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) accessToken = strings.TrimSpace(accessToken) - if userID != "" { + if accountID != "" && userID != "" { + keys = append(keys, "account_user:"+codexCompositeIdentityKey(accountID, userID)) + } else if userID != "" { keys = append(keys, "user:"+userID) } if accountID == "" && userID == "" { @@ -932,6 +934,10 @@ func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) return keys } +func codexCompositeIdentityKey(accountID, userID string) string { + return fmt.Sprintf("%d:%s%d:%s", len(accountID), accountID, len(userID), userID) +} + func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { index := &codexAccountIndex{ accountsByKey: map[string][]service.Account{}, diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index dc2903ccbe..e26a112dad 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -303,8 +303,8 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh") - if len(keys) == 0 || keys[0] != "user:user-1" { - t.Fatalf("user key should have highest priority when refresh token exists: %v", keys) + if len(keys) == 0 || keys[0] != "account_user:6:acct-16:user-1" { + t.Fatalf("workspace-scoped user key should have highest priority when refresh token exists: %v", keys) } if keys[len(keys)-1] != "account:acct-1" { t.Fatalf("shared account key should be the last fallback: %v", keys) @@ -465,12 +465,84 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { if got == nil || got.ID != member.ID { t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) } - if matchedKey != "user:user-2" { - t.Fatalf("matched key = %q, want user:user-2", matchedKey) + if matchedKey != "account_user:6:team-16:user-2" { + t.Fatalf("matched key = %q, want account_user:6:team-16:user-2", matchedKey) } } } +func TestImportCodexSessionsSameUserDifferentWorkspacesCreatesSeparateAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "shared-user", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-2", "shared-user", "refresh-2")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } + if got := svc.createdAccounts[0].Credentials["chatgpt_account_id"]; got != "workspace-1" { + t.Fatalf("first workspace = %v, want workspace-1", got) + } + if got := svc.createdAccounts[1].Credentials["chatgpt_account_id"]; got != "workspace-2" { + t.Fatalf("second workspace = %v, want workspace-2", got) + } +} + +func TestCodexImportIdentityKeySeparatesDelimiterValues(t *testing.T) { + left := buildCodexImportIdentityKeys("ab:c", "shared-user", "", "", "refresh") + right := buildCodexImportIdentityKeys("ab", "c:shared-user", "", "", "refresh") + if left[0] == right[0] { + t.Fatalf("composite identity keys collided: %q", left[0]) + } +} + +func TestImportCodexSessionsSameWorkspaceAccountUpdatesAndDifferentUserIsolated(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + initialEntries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-2", "refresh-2")}, + } + + initialResult, err := handler.importCodexSessions(context.Background(), req, initialEntries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if initialResult.Created != 2 || initialResult.Updated != 0 || initialResult.Skipped != 0 || initialResult.Failed != 0 { + t.Fatalf("initial result = %+v, want two created accounts", initialResult) + } + + updateResult, err := handler.importCodexSessions(context.Background(), req, []codexImportEntry{ + {Index: 3, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-3")}, + }) + if err != nil { + t.Fatalf("importCodexSessions update error = %v", err) + } + if updateResult.Created != 0 || updateResult.Updated != 1 || updateResult.Skipped != 0 || updateResult.Failed != 0 { + t.Fatalf("update result = %+v, want one updated account", updateResult) + } + if len(svc.createdAccounts) != 2 || len(svc.updatedAccounts) != 1 { + t.Fatalf("created = %d, updated = %d, want 2 and 1", len(svc.createdAccounts), len(svc.updatedAccounts)) + } + if svc.updatedAccounts[0].id != 100 { + t.Fatalf("updated account ID = %d, want user-1 account ID 100", svc.updatedAccounts[0].id) + } + if got := svc.updatedAccounts[0].input.Credentials["chatgpt_user_id"]; got != "user-1" { + t.Fatalf("updated user = %v, want user-1", got) + } +} + func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { legacy := service.Account{ ID: 40,