CredentialsProviderService.java

package com.maybeitssquid.rotatingsecrets;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

/**
 * Service that reads database credentials from Kubernetes-mounted secret files and notifies
 * registered {@link UpdatableCredential} components when credentials change.
 *
 * <p>This service implements the credential rotation pattern for Kubernetes environments. It
 * watches the secrets directory for changes using {@link WatchService} (typically mounted by a
 * secrets manager like HashiCorp Vault, OpenBao, or the External Secrets Operator). When
 * credentials change, all registered connection pools are notified to update their credentials.
 *
 * <h2>File Structure</h2>
 *
 * <p>The service expects the following files in the secrets directory:
 *
 * <ul>
 *   <li>{@code username} - Contains the database username
 *   <li>{@code password} - Contains the database password
 * </ul>
 *
 * <h2>Kubernetes Secret Mounting</h2>
 *
 * <p>Kubernetes mounts secrets via atomic symlink swaps on the {@code ..data} directory. Because
 * individual credential files are symlinks, the watch is registered on the parent directory rather
 * than the files themselves, so that the directory-level events fired during the symlink swap are
 * captured.
 *
 * <h2>Configuration Properties</h2>
 *
 * <ul>
 *   <li>{@code k8s.secrets.path} - Base directory for secret files (default: {@code
 *       /var/run/secrets/database})
 *   <li>{@code k8s.secrets.refreshInterval} - Fallback poll timeout in milliseconds; a credential
 *       re-check is forced after this interval even if no watch event fires (default: 30000)
 * </ul>
 *
 * <h2>Thread Safety</h2>
 *
 * <p>This service is thread-safe. Credential reads and updates are performed atomically, and the
 * list of updatable components is safely managed.
 *
 * @see UpdatableCredential
 * @see com.maybeitssquid.rotatingsecrets.hikari.HikariCredentialsUpdater
 * @see com.maybeitssquid.rotatingsecrets.ucp.UcpCredentialsUpdater
 */
@Service("credentialsProvider")
public class CredentialsProviderService {

  private static final Logger log = LoggerFactory.getLogger(CredentialsProviderService.class);

  /** Path to the file containing the database username. */
  protected final Path usernamePath;

  /** Path to the file containing the database password. */
  protected final Path passwordPath;

  private final long refreshIntervalMs;

  private volatile String username;
  private volatile String password;
  private volatile boolean warnedFilesUnavailable = false;

  private final List<UpdatableCredential<String>> updatables = new CopyOnWriteArrayList<>();

  private Thread watchThread;
  private WatchService watchService;

  /**
   * Creates a new credentials provider reading from the specified secrets path.
   *
   * <p>The provider will look for {@code username} and {@code password} files within the specified
   * directory.
   *
   * @param secretsPath base path where Kubernetes mounts the secret files; defaults to {@code
   *     /var/run/secrets/database}
   * @param refreshIntervalMs fallback poll timeout in milliseconds; a credential re-check is forced
   *     after this interval even if no watch event fires; defaults to 30000
   */
  public CredentialsProviderService(
      @Value("${k8s.secrets.path:/var/run/secrets/database}") String secretsPath,
      @Value("${k8s.secrets.refreshInterval:30000}") long refreshIntervalMs) {
    Path basePath = Path.of(secretsPath);
    this.usernamePath = basePath.resolve("username");
    this.passwordPath = basePath.resolve("password");
    this.refreshIntervalMs = refreshIntervalMs;
  }

  /**
   * Validates file permissions on startup and starts the directory watch thread.
   *
   * <p>Checks if secret files are world-readable and logs a security warning if they are. On
   * non-POSIX filesystems, the permission check is skipped.
   *
   * @throws IOException if the {@link WatchService} cannot be created or the directory cannot be
   *     registered
   */
  @PostConstruct
  public void start() throws IOException {
    checkPermissions(usernamePath);
    checkPermissions(passwordPath);

    watchService = FileSystems.getDefault().newWatchService();
    Path watchDir = usernamePath.getParent();
    watchDir.register(
        watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY);

    refreshCredentials();

    watchThread = new Thread(this::watchLoop, "credentials-watch");
    watchThread.setDaemon(true);
    watchThread.start();
  }

