Cherry-Pick: Do not fail if non-visible users are involved

If a change is cherry-picked, Gerrit automatically adds the change owner
and the reviewers of the cherry-picked change as reviewers on the
cherry-pick change. CCs of the cherry-picked change are automatically
added as CCs on the cherry-pick change.

Same as for revert (see change I8264d96f7) the visibility check for
these explicit reviewers/CCs should be skipped.

So far cherry-pick failed if any of the accounts that are added as
reviewers/CCs on the cherry-pick change are not visible to the caller.
Failing in this case is unnecessary since the user doing the cherry-pick
already knows about the existence of the reviewer/CC accounts (see
below) and hence we can just skip the account visibility check for them
during cherry-pick.

Cherry-picking a change is only possible if the calling user can see the
change that is being cherry-picked. If a user can see the change, they
can also see the change owner and all its reviewers/CCs regardless of
whether these accounts are visible. This means the user doing the
cherry-pick knows that Gerrit accounts exists for all users the are
either change owners, reviewer or CC on the cherry-picked change. This
means we can preserve them as reviewers/CCs on the cherry-pick change,
even if their accounts are not visible to the user doing the cherry-pick
(as it doesn't expose the existence of accounts that the user didn't
already know before).

In addition cherry-pick also implicitly CCs the author and committer if
they are forged. Here the situation is a bit different. It's possible
that there are no matching accounts for the author and committer, hence
from being able to see the author and committer information on the
cherry-picked change one cannot deduce that corresponding Gerrit
accounts exists. Hence we can only CC them on the cherry-pick change if
they are visible to the user doing the cherry-pick, as otherwise the
account existence would be revealed. If the author/committer accounts
are not visible we silently drop CCing them now so that the cherry-pick
can still succeed in this case. We do the same when pushing commits with
forged authors/committers so that doing local cherry-picks can also
succeed if the accounts of the forged authors/committers are not
visible.

Bug: Issue 16274
Bug: Google b/232285749
Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I1531d1f95b572b89998a82d503c3a3bb23f8712d
Release-Notes: skip
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 1177734..c5d319f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -32,6 +32,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -89,6 +90,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -98,6 +100,7 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -1100,6 +1103,42 @@
     gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
+  protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+    for (TestAccount testAccount : testAccounts) {
+      assertThrows(
+          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+    }
+  }
+
+  protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+      throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertNoCcs(String changeId) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
     assertSubmittedTogether(chId, ImmutableSet.of(), expected);
   }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 299256b..f3ad4f7 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -105,7 +105,10 @@
     FAIL,
 
     // Only not found failures cause the operation to fail, all other failures are ignored.
-    IGNORE_EXCEPT_NOT_FOUND;
+    IGNORE_EXCEPT_NOT_FOUND,
+
+    // All failures are ignored.
+    IGNORE_ALL;
   }
 
   private enum FailureType {
@@ -155,7 +158,7 @@
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
-    in.otherFailureBehavior = FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+    in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     return Optional.of(in);
   }
 
@@ -595,8 +598,9 @@
           (input instanceof InternalReviewerInput)
               ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
-      return failureType == FailureType.OTHER
-          && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+      return behavior == FailureBehavior.IGNORE_ALL
+          || (failureType == FailureType.OTHER
+              && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 00c48dc..d0113e5 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -527,7 +527,7 @@
       reviewers.remove(user.get().getAccountId());
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
-      ins.setReviewersAndCcs(reviewers, ccs);
+      ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     }
     // If there is a base, and the base is not merged, the groups will be overridden by the base's
     // groups.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index f3d3420..93ac038 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -60,7 +59,6 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -1489,36 +1487,6 @@
     return result;
   }
 
-  private void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
-    for (TestAccount testAccount : testAccounts) {
-      assertThrows(
-          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
-    }
-  }
-
-  private void assertReviewers(String changeId, TestAccount... expectedReviewers)
-      throws RestApiException {
-    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
-        gApi.changes().id(changeId).get().reviewers;
-    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
-    List<Integer> reviewers =
-        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
-    assertThat(reviewers)
-        .containsExactlyElementsIn(
-            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
-  }
-
-  private void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
-    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
-        gApi.changes().id(changeId).get().reviewers;
-    assertThat(reviewerMap).containsKey(ReviewerState.CC);
-    List<Integer> ccs =
-        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
-    assertThat(ccs)
-        .containsExactlyElementsIn(
-            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
-  }
-
   private void addPureRevertSubmitRule() throws Exception {
     modifySubmitRules(
         "submit_rule(submit(R)) :- \n"
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2c80333..8d64cf7 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1009,6 +1009,84 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void cherryPickWithNonVisibleUsers() throws Exception {
+    // Create a target branch for the cherry-pick.
+    createBranch(BranchNameKey.create(project, "stable"));
+
+    // Define readable names for the users we use in this test.
+    TestAccount cherryPicker = user;
+    TestAccount changeOwner = admin;
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+    TestAccount authorCommitter =
+        accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);
+
+    // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
+    // authorCommitter.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);
+
+    // Create the change with authorCommitter as the author and the committer.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that authorCommitter was set as the author and committer.
+    ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
+    CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(authorCommitter.email());
+    assertThat(commit.committer.email).isEqualTo(authorCommitter.email());
+
+    // Pushing a commit with a forged author/committer adds the author/committer as a CC.
+    assertCcs(r.getChangeId(), authorCommitter);
+
+    // Remove the author/committer as a CC because because otherwise there are two signals for CCing
+    // authorCommitter on the cherry-pick change: once because they are author and committer and
+    // once because they are a CC. For authorCommitter we only want to test the first signal here
+    // (the second signal is covered by adding an explicit CC below).
+    gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
+    assertNoCcs(r.getChangeId());
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Cherry-pick the change.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.message = "Cherry-pick to stable branch";
+    cherryPickInput.destination = "stable";
+    cherryPickInput.keepReviewers = true;
+    String cherryPickChangeId =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;
+
+    // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
+    // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
+    // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
+    // them even if their accounts are not visible to the cherry-picker.
+    // In contrast to this for implicit CCs that are added for the author/committer the account
+    // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
+    // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
+    // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
+    // as a CC here. The reason that the visibility for author/committer must be checked is that
+    // author/committer may not match a Gerrit account (if they are forged). This means by seeing
+    // the author/committer on the cherry-picked change, it's not possible to deduce that these
+    // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
+    // they are not visible the account existence would be exposed.
+    assertReviewers(cherryPickChangeId, changeOwner, reviewer);
+    assertCcs(cherryPickChangeId, cc);
+  }
+
+  @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
     createBranch(BranchNameKey.create(project, "foo"));
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cd1d911..a27218f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -128,6 +128,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -1154,7 +1155,7 @@
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
-    // Push commit as "Admnistrator".
+    // Push commit as "Administrator".
     pushHead(testRepo, "refs/for/master");
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
@@ -1168,6 +1169,53 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .author(author.newIdent())
+            .committer(committer.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
   public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
     TestAccount user2 = accountCreator.user2();
     // First patch set has author and committer matching change owner.
@@ -1192,6 +1240,55 @@
         .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushNewPatchSetForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // First patch set has author and committer matching uploader.
+    PushOneCommit push = pushFactory.create(uploader.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    testRepo
+        .amendRef("HEAD")
+        .author(author.newIdent())
+        .committer(committer.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+
+    PushResult r2 = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r2.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not