001/*
002 * Copyright 2025 devteam@scivics-lab.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing,
011 * software distributed under the License is distributed on an
012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied.  See the License for the
014 * specific language governing permissions and limitations
015 * under the License.
016 */
017
018package com.scivicslab.actoriac;
019
020import org.yaml.snakeyaml.Yaml;
021
022import java.io.IOException;
023import java.net.URI;
024import java.net.http.HttpClient;
025import java.net.http.HttpRequest;
026import java.net.http.HttpResponse;
027import java.time.Duration;
028import java.util.Map;
029
030/**
031 * Client for HashiCorp Vault API communication.
032 * Supports reading secrets from Vault KV v2 engine.
033 *
034 * @author devteam@scivics-lab.com
035 */
036public class VaultClient {
037    private final VaultConfig config;
038    private final HttpClient httpClient;
039
040    /**
041     * Creates a new VaultClient with the given configuration.
042     *
043     * @param config Vault configuration
044     */
045    public VaultClient(VaultConfig config) {
046        this.config = config;
047        this.httpClient = HttpClient.newBuilder()
048                .connectTimeout(Duration.ofSeconds(10))
049                .build();
050    }
051
052    /**
053     * Reads a secret from Vault.
054     *
055     * @param path Secret path (e.g., "secret/data/ssh/iacuser/private_key")
056     * @return Secret value as String
057     * @throws VaultException if Vault communication fails or secret not found
058     */
059    public String readSecret(String path) throws VaultException {
060        try {
061            String url = config.getAddress() + "/v1/" + path;
062
063            HttpRequest request = HttpRequest.newBuilder()
064                    .uri(URI.create(url))
065                    .header("X-Vault-Token", config.getToken())
066                    .GET()
067                    .build();
068
069            HttpResponse<String> response = httpClient.send(request,
070                    HttpResponse.BodyHandlers.ofString());
071
072            if (response.statusCode() == 404) {
073                throw new VaultException("Secret not found at path: " + path);
074            }
075
076            if (response.statusCode() != 200) {
077                throw new VaultException("Vault returned status " + response.statusCode() +
078                        ": " + response.body());
079            }
080
081            return extractSecretValue(response.body());
082
083        } catch (IOException | InterruptedException e) {
084            throw new VaultException("Failed to read secret from Vault: " + e.getMessage(), e);
085        }
086    }
087
088    /**
089     * Extracts the secret value from Vault API response.
090     *
091     * For KV v2 engine, the response structure is:
092     * {
093     *   "data": {
094     *     "data": {
095     *       "value": "actual-secret-value"
096     *     }
097     *   }
098     * }
099     *
100     * @param jsonResponse JSON response from Vault
101     * @return Secret value
102     * @throws VaultException if response format is invalid
103     */
104    @SuppressWarnings("unchecked")
105    private String extractSecretValue(String jsonResponse) throws VaultException {
106        try {
107            Yaml yaml = new Yaml();
108            Map<String, Object> response = yaml.load(jsonResponse);
109
110            Object dataObj = response.get("data");
111            if (!(dataObj instanceof Map)) {
112                throw new VaultException("Invalid Vault response: 'data' field missing or not a map");
113            }
114
115            Map<String, Object> data = (Map<String, Object>) dataObj;
116            Object innerDataObj = data.get("data");
117            if (!(innerDataObj instanceof Map)) {
118                throw new VaultException("Invalid Vault response: nested 'data' field missing or not a map");
119            }
120
121            Map<String, Object> innerData = (Map<String, Object>) innerDataObj;
122            Object value = innerData.get("value");
123            if (value == null) {
124                throw new VaultException("Invalid Vault response: 'value' field missing");
125            }
126
127            return value.toString();
128
129        } catch (Exception e) {
130            throw new VaultException("Failed to parse Vault response: " + e.getMessage(), e);
131        }
132    }
133
134    /**
135     * Exception thrown when Vault operations fail.
136     */
137    public static class VaultException extends Exception {
138        public VaultException(String message) {
139            super(message);
140        }
141
142        public VaultException(String message, Throwable cause) {
143            super(message, cause);
144        }
145    }
146}