diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 8cf1ced..6a5492f 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -7201,6 +7201,10 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc } where := []string{"u.id<>?", "u.state=?"} args := []any{decodeUID(viewer), t.StateOK} + if !a.tenantScopedDB { + where = append([]string{"u.tenant_id=?"}, where...) + args = append([]any{id}, args...) + } if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { @@ -7213,6 +7217,12 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc "LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id", "LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id", } + if !a.tenantScopedDB { + joins = []string{ + "LEFT JOIN im_user_privacy_settings AS privacy ON privacy.tenant_id=u.tenant_id AND privacy.user_id=u.id", + "LEFT JOIN im_user_profiles AS profile ON profile.tenant_id=u.tenant_id AND profile.user_id=u.id", + } + } addMethod := "account" switch searchType { case "", "account": diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index 96e3e9f..2aa40b1 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -407,6 +407,69 @@ func TestTenantUserSearchMatchesUsernamePhoneAndPublicAccount(t *testing.T) { } } +func TestTenantUserSearchDoesNotLeakAcrossTenants(t *testing.T) { + businessAdp, ok := adp.(adapter.TenantBusinessAdapter) + if !ok { + t.Fatal("MySQL adapter does not implement TenantBusinessAdapter") + } + var tenantColumns int + if err := db.Get(&tenantColumns, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='tenant_id'`); err != nil { + t.Fatal(err) + } + if tenantColumns == 0 { + t.Skip("im_users.tenant_id is missing in the current test database") + } + var tenantID types.TenantID + if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil { + t.Fatal(err) + } + + now := types.TimeNow() + viewerID := now.UnixNano() & 0x3fffffffffffffff + targetID := viewerID + 1 + otherTenantID := tenantID + 1000 + otherTargetID := targetID + 1000 + viewer := store.EncodeUid(viewerID) + target := store.EncodeUid(targetID) + t.Cleanup(func() { + db.Exec("DELETE FROM im_user_privacy_settings WHERE user_id IN (?,?,?)", viewerID, targetID, otherTargetID) + db.Exec("DELETE FROM im_user_profiles WHERE user_id IN (?,?,?)", viewerID, targetID, otherTargetID) + db.Exec("DELETE FROM im_users WHERE id IN (?,?,?)", viewerID, targetID, otherTargetID) + }) + + for _, row := range []struct { + tenantID types.TenantID + userID int64 + username string + }{ + {tenantID, viewerID, "viewer-scope"}, + {tenantID, targetID, "shared-name"}, + {otherTenantID, otherTargetID, "shared-name"}, + } { + if _, err := db.Exec( + `INSERT INTO im_users(tenant_id,id,account_no,username,createdat,updatedat,state,access,phone,trusted) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + row.tenantID, row.userID, row.userID%1000000, row.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, nil, `{}`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec( + `INSERT INTO im_user_privacy_settings(tenant_id,id,user_id,add_me_methods,created_at,updated_at) + VALUES(?,?,?,?,?,?)`, + row.tenantID, row.userID+1000, row.userID, `{"account":true,"phone":true,"qr":true,"group":true}`, now, now); err != nil { + t.Fatal(err) + } + } + + users, _, err := businessAdp.TenantUserSearch(tenantID, viewer, "shared-name", "account", "", 20) + if err != nil { + t.Fatalf("tenant-scoped search failed: %v", err) + } + if len(users) != 1 || users[0].Uid() != target { + t.Fatalf("tenant-scoped search returned %+v, want %s only", users, target.UserId()) + } +} + // ================== Create tests ================================ func TestUserCreate(t *testing.T) { for _, user := range testData.Users {