Merge branch 'stable-3.1' into stable-3.2

* stable-3.1:
  Fix header of "Pitfalls" subsection of Private Changes documentation
  AccountIT#accountIdApi: Get account API with id rather than name
  Fix header of "Pitfalls" subsection of Private Changes documentation
  Set version to 2.15.20-SNAPSHOT
  Set version to 2.15.19
  Close open SSH connections upon account deactivation
  Allow to listen for account deactivations
  Extract method to iterate SSH sessions
  CacheBasedWebSession: Remove unnecessary 'final' in constructor args
  Add account listener integration tests using a real plugin
  Bazel: Add always pass test to avoid boilerplate in the CI
  Deny access over HTTP for disabled accounts
  Bazel: Consistently use bazelisk during publishing of artifacts

Change-Id: I2d23a6fdcf6b44a9f55f3c01f5090aa8f0a343d4
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8ab3d62..c8c2dff 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -391,6 +391,10 @@
 same link:cmd-stream-events.html#events[events] that are also streamed
 by the link:cmd-stream-events.html[gerrit stream-events] command.
 
+* `com.google.gerrit.extensions.events.AccountActivationListener`:
++
+User account got activated or deactivated
+
 * `com.google.gerrit.extensions.events.LifecycleListener`:
 +
 Plugin start and stop
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 75ad9c2..2323df3 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -615,8 +615,7 @@
 exposing secret details.
 
 [[private-changes-pitfalls]]
-Pitfalls
-===
+=== Pitfalls
 
 If private changes are used, be aware of the following pitfalls:
 
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 5376d23..cfe7964 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -73,6 +74,7 @@
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
       accountActivationValidationListeners;
+  private final DynamicSet<AccountActivationListener> accountActivationListeners;
   private final DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
@@ -101,6 +103,7 @@
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
+      DynamicSet<AccountActivationListener> accountActivationListeners,
       DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
@@ -126,6 +129,7 @@
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
+    this.accountActivationListeners = accountActivationListeners;
     this.onSubmitValidationListeners = onSubmitValidationListeners;
     this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
     this.capabilityDefinitions = capabilityDefinitions;
@@ -229,6 +233,10 @@
       return add(accountActivationValidationListeners, accountActivationValidationListener);
     }
 
+    public Registration add(AccountActivationListener accountDeactivatedListener) {
+      return add(accountActivationListeners, accountDeactivatedListener);
+    }
+
     public Registration add(OnSubmitValidationListener onSubmitValidationListener) {
       return add(onSubmitValidationListeners, onSubmitValidationListener);
     }
diff --git a/java/com/google/gerrit/extensions/events/AccountActivationListener.java b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
new file mode 100644
index 0000000..b45533b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Notified whenever an account got activated or deactivated.
+ *
+ * <p>This listener is called only after an account got (de)activated and hence cannot cancel the
+ * (de)activation. See {@link
+ * com.google.gerrit.server.validators.AccountActivationValidationListener} for a listener that can
+ * cancel a (de)activation.
+ */
+@ExtensionPoint
+public interface AccountActivationListener {
+  /**
+   * Invoked after an account got activated
+   *
+   * @param id of the account
+   */
+  default void onAccountActivated(int id) {}
+
+  /**
+   * Invoked after an account got deactivated
+   *
+   * @param id of the account
+   */
+  default void onAccountDeactivated(int id) {}
+}
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7f878aa..5c4830c 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -27,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
@@ -40,7 +42,7 @@
 
 @RequestScoped
 public abstract class CacheBasedWebSession implements WebSession {
-  private static final String ACCOUNT_COOKIE = "GerritAccount";
+  @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
   protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
@@ -50,6 +52,7 @@
   private final Provider<AnonymousUser> anonymousProvider;
   private final IdentifiedUser.RequestFactory identified;
   private final EnumSet<AccessPath> okPaths = EnumSet.of(AccessPath.UNKNOWN);
+  private final AccountCache byIdCache;
   private Cookie outCookie;
 
   private Key key;
@@ -62,13 +65,15 @@
       WebSessionManager manager,
       AuthConfig authConfig,
       Provider<AnonymousUser> anonymousProvider,
-      IdentifiedUser.RequestFactory identified) {
+      IdentifiedUser.RequestFactory identified,
+      AccountCache byIdCache) {
     this.request = request;
     this.response = response;
     this.manager = manager;
     this.authConfig = authConfig;
     this.anonymousProvider = anonymousProvider;
     this.identified = identified;
+    this.byIdCache = byIdCache;
 
     if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
       String cookie = readCookie(request);
@@ -85,6 +90,10 @@
           authFromQueryParameter(token);
         }
       }
