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