Vault.java

/*
 * Copyright © 2025 Indiana University
 * All rights reserved.
 *
 * BSD 3-Clause License
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * - Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * - Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * - Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package iu.client;

import java.lang.reflect.Type;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Logger;

import edu.iu.IuCacheMap;
import edu.iu.IuException;
import edu.iu.IuObject;
import edu.iu.IuRuntimeEnvironment;
import edu.iu.client.HttpException;
import edu.iu.client.HttpResponseHandler;
import edu.iu.client.IuHttp;
import edu.iu.client.IuJson;
import edu.iu.client.IuJsonAdapter;
import edu.iu.client.IuVault;
import edu.iu.client.IuVaultKeyedValue;
import edu.iu.client.IuVaultSecret;
import jakarta.json.JsonObject;
import jakarta.json.JsonValue;

/**
 * Provides access to secrets stored in HashiCorp Vault.
 * 
 */
public final class Vault implements IuVault {
	static {
		IuObject.assertNotOpen(Vault.class);
	}

	private static final Logger LOG = Logger.getLogger(Vault.class.getName());

	/**
	 * Implements {@link IuVault#of(Properties, Function)}.
	 * 
	 * @param properties   optional property overrides
	 * @param valueAdapter
	 * @return {@link Vault} instance
	 */
	public static Vault of(Properties properties, Function<Type, IuJsonAdapter<?>> valueAdapter) {
		final var endpoint = prop(properties, "iu.vault.endpoint", URI::create);
		if (endpoint == null) {
			if (properties == null)
				return null;
			else
				throw new NullPointerException("Missing iu.vault.endpoint");
		}

		final var cacheTtl = prop(properties, "iu.vault.cacheTtl", Duration::parse);
		final Map<String, JsonObject> secretCache;
		if (cacheTtl == null)
			secretCache = null;
		else
			secretCache = new IuCacheMap<>(cacheTtl);

		final var secretNames = prop(properties, "iu.vault.secrets", a -> a.split(","));
		final var token = prop(properties, "iu.vault.token", a -> a);
		final var cubbyhole = "true".equals(prop(properties, "iu.vault.cubbyhole", a -> a));
		if (token != null)
			return new Vault(endpoint, secretNames, token, cubbyhole, valueAdapter, secretCache);
		else {
			final var loginEndpoint = Objects.requireNonNull( //
					prop(properties, "iu.vault.loginEndpoint", URI::create),
					"Missing iu.vault.loginEndpoint or iu.vault.token");

			final var roleId = Objects.requireNonNull( //
					prop(properties, "iu.vault.roleId", a -> a), "Missing iu.vault.roleId");
			final var secretId = Objects.requireNonNull( //
					prop(properties, "iu.vault.secretId", a -> a), "Missing iu.vault.secretId");

			return new Vault(endpoint, secretNames, loginEndpoint, roleId, secretId, cubbyhole, valueAdapter,
					secretCache);
		}
	}

	/**
	 * Determines if the {@link IuVault#RUNTIME} is configured and will be non-null.
	 * 
	 * @return true if {@link IuVault#RUNTIME} is configured; else false
	 */
	public static boolean isConfigured() {
		return prop(null, "iu.vault.endpoint", URI::create) != null;
	}

	/**
	 * Reads a configuration property.
	 * 
	 * @param <T>                 property type
	 * @param properties          runtime environment overrides
	 * @param name                property name
	 * @param textToValueFunction conversion function
	 * @return property value
	 */
	static <T> T prop(Properties properties, String name, Function<String, T> textToValueFunction) {
		if (properties != null) {
			final var propertyValue = properties.getProperty(name);
			if (propertyValue != null)
				return textToValueFunction.apply(propertyValue);
		}

		return IuRuntimeEnvironment.envOptional(name, textToValueFunction);
	}

	private final URI endpoint;
	private final String[] secretNames;
	private final URI loginEndpoint;
	private final String roleId;
	private final String secretId;
	private final boolean cubbyhole;
	private final Function<Type, IuJsonAdapter<?>> valueAdapter;
	private final Map<String, JsonObject> secretCache;

