RotatePatKeysJob.java

package edu.ucsb.cs.scaffold.jobs;

import edu.ucsb.cs.scaffold.entity.PatCredential;
import edu.ucsb.cs.scaffold.errors.NoSuchKeyVersionException;
import edu.ucsb.cs.scaffold.repository.PatCredentialRepository;
import edu.ucsb.cs.scaffold.services.PatEncryptionService;
import edu.ucsb.cs156.jobs.services.JobContext;
import edu.ucsb.cs156.jobs.services.JobContextConsumer;
import lombok.Builder;

/**
 * Re-encrypts every stored PAT under the current PAT_ENCRYPTION_KEY. Launched by an admin (POST
 * /api/jobs/launch/rotatePatKeys) after the key rotation script has moved the retiring key to
 * PREVIOUS_PAT_ENCRYPTION_KEY and installed a new PAT_ENCRYPTION_KEY; see
 * docs/PAT_ENCRYPTION_KEY_instructions.md.
 *
 * <p>Credentials already on the current key version are skipped, so the job is idempotent and can
 * simply be re-launched after a partial failure. A credential whose key version has no configured
 * key (e.g. PREVIOUS_PAT_ENCRYPTION_KEY was removed too early) is logged and skipped rather than
 * failing the whole job; that user must re-enter their PAT.
 */
@Builder
public class RotatePatKeysJob implements JobContextConsumer {

  private PatEncryptionService patEncryptionService;
  private PatCredentialRepository patCredentialRepository;

  @Override
  public void accept(JobContext ctx) throws Exception {
    int currentVersion = patEncryptionService.currentKeyVersion();
    ctx.log("Rotating stored PATs to key version %d".formatted(currentVersion));
    int rotated = 0;
    int alreadyCurrent = 0;
    int undecryptable = 0;
    for (PatCredential credential : patCredentialRepository.findAll()) {
      int oldVersion = credential.getKeyVersion();
      if (oldVersion == currentVersion) {
        alreadyCurrent++;
        continue;
      }
      try {
        String plaintext = patEncryptionService.decrypt(credential.getCiphertext(), oldVersion);
        PatEncryptionService.EncryptedPat encrypted = patEncryptionService.encrypt(plaintext);
        credential.setCiphertext(encrypted.ciphertext());
        credential.setKeyVersion(encrypted.keyVersion());
        patCredentialRepository.save(credential);
        rotated++;
        ctx.log(
            "Rotated credential id %d from key version %d to %d"
                .formatted(credential.getId(), oldVersion, currentVersion));
      } catch (NoSuchKeyVersionException e) {
        undecryptable++;
        ctx.log("Cannot rotate credential id %d: %s".formatted(credential.getId(), e.getMessage()));
      }
    }
    ctx.log(
        "Done: %d rotated, %d already on the current key version, %d could not be decrypted"
            .formatted(rotated, alreadyCurrent, undecryptable));
  }
}