SecretsManager.java

/*
 * Copyright 2023 Morimekta Utils Authors
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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 net.morimekta.config;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import net.morimekta.collect.util.Binary;
import net.morimekta.file.FileEvent;
import net.morimekta.file.FileEventListener;
import net.morimekta.file.FileUtil;
import net.morimekta.file.FileWatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import static com.fasterxml.jackson.core.JsonToken.START_OBJECT;
import static com.fasterxml.jackson.core.JsonToken.VALUE_STRING;
import static java.util.Objects.requireNonNull;
import static net.morimekta.file.FileUtil.readCanonicalPath;
import static net.morimekta.strings.EscapeUtil.javaEscape;

/**
 * A class managing secrets as read from files in a single directory.
 */
@JsonDeserialize(using = SecretsManager.SecretManagerDeserializer.class)
public class SecretsManager implements Closeable {
    private final Map<String, Secret> secrets;

    private final Path              secretsDir;
    private final FileWatcher       fileWatcher;
    private final FileEventListener fileEventListener;

    /**
     * Create a secrets manager getting secrets from the specified directory.
     *
     * @param secretsDir The secrets directory. All non-hidden files in the
     * @throws IOException If the path does not point to an existing directory.
     */
    public SecretsManager(Path secretsDir) throws IOException {
        this(secretsDir, ConfigWatcher.FILE_WATCHER.get());
    }

    /**
     * Create a secrets manager getting secrets from the specified directory.
     *
     * @param secretsDir  The secrets' directory. All non-hidden files in the
     * @param fileWatcher File watcher used to get file events.
     * @throws IOException If the path does not point to an existing directory.
     */
    public SecretsManager(Path secretsDir, FileWatcher fileWatcher) throws IOException {
        requireNonNull(secretsDir, "secretsDir == null");
        requireNonNull(fileWatcher, "fileWatcher == null");
        if (!Files.isDirectory(secretsDir)) {
            throw new IOException("Secrets location \"" + secretsDir + "\" not a directory.");
        }
        this.secrets = new ConcurrentHashMap<>();
        this.secretsDir = secretsDir;
        this.fileWatcher = fileWatcher;
        this.fileEventListener = this::onFileEvent;
    }

    /**
     * @return Get the configured secrets directory path.
     */
    @JsonValue
    public Path getSecretsPath() {
        return secretsDir;
    }

    /**
     * Check if secret with given name exists.
     *
     * @param name The secret name.
     * @return If the secret exists.
     */
    @JsonIgnore
    public boolean exists(String name) {
        requireNonNull(name, "name == null");
        if (name.isBlank()) {
            throw new IllegalArgumentException("Empty secret name.");
        }
        if (name.startsWith(".") || name.contains(File.separator)) {
            throw new IllegalArgumentException(
                    "Hidden file or path in name, not allowed: \"" + javaEscape(name) + "\".");
        }
        try {
            var path = secretsDir.resolve(name);
            if (Files.isSymbolicLink(path)) {
                return Files.isRegularFile(readCanonicalPath(path));
            } else {
                return Files.isRegularFile(path);
            }
        } catch (IOException e) {
            return false;
        }
    }

    /**
     * @return Get a list a set of known secret names.
     */
    @JsonIgnore
    public Set<String> getKnownSecrets() {
        try {
            return FileUtil.list(getSecretsPath())
                           .stream()
                           .map(Path::getFileName)
                           .map(Path::toString)
                           .filter(name -> !name.startsWith("."))
                           .collect(Collectors.toSet());
        } catch (IOException e) {
            return Set.of();
        }
    }

    /**
     * Get the path to a named secret from the secret store.
     *
     * @param name The secret name.
     * @return The secret file path.
     */
    @JsonIgnore
    public Path getAsPath(String name) {
        if (exists(name)) {
            return secretsDir.resolve(name);
        }
        throw new NoSuchElementException("No secret with name \"" + name + "\"");
    }

    /**
     * Get a named secret from the secret store as a string.
     *
     * @param name The secret name.
     * @return The secret value as a string.
     */
    @JsonIgnore
    public String getAsString(String name) {
        return get(name).getAsString();
    }

    /**
     * Get a named secret from the secret store as a byte array.
     *
     * @param name The secret name.
     * @return The secret value as a string.
     */
    @JsonIgnore
    public byte[] getAsBytes(String name) {
        return get(name).getAsBytes();
    }

    /**
     * Get a named secret from the secret store as a binary.
     *
     * @param name The secret name.
     * @return The secret value as a binary.
     */
    @JsonIgnore
    public Binary getAsBinary(String name) {
        return get(name).getAsBinary();
    }