+      if (val != null && !checkAccountStatus(val.getAccountId())) {
+        val = null;
+        okPaths.clear();
+      }
       if (val != null && val.needsCookieRefresh()) {
         // Session is more than half old; update cache entry with new expiration date.
         val = manager.createVal(key, val);
@@ -177,6 +186,11 @@
       manager.destroy(key);
     }
 
+    if (!checkAccountStatus(id)) {
+      val = null;
+      return;
+    }
+
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
@@ -207,6 +221,10 @@
     return val != null ? val.getSessionId() : null;
   }
 
+  private boolean checkAccountStatus(Account.Id id) {
+    return byIdCache.get(id).filter(as -> as.account().isActive()).isPresent();
+  }
+
   private void saveCookie() {
     if (response == null) {
       return;
diff --git a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
index caced27..830d8d6 100644
--- a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser.RequestFactory;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
@@ -59,8 +60,15 @@
       @Named(WebSessionManager.CACHE_NAME) Cache<String, Val> cache,
       AuthConfig authConfig,
       Provider<AnonymousUser> anonymousProvider,
-      RequestFactory identified) {
+      RequestFactory identified,
+      AccountCache byIdCache) {
     super(
-        request, response, managerFactory.create(cache), authConfig, anonymousProvider, identified);
+        request,
+        response,
+        managerFactory.create(cache),
+        authConfig,
+        anonymousProvider,
+        identified,
+        byIdCache);
   }
 }
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 32ed694..4b68198 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -38,13 +39,16 @@
   private final PluginSetContext<AccountActivationValidationListener>
       accountActivationValidationListeners;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final PluginSetContext<AccountActivationListener> accountActivationListeners;
 
   @Inject
   SetInactiveFlag(
       PluginSetContext<AccountActivationValidationListener> accountActivationValidationListeners,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      PluginSetContext<AccountActivationListener> accountActivationListeners) {
     this.accountActivationValidationListeners = accountActivationValidationListeners;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.accountActivationListeners = accountActivationListeners;
   }
 
   public Response<?> deactivate(Account.Id accountId)
@@ -77,6 +81,12 @@
     if (alreadyInactive.get()) {
       throw new ResourceConflictException("account not active");
     }
+
+    // At this point the account got set inactive and no errors occurred
+
+    int id = accountId.get();
+    accountActivationListeners.runEach(l -> l.onAccountDeactivated(id));
+
     return Response.none();
   }
 
@@ -107,6 +117,16 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyActive.get() ? Response.ok() : Response.created();
+
+    Response<String> res;
+    if (alreadyActive.get()) {
+      res = Response.ok();
+    } else {
+      res = Response.created();
+
+      int id = accountId.get();
+      accountActivationListeners.runEach(l -> l.onAccountActivated(id));
+    }
+    return res;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index e447f2b..cf592bf 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
@@ -343,6 +344,7 @@
     DynamicSet.setOf(binder(), PostReceiveHook.class);
     DynamicSet.setOf(binder(), PreUploadHook.class);
     DynamicSet.setOf(binder(), PostUploadHook.class);
+    DynamicSet.setOf(binder(), AccountActivationListener.class);
     DynamicSet.setOf(binder(), AccountIndexedListener.class);
     DynamicSet.setOf(binder(), ChangeIndexedListener.class);
     DynamicSet.setOf(binder(), GroupIndexedListener.class);