  /** Stops the directory watch thread and closes the {@link WatchService}. */
  @PreDestroy
  public void stop() {
    if (watchThread != null) {
      watchThread.interrupt();
    }
    if (watchService != null) {
      try {
        watchService.close();
      } catch (IOException e) {
        log.debug("Error closing WatchService: {}", e.getMessage());
      }
    }
  }

  /**
   * Checks if a file is world-readable and logs a security warning if so.
   *
   * @param path the path to check
   */
  private void checkPermissions(Path path) {
    if (!Files.exists(path)) {
      return;
    }
    try {
      Set<PosixFilePermission> perms = Files.getPosixFilePermissions(path);
      if (perms.contains(PosixFilePermission.OTHERS_READ)) {
        log.warn("SECURITY: {} is world-readable. Recommend chmod 600.", path);
      }
    } catch (UnsupportedOperationException e) {
      // Non-POSIX filesystem, skip check
    } catch (IOException e) {
      log.debug("Could not check permissions for {}: {}", path, e.getMessage());
    }
  }

  /**
   * Blocks on the {@link WatchService}, calling {@link #refreshCredentials()} whenever a directory
   * event fires or the fallback timeout elapses.
   */
  private void watchLoop() {
    while (!Thread.currentThread().isInterrupted()) {
      try {
        WatchKey key = watchService.poll(refreshIntervalMs, TimeUnit.MILLISECONDS);
        if (key != null) {
          key.pollEvents();
          key.reset();
        }
        refreshCredentials();
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      } catch (Exception e) {
        log.warn("Unexpected error in credential watch loop", e);
      }
    }
  }

  /**
   * Reads the current credentials from the mounted secret files and notifies registered {@link
   * UpdatableCredential} components if they have changed.
   */
  void refreshCredentials() {
    if (!Files.exists(usernamePath) || !Files.exists(passwordPath)) {
      if (!warnedFilesUnavailable) {
        log.warn("Credential files no longer available at {}", usernamePath.getParent());
        warnedFilesUnavailable = true;
      }
      return;
    }
    warnedFilesUnavailable = false;

    final String newUsername = readSecret(usernamePath, "username");
    final String newPassword = readSecret(passwordPath, "password");

    synchronized (this) {
      boolean changed = !newUsername.equals(this.username) || !newPassword.equals(this.password);
      if (changed) {
        this.username = newUsername;
        this.password = newPassword;
        updateCredentials();
      }
    }
  }

  /**
   * Registers the HikariCP credentials updater to receive credential change notifications.
   *
   * @param updatable the HikariCP credentials updater bean
   */
  @Autowired
  @Qualifier("hikariUpdater")
  public void setHikariUpdatable(UpdatableCredential<String> updatable) {
    this.updatables.add(updatable);
  }

  /**
   * Registers the Oracle UCP credentials updater to receive credential change notifications.
   *
   * @param updatable the Oracle UCP credentials updater bean
   */
  @Autowired
  @Qualifier("ucpUpdater")
  public void setUcpUpdatable(UpdatableCredential<String> updatable) {
    this.updatables.add(updatable);
  }

  /**
   * Notifies all registered {@link UpdatableCredential} components of the current credentials.
   *
   * <p>This method iterates through all registered updatable components and calls {@link
   * UpdatableCredential#setCredential(String, Object)} with the current username and password. Each
   * component is responsible for its own thread-safe credential update logic.
   */
  void updateCredentials() {
    for (UpdatableCredential<String> updatable : updatables) {
      updatable.setCredential(this.username, this.password);
    }
  }

  /**
   * Reads and trims a secret value from a file.
   *
   * @param path the path to the secret file
   * @param name a descriptive name for logging purposes
   * @return the trimmed content of the secret file
   * @throws RuntimeException if the file cannot be read
   */
  private String readSecret(Path path, String name) {
    try {
      String value = Files.readString(path).trim();
      log.debug("Read {} from Kubernetes secrets", name);
      return value;
    } catch (IOException e) {
      throw new RuntimeException("Failed to read " + name + " from " + path, e);
    }
  }
}