Send auth token expiry notifications to serviceuser owners Change-Id: I7e6274e2ce5a139aa67f4812647648311d987eba
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java index a4ced57..de75c2c 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java +++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateServiceUser.java
@@ -60,7 +60,7 @@ @RequiresCapability(CreateServiceUserCapability.ID) @Singleton -class CreateServiceUser +public class CreateServiceUser implements RestCollectionCreateView<ConfigResource, ServiceUserResource, Input> { public static final String USER = "user"; public static final String KEY_CREATED_BY = "createdBy";
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java index 449657a..a94edee 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java +++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
@@ -77,8 +77,7 @@ } } - Response<AuthTokenInfo> resp = - createToken.apply(rsrc.getUser(), id.get(), input); + Response<AuthTokenInfo> resp = createToken.apply(rsrc.getUser(), id.get(), input); if (resp.statusCode() == Response.created().statusCode()) { outgoingEmailFactory.create(rsrc, Operation.CREATE_TOKEN).send(); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserAuthTokenExpiryNotifier.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserAuthTokenExpiryNotifier.java new file mode 100644 index 0000000..dc2732b --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserAuthTokenExpiryNotifier.java
@@ -0,0 +1,194 @@ +// Copyright (C) 2025 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.googlesource.gerrit.plugins.serviceuser.email; + +import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_WILL_EXPIRE; +import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.KEY_OWNER; +import static com.googlesource.gerrit.plugins.serviceuser.CreateServiceUser.USER; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.GroupDescription; +import com.google.gerrit.exceptions.EmailException; +import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.lifecycle.LifecycleModule; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.AccountState; +import com.google.gerrit.server.account.AuthToken; +import com.google.gerrit.server.account.AuthTokenAccessor; +import com.google.gerrit.server.account.GroupControl; +import com.google.gerrit.server.config.CanonicalWebUrl; +import com.google.gerrit.server.config.ScheduleConfig; +import com.google.gerrit.server.config.ScheduleConfig.Schedule; +import com.google.gerrit.server.git.WorkQueue; +import com.google.gerrit.server.group.GroupResolver; +import com.google.gerrit.server.group.GroupResource; +import com.google.gerrit.server.mail.EmailFactories; +import com.google.gerrit.server.restapi.group.ListMembers; +import com.google.gerrit.server.util.ManualRequestContext; +import com.google.gerrit.server.util.OneOffRequestContext; +import com.google.inject.Inject; +import com.google.inject.Module; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.serviceuser.StorageCache; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class ServiceUserAuthTokenExpiryNotifier implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final long FIRST_NOTIFICATION_BEFORE_EXPIRY = 7L; // 7 days + + private final StorageCache storageCache; + private final AuthTokenAccessor tokenAccessor; + private final EmailFactories emailFactories; + private final AccountCache accountCache; + private final Provider<ListMembers> listMembers; + private final OneOffRequestContext oneOffRequestContext; + private final GroupControl.Factory groupControlFactory; + private final GroupResolver groupResolver; + private final String canonicalWebUrl; + + public static Module module() { + return new LifecycleModule() { + @Override + protected void configure() { + bind(ServiceUserAuthTokenExpiryNotifier.class); + listener().to(ServiceUserAuthTokenExpiryNotifier.Lifecycle.class); + } + }; + } + + static class Lifecycle implements LifecycleListener { + private final WorkQueue queue; + private final ServiceUserAuthTokenExpiryNotifier notifier; + private final Optional<Schedule> schedule; + + @Inject + Lifecycle(WorkQueue queue, ServiceUserAuthTokenExpiryNotifier notifier) { + this.queue = queue; + this.notifier = notifier; + schedule = ScheduleConfig.Schedule.create(TimeUnit.DAYS.toMillis(1), "00:00"); + } + + @Override + public void start() { + if (schedule.isPresent()) { + queue.scheduleAtFixedRate(notifier, schedule.get()); + } + } + + @Override + public void stop() { + // handled by WorkQueue.stop() already + } + } + + @Inject + public ServiceUserAuthTokenExpiryNotifier( + StorageCache storageCache, + AuthTokenAccessor tokenAccessor, + EmailFactories emailFactories, + AccountCache accountCache, + Provider<ListMembers> listMembers, + OneOffRequestContext oneOffRequestContext, + GroupControl.Factory groupControlFactory, + GroupResolver groupResolver, + @CanonicalWebUrl String canonicalWebUrl) { + this.storageCache = storageCache; + this.tokenAccessor = tokenAccessor; + this.emailFactories = emailFactories; + this.accountCache = accountCache; + this.listMembers = listMembers; + this.oneOffRequestContext = oneOffRequestContext; + this.groupControlFactory = groupControlFactory; + this.groupResolver = groupResolver; + this.canonicalWebUrl = canonicalWebUrl; + } + + @Override + public void run() { + Instant now = Instant.now(); + try { + Config db = storageCache.get(); + for (String username : db.getSubsections(USER)) { + Optional<AccountState> optAccount = accountCache.getByUsername(username); + Set<Account.Id> owners = resolveOwners(username, db); + if (optAccount.isPresent()) { + Account account = optAccount.get().account(); + for (AuthToken token : tokenAccessor.getTokens(account.id())) { + if (token.expirationDate().isEmpty()) { + continue; + } + Instant expirationDate = token.expirationDate().get(); + if (expirationDate.isBefore(now.plus(FIRST_NOTIFICATION_BEFORE_EXPIRY, ChronoUnit.DAYS)) + && expirationDate.isAfter( + now.plus(FIRST_NOTIFICATION_BEFORE_EXPIRY - 1, ChronoUnit.DAYS))) { + logger.atInfo().log( + "Token %s for account %s is expiring soon.", token.id(), account.id()); + String authTokenSettingsUrl = + String.format("%sx/serviceuser/user/%d", canonicalWebUrl, account.id().get()); + emailFactories + .createOutgoingEmail( + AUTH_TOKEN_WILL_EXPIRE, + emailFactories.createAuthTokenWillExpireEmail( + account, token, owners, authTokenSettingsUrl)) + .send(); + } + } + } + } + } catch (IOException | ConfigInvalidException e) { + throw new RuntimeException("Failed to read accounts from NoteDB", e); + } catch (EmailException e) { + logger.atSevere().withCause(e).log("Failed to send token expiry notification email"); + } + } + + private Set<Account.Id> resolveOwners(String username, Config db) throws EmailException { + Set<Account.Id> owners = new HashSet<>(); + + String ownerGroup = db.getString(USER, username, KEY_OWNER); + + if (ownerGroup != null) { + try (ManualRequestContext ctx = oneOffRequestContext.open()) { + GroupDescription.Basic group = groupResolver.parseId(ownerGroup); + GroupControl ctl = groupControlFactory.controlFor(group); + ListMembers lm = listMembers.get(); + GroupResource rsrc = new GroupResource(ctl); + lm.setRecursive(true); + try { + for (AccountInfo a : lm.apply(rsrc).value()) { + owners.add(Account.id(a._accountId)); + } + } catch (Exception e) { + throw new EmailException( + "Could not compute receipients for serviceuser update notice.", e); + } + } + } + + return owners; + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java index 072dbff..0d26150 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java
@@ -28,5 +28,6 @@ .in(SINGLETON); factory(ServiceUserUpdatedEmailDecorator.Factory.class); factory(ServiceUserOutgoingEmail.Factory.class); + install(ServiceUserAuthTokenExpiryNotifier.module()); } }