diff options
author | Luca Milanesio <luca.milanesio@gmail.com> | 2020-06-07 09:49:42 +0100 |
---|---|---|
committer | Luca Milanesio <luca.milanesio@gmail.com> | 2020-06-07 09:49:42 +0100 |
commit | 28869fc35ae06fee6d88eb45037dbd51352e7959 (patch) | |
tree | 3515a2d02457ed5aae9ca2cd973e3bda97e5459c | |
parent | 82db49e3174897fb733295d4c3153b140d8d15de (diff) | |
parent | 3e316093bd172a2d1a2d2e14eff9615834907197 (diff) |
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
16 files changed, 594 insertions, 42 deletions
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt index 8ab3d62bed..c8c2dff80f 100644 --- a/Documentation/dev-plugins.txt +++ b/Documentation/dev-plugins.txt @@ -391,6 +391,10 @@ Allows to listen to events visible to the specified user. These are the 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 75ad9c2cf4..2323df3a0c 100644 --- a/Documentation/intro-user.txt +++ b/Documentation/intro-user.txt @@ -615,8 +615,7 @@ In that case, care should be taken to prevent the CI system from 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 5376d23a02..cfe796478e 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.api.changes.ActionVisitor; 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 @@ public class ExtensionRegistry { 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 @@ public class ExtensionRegistry { 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 @@ public class ExtensionRegistry { 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 @@ public class ExtensionRegistry { 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 0000000000..b45533b8bf --- /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 7f878aa9fd..5c4830c617 100644 --- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java +++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java @@ -16,6 +16,7 @@ package com.google.gerrit.httpd; 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.AccessPath; 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 @@ import org.eclipse.jgit.http.server.GitSmartHttpTools; @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 @@ public abstract class CacheBasedWebSession implements WebSession { 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 @@ public abstract class CacheBasedWebSession implements WebSession { 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 @@ public abstract class CacheBasedWebSession implements WebSession { 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 @@ public abstract class CacheBasedWebSession implements WebSession { 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 @@ public abstract class CacheBasedWebSession implements WebSession { 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 caced27a79..830d8d609b 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.extensions.registration.DynamicItem; 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 @@ public class H2CacheBasedWebSession extends CacheBasedWebSession { @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 32ed6943ec..4b68198dca 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 @@ public class SetInactiveFlag { 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 @@ public class SetInactiveFlag { 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 @@ public class SetInactiveFlag { 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 e447f2b59e..cf592bf772 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.DownloadScheme; 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 @@ public class GerritGlobalModule extends FactoryModule { 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 bc52308609..9fdc9e6d71 100644 --- a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java +++ b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java @@ -26,6 +26,9 @@ public interface AccountActivationValidationListener { /** * 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 @@ public interface AccountActivationValidationListener { /** * 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 0000000000..1086626ce6 --- /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 e4aa14cf30..9301f8a39a 100644 --- a/java/com/google/gerrit/sshd/SshModule.java +++ b/java/com/google/gerrit/sshd/SshModule.java @@ -19,6 +19,7 @@ import static com.google.inject.Scopes.SINGLETON; 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 @@ public class SshModule extends LifecycleModule { 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 eac97375b2..abbd81d83a 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.entities.Account; 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.DSAPublicKey; 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 @@ public class SshUtil { 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 60a878a62c..093f647997 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.AdminHighPriorityCommand; 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 @@ final class CloseConnection extends SshCommand { @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 d5dd2419cb..f9ba8a2272 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.concurrent.TimeUnit.SECONDS; 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.Change; 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.EmailInfo; 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.git.LockFailureException; 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.Optional; 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.PushResult; 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 @@ public class AccountIT extends AbstractDaemonTest { @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 @@ public class AccountIT extends AbstractDaemonTest { } } + @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 @@ public class AccountIT extends AbstractDaemonTest { } @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 @@ public class AccountIT extends AbstractDaemonTest { } } }; - 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 @@ public class AccountIT extends AbstractDaemonTest { () -> 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 @@ public class AccountIT extends AbstractDaemonTest { () -> 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 @@ public class AccountIT extends AbstractDaemonTest { () -> 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 @@ public class AccountIT extends AbstractDaemonTest { () -> 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 @@ public class AccountIT extends AbstractDaemonTest { 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 0000000000..80eff9606a --- /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 5421d8c86a..bbe7b814b7 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.collect.ImmutableList.toImmutableList; 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 com.google.gerrit.acceptance.UseSsh; 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 @@ public class SshCommandsIT extends AbstractDaemonTest { 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 @@ public class SshCommandsIT extends AbstractDaemonTest { 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(); + } } |