BootstrapAdminCredentialStore.java

package com.taxonomy.security.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.AclEntry;
import java.nio.file.attribute.AclEntryPermission;
import java.nio.file.attribute.AclEntryType;
import java.nio.file.attribute.AclFileAttributeView;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.EnumSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;

/**
 * Delivers a generated local-administrator bootstrap credential outside the
 * application log stream in an owner-only temporary file.
 *
 * <p>The final file name is derived from a one-way fingerprint of the configured
 * data-source URL. That makes the deletion target stable across process restarts
 * without writing the URL or credential into the file name or log.</p>
 */
@Service
@Profile("!keycloak")
public class BootstrapAdminCredentialStore {

    private static final Logger log =
            LoggerFactory.getLogger(BootstrapAdminCredentialStore.class);
    private static final String FILE_PREFIX = "taxonomy-admin-bootstrap-";
    private static final String FILE_SUFFIX = ".txt";
    private static final String PENDING_SUFFIX = ".pending";
    private static final String DEFAULT_STORAGE_IDENTITY = "taxonomy-local";
    private static final Set<PosixFilePermission> OWNER_ONLY_PERMISSIONS = Set.of(
            PosixFilePermission.OWNER_READ,
            PosixFilePermission.OWNER_WRITE);

    private final Path directory;
    private final Path credentialFile;

    @Autowired
    public BootstrapAdminCredentialStore(
            @Value("${spring.datasource.url:taxonomy-local}") String storageIdentity) {
        this(Path.of(System.getProperty("java.io.tmpdir")), storageIdentity);
    }

    BootstrapAdminCredentialStore(Path directory) {
        this(directory, DEFAULT_STORAGE_IDENTITY);
    }

    BootstrapAdminCredentialStore(Path directory, String storageIdentity) {
        this.directory = Objects.requireNonNull(directory, "directory")
                .toAbsolutePath()
                .normalize();
        this.credentialFile = this.directory.resolve(
                FILE_PREFIX + stableKey(storageIdentity) + FILE_SUFFIX);
    }

    /**
     * Writes the credential to an owner-only staging file and publishes it at the
     * restart-stable data-source-specific path.
     */
    public Path publish(String credential) {
        if (credential == null || credential.isBlank()) {
            throw new IllegalArgumentException("Bootstrap credential must not be blank");
        }

        Path stagingFile = null;
        boolean movedToPublishedPath = false;
        try {
            stagingFile = createOwnerOnlyStagingFile();
            Files.writeString(stagingFile,
                    credential + System.lineSeparator(),
                    StandardCharsets.UTF_8,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE);
            restrictToOwner(stagingFile);

            moveReplacing(stagingFile, credentialFile);
            movedToPublishedPath = true;
            stagingFile = null;
            restrictToOwner(credentialFile);

            Path absoluteFile = credentialFile.toAbsolutePath().normalize();
            log.warn("BOOTSTRAP_ADMIN_CREDENTIAL_FILE path={} "
                            + "Read this owner-only file once, sign in as 'admin', "
                            + "and replace the password immediately. The file is "
                            + "removed after a successful administrator password change.",
                    absoluteFile);
            return absoluteFile;
        } catch (IOException | RuntimeException exception) {
            if (stagingFile != null) {
                deleteQuietly(stagingFile);
            }
            if (movedToPublishedPath) {
                deleteQuietly(credentialFile);
            }
            throw new IllegalStateException(
                    "Unable to create an owner-only administrator bootstrap credential file",
                    exception);
        }
    }

    /**
     * Removes this data source's published credential, including after a process
     * restart, if the file exists.
     */
    public void deletePublishedCredential() {
        try {
            if (Files.deleteIfExists(credentialFile)) {
                log.info("BOOTSTRAP_ADMIN_CREDENTIAL_FILE_REMOVED path={}",
                        credentialFile.toAbsolutePath().normalize());
            }
        } catch (IOException exception) {
            log.error("BOOTSTRAP_ADMIN_CREDENTIAL_FILE_REMOVE_FAILED path={} "
                            + "Delete this owner-only file manually.",
                    credentialFile.toAbsolutePath().normalize());
        }
    }

    /**
     * Creates an empty staging file with owner-only POSIX permissions as part of
     * the create operation. Providers without POSIX creation attributes fall back
     * to restricting the still-empty file before any credential bytes are written.
     */
    Path createOwnerOnlyStagingFile() throws IOException {
        Files.createDirectories(directory);
        if (supportsPosixCreationAttributes()) {
            try {
                return Files.createTempFile(
                        directory,
                        credentialFile.getFileName().toString() + "-",
                        PENDING_SUFFIX,
                        PosixFilePermissions.asFileAttribute(
                                OWNER_ONLY_PERMISSIONS));
            } catch (UnsupportedOperationException unsupportedCreationAttribute) {
                // Fall through only when the provider cannot apply POSIX attributes
                // during creation. The empty file is restricted before secret data.
            }
        }

        Path stagingFile = Files.createTempFile(
                directory,
                credentialFile.getFileName().toString() + "-",
                PENDING_SUFFIX);
        try {
            restrictToOwner(stagingFile);
            return stagingFile;
        } catch (IOException | RuntimeException exception) {
            deleteQuietly(stagingFile);
            throw exception;
        }
    }

    boolean supportsPosixCreationAttributes() {
        return Files.getFileAttributeView(
                directory,
                PosixFileAttributeView.class,
                LinkOption.NOFOLLOW_LINKS) != null;
    }

    private static void moveReplacing(Path source, Path target) throws IOException {
        try {
            Files.move(
                    source,
                    target,
                    StandardCopyOption.ATOMIC_MOVE,
                    StandardCopyOption.REPLACE_EXISTING);
        } catch (AtomicMoveNotSupportedException
                | FileAlreadyExistsException
                | UnsupportedOperationException exception) {
            Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }

    private static String stableKey(String storageIdentity) {
        String normalized = storageIdentity == null || storageIdentity.isBlank()
                ? DEFAULT_STORAGE_IDENTITY
                : storageIdentity.strip();
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest(normalized.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest).substring(0, 32);
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 is unavailable", exception);
        }
    }

    void restrictToOwner(Path file) throws IOException {
        PosixFileAttributeView posixView = Files.getFileAttributeView(
                file, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
        if (posixView != null) {
            Files.setPosixFilePermissions(file, OWNER_ONLY_PERMISSIONS);
            return;
        }

        AclFileAttributeView aclView = Files.getFileAttributeView(
                file, AclFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
        if (aclView != null) {
            AclEntry ownerOnly = AclEntry.newBuilder()
                    .setType(AclEntryType.ALLOW)
                    .setPrincipal(Files.getOwner(file, LinkOption.NOFOLLOW_LINKS))
                    .setPermissions(EnumSet.allOf(AclEntryPermission.class))
                    .build();
            aclView.setAcl(List.of(ownerOnly));
            return;
        }

        throw new IOException(
                "Filesystem does not expose POSIX permissions or an ACL view");
    }

    private static void deleteQuietly(Path file) {
        try {
            Files.deleteIfExists(file);
        } catch (IOException ignored) {
            // The primary operation reports its own failure. Never log credential data.
        }
    }
}