Merge changes I92dad4a4,Ia09d000c,I34974d84,I526d474a,I5827288d

* changes:
  Introduce `ignoreAccountId` setting
  Do not automatically disable non-interactive users
  TrackActive: Automatically disable inactive accounts
  Rename track-active-users to track-and-disable-inactive-users
  Track user activity in a persistent cache
diff --git a/admin/README.md b/admin/README.md
index 7143588..7c0ba9f 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -11,3 +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-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.2.groovy b/admin/track-and-disable-inactive-users-1.2.groovy
new file mode 100644
index 0000000..68ebb43
--- /dev/null
+++ b/admin/track-and-disable-inactive-users-1.2.groovy
@@ -0,0 +1,215 @@
+// Copyright (C) 2024 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.
+
+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.*
+import com.google.gerrit.server.config.*
+import com.google.gerrit.server.project.*
+import com.google.inject.*
+import com.google.inject.name.*
+
+import java.time.*
+import java.util.function.*
+import java.util.stream.Collectors
+
+import static java.util.concurrent.TimeUnit.*
+
+class TrackActiveUsersCache extends CacheModule {
+  static final NAME = "users_cache"
+  static final DEFAULT_CACHE_TTL = Duration.ofDays(90)
+  static final MAX_SIZE = 300_000
+
+  @Override
+  protected void configure() {
+    persist(NAME, Integer, Long)
+        .diskLimit(MAX_SIZE)
+        .maximumWeight(MAX_SIZE)
+        .expireAfterWrite(DEFAULT_CACHE_TTL)
+  }
+}
+
+@Singleton
+class TrackingGroupBackend implements GroupBackend {
+  @Inject
+  @Named(TrackActiveUsersCache.NAME)
+  Cache<Integer, Long> trackActiveUsersCache
+
+  @Override
+  boolean handles(AccountGroup.UUID uuid) {
+    return true
+  }
+
+  @Override
+  GroupDescription.Basic get(AccountGroup.UUID uuid) {
+    return null
+  }
+
+  @Override
+  Collection<GroupReference> suggest(String name, @Nullable ProjectState project) {
+    return Collections.emptyList()
+  }
+
+  @Override
+  GroupMembership membershipsOf(CurrentUser user) {
+    if (user.identifiedUser) {
+      def accountId = user.accountId.get()
+      def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis())
+      if (trackActiveUsersCache.getIfPresent(accountId) != currentMinutes) {
+        trackActiveUsersCache.put(accountId, currentMinutes)
+      }
+    }
+    return GroupMembership.EMPTY
+  }
+
+  @Override
+  boolean isVisibleToAll(AccountGroup.UUID uuid) {
+    return false
+  }
+}
+class AutoDisableInactiveUsersConfig {
+  final Set<Account.Id> ignoreAccountIds
+
+  private final PluginConfig config
+
+  @Inject
+  AutoDisableInactiveUsersConfig(
+      PluginConfigFactory configFactory,
+      @PluginName String pluginName
+  ) {
+    config = configFactory.getFromGerritConfig(pluginName)
+
+    ignoreAccountIds = ignoreAccountIdsFromConfig("ignoreAccountId")
+  }
+
+  private Set<Account.Id> ignoreAccountIdsFromConfig(String name) {
+    def strings = config.getStringList(name) as Set
+    strings.stream()
+        .map(Account.Id.&tryParse)
+        .filter { it.isPresent() }
+        .map { it.get() }
+        .collect(Collectors.toSet())
+  }
+}
+
+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
+  private final AutoDisableInactiveUsersConfig autoDisableConfig
+
+  @Inject
+  AutoDisableInactiveUsersEvictionListener(
+      @PluginName String pluginName,
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdate,
+      @Named(TrackActiveUsersCache.NAME) Cache<Integer, Long> trackActiveUsersCache,
+      AutoDisableInactiveUsersConfig autoDisableConfig
+  ) {
+    this.pluginName = pluginName
+    this.accountsUpdate = accountsUpdate
+    this.autoDisableConfig = autoDisableConfig
+    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) {
+    if (autoDisableConfig.ignoreAccountIds.contains(accountId)) {
+      return
+    }
+
+    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
+  ServiceUserClassifier serviceUserClassifier
+
+  @Inject
+  @Named(TrackActiveUsersCache.NAME)
+  Cache<Integer, Long> trackActiveUsersCache
+
+  @Override
+  void start() {
+    def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis())
+    accounts.all()
+        .findAll {
+          it.account().isActive()
+              && !serviceUserClassifier.isServiceUser(it.account().id()
+              && !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)
+  }
+}
+
+modules = [TrackAndDisableInactiveUsersModule]
diff --git a/admin/track-and-disable-inactive-users.md b/admin/track-and-disable-inactive-users.md
new file mode 100644
index 0000000..c569d0b
--- /dev/null
+++ b/admin/track-and-disable-inactive-users.md
@@ -0,0 +1,44 @@
+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
+---------------------
+
+=======
+```plugin.@PLUGIN@.ignoreAccountId```
+:  Specify an account Id that should not be auto disabled.
+   May be specified more than once to specify multiple account Ids, for example:
+
+   ```
+   ignoreAccountId = 1000001
+   ignoreAccountId = 1000002
+   ```
+
+```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