TrackActive: Automatically disable inactive accounts

Build on top of the `track-active-users-1.0` (already renamed to
`track-and-disable-inactive-users-1.0.groovy) script and add the
possibility of automatically disabling inactive accounts after
a preconfigured period.

The new functionality will mark inactive accounts that are not present
in `track-and-disable-inactive.users_cache`. The inactivity period can
be controlled by the `users_cache` `maxAge` configuration option.

To automatically disable users that are removed from `users_cache` we
register a `CacheRemovalListener` that will disable accounts as they
are evicted. Additionally, in case of explicit removal, the entry will
be re-added. This is to prevent full instance lockdown when
`users_cache` is flushed using the SSH command.

To test this feature, copy the
`admin/track-and-disable-inactive-users-1.0.groovy` into
`$gerrit_site/plugins` and add the following configuration to
`$gerrit_site/etc/gerrit.config`:

[cache "track-and-disable-inactive-users.users_cache"]
  maxAge = 1min

The `CacheRemovalListener` only fires, when the cache is updated. To
see any evictions, you would need at least two accounts in Gerrit and
make sure that one of them is constantly being used (eg. reloading the
web page). Eventually, you should see a log message saying that the
other account was disabled.

Bug: Issue: 327730872
Change-Id: I34974d842d6f73784d43932b587a6461eaf2ffae
diff --git a/admin/README.md b/admin/README.md
index 9cca543..7c0ba9f 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -11,4 +11,4 @@
 * [warm-cache-1.0.groovy](/admin/warm-cache-1.0.groovy) - Controls the Gerrit cache warm-up via command-line
 * [readonly-1.0.groovy](/admin/readonly-1.0.groovy) - Set all Gerrit projects in read-only mode during maintenance
 * [stale-packed-refs-1.0.groovy](/admin/stale-packed-refs-1.0.groovy) - Check all specified projects and expose metric with age of `packed-refs.lock` files
-* [track-active-users.groovy](/admin/track-active-users.groovy) - Tracks users login in `track-active-users_cache`
+* [track-and-disable-inactive-users.groovy](/admin/track-and-disable-inactive-users.groovy) - Tracks users login in `track-active-users_cache` and automatically disables inactive users
diff --git a/admin/track-and-disable-inactive-users-1.0.groovy b/admin/track-and-disable-inactive-users-1.0.groovy
index d0d5c61..33ae335 100644
--- a/admin/track-and-disable-inactive-users-1.0.groovy
+++ b/admin/track-and-disable-inactive-users-1.0.groovy
@@ -13,9 +13,13 @@
 // limitations under the License.
 
 import com.google.common.cache.*
+import com.google.common.flogger.*
 import com.google.gerrit.common.*
 import com.google.gerrit.entities.*
+import com.google.gerrit.extensions.annotations.*
+import com.google.gerrit.extensions.events.*
 import com.google.gerrit.extensions.registration.*
+import com.google.gerrit.lifecycle.*
 import com.google.gerrit.server.*
 import com.google.gerrit.server.account.*
 import com.google.gerrit.server.cache.*
@@ -24,6 +28,7 @@
 import com.google.inject.name.*
 
 import java.time.*
+import java.util.function.*
 
 import static java.util.concurrent.TimeUnit.*
 
@@ -80,10 +85,89 @@
   }
 }
 
-class TrackAndDisableInactiveUsersModule extends AbstractModule {
+class AutoDisableInactiveUsersEvictionListener implements CacheRemovalListener<Integer, Long> {
+  static final FluentLogger logger = FluentLogger.forEnclosingClass()
+
+  private final String pluginName
+  private final String fullCacheName
+  private final Cache<Integer, Long> trackActiveUsersCache
+  private final Provider<AccountsUpdate> accountsUpdate
+
+  @Inject
+  AutoDisableInactiveUsersEvictionListener(
+      @PluginName String pluginName,
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdate,
+      @Named(TrackActiveUsersCache.NAME) Cache<Integer, Long> trackActiveUsersCache
+  ) {
+    this.pluginName = pluginName
+    this.accountsUpdate = accountsUpdate
+    this.trackActiveUsersCache = trackActiveUsersCache
+    fullCacheName = "${pluginName}.${TrackActiveUsersCache.NAME}"
+  }
+
+  @Override
+  void onRemoval(String pluginName, String cacheName, RemovalNotification<Integer, Long> notification) {
+    if (fullCacheName != cacheName) {
+      return
+    }
+
+    if (notification.cause == RemovalCause.EXPIRED) {
+      disableAccount(Account.id(notification.key))
+    } else if (notification.cause == RemovalCause.EXPLICIT) {
+      logger.atWarning().log(
+          "cache %s do not support eviction, entry for user %d will be added back", fullCacheName, notification.key)
+      trackActiveUsersCache.put(notification.key, notification.value)
+    }
+  }
+
+  private void disableAccount(Account.Id accountId) {
+    logger.atInfo().log("Automatically disabling user id: %d", accountId.get())
+
+    accountsUpdate.get().update(
+        """Automatically disabling after inactivity
+
+Disabled by ${pluginName}""",
+        accountId,
+        new Consumer<AccountDelta.Builder>() {
+          @Override
+          void accept(AccountDelta.Builder builder) {
+            builder.setActive(false)
+          }
+        })
+  }
+}
+
+@Singleton
+class AutoDisableInactiveUsersListener implements LifecycleListener {
+  static final FluentLogger logger = FluentLogger.forEnclosingClass()
+
+  @Inject
+  Accounts accounts
+
+  @Inject
+  @Named(TrackActiveUsersCache.NAME)
+  Cache<Integer, Long> trackActiveUsersCache
+
+  @Override
+  void start() {
+    def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis())
+    accounts.all()
+        .findAll { it.account().isActive() && !trackActiveUsersCache.getIfPresent(it.account().id().get()) }
+        .each { trackActiveUsersCache.put(it.account().id().get(), currentMinutes) }
+  }
+
+  @Override
+  void stop() {
+    // no-op
+  }
+}
+
+class TrackAndDisableInactiveUsersModule extends LifecycleModule {
   @Override
   void configure() {
     install(new TrackActiveUsersCache())
+    listener().to(AutoDisableInactiveUsersListener)
+    DynamicSet.bind(binder(), CacheRemovalListener).to(AutoDisableInactiveUsersEvictionListener)
 
     DynamicSet.bind(binder(), GroupBackend).to(TrackingGroupBackend)
   }
diff --git a/admin/track-and-disable-inactive-users.md b/admin/track-and-disable-inactive-users.md
new file mode 100644
index 0000000..6a91de5
--- /dev/null
+++ b/admin/track-and-disable-inactive-users.md
@@ -0,0 +1,34 @@
+Track Active Users
+==============================
+
+DESCRIPTION
+-----------
+Track user's activity over REST, SSH and UI and allow disabling inactive
+accounts after the configured inactivity period.
+
+Configuration
+=========================
+
+The track-active-users plugin is configured in
+$site_path/etc/gerrit.config` files, example:
+
+```text
+[cache "@PLUGIN@.users_cache"]
+  maxAge = 90d
+```
+
+Configuration parameters
+---------------------
+
+```cache."@PLUGIN@.users_cache".maxAge```
+:  Maximum allowed inactivity time for user.
+   Value should use common time unit suffixes to express their setting:
+
+   * h, hr, hour, hours
+   * d, day, days
+   * w, week, weeks (`1 week` is treated as `7 days`)
+   * mon, month, months (`1 month` is treated as `30 days`)
+   * y, year, years (`1 year` is treated as `365 days`)
+
+   If a time unit suffix is not specified, `hours` is assumed.
+   Default: 90 days