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