SecretValueDeserializer.java

/*
 * Copyright 2024 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.jackson;

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.deser.std.StdDeserializer;
import net.morimekta.config.SecretsManager;

import java.io.IOException;
import java.util.NoSuchElementException;
import java.util.Objects;

/**
 * Deserializer meant to read secrets into string fields. The default behavior
 * is to just read secrets from environment variables. To read secrets from the
 * secrets manager, it has to be set in the deserialization context as an
 * attribute:
 *
 * <pre><code>{@code
 * objectManager
 *     .reader()
 *     .withAttribute(SecretsManager.class, secretsManager)
 *     .readValue(from, MyConfig.class)
 * }</code></pre>
 * <p>
 * Or use the feature of the <code>SecretManagerDeserializer</code> that it will
 * insert itself into the deserializer context when itself is deserialized.
 * Example for yaml:
 * <pre><code>{@code
 * ---
 * secretsManager:
 *   dir: "/my/secrets"
 * mySecret: "${SECRET_NAME}"
 * }</code></pre>
 */
public class SecretValueDeserializer extends StdDeserializer<String> {
    public SecretValueDeserializer() {
        super(String.class);
    }

    @Override
    public String deserialize(
            JsonParser jsonParser,
            DeserializationContext deserializationContext) throws IOException {
        if (Objects.requireNonNull(jsonParser.currentToken()) == JsonToken.VALUE_STRING) {
            var value = jsonParser.getValueAsString();
            try {
                if (value.startsWith("${") && value.endsWith("}")) {
                    var manager = (SecretsManager) deserializationContext.getAttribute(SecretsManager.class);
                    var secretName = value.substring(2, value.length() - 1).trim();
                    if (manager != null) {
                        if (manager.exists(secretName)) {
                            return manager.get(secretName).getAsString();
                        }
                    }

                    var envValue = System.getenv(secretName);
                    if (envValue == null || envValue.isEmpty()) {
                        throw new JsonParseException(
                                jsonParser,
                                "Unknown secret '" + secretName + "'",
                                jsonParser.currentTokenLocation());
                    }
                    return envValue;
                }
            } catch (IllegalArgumentException | IllegalStateException e) {
                throw new JsonParseException(
                        jsonParser,
                        e.getMessage(),
                        jsonParser.currentTokenLocation(),
                        e);
            } catch (NoSuchElementException e) {
                throw new JsonParseException(
                        jsonParser,
                        "Unknown secret " + value,
                        jsonParser.currentTokenLocation(),
                        e);
            }
        }
        // Fall back to standard string parsing.
        return this._parseString(jsonParser, deserializationContext, this);
    }
}