Skip to content

Commit

Permalink
Merge pull request #550 from boozallen/port-secret-injection
Browse files Browse the repository at this point in the history
#547 SLACK: Port secret configuration store injection support
  • Loading branch information
jaebchoi authored Jan 30, 2025
2 parents 3ad329f + 23b146c commit 926da92
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ helm install hive-metastore-service oci://ghcr.io/boozallen/aissemble-hive-metas
See [the official bitnami documentation](https://github.com/bitnami/charts/tree/main/bitnami/mysql) for full
configuration options.

| Property | Default |
|------------------|------------------------------------------------------------------------------------|
| fullnameOverride | "hive-metastore-db" |
| auth.database | "metastore" |
| Property | Default |
|------------------|---------------------|
| fullnameOverride | "hive-metastore-db" |
| auth.database | "metastore" |

**Note**:
**Note**:
Injected Config value except Secret will not show as out of sync as we enabled [Server-Side Diff Strategies.](https://argo-cd.readthedocs.io/en/stable/user-guide/diff-strategies/#server-side-diff)

# Migration from aiSSEMBLE v1 Helm Charts
Expand Down
5 changes: 5 additions & 0 deletions foundation/foundation-configuration-store/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@
<artifactId>jakarta.ws.rs-api</artifactId>
<version>${version.jakarta.wr.rs}</version>
</dependency>
<dependency>
<groupId>com.tdunning</groupId>
<artifactId>json</artifactId>
<version>1.8</version>
</dependency>

<!-- quarkus dependencies -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@
* This software package is licensed under the Booz Allen Public License. All Rights Reserved.
* #L%
*/

import com.boozallen.aissemble.configuration.store.ConfigLoader;
import com.boozallen.aissemble.configuration.store.Property;
import com.boozallen.aissemble.configuration.store.PropertyKey;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.flipkart.zjsonpatch.JsonDiff;
import io.fabric8.kubernetes.api.model.StatusBuilder;
import org.apache.commons.lang3.StringUtils;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -36,6 +39,7 @@
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;

import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
Expand All @@ -58,7 +62,7 @@ public class ConfigMutatingWebhook {

@Inject
public ConfigLoader configLoader;

@POST
@Path("/process")
@Consumes(MediaType.APPLICATION_JSON)
Expand All @@ -83,9 +87,19 @@ public AdmissionReview validate(AdmissionReview admissionReviewRequest) {
AdmissionResponse response;
try {
String json = mapper.writeValueAsString(request.getObject());

//This scans to see if there's any config need to be injected and replace corresponding values and saves into updatedJson
String updatedJson = replaceConfigStoreKeysWithConfigValue(json);

if (!updatedJson.isEmpty()) {
JSONObject jsonObject = new JSONObject(json);
if(request.getKind().getKind().equals("Secret") && jsonObject.has("data") ) {
//if replaceConfigStoreKeysWithConfigValue doesn't have any injection it would return empty, so we need original json to scan.
boolean hasPlainTextInjection = !updatedJson.isEmpty();
String secretJson = hasPlainTextInjection ? updatedJson: json;
updatedJson = replaceSecretConfigStoreKeyWithConfigValue(secretJson, mapper, hasPlainTextInjection);
}

if (!updatedJson.isEmpty()){
JsonNode patch = JsonDiff.asJson(mapper.readTree(json), mapper.readTree(updatedJson));
response = new AdmissionResponseBuilder()
.withAllowed(true)
Expand All @@ -94,7 +108,7 @@ public AdmissionReview validate(AdmissionReview admissionReviewRequest) {
.withPatchType("JSONPatch")
.build();
logger.info("The kubernetes resource request has been updated.");
} else {
}else {
response = new AdmissionResponseBuilder()
.withAllowed(true)
.withUid(request.getUid())
Expand Down Expand Up @@ -129,9 +143,51 @@ private String getProperty(String groupName,String propertyName) {
}
}


/**
* This method is to inject values in the data fields for any encoded Configvalues from the properties file and save it as based64 encoded.
*
* @param json json string to scan
* @param mapper json object mapper
* @param hasPlainTextInjection boolean that plain text injection is updated.
* @return updatedJSON If no injection, return empty.
*/
private String replaceSecretConfigStoreKeyWithConfigValue(String json, ObjectMapper mapper, boolean hasPlainTextInjection) throws JsonProcessingException {
String updatedJSON = json;
JSONObject jsonObject = new JSONObject(json);
String dataString = jsonObject.getString("data");
Map<String, String> secretDataMap = mapper.readValue(dataString, new TypeReference<>() {});

//key being base64 value that contains getConfigValue(), value being base64 value injected with the actual property.
Map<String, String> injectionMap = new HashMap<>();
for(String secretDataVal : secretDataMap.values()) {
String decodedStr = new String(Base64.getDecoder().decode(secretDataVal));
String updatedDecodedStr = replaceConfigStoreKeysWithConfigValue(decodedStr);
if (!updatedDecodedStr.isEmpty()) {
injectionMap.put(secretDataVal, new String(Base64.getEncoder().encode(updatedDecodedStr.getBytes())));
}
}

for(Map.Entry<String, String> injection: injectionMap.entrySet()) {
updatedJSON = updatedJSON.replace(injection.getKey(), injection.getValue());
}

if (updatedJSON.equals(json)) {
if (hasPlainTextInjection) {
return json; // no change in secret text, return the json with plain text injections
} else {
return ""; // no change in both secret and plain text, return empty
}
} else {
return updatedJSON;
}

}

private String replaceConfigStoreKeysWithConfigValue(String json) {
StringBuilder updatedJson = new StringBuilder();
String[] data = json.split(Pattern.quote(CONFIG_STORE_INJECT_START));

// if the config store key is found
if (data.length > 1) {
updatedJson.append(data[0]);
Expand Down Expand Up @@ -161,5 +217,4 @@ private Map<String, String> convertToKeyMap(String[] key) {

return map;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.GroupVersionKind;
import io.fabric8.kubernetes.api.model.GroupVersionResource;
import io.fabric8.kubernetes.api.model.KubernetesResource;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.api.model.admission.v1.AdmissionRequest;
import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReviewBuilder;
Expand All @@ -35,38 +38,79 @@
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.List;

public class MutatingWebhookSteps {
private static final String expectedInjectedValue = "env-access-key-id";
private static final String expectedInjectedSecretValue = "env-pass";
private final ObjectMapper objectMapper = new ObjectMapper();
private ValidatableResponse response;
private AdmissionReview admissionReviewRequest;
private ConfigMap configMap;
private Secret secret;
private ObjectMeta objectMeta;
private Map<String, String > configMapData;
private Map<String, String> secretData;
private String responsePatch;


@Given("a ConfigMap definition that contains the substitution key exists")
public void aConfigMapDefinitionThatContainsTheSubstituionKeyExists() {
final String propertyKey = "groupName=aws-credentials;propertyName=AWS_ACCESS_KEY_ID";
objectMeta = new ObjectMeta();
HashMap<String, String> metaLabels = new HashMap();
metaLabels.put("aissemble-configuration-store", "enabled");
objectMeta.setLabels(metaLabels);
configMapData = new HashMap<>();
configMapData.put("AWS_ACCESS_KEY_ID", ConfigMutatingWebhook.CONFIG_STORE_INJECT_START + propertyKey + ConfigMutatingWebhook.CONFIG_STORE_INJECT_END);
}

@Given("the ConfigMap definition has the injection metadata label")
public void theConfigMapDefinitionHasTheInjectionMetatdataLabel() {
@Given("a Secret definition that contains the encoded substitution key exists")
public void aSecretDefinitionThatContainsTheEncodedSubstituionKeyExists() {
final String testPasswordValue = ConfigMutatingWebhook.CONFIG_STORE_INJECT_START + "groupName=aws-credentials;propertyName=AWS_PASSWORD" + ConfigMutatingWebhook.CONFIG_STORE_INJECT_END;
objectMeta = new ObjectMeta();
HashMap<String, String> metaLabels = new HashMap();
metaLabels.put("aissemble-configuration-store", "enabled");
ObjectMeta objectMeta = new ObjectMeta();
objectMeta.setLabels(metaLabels);

secretData = new HashMap<>();
secretData.put("AWS_PASSWORD", new String(org.apache.commons.codec.binary.Base64.encodeBase64(testPasswordValue.getBytes())));
}

@Given("a Secret definition that contains the encoded and plain text substitution key exists")
public void aSecretDefinitionThatContainsTheEncodedAndPlainTextSubstituionKeyExists() {
final String testPasswordValue = ConfigMutatingWebhook.CONFIG_STORE_INJECT_START + "groupName=aws-credentials;propertyName=AWS_PASSWORD" + ConfigMutatingWebhook.CONFIG_STORE_INJECT_END;
final String propertyKey = "groupName=aws-credentials;propertyName=AWS_ACCESS_KEY_ID";

objectMeta = new ObjectMeta();
HashMap<String, String> metaLabels = new HashMap();
metaLabels.put("aissemble-configuration-store", "enabled");
metaLabels.put("TEST_META", ConfigMutatingWebhook.CONFIG_STORE_INJECT_START + propertyKey + ConfigMutatingWebhook.CONFIG_STORE_INJECT_END);
objectMeta.setLabels(metaLabels);

secretData = new HashMap<>();
secretData.put("AWS_PASSWORD", new String(org.apache.commons.codec.binary.Base64.encodeBase64(testPasswordValue.getBytes())));
}

@Given("the ConfigMap definition has the injection metadata label")
public void theConfigMapDefinitionHasTheInjectionMetatdataLabel() {
configMap = new ConfigMapBuilder()
.withData(configMapData)
.withMetadata(objectMeta)
.build();
}

@Given("the Secret definition has the injection metadata label")
public void theSecretDefinitionHasTheInjectionMetatdataLabel() {
secret = new SecretBuilder()
.withData(secretData)
.withMetadata(objectMeta)
.build();
}

@When("a kubernetes resource request is made to create a ConfigMap")
public void aKubernetesResourceRequestIsMade() throws JsonProcessingException {
this.admissionReviewRequest = createAdmissionReviewRequest();
this.admissionReviewRequest = createAdmissionReviewRequest("ConfigMap", "configmaps", configMap);

// Convert AdmissionReview object to JSON
String admissionReviewRequestJson = this.objectMapper.writeValueAsString(admissionReviewRequest);
Expand All @@ -79,7 +123,22 @@ public void aKubernetesResourceRequestIsMade() throws JsonProcessingException {
.then();
}

@Then("the ConfigMap patch is returned")
@When("a kubernetes resource request is made to create a Secret")
public void aKubernetesResourceRequestIsMadeForSecret() throws JsonProcessingException {
this.admissionReviewRequest = createAdmissionReviewRequest("Secret", "secret", secret);

// Convert AdmissionReview object to JSON
String admissionReviewRequestJson = this.objectMapper.writeValueAsString(admissionReviewRequest);

response = given()
.contentType("application/json")
.body(admissionReviewRequestJson)
.when()
.post("/webhook/process")
.then();
}

@Then("the patch is returned")
public void theProcessedKubernetesResourceIsReturned() throws JsonProcessingException {
response.statusCode(200);

Expand All @@ -98,19 +157,37 @@ public void theConfigMapDefinitionContainsTheInjectedValue() throws JsonProcessi
JsonNode jsonPatch = objectMapper.readTree(new String(Base64.getDecoder().decode(responsePatch)));
assertEquals("The response patch does not contain the injected value", expectedInjectedValue, jsonPatch.findValue("value").asText());
}

@Then("the Secret patch contains the encoded injected value")
public void theSecretDefinitionContainsTheInjectedValue() throws JsonProcessingException {
JsonNode jsonPatch = objectMapper.readTree(new String(Base64.getDecoder().decode(responsePatch)));
String encodedInjectedSecretValue = new String(Base64.getEncoder().encode(expectedInjectedSecretValue.getBytes()));
List<String> valuesList = jsonPatch.findValues("value").stream().map(JsonNode::asText).toList();
assertTrue("The response patch does not contain the injected value", valuesList.contains(encodedInjectedSecretValue));
}

@Then("the Secret patch contains the both plain text and encoded injected value")
public void theSecretDefinitionContainsPlainAndEncodedTheInjectedValue() throws JsonProcessingException {
JsonNode jsonPatch = objectMapper.readTree(new String(Base64.getDecoder().decode(responsePatch)));
String encodedInjectedSecretValue = new String(Base64.getEncoder().encode(expectedInjectedSecretValue.getBytes()));
List<String> valuesList = jsonPatch.findValues("value").stream().map(JsonNode::asText).toList();
assertTrue("The response patch does not contain the injected value", valuesList.contains(encodedInjectedSecretValue));
assertTrue("The response patch does not contain the injected value", valuesList.contains(expectedInjectedValue));
}

/**
* Create an {@link AdmissionReviewRequest} for testing
*/
private AdmissionReview createAdmissionReviewRequest() {
private AdmissionReview createAdmissionReviewRequest(String kind, String resource, KubernetesResource resourceObj) {
AdmissionRequest request = new AdmissionRequest();
request.setUid("example-uid");
request.setKind(new GroupVersionKind("", "ConfigMap", "v1"));
request.setResource(new GroupVersionResource("", "configmaps", "v1"));
request.setKind(new GroupVersionKind("", kind, "v1"));
request.setResource(new GroupVersionResource("", resource, "v1"));
request.setName("example-pod");
request.setNamespace("default");

// Set the pod on the request
request.setObject(configMap);
request.setObject(resourceObj);

// Create AdmissionReview with the request
return new AdmissionReviewBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ public void consumesTheBaseProperties() {

@Then("augments the base with the environment properties")
public void augmentsTheBaseWithTheEnvironmentConfigurations() {
assertEquals(13, TestPropertyDao.loadedProperties.size());
assertEquals(14, TestPropertyDao.loadedProperties.size());
assertPropertySetsEqual(createExpectedProperties(), new HashSet<>(TestPropertyDao.loadedProperties.values()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
# #L%
###
AWS_ACCESS_KEY_ID=base-access-key-id
AWS_PASSWORD=env-pass
# This is a test only value and holds no significance
AWS_SECRET_ACCESS_KEY=ENC(Hs0eE3pKIxqntLXVuqgBJE9e9uFy29W8bMDocqbiakbFPhQf6Xp5oAL1Z6m6tdd3i0sEREN2pX2chS4q6KxiIw==)
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,22 @@ Feature: Modify kubernetes resources with config values using a mutating webhook
And a ConfigMap definition that contains the substitution key exists
And the ConfigMap definition has the injection metadata label
When a kubernetes resource request is made to create a ConfigMap
Then the ConfigMap patch is returned
And the ConfigMap patch contains the injected value
Then the patch is returned
And the ConfigMap patch contains the injected value

Scenario: The configuration service can inject encoded values to newly created Secret
Given the configuration service has started
And a Secret definition that contains the encoded substitution key exists
And the Secret definition has the injection metadata label
When a kubernetes resource request is made to create a Secret
Then the patch is returned
And the Secret patch contains the encoded injected value


Scenario: The configuration service can inject both plain text and encoded values to newly created Secret
Given the configuration service has started
And a Secret definition that contains the encoded and plain text substitution key exists
And the Secret definition has the injection metadata label
When a kubernetes resource request is made to create a Secret
Then the patch is returned
And the Secret patch contains the both plain text and encoded injected value

0 comments on commit 926da92

Please sign in to comment.