diff --git a/api/src/main/java/keywhiz/api/automation/v2/CloneSecretRequestV2.java b/api/src/main/java/keywhiz/api/automation/v2/CloneSecretRequestV2.java new file mode 100644 index 000000000..98fe58002 --- /dev/null +++ b/api/src/main/java/keywhiz/api/automation/v2/CloneSecretRequestV2.java @@ -0,0 +1,62 @@ +package keywhiz.api.automation.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.auto.value.AutoValue; +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import keywhiz.api.validation.ValidBase64; + +import javax.annotation.Nullable; +import java.util.Base64; +import java.util.Map; + +@AutoValue public abstract class CloneSecretRequestV2 { + CloneSecretRequestV2() {} // prevent sub-classing + + public static Builder builder() { + return new AutoValue_CloneSecretRequestV2.Builder() + .name("") + .newName(""); + } + + @AutoValue.Builder public abstract static class Builder { + // intended to be package-private + abstract CloneSecretRequestV2 autoBuild(); + + public abstract Builder name(String name); + public abstract Builder newName(String newName); + + /** + * @throws IllegalArgumentException if builder data is invalid. + */ + public CloneSecretRequestV2 build() { + // throws IllegalArgumentException if content not valid base64. + return autoBuild(); + } + } + + /** + * Static factory method used by Jackson for deserialization + */ + @SuppressWarnings("unused") + @JsonCreator public static CloneSecretRequestV2 fromParts( + @JsonProperty("name") String name, + @JsonProperty("newName") String newName) { + return builder() + .name(name) + .newName(newName) + .build(); + } + + @JsonProperty("name") public abstract String name(); + @JsonProperty("newName") public abstract String newName(); + + @Override public final String toString() { + return MoreObjects.toStringHelper(this) + .add("name", name()) + .add("newName", newName()) + .toString(); + } +} diff --git a/cli/src/main/java/keywhiz/cli/CommandExecutor.java b/cli/src/main/java/keywhiz/cli/CommandExecutor.java index aaf0963f7..88877e4d2 100644 --- a/cli/src/main/java/keywhiz/cli/CommandExecutor.java +++ b/cli/src/main/java/keywhiz/cli/CommandExecutor.java @@ -31,6 +31,7 @@ import javax.inject.Inject; import keywhiz.cli.commands.AddAction; import keywhiz.cli.commands.AssignAction; +import keywhiz.cli.commands.CloneAction; import keywhiz.cli.commands.DeleteAction; import keywhiz.cli.commands.DescribeAction; import keywhiz.cli.commands.ListAction; @@ -43,6 +44,7 @@ import keywhiz.cli.configs.AddActionConfig; import keywhiz.cli.configs.AssignActionConfig; import keywhiz.cli.configs.CliConfiguration; +import keywhiz.cli.configs.CloneActionConfig; import keywhiz.cli.configs.DeleteActionConfig; import keywhiz.cli.configs.DescribeActionConfig; import keywhiz.cli.configs.ListActionConfig; @@ -67,6 +69,7 @@ public class CommandExecutor { public enum Command { ADD, ASSIGN, + CLONE, DELETE, DESCRIBE, LIST, @@ -203,6 +206,9 @@ public void executeCommand() throws IOException { new RenameAction((RenameActionConfig) commands.get(command), client).run(); break; + case CLONE: + new CloneAction((CloneActionConfig) commands.get(command), client, printing).run(); + case LOGIN: // User is already logged in at this point break; diff --git a/cli/src/main/java/keywhiz/cli/commands/CloneAction.java b/cli/src/main/java/keywhiz/cli/commands/CloneAction.java new file mode 100644 index 000000000..0c3010f57 --- /dev/null +++ b/cli/src/main/java/keywhiz/cli/commands/CloneAction.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed 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 keywhiz.cli.commands; + +import keywhiz.api.SecretDetailResponse; +import keywhiz.api.model.Group; +import keywhiz.cli.Printing; +import keywhiz.cli.configs.CloneActionConfig; +import keywhiz.client.KeywhizClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +import static java.lang.String.format; +import static keywhiz.cli.Utilities.VALID_NAME_PATTERN; +import static keywhiz.cli.Utilities.validName; + +public class CloneAction implements Runnable { + private static final Logger logger = LoggerFactory.getLogger(CloneAction.class); + + private final CloneActionConfig cloneActionConfig; + private final KeywhizClient keywhizClient; + private final Printing printing; + + public CloneAction(CloneActionConfig cloneActionConfig, KeywhizClient client, Printing printing) { + this.cloneActionConfig = cloneActionConfig; + this.keywhizClient = client; + this.printing = printing; + } + + @Override public void run() { + if (cloneActionConfig.newName == null || !validName(cloneActionConfig.newName)) { + throw new IllegalArgumentException(format("Invalid name, must match %s", VALID_NAME_PATTERN)); + } + + try { + logger.info("Cloning secret '{}' to new name '{}'", cloneActionConfig.name, cloneActionConfig.newName); + long existingId = keywhizClient.getSanitizedSecretByName(cloneActionConfig.name).id(); + SecretDetailResponse existingSecretDetails = keywhizClient.secretDetailsForId(existingId); + + long newId = keywhizClient.cloneSecret(cloneActionConfig.name, cloneActionConfig.newName).id; + for (Group group : existingSecretDetails.groups) { + keywhizClient.grantSecretToGroupByIds(newId, group.getId()); + } + printing.printSecretWithDetails(newId); + } catch (KeywhizClient.NotFoundException e) { + throw new AssertionError("Source secret doesn't exist."); + } catch (KeywhizClient.ConflictException e) { + throw new AssertionError("New secret name is already in use."); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/cli/src/main/java/keywhiz/cli/configs/CloneActionConfig.java b/cli/src/main/java/keywhiz/cli/configs/CloneActionConfig.java new file mode 100644 index 000000000..0ddbe8d07 --- /dev/null +++ b/cli/src/main/java/keywhiz/cli/configs/CloneActionConfig.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed 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 keywhiz.cli.configs; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; + +import java.util.List; + +@Parameters(commandDescription = "Clone an existing secret") +public class CloneActionConfig { + + @Parameter(names = "--name", description = "Name of the secret to clone", required = true) + public String name; + + @Parameter(names = "--new-name", description = "Name for the clone", + required = true) + public String newName; +} diff --git a/cli/src/test/java/keywhiz/cli/commands/CloneActionTest.java b/cli/src/test/java/keywhiz/cli/commands/CloneActionTest.java new file mode 100644 index 000000000..b08cdd4ea --- /dev/null +++ b/cli/src/test/java/keywhiz/cli/commands/CloneActionTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed 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 keywhiz.cli.commands; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import keywhiz.api.ApiDate; +import keywhiz.api.SecretDetailResponse; +import keywhiz.api.model.Group; +import keywhiz.api.model.SanitizedSecret; +import keywhiz.api.model.Secret; +import keywhiz.cli.Printing; +import keywhiz.cli.configs.CloneActionConfig; +import keywhiz.client.KeywhizClient; +import keywhiz.client.KeywhizClient.NotFoundException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.io.IOException; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CloneActionTest { + private static final ApiDate NOW = ApiDate.now(); + + @Rule public MockitoRule mockito = MockitoJUnit.rule(); + + @Mock KeywhizClient keywhizClient; + @Mock Printing printing; + + CloneActionConfig cloneActionConfig; + CloneAction cloneAction; + + Secret oldSecret = new Secret(0, "oldSecret", null, null, () -> "c2VjcmV0MQ==", "checksum", NOW, null, NOW, null, null, null, + ImmutableMap.of(), 0, 1L, NOW, null); + Secret newSecret = new Secret(1, "newSecret", null, null, () -> "c2VjcmV0MQ==", "checksum", NOW, null, NOW, null, null, null, + ImmutableMap.of(), 0, 1L, NOW, null); + Group group = new Group(5, "group", null, null, null, null, null, null); + + SecretDetailResponse detailResponse = SecretDetailResponse.fromSecret( + newSecret, ImmutableList.of(group), ImmutableList.of()); + + @Before + public void setUp() throws IOException { + cloneActionConfig = new CloneActionConfig(); + cloneAction = new CloneAction(cloneActionConfig, keywhizClient, printing); + + when(keywhizClient.getSanitizedSecretByName("oldSecret")).thenReturn(SanitizedSecret.fromSecret(oldSecret)); + when(keywhizClient.secretDetailsForId(0)).thenReturn(detailResponse); + } + + @Test + public void cloneCopiesSecret() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenReturn(detailResponse); + + cloneAction.run(); + verify(keywhizClient).cloneSecret("oldSecret", "newSecret"); + } + + @Test + public void cloneCopiesGroupAssignments() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenReturn(detailResponse); + + cloneAction.run(); + verify(keywhizClient).grantSecretToGroupByIds(1, 5); + } + + @Test + public void cloneCallsPrint() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenReturn(detailResponse); + + cloneAction.run(); + verify(printing).printSecretWithDetails(1); + } + + @Test + public void cloneThrowsIfOldSecretDoesNotExist() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenThrow(NotFoundException.class); + AssertionError ex = assertThrows( + AssertionError.class, + () -> cloneAction.run() + ); + assertTrue(ex.getMessage().contains("Source secret doesn't exist")); + } + + @Test + public void cloneThrowsIfNewNameIsInvalid() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "totally invalid name!!!😱"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenReturn(detailResponse); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> cloneAction.run() + ); + assertTrue(ex.getMessage().contains("Invalid name")); + } + + @Test + public void cloneThrowsIfNewNameConflicts() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenThrow(KeywhizClient.ConflictException.class); + AssertionError ex = assertThrows( + AssertionError.class, + () -> cloneAction.run() + ); + assertTrue(ex.getMessage().contains("New secret name is already in use")); + } + + @Test + public void cloneWrapsIOException() throws Exception { + cloneActionConfig.name = "oldSecret"; + cloneActionConfig.newName = "newSecret"; + + when(keywhizClient.cloneSecret("oldSecret", "newSecret")).thenThrow(new IOException("uh oh spaghettios!")); + RuntimeException ex = assertThrows( + RuntimeException.class, + () -> cloneAction.run() + ); + assertTrue(ex.getMessage().contains("uh oh spaghettios!")); + } +} diff --git a/client/src/main/java/keywhiz/client/KeywhizClient.java b/client/src/main/java/keywhiz/client/KeywhizClient.java index b365a7293..546d22b67 100644 --- a/client/src/main/java/keywhiz/client/KeywhizClient.java +++ b/client/src/main/java/keywhiz/client/KeywhizClient.java @@ -28,6 +28,7 @@ import keywhiz.api.GroupDetailResponse; import keywhiz.api.LoginRequest; import keywhiz.api.SecretDetailResponse; +import keywhiz.api.automation.v2.CloneSecretRequestV2; import keywhiz.api.automation.v2.CreateClientRequestV2; import keywhiz.api.automation.v2.CreateGroupRequestV2; import keywhiz.api.automation.v2.CreateSecretRequestV2; @@ -384,6 +385,11 @@ public List getDeletedSecretsByName(String name) throws IOExceptio }); } + public SecretDetailResponse cloneSecret(String sourceName, String newName) throws IOException { + String response = httpPost(baseUrl.resolve("clone"), CloneSecretRequestV2.fromParts(sourceName, newName)); + return mapper.readValue(response, SecretDetailResponse.class); + } + public boolean isLoggedIn() throws IOException { HttpUrl url = baseUrl.resolve("/admin/me"); Call call = client.newCall(new Request.Builder().get().url(url).build()); diff --git a/server/src/main/java/keywhiz/service/resources/admin/SecretsResource.java b/server/src/main/java/keywhiz/service/resources/admin/SecretsResource.java index f02de555c..45d3e7277 100644 --- a/server/src/main/java/keywhiz/service/resources/admin/SecretsResource.java +++ b/server/src/main/java/keywhiz/service/resources/admin/SecretsResource.java @@ -46,6 +46,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import keywhiz.api.SecretDetailResponse; +import keywhiz.api.automation.v2.CloneSecretRequestV2; import keywhiz.api.automation.v2.CreateOrUpdateSecretRequestV2; import keywhiz.api.automation.v2.CreateSecretRequestV2; import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2; @@ -696,6 +697,74 @@ public Response updateCurrentSecretVersion(@Auth User user, @PathParam("secretId return Response.noContent().build(); } + /** + * Clones a secret, creating a duplicate of all its data under a new name. Group assignments are not copied; callers + * should handle that separately. + * + * @param user the admin user performing this operation + * @param request a request object containing the current secret name and the new name to use for the clone + * @return 200 with new secret details if secret was cloned, 404 if not found + *

+ * description Clones a secret to a new name. Used by Keywhiz CLI. + *

+ * responseMessage 200 Cloned the secret to a new name + *

+ * responseMessage 400 Secret cannot be created with the new name + *

+ * responseMessage 404 Secret with original name not found + *

+ * responseMessage 409 A secret with the new name already exists + */ + @Path("clone") + @Timed @ExceptionMetered + @POST + public Response cloneSecret(@Auth User user, @Valid CloneSecretRequestV2 request) { + logger.info("User '{}' cloned secret original name={}, clone name={}", user, request.name(), request.newName()); + Secret existingSecret = secretController.getSecretByName(request.name()).orElseThrow(NotFoundException::new); + long newId; + try { + Secret newSecret = secretController.builder(existingSecret.getName(), + existingSecret.getSecret(), + existingSecret.getCreatedBy(), + existingSecret.getExpiry()) + .withDescription(existingSecret.getDescription()) + .withMetadata(existingSecret.getMetadata()) + .withOwnerName(existingSecret.getOwner()) + .withType(existingSecret.getType().orElse("")) + .create(); + newId = newSecret.getId(); + } catch (DataAccessException e) { + logger.info(format("Cannot create secret %s", request.name()), e); + throw new ConflictException(format("Cannot create secret %s.", request.name())); + } + + URI uri = + UriBuilder.fromResource(SecretsResource.class).path("{secretId}").build(newId); + Response response = Response + .created(uri) + .entity(secretDetailResponseFromId(newId)) + .build(); + + if (response.getStatus() == HttpStatus.SC_CREATED) { + Map extraInfo = new HashMap<>(); + if (existingSecret.getDescription() != null) { + extraInfo.put("description", existingSecret.getDescription()); + } + if (existingSecret.getMetadata() != null) { + extraInfo.put("metadata", existingSecret.getMetadata().toString()); + } + if (existingSecret.getOwner() != null) { + extraInfo.put("owner", existingSecret.getOwner()); + } + extraInfo.put("expiry", Long.toString(existingSecret.getExpiry())); + auditLog.recordEvent( + new Event(Instant.now(), EventTag.SECRET_CREATE, user.getName(), request.name(), + extraInfo)); + } + + return response; + } + private SecretDetailResponse secretDetailResponseFromId(long secretId) { Optional secret = secretController.getSecretById(secretId); return secretDetailResponseFromSecret(secret); diff --git a/server/src/test/java/keywhiz/service/resources/admin/SecretsResourceTest.java b/server/src/test/java/keywhiz/service/resources/admin/SecretsResourceTest.java index c315ad2d5..9c5a51be9 100644 --- a/server/src/test/java/keywhiz/service/resources/admin/SecretsResourceTest.java +++ b/server/src/test/java/keywhiz/service/resources/admin/SecretsResourceTest.java @@ -30,8 +30,11 @@ import javax.ws.rs.NotFoundException; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + import keywhiz.api.ApiDate; import keywhiz.api.SecretDetailResponse; +import keywhiz.api.automation.v2.CloneSecretRequestV2; import keywhiz.api.automation.v2.CreateOrUpdateSecretRequestV2; import keywhiz.api.automation.v2.CreateSecretRequestV2; import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2; @@ -64,10 +67,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -502,4 +502,31 @@ public void renameSecretNotFound() { assertThat(resource.findDeletedSecretsByName(user, "name1")) .containsExactlyInAnyOrder(secretSeries1, secretSeries2); } + + @Test + public void cloneSecret() { + when(secretDAO.getSecretByName("name1")).thenReturn(Optional.of(secretSeriesAndContent1)); + when(secretDAO.createSecret( + "name1Clone", + secretSeriesAndContent1.series().owner(), + secretSeriesAndContent1.content().encryptedContent(), + secretSeriesAndContent1.content().hmac(), + secretSeriesAndContent1.series().createdBy(), + secretSeriesAndContent1.content().metadata(), + secretSeriesAndContent1.content().expiry(), + secretSeriesAndContent1.series().description(), + secretSeriesAndContent1.series().type().orElse(null), + secretSeriesAndContent1.series().generationOptions() + )).thenReturn(42L); + // this fetch operation is used by SecretsResource to build the secret detail response + when(secretController.getSecretById(42L)).thenReturn(Optional.of(secret)); + + Response cloneResponse = resource.cloneSecret(user, CloneSecretRequestV2.fromParts("name1", "name1Clone")); + SecretDetailResponse cloneDetails = (SecretDetailResponse) cloneResponse.getEntity(); + assertThat(cloneDetails.id).isEqualTo(secret.getId()); + assertThat(cloneDetails.name).isEqualTo(secret.getName()); + assertThat(cloneDetails.checksum).isEqualTo(secret.getChecksum()); + assertThat(cloneResponse.getLocation()).isEqualTo( + UriBuilder.fromResource(SecretsResource.class).path("{secretId}").build(42L)); + } }