    /**
     * Get a named secret from the secret store.
     *
     * @param name The secret name.
     * @return The loaded secret.
     */
    @JsonIgnore
    public Secret get(String name) {
        requireNonNull(name, "name == null");
        if (name.isBlank()) {
            throw new IllegalArgumentException("Empty secret name.");
        }
        if (name.startsWith(".") || name.contains(File.separator)) {
            throw new IllegalArgumentException(
                    "Hidden file or path in name, not allowed: \"" + javaEscape(name) + "\".");
        }
        Path path = secretsDir.resolve(name);
        if (!Files.exists(path)) {
            if (!Files.exists(secretsDir)) {
                throw new IllegalStateException("Secrets directory deleted: \"" + secretsDir + "\".");
            }
            throw new NoSuchElementException("No secret file for name \"" + name + "\".");
        }
        return secrets.computeIfAbsent(name, (k) -> {
            var secret = new Secret(name, false, readSecret(name, path));
            fileWatcher.weakAddWatcher(path, fileEventListener);
            return secret;
        });
    }


    // ---- Closable ---

    @Override
    public void close() {
        fileWatcher.removeWatcher(fileEventListener);
    }

    // --- Object ---

    @Override
    public String toString() {
        return "SecretsManager{dir=" + secretsDir + '}';
    }

    // --- Static ---

    /**
     * A deserializer for secrets manager to make it easy to include the
     * manager in a standard jackson-parsed config file (JSON or YAML).
     */
    public static class SecretManagerDeserializer extends JsonDeserializer<SecretsManager> {
        @Override
        public SecretsManager deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
                throws IOException {
            if (isTokenType(jsonParser.currentToken(), VALUE_STRING)) {
                var location = jsonParser.currentTokenLocation();
                try {
                    var path = Paths.get(jsonParser.getValueAsString());
                    var canonical = readCanonicalPath(path);
                    var manager = new SecretsManager(canonical);
                    // this was secrets read *after* the secrets manager can use
                    // the secrets manager to load itself. See Secret class.
                    deserializationContext.setAttribute(SecretsManager.class, manager);
                    return manager;
                } catch (IOException e) {
                    throw new JsonParseException(
                            jsonParser,
                            e.getMessage(),
                            location,
                            e);
                }
            } else if (isTokenType(jsonParser.currentToken(), START_OBJECT)) {
                var location = jsonParser.currentTokenLocation();
                try {
                    var config = jsonParser.readValueAs(SecretsConfig.class);
                    var canonical = readCanonicalPath(config.dir);
                    var manager = new SecretsManager(canonical);
                    // this was secrets read *after* the secrets manager can use
                    // the secrets manager to load itself. See Secret class.
                    deserializationContext.setAttribute(SecretsManager.class, manager);
                    return manager;
                } catch (JsonMappingException | JsonParseException e) {
                    throw e;
                } catch (IOException e) {
                    throw new JsonParseException(
                            jsonParser,
                            e.getMessage(),
                            location,
                            e);
                }
            }
            throw new JsonParseException(
                    jsonParser,
                    "Invalid SecretManager config value: " + jsonParser.getValueAsString() + "\n" +
                    "Must either be a directory path as string, or a config object with a \"dir\" property.",
                    jsonParser.currentTokenLocation());
        }
    }

    /**
     * Simple configuration of the secrets manager as an object.
     */
    public static class SecretsConfig {
        @JsonProperty(required = true)
        public Path dir;
    }

    // --- Private ---

    private void onFileEvent(Path file, FileEvent event) {
        String name = file.getFileName().toString();
        if (event == FileEvent.DELETED) {
            if (Files.exists(file)) {
                // Ignore, same special case as with ConfigWatcher#onFileEventInternal
                return;
            }
            secrets.remove(name);
            fileWatcher.removeWatcher(file, fileEventListener);
            return;
        }
        try {
            var secret = secrets.get(name);
            if (secret != null) {
                secret.setSecret(readSecret(name, file));
            } else {
                fileWatcher.removeWatcher(file, fileEventListener);
            }
        } catch (NoSuchElementException e) {
            // Ignore, nothing we can do at this point. It has already been
            // logged. Just stop watching it.
            fileWatcher.removeWatcher(file, fileEventListener);
        }
    }

    private static Binary readSecret(String name, Path file) {
        try {
            var canonical = readCanonicalPath(file);
            if (!Files.isRegularFile(canonical)) {
                throw new NoSuchElementException("Secret " + name + " is not a file.");
            }
            try (var in = Files.newInputStream(canonical)) {
                return Binary.read(in);
            }
        } catch (IOException e) {
            LOGGER.error("Unable to read secret {}: {}", file, e.getMessage(), e);
            throw new NoSuchElementException("No secret for " + name + ".");
        }
    }

    private static final Logger LOGGER = LoggerFactory.getLogger(SecretsManager.class);

    private static boolean isTokenType(JsonToken token, JsonToken type) {
        return token.id() == type.id();
    }
}