diff --git a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
index bc52308..9fdc9e6 100644
--- a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
+++ b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
@@ -26,6 +26,9 @@
   /**
    * Called when an account should be activated to allow validation of the account activation.
    *
+   * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+   * that's run after the account got activated.
+   *
    * @param account the account that should be activated
    * @throws ValidationException if validation fails
    */
@@ -34,6 +37,9 @@
   /**
    * Called when an account should be deactivated to allow validation of the account deactivation.
    *
+   * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+   * that's run after the account got deactivated.
+   *
    * @param account the account that should be deactivated
    * @throws ValidationException if validation fails
    */
diff --git a/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
new file mode 100644
index 0000000..1086626
--- /dev/null
+++ b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.sshd.BaseCommand.Failure;
+import com.google.inject.Inject;
+import java.io.IOException;
+
+/** Closes open SSH connections upon account deactivation. */
+public class InactiveAccountDisconnector implements AccountActivationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SshDaemon sshDaemon;
+
+  @Inject
+  InactiveAccountDisconnector(SshDaemon sshDaemon) {
+    this.sshDaemon = sshDaemon;
+  }
+
+  @Override
+  public void onAccountDeactivated(int id) {
+    try {
+      SshUtil.forEachSshSession(
+          sshDaemon,
+          (sshId, sshSession, abstractSession, ioSession) -> {
+            CurrentUser sessionUser = sshSession.getUser();
+            if (sessionUser.isIdentifiedUser() && sessionUser.getAccountId().get() == id) {
+              logger.atInfo().log(
+                  "Disconnecting SSH session %s because user %s(%d) got deactivated",
+                  abstractSession, sessionUser.getLoggableName(), id);
+              try {
+                abstractSession.disconnect(-1, "user deactivated");
+              } catch (IOException e) {
+                logger.atWarning().withCause(e).log(
+                    "Failure while deactivating session %s", abstractSession);
+              }
+            }
+          });
+    } catch (Failure e) {
+      // Ssh Daemon no longer running. Since we're only disconnecting connections anyways, this is
+      // most likely ok, so we log only at info level.
+      logger.atInfo().withCause(e).log("Failure while disconnecting deactivated account %d", id);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index e4aa14c..9301f8a 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -103,6 +104,9 @@
     DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
     DynamicSet.setOf(binder(), SshExecuteCommandInterceptor.class);
 
+    DynamicSet.bind(binder(), AccountActivationListener.class)
+        .to(InactiveAccountDisconnector.class);
+
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index eac9737..abbd81d 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.sshd.BaseCommand.Failure;
 import com.google.gerrit.sshd.SshScope.Context;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -30,7 +31,10 @@
 import java.security.interfaces.RSAPublicKey;
 import java.security.spec.InvalidKeySpecException;
 import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.session.ServerSession;
 
@@ -152,4 +156,30 @@
       final Account.Id account) {
     return userFactory.create(sd.getRemoteAddress(), account);
   }
+
+  public static void forEachSshSession(SshDaemon sshDaemon, SessionConsumer consumer)
+      throws Failure {
+    IoAcceptor ioAcceptor = sshDaemon.getIoAcceptor();
+    if (ioAcceptor == null) {
+      throw new Failure(1, "fatal: sshd no longer running");
+    }
+    ioAcceptor
+        .getManagedSessions()
+        .forEach(
+            (id, ioSession) -> {
+              AbstractSession abstractSession = AbstractSession.getSession(ioSession, true);
+              if (abstractSession != null) {
+                SshSession sshSession = abstractSession.getAttribute(SshSession.KEY);
+                if (sshSession != null) {
+                  consumer.accept(id, sshSession, abstractSession, ioSession);
+                }
+              }
+            });
+  }
+
+  @FunctionalInterface
+  public interface SessionConsumer {
+    public void accept(
+        Long id, SshSession sshSession, AbstractSession abstractSession, IoSession ioSession);
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 60a878a..093f647 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -23,15 +23,12 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
-import com.google.gerrit.sshd.SshSession;
+import com.google.gerrit.sshd.SshUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -60,36 +57,26 @@
 
   @Override
   protected void run() throws Failure {
-    IoAcceptor acceptor = sshDaemon.getIoAcceptor();
-    if (acceptor == null) {
-      throw new Failure(1, "fatal: sshd no longer running");
-    }
-    for (String sessionId : sessionIds) {
-      boolean connectionFound = false;
-      int id = (int) Long.parseLong(sessionId, 16);
-      for (IoSession io : acceptor.getManagedSessions().values()) {
-        AbstractSession serverSession = AbstractSession.getSession(io, true);
-        SshSession sshSession =
-            serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
-        if (sshSession != null && sshSession.getSessionId() == id) {
-          connectionFound = true;
-          stdout.println("closing connection " + sessionId + "...");
-          CloseFuture future = io.close(true);
-          if (wait) {
-            try {
-              future.await();
-              stdout.println("closed connection " + sessionId);
-            } catch (IOException e) {
-              logger.atWarning().log(
-                  "Wait for connection to close interrupted: %s", e.getMessage());
+    SshUtil.forEachSshSession(
+        sshDaemon,
+        (k, sshSession, abstractSession, ioSession) -> {
+          String sessionId = String.format("%08x", sshSession.getSessionId());
+          if (sessionIds.remove(sessionId)) {
+            stdout.println("closing connection " + sessionId + "...");
+            CloseFuture future = ioSession.close(true);
+            if (wait) {
+              try {
+                future.await();
+                stdout.println("closed connection " + sessionId);
+              } catch (IOException e) {
+                logger.atWarning().log(
+                    "Wait for connection to close interrupted: %s", e.getMessage());
+              }
             }
           }
-          break;
-        }
-      }
-      if (!connectionFound) {
-        stderr.print("close connection " + sessionId + ": no such connection\n");
-      }
+        });
+    for (String sessionId : sessionIds) {
+      stderr.print("close connection " + sessionId + ": no such connection\n");
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index d5dd241..f9ba8a2 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -45,6 +45,10 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
@@ -84,6 +88,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
@@ -104,6 +109,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -115,6 +121,7 @@
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
@@ -159,6 +166,14 @@
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -183,6 +198,7 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 public class AccountIT extends AbstractDaemonTest {
@@ -221,6 +237,9 @@
 
   @Inject protected GroupOperations groupOperations;
 
+  private BasicCookieStore httpCookieStore;
+  private CloseableHttpClient httpclient;
+
   @After
   public void clearPublicKeyStore() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -247,6 +266,16 @@
     }
   }
 
+  @Before
+  public void createHttpClient() {
+    httpCookieStore = new BasicCookieStore();
+    httpclient =
+        HttpClientBuilder.create()
+            .disableRedirectHandling()
+            .setDefaultCookieStore(httpCookieStore)
+            .build();
+  }
+
   protected void assertLabelPermission(
       Project.NameKey project,
       GroupReference groupReference,
@@ -577,13 +606,50 @@
   }
 
   @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void activeUserGetSessionCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void inactiveUserDoesNotGetCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void userDeactivatedAfterLoginDoesNotGetCookie() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_OK);
+
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_FORBIDDEN);
+  }
+
+  @Test
   public void validateAccountActivation() throws Exception {
     Account.Id activatableAccountId =
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
     Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
 
-    AccountActivationValidationListener listener =
+    AccountActivationValidationListener validationListener =
         new AccountActivationValidationListener() {
           @Override
           public void validateActivation(AccountState account) throws ValidationException {
@@ -601,7 +667,11 @@
             }
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+
+    AccountActivationListener listener = mock(AccountActivationListener.class);
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(validationListener).add(listener)) {
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
       ResourceConflictException thrown =
@@ -610,14 +680,18 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
 
       // Activate account that can be activated
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verify(listener).onAccountActivated(activatableAccountId.get());
+      verifyNoMoreInteractions(listener);
 
       // Activate account that is already active
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       // Try deactivating account that cannot be deactivated
       thrown =
@@ -626,15 +700,19 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
       gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       // Deactivate account that can be deactivated
       gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verify(listener).onAccountDeactivated(deactivatableAccountId.get());
+      verifyNoMoreInteractions(listener);
 
       // Deactivate account that is already inactive
       thrown =
@@ -643,6 +721,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
 
       // Try activating account that cannot be activated
       thrown =
@@ -651,6 +730,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
     }
   }
 
@@ -2981,6 +3061,30 @@
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
   }
 
+  private AccountApi accountIdApi() throws RestApiException {
+    return gApi.accounts().id(user.id().get());
+  }
+
+  private Set<String> getCookiesNames() {
+    Set<String> cookieNames =
+        httpCookieStore.getCookies().stream()
+            .map(cookie -> cookie.getName())
+            .collect(Collectors.toSet());
+    return cookieNames;
+  }
+
+  private void webLogin(Integer accountId) throws IOException, ClientProtocolException {
+    httpGetAndAssertStatus(
+        "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
+  }
+
+  private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
+      throws ClientProtocolException, IOException {
+    HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
+    HttpResponse loginResponse = httpclient.execute(httpGet);
+    assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
+  }
+
   private static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
new file mode 100644
index 0000000..80eff96
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests the wiring of a real plugin's account listeners
+ *
+ * <p>This test really puts focus on the wiring of the account listeners. Tests for the inner
+ * workings of account activation/deactivation can be found in {@link AccountIT}.
+ */
+@TestPlugin(
+    name = "account-listener-it-plugin",
+    sysModule = "com.google.gerrit.acceptance.api.accounts.AccountListenersIT$Module")
+public class AccountListenersIT extends LightweightPluginDaemonTest {
+  @Inject private AccountOperations accountOperations;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+      DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
+    }
+  }
+
+  Validator validator;
+  Listener listener;
+
+  @Before
+  public void setUp() {
+    validator = plugin.getSysInjector().getInstance(Validator.class);
+
+    listener = plugin.getSysInjector().getInstance(Listener.class);
+  }
+
+  @Test
+  public void testActivation() throws RestApiException {
+    int id = accountOperations.newAccount().inactive().create().get();
+
+    gApi.accounts().id(id).setActive(true);
+
+    validator.assertActivationValidation(id);
+    listener.assertActivated(id);
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isTrue();
+  }
+
+  @Test
+  public void testActivationProhibited() throws RestApiException {
+    int id = accountOperations.newAccount().inactive().create().get();
+
+    validator.failActivationValidations();
+
+    assertThrows(
+        ResourceConflictException.class,
+        () -> {
+          gApi.accounts().id(id).setActive(true);
+        });
+
+    validator.assertActivationValidation(id);
+    // No call to activation listener as validation failed
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+  }
+
+  @Test
+  public void testDeactivation() throws RestApiException {
+    int id = accountOperations.newAccount().active().create().get();
+
+    gApi.accounts().id(id).setActive(false);
+
+    validator.assertDeactivationValidation(id);
+    listener.assertDeactivated(id);
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+  }
+
+  @Test
+  public void testDeactivationProhibited() throws RestApiException {
+    int id = accountOperations.newAccount().active().create().get();
+
+    validator.failDeactivationValidations();
+
+    assertThrows(
+        ResourceConflictException.class,
+        () -> {
+          gApi.accounts().id(id).setActive(false);
+        });
+
+    validator.assertDeactivationValidation(id);
+    // No call to activation listener as validation failed
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isTrue();
+  }
+
+  private void assertNoMoreEvents() {
+    validator.assertNoMoreEvents();
+    listener.assertNoMoreEvents();
+  }
+
+  @Singleton
+  public static class Validator implements AccountActivationValidationListener {
+    private Integer lastIdActivationValidation;
+    private Integer lastIdDeactivationValidation;
+    private boolean failActivationValidations;
+    private boolean failDeactivationValidations;
+
+    @Override
+    public void validateActivation(AccountState account) throws ValidationException {
+      assertThat(lastIdActivationValidation).isNull();
+      lastIdActivationValidation = account.account().id().get();
+      if (failActivationValidations) {
+        throw new ValidationException("testing validation failure");
+      }
+    }
+
+    @Override
+    public void validateDeactivation(AccountState account) throws ValidationException {
+      assertThat(lastIdDeactivationValidation).isNull();
+      lastIdDeactivationValidation = account.account().id().get();
+      if (failDeactivationValidations) {
+        throw new ValidationException("testing validation failure");
+      }
+    }
+
+    public void failActivationValidations() {
+      failActivationValidations = true;
+    }
+
+    public void failDeactivationValidations() {
+      failDeactivationValidations = true;
+    }
+
+    private void assertNoMoreEvents() {
+      assertThat(lastIdActivationValidation).isNull();
+      assertThat(lastIdDeactivationValidation).isNull();
+    }
+
+    private void assertActivationValidation(int id) {
+      assertThat(lastIdActivationValidation).isEqualTo(id);
+      lastIdActivationValidation = null;
+    }
+
+    private void assertDeactivationValidation(int id) {
+      assertThat(lastIdDeactivationValidation).isEqualTo(id);
+      lastIdDeactivationValidation = null;
+    }
+  }
+
+  @Singleton
+  public static class Listener implements AccountActivationListener {
+    private Integer lastIdActivated;
+    private Integer lastIdDeactivated;
+
+    @Override
+    public void onAccountActivated(int id) {
+      assertThat(lastIdActivated).isNull();
+      lastIdActivated = id;
+    }
+
+    @Override
+    public void onAccountDeactivated(int id) {
+      assertThat(lastIdDeactivated).isNull();
+      lastIdDeactivated = id;
+    }
+
+    private void assertNoMoreEvents() {
+      assertThat(lastIdActivated).isNull();
+      assertThat(lastIdDeactivated).isNull();
+    }
+
+    private void assertDeactivated(int id) {
+      assertThat(lastIdDeactivated).isEqualTo(id);
+      lastIdDeactivated = null;
+    }
+
+    private void assertActivated(int id) {
+      assertThat(lastIdActivated).isEqualTo(id);
+      lastIdActivated = null;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5421d8c..bbe7b81 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
@@ -29,6 +30,9 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Spliterator;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import org.junit.Test;
 
 @NoHttpd
@@ -165,6 +169,38 @@
     assertThat(commands).containsExactlyElementsIn(SLAVE_COMMANDS.get("gerrit")).inOrder();
   }
 
+  @Test
+  @Sandboxed
+  public void showConnections() throws Exception {
+    Spliterator<String> connectionsOutput =
+        getOutputLines(adminSshSession.exec("gerrit show-connections"));
+
+    assertThat(findConnectionsInOutput(connectionsOutput, "user")).hasSize(1);
+  }
+
+  @Test
+  @Sandboxed
+  public void cloeConnections() throws Exception {
+    List<String> connectionsOutput =
+        findConnectionsInOutput(
+            getOutputLines(adminSshSession.exec("gerrit show-connections")), "user");
+    String connectionId =
+        Splitter.on(" ")
+            .trimResults()
+            .omitEmptyStrings()
+            .split(connectionsOutput.get(0))
+            .iterator()
+            .next();
+
+    String closeConnectionOutput = adminSshSession.exec("gerrit close-connection " + connectionId);
+    assertThat(closeConnectionOutput).contains(connectionId);
+
+    assertThat(
+            findConnectionsInOutput(
+                getOutputLines(adminSshSession.exec("gerrit show-connections")), "user"))
+        .isEmpty();
+  }
+
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
     List<String> commands = new ArrayList<>();
 
@@ -197,4 +233,16 @@
 
     return commands;
   }
+
+  private List<String> findConnectionsInOutput(Spliterator<String> connectionsOutput, String user) {
+    List<String> connections =
+        StreamSupport.stream(connectionsOutput, false)
+            .filter(s -> s.contains("localhost") && s.contains(user))
+            .collect(Collectors.toList());
+    return connections;
+  }
+
+  private Spliterator<String> getOutputLines(String output) throws Exception {
+    return Splitter.on("\n").trimResults().omitEmptyStrings().split(output).spliterator();
+  }
 }