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