	private String token;
	private Instant tokenExpires;

	private Vault(URI endpoint, String[] secretNames, String token, boolean cubbyhole,
			Function<Type, IuJsonAdapter<?>> valueAdapter, Map<String, JsonObject> secretCache) {
		this.endpoint = endpoint;
		this.secretNames = secretNames;
		this.token = token;
		this.loginEndpoint = null;
		this.roleId = null;
		this.secretId = null;
		this.cubbyhole = cubbyhole;
		this.valueAdapter = valueAdapter;
		this.secretCache = secretCache;
	}

	private Vault(URI endpoint, String[] secretNames, URI loginEndpoint, String roleId, String secretId,
			boolean cubbyhole, Function<Type, IuJsonAdapter<?>> valueAdapter, Map<String, JsonObject> secretCache) {
		this.endpoint = endpoint;
		this.secretNames = secretNames;
		this.token = null;
		this.loginEndpoint = loginEndpoint;
		this.roleId = roleId;
		this.secretId = secretId;
		this.cubbyhole = cubbyhole;
		this.valueAdapter = valueAdapter;
		this.secretCache = secretCache;
	}

	@Override
	public Iterable<IuVaultKeyedValue<?>> list() {
		if (secretNames == null)
			throw new UnsupportedOperationException();

		final Map<String, IuVaultKeyedValue<?>> values = new LinkedHashMap<>();
		for (final var secretName : secretNames) {
			final var secret = getSecret(secretName);
			for (final var key : secret.getData().keySet())
				if (!values.containsKey(key))
					values.put(key, secret.get(key, Object.class));
		}

		return values.values();
	}

	@Override
	public IuVaultKeyedValue<String> get(String name) {
		return get(name, String.class);
	}

	@Override
	public <T> IuVaultKeyedValue<T> get(String name, Class<T> type) {
		if (secretNames == null)
			throw new UnsupportedOperationException();

		for (String secretName : secretNames) {
			final var secret = getSecret(secretName);
			if (secret.getData().containsKey(name))
				return secret.get(name, type);
		}

		throw new IllegalArgumentException(
				name + " not found in Vault using " + endpoint + '/' + Arrays.toString(secretNames));
	}

	@Override
	public IuVaultSecret getSecret(String secret) {
		class Ref {
			JsonObject data;
		}

		final var dataUri = dataUri(secret);

		final Ref ref;
		final Supplier<JsonObject> dataSupplier;
		final Supplier<JsonObject> metadataSupplier;

		final Function<JsonObject, JsonObject> convertData;
		final Function<JsonObject, JsonObject> convertMetadata;
		if (cubbyhole) {
			convertData = a -> a;
			convertMetadata = a -> null;
		} else {
			// TODO: support launchpad through configuration
			if (secret.startsWith("managed/")) {
				final var lastSlash = secret.lastIndexOf('/');
				final var prefix = secret.substring(lastSlash + 1) + '/';
				convertData = a -> {
					final var b = IuJson.object();
					for (final var e : a.getJsonObject("data").entrySet())
						b.add(prefix + e.getKey(), e.getValue());
					return b.build();
				};
			} else
				convertData = a -> a.getJsonObject("data");
			convertMetadata = a -> a.getJsonObject("metadata");
		}

		if (secretCache == null) {
			ref = new Ref();
			ref.data = readSecret(secret);
			dataSupplier = () -> convertData.apply(ref.data);
			metadataSupplier = () -> convertMetadata.apply(ref.data);
		} else {
			ref = null;
			dataSupplier = () -> convertData.apply(readSecretUsingCache(secret));
			metadataSupplier = () -> convertMetadata.apply(readSecretUsingCache(secret));
		}

		final Consumer<JsonObject> mergePatchConsumer;
		if (secret.startsWith("managed/"))
			mergePatchConsumer = a -> {
				// TODO: support launchpad through configuration
				throw new UnsupportedOperationException();
			};
		else
			mergePatchConsumer = mergePatch -> IuException.unchecked(() -> {
				final var data = dataSupplier.get();
				final var metadata = metadataSupplier.get();

				final var updatedData = IuJson.PROVIDER.createMergePatch(mergePatch).apply(data).asJsonObject();

				final String dataRequestPayload;
				if (cubbyhole)
					dataRequestPayload = updatedData.toString();
				else {
					final var dataRequestPayloadBuilder = IuJson.object();
					dataRequestPayloadBuilder.add("options", IuJson.object().add("cas", metadata.getInt("version")));
					dataRequestPayloadBuilder.add("data", updatedData);
					dataRequestPayload = dataRequestPayloadBuilder.build().toString();
				}

				final HttpResponseHandler<?> responseHandler;
				if (cubbyhole)
					responseHandler = IuHttp.NO_CONTENT;
				else
					responseHandler = IuHttp.READ_JSON_OBJECT;

				if (cubbyhole && updatedData.isEmpty())
					IuHttp.send(dataUri, rb -> {
						rb.DELETE();
						this.authorize(rb);
					}, responseHandler);
				else
					IuHttp.send(dataUri, rb -> {
						rb.POST(BodyPublishers.ofString(dataRequestPayload));
						this.authorize(rb);
					}, responseHandler);

				final var delete = mergePatch.values().stream().allMatch(JsonValue.NULL::equals);
				LOG.config(() -> "vault:" + (delete ? "delete:" : "set:") + dataUri + ":" + mergePatch.keySet());

				final var updated = readSecret(secret);
				if (secretCache == null)
					ref.data = updated;
				else
					secretCache.put(secret, updated);
			});

		return new VaultSecret(secret, dataUri, dataSupplier, metadataSupplier, mergePatchConsumer, valueAdapter);
	}

