Skip to content

Commit

Permalink
Add purge key/secret feature (#75)
Browse files Browse the repository at this point in the history
- Implements new endpoints for purging keys/secrets
- Implements new management endpoint for purging vaults (including client)
- Adds purge features for services
- Adds new test cases

Resolves #70
{minor}
  • Loading branch information
nagyesta authored Mar 15, 2022
1 parent e86e6f6 commit 1cb235d
Show file tree
Hide file tree
Showing 32 changed files with 569 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,9 @@ public ResponseEntity<VaultModel> recoverVault(@RequestParam final URI baseUri)
final VaultFake fake = vaultService.findByUri(baseUri);
return ResponseEntity.ok(vaultFakeToVaultModelConverter.convert(fake));
}

@DeleteMapping("/purge")
public ResponseEntity<Boolean> purgeVault(@RequestParam final URI baseUri) {
return ResponseEntity.ok(vaultService.purge(baseUri));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,21 @@ public ResponseEntity<KeyVaultKeyModel> recoverDeletedKey(@PathVariable @Valid @
return ResponseEntity.ok(getModelById(keyVaultFake, latestVersion));
}

@DeleteMapping(value = "/deletedkeys/{keyName}",
params = API_VERSION_7_2,
consumes = APPLICATION_JSON_VALUE,
produces = APPLICATION_JSON_VALUE)
public ResponseEntity<Void> purgeDeleted(@PathVariable @Valid @Pattern(regexp = NAME_PATTERN) final String keyName,
@RequestAttribute(name = ApiConstants.REQUEST_BASE_URI) final URI baseUri) {
log.info("Received request to {} purge deleted key: {} using API version: {}",
baseUri.toString(), keyName, V_7_2);

final KeyVaultFake keyVaultFake = getVaultByUri(baseUri);
final KeyEntityId entityId = new KeyEntityId(baseUri, keyName);
keyVaultFake.purge(entityId);
return ResponseEntity.noContent().build();
}

@Override
protected VersionedKeyEntityId versionedEntityId(final URI baseUri, final String name, final String version) {
return new VersionedKeyEntityId(baseUri, name, version);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ public ResponseEntity<KeyVaultSecretModel> getDeletedSecret(
return ResponseEntity.ok(getDeletedModelById(secretVaultFake, latestVersion));
}

@DeleteMapping(value = "/deletedsecrets/{secretName}",
params = API_VERSION_7_2,
consumes = APPLICATION_JSON_VALUE,
produces = APPLICATION_JSON_VALUE)
public ResponseEntity<Void> purgeDeleted(
@PathVariable @Valid @Pattern(regexp = NAME_PATTERN) final String secretName,
@RequestAttribute(name = ApiConstants.REQUEST_BASE_URI) final URI baseUri) {
log.info("Received request to {} purge deleted secret: {} using API version: {}",
baseUri.toString(), secretName, V_7_2);

final SecretVaultFake secretVaultFake = getVaultByUri(baseUri);
final SecretEntityId entityId = new SecretEntityId(baseUri, secretName);
secretVaultFake.purge(entityId);
return ResponseEntity.noContent().build();
}

@PostMapping(value = "/deletedsecrets/{secretName}/recover",
params = API_VERSION_7_2,
consumes = APPLICATION_JSON_VALUE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import lombok.Data;
import lombok.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

import java.net.URI;
import java.util.List;
Expand All @@ -22,7 +21,6 @@ public class KeyVaultItemListModel<E> {

public KeyVaultItemListModel(@NonNull final List<E> value,
@Nullable final URI nextLinkUri) {
Assert.notEmpty(value, "Value cannot be empty.");
this.value = List.copyOf(value);
this.nextLink = Optional.ofNullable(nextLinkUri).map(URI::toString).orElse(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ public interface BaseVaultEntity<V extends EntityId> {
void setScheduledPurgeDate(OffsetDateTime scheduledPurgeDate);

boolean isPurgeExpired();

boolean canPurge();
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ public interface BaseVaultFake<K extends EntityId, V extends K, E extends BaseVa
void delete(K entityId);

void recover(K entityId);

void purge(K entityId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ public interface VersionedEntityMultiMap<K extends EntityId, V extends K, RE ext
void moveTo(K entityId, VersionedEntityMultiMap<K, V, RE, ME> destination, Function<ME, ME> applyToAll);

void purgeExpired();

void purgeDeleted(K entityId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ public void recover(@NonNull final K entityId) {
deletedEntities.moveTo(entityId, entities, this::markRestored);
}

@Override
public void purge(@NonNull final K entityId) {
deletedEntities.purgeExpired();
if (!deletedEntities.containsName(entityId.id())) {
throw new NotFoundException("Entity not found: " + entityId);
}
deletedEntities.purgeDeleted(entityId);
}

protected abstract V createVersionedId(String id, String version);

protected VersionedEntityMultiMap<K, V, RE, ME> getEntitiesInternal() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,13 @@ public void purgeExpired() {
versions.remove(key);
});
}

@Override
public void purgeDeleted(@NonNull final K entityId) {
Assert.state(isDeleted(), "Purge cannot be called when map is not in deleted role.");
final Map<String, ME> map = entities.get(entityId.id());
Assert.state(map.values().stream().allMatch(ME::canPurge), "The selected elements cannot be purged.");
entities.remove(entityId.id());
versions.remove(entityId.id());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,9 @@ public boolean isPurgeExpired() {
.filter(date -> date.isBefore(now()))
.isPresent();
}

@Override
public boolean canPurge() {
return getScheduledPurgeDate().isPresent() && getRecoveryLevel().isPurgeable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public interface VaultService {
boolean delete(URI uri);

void recover(URI uri);

boolean purge(URI uri);
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public void recover(final URI uri) {
}
}

@Override
public boolean purge(final URI uri) {
purgeExpired();
synchronized (vaultFakes) {
final Optional<VaultFake> vaultFake = findByUriAndDeleteStatus(uri, VaultFake::isDeleted);
final VaultFake found = vaultFake
.orElseThrow(() -> new NotFoundException("Unable to find deleted vault: " + uri));
return vaultFakes.remove(found);
}
}

private Optional<VaultFake> findByUriAndDeleteStatus(final URI uri, final Predicate<VaultFake> deletedPredicate) {
return vaultFakes.stream()
.filter(v -> v.matches(uri))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,5 +185,20 @@ void testRecoverVaultShouldCallServiceWhenCalled() {
inOrder.verify(converter).convert(same(VAULT_FAKE_DELETED));
verifyNoMoreInteractions(vaultService, converter);
}

@Test
void testPurgeVaultShouldCallServiceWhenCalled() {
//given
when(vaultService.purge(eq(HTTPS_DEFAULT_LOWKEY_VAULT_8443))).thenReturn(true);

//when
final ResponseEntity<Boolean> actual = underTest.purgeVault(HTTPS_DEFAULT_LOWKEY_VAULT_8443);

//then
Assertions.assertEquals(true, actual.getBody());
Assertions.assertEquals(HttpStatus.OK, actual.getStatusCode());
verify(vaultService).purge(eq(HTTPS_DEFAULT_LOWKEY_VAULT_8443));
verifyNoMoreInteractions(vaultService, converter);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,7 @@

import java.net.URI;
import java.time.OffsetDateTime;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
Expand Down Expand Up @@ -110,6 +107,10 @@ public static Stream<Arguments> keyAttributeProvider() {
RecoveryLevel.RECOVERABLE, 90, null, null))
.add(Arguments.of(List.of(),
RecoveryLevel.RECOVERABLE, 90, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(List.of(),
RecoveryLevel.RECOVERABLE_AND_PURGEABLE, 90, null, null))
.add(Arguments.of(List.of(),
RecoveryLevel.RECOVERABLE_AND_PURGEABLE, 90, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(List.of(KeyOperation.ENCRYPT),
RecoveryLevel.CUSTOMIZED_RECOVERABLE, 42, null, null))
.add(Arguments.of(List.of(KeyOperation.ENCRYPT),
Expand Down Expand Up @@ -626,7 +627,6 @@ void testGetKeysShouldReturnNextLinkWhenNotOnLastPage(
verify(keyEntityToV72KeyItemModelConverter).convert(same(entity));
}


@SuppressWarnings("checkstyle:MagicNumber")
@ParameterizedTest
@MethodSource("keyAttributeProvider")
Expand Down Expand Up @@ -672,6 +672,49 @@ void testGetDeletedKeysShouldReturnEntryWhenKeyIsFound(
verify(keyEntityToV72KeyItemModelConverter).convertDeleted(same(entity));
}

@SuppressWarnings("checkstyle:MagicNumber")
@ParameterizedTest
@MethodSource("keyAttributeProvider")
void testPurgeDeletedShouldRemoveEntryWhenDeletedKeyIsPurgeable(
final List<KeyOperation> operations, final RecoveryLevel recoveryLevel, final Integer recoverableDays,
final OffsetDateTime expiry, final OffsetDateTime notBefore) {
//given
when(keyVaultFake.getDeletedEntities())
.thenReturn(entities);
when(vaultFake.getRecoveryLevel())
.thenReturn(recoveryLevel);
when(vaultFake.getRecoverableDays())
.thenReturn(recoverableDays);
final CreateKeyRequest request = createRequest(operations, expiry, notBefore);
final ReadOnlyKeyVaultKeyEntity entity = createEntity(VERSIONED_KEY_ENTITY_ID_1_VERSION_1, request);
entity.setDeletedDate(TIME_10_MINUTES_AGO);
entity.setScheduledPurgeDate(TIME_IN_10_MINUTES);
final RecoveryLevel nonNullRecoveryLevel = Optional.ofNullable(recoveryLevel).orElse(RecoveryLevel.PURGEABLE);
if (!nonNullRecoveryLevel.isPurgeable()) {
doThrow(IllegalStateException.class).when(keyVaultFake).purge(eq(UNVERSIONED_KEY_ENTITY_ID_1));
}

//when
if (nonNullRecoveryLevel.isPurgeable()) {
final ResponseEntity<Void> response = underTest.purgeDeleted(KEY_NAME_1, HTTPS_LOCALHOST_8443);
Assertions.assertNotNull(response);
Assertions.assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
} else {
Assertions.assertThrows(IllegalStateException.class, () -> underTest.purgeDeleted(KEY_NAME_1, HTTPS_LOCALHOST_8443));
}

//then
verify(vaultService).findByUri(eq(HTTPS_LOCALHOST_8443));
verify(vaultFake).keyVaultFake();
verify(vaultFake).getRecoveryLevel();
verify(vaultFake).getRecoverableDays();
verify(keyVaultFake, never()).getDeletedEntities();
verify(keyVaultFake, atLeastOnce()).purge(eq(UNVERSIONED_KEY_ENTITY_ID_1));
verify(keyVaultFake, never()).getEntities();
verify(entities, never()).listLatestEntities();
verify(keyEntityToV72KeyItemModelConverter, never()).convertDeleted(same(entity));
}

@SuppressWarnings("checkstyle:MagicNumber")
@ParameterizedTest
@MethodSource("keyAttributeProvider")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@

import java.net.URI;
import java.time.OffsetDateTime;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
Expand Down Expand Up @@ -98,6 +95,8 @@ public static Stream<Arguments> secretAttributeProvider() {
.add(Arguments.of(null, null, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(RecoveryLevel.RECOVERABLE, 90, null, null))
.add(Arguments.of(RecoveryLevel.RECOVERABLE, 90, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(RecoveryLevel.RECOVERABLE_AND_PURGEABLE, 90, null, null))
.add(Arguments.of(RecoveryLevel.RECOVERABLE_AND_PURGEABLE, 90, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(RecoveryLevel.CUSTOMIZED_RECOVERABLE, 42, null, null))
.add(Arguments.of(RecoveryLevel.CUSTOMIZED_RECOVERABLE, 42, TIME_10_MINUTES_AGO, TIME_IN_10_MINUTES))
.add(Arguments.of(RecoveryLevel.PURGEABLE, null, null, null))
Expand Down Expand Up @@ -438,6 +437,53 @@ void testGetDeletedSecretShouldReturnEntryWhenSecretIsFound(
verify(secretEntityToV72ModelConverter).convertDeleted(same(entity));
}

@SuppressWarnings("checkstyle:MagicNumber")
@ParameterizedTest
@MethodSource("secretAttributeProvider")
void testPurgeDeletedShouldSucceedWhenDeletedSecretIsPurgeable(
final RecoveryLevel recoveryLevel, final Integer recoverableDays,
final OffsetDateTime expiry, final OffsetDateTime notBefore) {
//given
final SecretEntityId baseUri = new SecretEntityId(HTTPS_LOCALHOST_8443, SECRET_NAME_1, null);
when(secretVaultFake.getDeletedEntities())
.thenReturn(entities);
when(vaultFake.getRecoveryLevel())
.thenReturn(recoveryLevel);
when(vaultFake.getRecoverableDays())
.thenReturn(recoverableDays);
final CreateSecretRequest request = createRequest(expiry, notBefore);
final ReadOnlyKeyVaultSecretEntity entity = createEntity(VERSIONED_SECRET_ENTITY_ID_1_VERSION_1, request);
entity.setDeletedDate(TIME_10_MINUTES_AGO);
entity.setScheduledPurgeDate(TIME_IN_10_MINUTES);
when(entities.getReadOnlyEntity(eq(VERSIONED_SECRET_ENTITY_ID_1_VERSION_3)))
.thenReturn(entity);
final RecoveryLevel nonNullRecoveryLevel = Optional.ofNullable(recoveryLevel).orElse(RecoveryLevel.PURGEABLE);
if (!nonNullRecoveryLevel.isPurgeable()) {
doThrow(IllegalStateException.class).when(secretVaultFake).purge(eq(UNVERSIONED_SECRET_ENTITY_ID_1));
}

//when
if (nonNullRecoveryLevel.isPurgeable()) {
final ResponseEntity<Void> response = underTest.purgeDeleted(SECRET_NAME_1, HTTPS_LOCALHOST_8443);
Assertions.assertNotNull(response);
Assertions.assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
} else {
Assertions.assertThrows(IllegalStateException.class, () -> underTest.purgeDeleted(SECRET_NAME_1, HTTPS_LOCALHOST_8443));
}

//then
verify(vaultService).findByUri(eq(HTTPS_LOCALHOST_8443));
verify(vaultFake).secretVaultFake();
verify(vaultFake).getRecoveryLevel();
verify(vaultFake).getRecoverableDays();
verify(secretVaultFake, never()).getEntities();
verify(secretVaultFake, never()).getDeletedEntities();
verify(secretVaultFake, atLeastOnce()).purge(eq(UNVERSIONED_SECRET_ENTITY_ID_1));
verify(entities, never()).getLatestVersionOfEntity(eq(baseUri));
verify(entities, never()).getReadOnlyEntity(eq(VERSIONED_SECRET_ENTITY_ID_1_VERSION_3));
verify(secretEntityToV72ModelConverter, never()).convertDeleted(same(entity));
}

@SuppressWarnings("checkstyle:MagicNumber")
@ParameterizedTest
@MethodSource("secretAttributeProvider")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.junit.jupiter.params.provider.MethodSource;

import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;

Expand All @@ -19,7 +18,6 @@ class KeyVaultKeyItemListModelTest {
public static Stream<Arguments> invalidInputProvider() {
return Stream.<Arguments>builder()
.add(Arguments.of(null, null))
.add(Arguments.of(Collections.emptyList(), null))
.add(Arguments.of(null, VERSIONED_KEY_ENTITY_ID_1_VERSION_1.asUri()))
.build();
}
Expand Down
Loading

0 comments on commit 1cb235d

Please sign in to comment.