Secret.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.JsonValue;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import net.morimekta.collect.util.Binary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import static com.fasterxml.jackson.core.JsonToken.VALUE_STRING;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static net.morimekta.collect.util.Binary.encodeFromString;
/**
* Keeps a secret.
*/
@JsonDeserialize(using = Secret.SecretDeserializer.class)
public final class Secret {
/**
* Create a secret.
*
* @param name The name of the secret.
* @param env If the secret comes from environment.
* @param value The initial secret value.
*/
public Secret(String name, boolean env, Binary value) {
this.name = requireNonNull(name, "name == null");
this.env = env;
this.reference = new AtomicReference<>(requireNonNull(value, "value == null"));
this.listeners = new ArrayList<>();
}
/**
* @return The secret name. The name is surrounded with '${}' if it comes
* from environment.
*/
@JsonValue
public String getName() {
if (env) {
return "${" + name + "}";
}
return name;
}
/**
* @return The secret value as string.
*/
@JsonIgnore
public String getAsString() {
return reference.get().decodeToString(UTF_8);
}
/**
* @return The secret value as bytes.
*/
@JsonIgnore
public byte[] getAsBytes() {
return reference.get().get();
}
/**
* @return The secret value as binary.
*/
@JsonIgnore
public Binary getAsBinary() {
return reference.get();
}
/**
* Add a listener for secret changes.
*
* @param listener The secret change listener.
*/
@JsonIgnore
public void addListener(SecretListener listener) {
synchronized (listeners) {
listeners.removeIf(it -> it == listener);
listeners.add(listener);
}
}
/**
* Remove a listener for secret changes.
*
* @param listener The secret change listener.
*/
@JsonIgnore
public void removeListener(SecretListener listener) {
synchronized (listeners) {
listeners.removeIf(it -> it == listener);
}
}
/**
* Set the secret to a new value. Should only be called from {@link SecretsManager}, and
* is mainly visible for testing purposes.
*
* @param secret The secret value.
*/
@JsonIgnore
public void setSecret(Binary secret) {
var old = reference.getAndSet(requireNonNull(secret));
if (secret.equals(old)) {
return;
}
var snapshot = new Secret(name, env, secret, listeners);
for (var listener : getListeners()) {
try {
listener.onSecretChange(snapshot);
} catch (Exception e) {
LOGGER.warn("Exception notifying on secret update: {}", e.getMessage(), e);
}
}
}
// --- Object ---
@Override
public String toString() {
return "Secret{'" + getName() + "'}";
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Secret secret = (Secret) o;
return env == secret.env &&
name.equals(secret.name) &&
Objects.equals(reference.get(), secret.reference.get());
}
@Override
public int hashCode() {
return Objects.hash(name, env, reference.get());
}
// --- Static ---
public static class SecretDeserializer extends JsonDeserializer<Secret> {
@Override
public Secret deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
throws IOException {
if (jsonParser.currentToken().id() == VALUE_STRING.id()) {
var name = jsonParser.getValueAsString();
var manager = (SecretsManager) deserializationContext.getAttribute(SecretsManager.class);
try {
if (name.startsWith("${") && name.endsWith("}")) {
var envName = name.substring(2, name.length() - 1);
if (manager != null) {
if (manager.exists(envName)) {
return manager.get(envName);
}
}
var value = System.getenv(envName);
if (value == null || value.isEmpty()) {
throw new JsonParseException(
jsonParser,
"Unknown secret " + name,
jsonParser.currentTokenLocation());
}
return new Secret(envName, true, encodeFromString(value, UTF_8));
}
if (manager == null) {
throw new JsonParseException(
jsonParser,
"No secrets manager to load secret: " + name,
jsonParser.currentTokenLocation());
}
return manager.get(name);
} catch (IllegalArgumentException | IllegalStateException e) {
throw new JsonParseException(
jsonParser,
e.getMessage(),
jsonParser.currentTokenLocation(),
e);
} catch (NoSuchElementException e) {
throw new JsonParseException(
jsonParser,
"Unknown secret " + name,
jsonParser.currentTokenLocation(),
e);
}
}
throw new JsonParseException(
jsonParser,
"Invalid Secret token " + jsonParser.getValueAsString() + ", expected string",
jsonParser.currentTokenLocation());
}
}
// --- Private ---
private final String name;
private final boolean env;
private final AtomicReference<Binary> reference;
private final List<SecretListener> listeners;
// Create a snapshot secret, used in events to ensure they behave as immutable
// objects.
private Secret(String name, boolean env, Binary value, List<SecretListener> listeners) {
this.name = name;
this.env = env;
this.reference = new AtomicReference<>(value);
this.listeners = listeners;
}
@JsonIgnore
private List<SecretListener> getListeners() {
synchronized (listeners) {
return List.copyOf(listeners);
}
}
private static final Logger LOGGER = LoggerFactory.getLogger(Secret.class);
}