	private JsonObject readSecretUsingCache(String secret) {
		var data = secretCache.get(secret);
		if (data == null)
			secretCache.put(secret, data = readSecret(secret));
		return data;
	}

	/**
	 * Reads a secret using the Vault API.
	 * 
	 * @param secret secret name
	 * @return parsed secret
	 */
	JsonObject readSecret(String secret) {
		return IuException.unchecked(() -> {
			try {
				return IuHttp.send(dataUri(secret), this::authorize, IuHttp.READ_JSON_OBJECT).getJsonObject("data");
			} catch (HttpException e) {
				if (e.getResponse().statusCode() == 404)
					if (cubbyhole)
						return IuJson.object().build();
					else
						return IuJson.object().add("data", IuJson.object()).build();
				else
					throw e;
			}
		});
	}

	/**
	 * Creates a data URI for calling the Vault API.
	 * 
	 * @param secret secret name
	 * @return Vault API data {@link URI}
	 */
	URI dataUri(String secret) {
		if (!secret.matches("[\\w\\/\\-]+"))
			throw new IllegalArgumentException("invalid secret name");

		final var sb = new StringBuilder();
		sb.append(endpoint);
		sb.append('/');
		if (cubbyhole)
			sb.append(secret);
		else
			sb.append(URLEncoder.encode(secret, StandardCharsets.UTF_8));
		return URI.create(sb.toString());
	}

	private void approle(HttpRequest.Builder requestBuilder) {
		final var payload = IuJson.object();
		payload.add("role_id", roleId);
		payload.add("secret_id", secretId);
		requestBuilder.header("Content-Type", "application/json;charset=utf-8");
		requestBuilder.POST(BodyPublishers.ofString(payload.build().toString()));
	}

	private void authorize(HttpRequest.Builder requestBuilder) {
		if (tokenExpires != null //
				&& Instant.now().isAfter(tokenExpires))
			token = null;

		if (token == null) {
			final var authResponse = IuException.unchecked( //
					() -> IuHttp.send(loginEndpoint, this::approle, IuHttp.READ_JSON_OBJECT) //
							.getJsonObject("auth"));

			tokenExpires = Instant.now().truncatedTo(ChronoUnit.SECONDS)
					.plusSeconds(authResponse.getInt("lease_duration", 0));
			token = authResponse.getString("client_token");
		}

		requestBuilder.header("X-Vault-Token", token);
	}

}