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();
}
}