fix(openai): isolate Codex imports by workspace

This commit is contained in:
wucm667
2026-10-14 18:34:52 +08:00
parent 9c2cca9e62
commit 16611a8ee0
2 changed files with 85 additions and 7 deletions
@@ -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{},
@@ -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,