From 7556404f3f2f24b90efe1e1153bf4a436825d1c8 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 19 May 2022 11:33:56 +0200 Subject: [PATCH] Support versioned key/value secrets engine using Vault repositories. We now support CRUD operations using key/value secrets engine version 2 and optimistic locking through Vault's cas mechanism. Closes gh-593 --- .../vault/core/util/KeyValueDelegate.java | 10 +- .../convert/MappingVaultConverter.java | 18 +- .../repository/convert/SecretDocument.java | 78 +++- .../convert/SecretDocumentAccessor.java | 13 + .../repository/core/VaultKeyValueAdapter.java | 232 ++++++++++- .../core/VaultKeyValueTemplate.java | 130 +++++- .../repository/core/VaultQueryEngine.java | 10 +- .../VaultKv2RepositoryIntegrationTests.java | 375 ++++++++++++++++++ .../VaultRepositoryIntegrationTests.java | 11 +- .../MappingVaultConverterUnitTests.java | 70 ++++ src/main/asciidoc/index.adoc | 2 +- src/main/asciidoc/new-features.adoc | 10 +- .../reference/vault-repositories.adoc | 139 +++++-- 13 files changed, 1015 insertions(+), 83 deletions(-) create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/repository/VaultKv2RepositoryIntegrationTests.java diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java index fd151bdd..94a37c8a 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/util/KeyValueDelegate.java @@ -118,7 +118,7 @@ public class KeyValueDelegate { return MountInfo.from((String) data.get("path"), (Map) data.get("options")); } - private MountInfo getMountInfo(String path) { + public MountInfo getMountInfo(String path) { MountInfo mountInfo = this.mountInfo.get(path); @@ -136,15 +136,15 @@ public class KeyValueDelegate { return mountInfo; } - static class MountInfo { + public static class MountInfo { static final MountInfo UNAVAILABLE = new MountInfo("", Collections.emptyMap(), false); - final String path; + private final String path; - final @Nullable Map options; + private final @Nullable Map options; - final boolean available; + private final boolean available; private MountInfo(String path, @Nullable Map options, boolean available) { this.path = path; diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/MappingVaultConverter.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/MappingVaultConverter.java index c626bd72..56903080 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/MappingVaultConverter.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/MappingVaultConverter.java @@ -443,8 +443,7 @@ public class MappingVaultConverter extends AbstractVaultConverter { writePropertyInternal(value, sink, prop); } else { - - sink.put(prop, getPotentiallyConvertedSimpleWrite(value)); + sink.put(prop, getPotentiallyConvertedSimpleWrite(value, prop.getTypeInformation().getType())); } } } @@ -529,7 +528,7 @@ public class MappingVaultConverter extends AbstractVaultConverter { Class elementType = element == null ? null : element.getClass(); if (elementType == null || this.conversions.isSimpleType(elementType)) { - sink.add(getPotentiallyConvertedSimpleWrite(element)); + sink.add(getPotentiallyConvertedSimpleWrite(element, elementType == null ? Object.class : elementType)); } else if (element instanceof Collection || elementType.isArray()) { sink.add(writeCollectionInternal(asCollection(element), componentType, new ArrayList<>())); @@ -627,10 +626,11 @@ public class MappingVaultConverter extends AbstractVaultConverter { * arbitrary simple Vault type. Returns the converted value if so. If not, we perform * special enum handling or simply return the value as is. * @param value the value to write. + * @param targetType * @return the converted value. Can be {@literal null}. */ @Nullable - private Object getPotentiallyConvertedSimpleWrite(@Nullable Object value) { + private Object getPotentiallyConvertedSimpleWrite(@Nullable Object value, Class targetType) { if (value == null) { return null; @@ -650,7 +650,15 @@ public class MappingVaultConverter extends AbstractVaultConverter { return asCollection(value); } - return Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value; + if (Enum.class.isAssignableFrom(value.getClass())) { + return ((Enum) value).name(); + } + + if (!ClassUtils.isAssignableValue(targetType, value)) { + return this.conversionService.convert(value, targetType); + } + + return value; } /** diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocument.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocument.java index b6286025..f689a532 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocument.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocument.java @@ -17,10 +17,10 @@ package org.springframework.vault.repository.convert; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Objects; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; import org.springframework.vault.support.VaultResponse; /** @@ -42,6 +42,8 @@ public class SecretDocument { private final Map body; + private @Nullable Integer version; + /** * Create a new, empty {@link SecretDocument}. */ @@ -70,6 +72,22 @@ public class SecretDocument { this.body = body; } + /** + * Create a new {@link SecretDocument} given an {@code id} and {@link Map body map}. + * @param id may be {@literal null}. + * @param version for versioned secrets, may be {@literal null} if not available. + * @param body must not be {@literal null}. + * @since 2.4 + */ + public SecretDocument(@Nullable String id, @Nullable Integer version, Map body) { + + Assert.notNull(body, "Body must not be null"); + + this.id = id; + this.version = version; + this.body = body; + } + public SecretDocument(String id) { this(id, new LinkedHashMap<>()); } @@ -87,7 +105,7 @@ public class SecretDocument { } /** - * @return the Id or {@literal null} if the Id is not set. + * @return the identifier or {@literal null} if the identifier is not set. */ @Nullable public String getId() { @@ -95,13 +113,47 @@ public class SecretDocument { } /** - * Set the Id. + * Return the required Id or throw {@link IllegalStateException} if the Id is not set. + * @return the required Id. + * @throws IllegalStateException if the Id is not set. + * @since 2.4 + */ + public String getRequiredId() { + + String id = getId(); + + if (id == null) { + throw new IllegalStateException("Id is not set"); + } + + return id; + } + + /** + * Set the identifier value. * @param id may be {@literal null}. */ public void setId(@Nullable String id) { this.id = id; } + /** + * @return the version number, may be {@code null} if absent. + * @since 2.4 + */ + @Nullable + public Integer getVersion() { + return version; + } + + /** + * @param version + * @since 2.4 + */ + public void setVersion(@Nullable Integer version) { + this.version = version; + } + /** * @return the body of this {@link SecretDocument} */ @@ -130,17 +182,28 @@ public class SecretDocument { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (!(o instanceof SecretDocument)) + } + if (!(o instanceof SecretDocument)) { return false; + } SecretDocument that = (SecretDocument) o; - return Objects.equals(this.id, that.id) && Objects.equals(this.body, that.body); + if (!ObjectUtils.nullSafeEquals(this.id, that.id)) { + return false; + } + if (!ObjectUtils.nullSafeEquals(this.body, that.body)) { + return false; + } + return ObjectUtils.nullSafeEquals(this.version, that.version); } @Override public int hashCode() { - return Objects.hash(this.id, this.body); + int result = ObjectUtils.nullSafeHashCode(this.id); + result = 31 * result + ObjectUtils.nullSafeHashCode(this.body); + result = 31 * result + ObjectUtils.nullSafeHashCode(this.version); + return result; } @Override @@ -149,6 +212,7 @@ public class SecretDocument { sb.append(getClass().getSimpleName()); sb.append(" [id='").append(this.id).append('\''); sb.append(", body=").append(this.body); + sb.append(", version=").append(this.version); sb.append(']'); return sb.toString(); } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocumentAccessor.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocumentAccessor.java index b919813c..b65d4bc4 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocumentAccessor.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/convert/SecretDocumentAccessor.java @@ -87,6 +87,11 @@ class SecretDocumentAccessor { return; } + if (prop.isVersionProperty()) { + this.document.setVersion(value == null ? null : ((Number) value).intValue()); + return; + } + if (!fieldName.contains(".")) { this.body.put(fieldName, value); return; @@ -125,6 +130,10 @@ class SecretDocumentAccessor { return this.document.getId(); } + if (property.isVersionProperty()) { + return this.document.getVersion(); + } + if (!fieldName.contains(".")) { return this.body.get(fieldName); } @@ -159,6 +168,10 @@ class SecretDocumentAccessor { return StringUtils.hasText(this.document.getId()); } + if (property.isVersionProperty()) { + return this.document.getVersion() != null; + } + String fieldName = property.getName(); if (!fieldName.contains(".")) { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueAdapter.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueAdapter.java index 9688f1a8..104eee1f 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueAdapter.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueAdapter.java @@ -19,21 +19,27 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.keyvalue.core.AbstractKeyValueAdapter; -import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.util.CloseableIterator; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.vault.VaultException; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.core.VaultKeyValueOperationsSupport; import org.springframework.vault.core.VaultOperations; +import org.springframework.vault.core.VaultVersionedKeyValueOperations; +import org.springframework.vault.core.util.KeyValueDelegate; import org.springframework.vault.repository.convert.MappingVaultConverter; import org.springframework.vault.repository.convert.SecretDocument; import org.springframework.vault.repository.convert.VaultConverter; import org.springframework.vault.repository.mapping.VaultMappingContext; -import org.springframework.vault.repository.mapping.VaultPersistentEntity; -import org.springframework.vault.repository.mapping.VaultPersistentProperty; import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.Versioned; /** * Vault-specific {@link org.springframework.data.keyvalue.core.KeyValueAdapter}. @@ -47,6 +53,10 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { private final VaultConverter vaultConverter; + private final KeyValueDelegate keyValueDelegate; + + private final Map accessors = new ConcurrentHashMap<>(); + /** * Create a new {@link VaultKeyValueAdapter} given {@link VaultOperations}. * @param vaultOperations must not be {@literal null}. @@ -70,6 +80,7 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { this.vaultOperations = vaultOperations; this.vaultConverter = vaultConverter; + this.keyValueDelegate = new KeyValueDelegate(vaultOperations); } @Override @@ -78,9 +89,9 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { SecretDocument secretDocument = new SecretDocument(id.toString()); this.vaultConverter.write(item, secretDocument); - this.vaultOperations.write(createKey(id, keyspace), secretDocument.getBody()); + SecretDocument saved = getAccessor(keyspace).put(secretDocument); - return secretDocument; + return this.vaultConverter.read(item.getClass(), saved); } @Override @@ -98,14 +109,14 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { @Override public T get(Object id, String keyspace, Class type) { - VaultResponse response = this.vaultOperations.read(createKey(id, keyspace)); + VaultKeyValueKeyspaceAccessor accessor = getAccessor(keyspace); - if (response == null) { + SecretDocument document = accessor.get(id.toString()); + + if (document == null) { return null; } - SecretDocument document = SecretDocument.from(id.toString(), response); - return this.vaultConverter.read(type, document); } @@ -125,7 +136,15 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { return null; } - this.vaultOperations.delete(createKey(id, keyspace)); + return deleteEntity(entity, keyspace); + } + + public T deleteEntity(T entity, String keyspace) { + + SecretDocument document = new SecretDocument(); + this.vaultConverter.write(entity, document); + + getAccessor(keyspace).delete(document); return entity; } @@ -137,7 +156,11 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { List items = new ArrayList<>(list.size()); for (String id : list) { - items.add(get(id, keyspace)); + + Object object = get(id, keyspace); + if (object != null) { + items.add(object); + } } return items; @@ -196,8 +219,9 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { List ids = doList(keyspace); + VaultKeyValueKeyspaceAccessor accessor = getAccessor(keyspace); for (String id : ids) { - this.vaultOperations.delete(createKey(id, keyspace)); + accessor.delete(id); } } @@ -220,17 +244,191 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter { List doList(String keyspace) { - List list = this.vaultOperations.list(keyspace); + VaultKeyValueKeyspaceAccessor accessor = getAccessor(keyspace); + + List list = accessor.list(keyspace); return list == null ? Collections.emptyList() : list; } - private String createKey(Object id, String keyspace) { - return String.format("%s/%s", keyspace, id); + private VaultKeyValueKeyspaceAccessor getAccessor(String keyspace) { + + KeyValueDelegate.MountInfo mountInfo = keyValueDelegate.getMountInfo(keyspace); + + return accessors.computeIfAbsent(keyspace, it -> { + + if (keyValueDelegate.isVersioned(it)) { + return new VaultKeyValue2KeyspaceAccessor(mountInfo, it, + this.vaultOperations.opsForVersionedKeyValue(mountInfo.getPath())); + } + + return new VaultKeyValue1KeyspaceAccessor(mountInfo, it, this.vaultOperations + .opsForKeyValue(mountInfo.getPath(), VaultKeyValueOperationsSupport.KeyValueBackend.KV_1)); + }); + } - MappingContext, VaultPersistentProperty> getMappingContext() { - return this.vaultConverter.getMappingContext(); + static abstract class VaultKeyValueKeyspaceAccessor { + + private final KeyValueDelegate.MountInfo mountInfo; + + private final String keyspace; + + private final String pathPrefix; + + protected VaultKeyValueKeyspaceAccessor(KeyValueDelegate.MountInfo mountInfo, String keyspace) { + this.mountInfo = mountInfo; + this.keyspace = keyspace; + this.pathPrefix = getPathInMount(keyspace); + } + + @Nullable + abstract List list(String pattern); + + String getPathInMount(String keyspace) { + + if (!keyspace.startsWith(this.mountInfo.getPath())) { + return keyspace; + } + + return keyspace.substring(this.mountInfo.getPath().length()); + } + + String createPath(String id) { + return this.pathPrefix + "/" + id; + } + + @Nullable + abstract SecretDocument get(String id); + + abstract SecretDocument put(SecretDocument secretDocument); + + abstract void delete(String id); + + abstract void delete(SecretDocument document); + + } + + static class VaultKeyValue1KeyspaceAccessor extends VaultKeyValueKeyspaceAccessor { + + private final VaultKeyValueOperations operations; + + public VaultKeyValue1KeyspaceAccessor(KeyValueDelegate.MountInfo mountInfo, String keyspace, + VaultKeyValueOperations operations) { + super(mountInfo, keyspace); + this.operations = operations; + } + + @Nullable + @Override + public List list(String pattern) { + return operations.list(getPathInMount(pattern)); + } + + @Nullable + @Override + SecretDocument get(String id) { + + VaultResponse vaultResponse = operations.get(createPath(id)); + + if (vaultResponse == null) { + return null; + } + + return new SecretDocument(id, vaultResponse.getRequiredData()); + } + + @Override + SecretDocument put(SecretDocument secretDocument) { + + operations.put(createPath(secretDocument.getRequiredId()), secretDocument.getBody()); + + return secretDocument; + } + + @Override + void delete(String id) { + operations.delete(createPath(id)); + } + + @Override + void delete(SecretDocument document) { + delete(document.getRequiredId()); + } + + } + + static class VaultKeyValue2KeyspaceAccessor extends VaultKeyValueKeyspaceAccessor { + + private final VaultVersionedKeyValueOperations operations; + + public VaultKeyValue2KeyspaceAccessor(KeyValueDelegate.MountInfo mountInfo, String keyspace, + VaultVersionedKeyValueOperations operations) { + super(mountInfo, keyspace); + this.operations = operations; + } + + @Nullable + @Override + public List list(String pattern) { + return operations.list(getPathInMount(pattern)); + } + + @Nullable + @Override + SecretDocument get(String id) { + + Versioned> versioned = operations.get(createPath(id)); + + if (versioned == null || !versioned.hasData()) { + return null; + } + + return new SecretDocument(id, versioned.getVersion().getVersion(), versioned.getRequiredData()); + } + + @Override + SecretDocument put(SecretDocument secretDocument) { + + try { + Versioned.Metadata metadata; + if (secretDocument.getVersion() != null) { + metadata = operations.put(createPath(secretDocument.getRequiredId()), Versioned + .create(secretDocument.getBody(), Versioned.Version.from(secretDocument.getVersion()))); + } + else { + metadata = operations.put(createPath(secretDocument.getRequiredId()), secretDocument.getBody()); + } + + return new SecretDocument(secretDocument.getRequiredId(), metadata.getVersion().getVersion(), + secretDocument.getBody()); + } + catch (VaultException e) { + if (e.getMessage() != null + && e.getMessage().contains("check-and-set parameter did not match the current version")) { + throw new OptimisticLockingFailureException(e.getMessage(), e); + } + + throw e; + } + } + + @Override + void delete(String id) { + operations.delete(createPath(id)); + } + + @Override + void delete(SecretDocument document) { + + if (document.getVersion() != null) { + operations.delete(createPath(document.getRequiredId()), Versioned.Version.from(document.getVersion())); + } + else { + delete(document.getRequiredId()); + } + } + } } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueTemplate.java index 81797e29..4cc6c8ba 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultKeyValueTemplate.java @@ -15,8 +15,19 @@ */ package org.springframework.vault.repository.core; +import java.util.Collections; +import java.util.Set; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.keyvalue.core.KeyValueAdapter; import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.keyvalue.core.event.KeyValueEvent; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; import org.springframework.vault.repository.mapping.VaultMappingContext; /** @@ -27,12 +38,20 @@ import org.springframework.vault.repository.mapping.VaultMappingContext; */ public class VaultKeyValueTemplate extends KeyValueTemplate { + @Nullable + private ApplicationEventPublisher eventPublisher; + + private boolean publishEvents = true; + + @SuppressWarnings("rawtypes") + private Set> eventTypesToPublish = Collections.emptySet(); + /** * Create a new {@link VaultKeyValueTemplate} given {@link KeyValueAdapter} and * {@link VaultMappingContext}. * @param adapter must not be {@literal null}. */ - public VaultKeyValueTemplate(KeyValueAdapter adapter) { + public VaultKeyValueTemplate(VaultKeyValueAdapter adapter) { this(adapter, new VaultMappingContext()); } @@ -42,13 +61,120 @@ public class VaultKeyValueTemplate extends KeyValueTemplate { * @param adapter must not be {@literal null}. * @param mappingContext must not be {@literal null}. */ - public VaultKeyValueTemplate(KeyValueAdapter adapter, VaultMappingContext mappingContext) { + public VaultKeyValueTemplate(VaultKeyValueAdapter adapter, VaultMappingContext mappingContext) { super(adapter, mappingContext); } + /** + * Define the event types to publish via {@link ApplicationEventPublisher}. + * @param eventTypesToPublish use {@literal null} or {@link Collections#emptySet()} to + * stop publishing. + */ + public void setEventTypesToPublish(Set> eventTypesToPublish) { + + if (CollectionUtils.isEmpty(eventTypesToPublish)) { + this.publishEvents = false; + } + else { + this.publishEvents = true; + this.eventTypesToPublish = Collections.unmodifiableSet(eventTypesToPublish); + } + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.eventPublisher = applicationEventPublisher; + } + + @Override + public T insert(Object id, T objectToInsert) { + Assert.notNull(id, "Id for object to be inserted must not be null!"); + Assert.notNull(objectToInsert, "Object to be inserted must not be null!"); + + String keyspace = resolveKeySpace(objectToInsert.getClass()); + + potentiallyPublishEvent(KeyValueEvent.beforeInsert(id, keyspace, objectToInsert.getClass(), objectToInsert)); + + T saved = execute(adapter -> { + + if (adapter.contains(id, keyspace)) { + throw new DuplicateKeyException( + String.format("Cannot insert existing object with id %s!. Please use update.", id)); + } + + return (T) adapter.put(id, objectToInsert, keyspace); + }); + + potentiallyPublishEvent(KeyValueEvent.afterInsert(id, keyspace, objectToInsert.getClass(), objectToInsert)); + + return saved; + } + + @Override + @SuppressWarnings("unchecked") + public T update(Object id, T objectToUpdate) { + + Assert.notNull(id, "Id for object to be inserted must not be null!"); + Assert.notNull(objectToUpdate, "Object to be updated must not be null!"); + + String keyspace = resolveKeySpace(objectToUpdate.getClass()); + + potentiallyPublishEvent(KeyValueEvent.beforeUpdate(id, keyspace, objectToUpdate.getClass(), objectToUpdate)); + + T updated = execute(adapter -> (T) adapter.put(id, objectToUpdate, keyspace)); + + potentiallyPublishEvent( + KeyValueEvent.afterUpdate(id, keyspace, objectToUpdate.getClass(), objectToUpdate, updated)); + + return updated; + } + + @Override + @SuppressWarnings("unchecked") + public T delete(T objectToDelete) { + + Class type = (Class) ClassUtils.getUserClass(objectToDelete); + KeyValuePersistentEntity entity = getEntity(type); + + Object id = entity.getIdentifierAccessor(objectToDelete).getIdentifier(); + + String keyspace = resolveKeySpace(type); + + potentiallyPublishEvent(KeyValueEvent.beforeDelete(id, keyspace, type)); + + T result = execute(adapter -> ((VaultKeyValueAdapter) adapter).deleteEntity(objectToDelete, keyspace)); + + potentiallyPublishEvent(KeyValueEvent.afterDelete(id, keyspace, type, result)); + + return result; + } + @Override public void destroy() throws Exception { // no-op to prevent clear() call. } + private String resolveKeySpace(Class type) { + + KeyValuePersistentEntity entity = getEntity(type); + return entity.getKeySpace(); + } + + @SuppressWarnings("rawtypes") + private KeyValuePersistentEntity getEntity(Class type) { + return (KeyValuePersistentEntity) getMappingContext().getRequiredPersistentEntity(type); + } + + @SuppressWarnings("rawtypes") + private void potentiallyPublishEvent(KeyValueEvent event) { + + if (eventPublisher == null) { + return; + } + + if (publishEvents && (eventTypesToPublish.isEmpty() || eventTypesToPublish.contains(event.getClass()))) { + eventPublisher.publishEvent(event); + } + } + } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultQueryEngine.java b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultQueryEngine.java index 1ae1baf1..1b94fcba 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultQueryEngine.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/repository/core/VaultQueryEngine.java @@ -77,7 +77,15 @@ class VaultQueryEngine extends QueryEngine typed = stream.map(it -> getRequiredAdapter().get(it, keyspace, type)); + Stream typed = stream.flatMap(it -> { + T result = getRequiredAdapter().get(it, keyspace, type); + + if (result == null) { + return Stream.empty(); + } + + return Stream.of(result); + }); if (comparator != null) { diff --git a/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultKv2RepositoryIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultKv2RepositoryIntegrationTests.java new file mode 100644 index 00000000..0cc14662 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultKv2RepositoryIntegrationTests.java @@ -0,0 +1,375 @@ +/* + * Copyright 2017-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.vault.repository; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; +import org.springframework.data.repository.CrudRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.ObjectUtils; +import org.springframework.vault.VaultException; +import org.springframework.vault.core.VaultIntegrationTestConfiguration; +import org.springframework.vault.core.VaultSysOperations; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.core.VaultVersionedKeyValueOperations; +import org.springframework.vault.repository.configuration.EnableVaultRepositories; +import org.springframework.vault.repository.mapping.Secret; +import org.springframework.vault.support.VaultMount; +import org.springframework.vault.support.Versioned; +import org.springframework.vault.util.IntegrationTestSupport; + +import static org.assertj.core.api.Assertions.*; + +/** + * Integration tests for Vault repositories using KeyValue version 2. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = VaultKv2RepositoryIntegrationTests.VaultRepositoryTestConfiguration.class) +class VaultKv2RepositoryIntegrationTests extends IntegrationTestSupport { + + @Configuration + @EnableVaultRepositories(considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = { VersionedRepository.class, SimpleRepository.class }, + type = FilterType.ASSIGNABLE_TYPE)) + static class VaultRepositoryTestConfiguration extends VaultIntegrationTestConfiguration { + + } + + @Autowired + VersionedRepository versionedRepository; + + @Autowired + SimpleRepository simpleRepository; + + @Autowired + VaultTemplate vaultTemplate; + + @BeforeEach + void before() { + + VaultSysOperations vaultSysOperations = this.vaultTemplate.opsForSys(); + + try { + vaultSysOperations.unmount("versioned"); + } + catch (VaultException e) { + } + + vaultSysOperations.mount("versioned", + VaultMount.builder().type("kv").options(Collections.singletonMap("version", "2")).build()); + } + + @Test + void loadAndSaveVersioned() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + + VersionedPerson saved = this.versionedRepository.save(person); + + assertThat(saved.getVersion()).isEqualTo(1); + + Iterable all = this.versionedRepository.findAll(); + + assertThat(all).contains(saved); + assertThat(this.versionedRepository.findById("foo-key")).contains(saved); + } + + @Test + void loadAndUpdateVersioned() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + + this.versionedRepository.save(person); + + VersionedPerson versionedPerson = this.versionedRepository.findById("foo-key").get(); + versionedPerson.setFirstname("baz"); + + VersionedPerson updated = this.versionedRepository.save(versionedPerson); + + assertThat(updated.getVersion()).isEqualTo(2); + } + + @Test + @Disabled("Requires newer Spring Data KeyValue version, https://github.com/spring-projects/spring-vault/issues/701") + void deleteVersioned() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + + this.versionedRepository.save(person); + + VersionedPerson versionedPerson = this.versionedRepository.findById("foo-key").get(); + versionedPerson.setFirstname("baz"); + + this.versionedRepository.save(versionedPerson); + + VaultVersionedKeyValueOperations versioned = vaultTemplate.opsForVersionedKeyValue("versioned"); + + Versioned objectVersioned = versioned.get("versionedPerson/foo-key", Versioned.Version.from(1)); + assertThat(objectVersioned.hasData()).isTrue(); + + this.versionedRepository.delete(versionedPerson); // delete v1 + + Versioned v1 = versioned.get("versionedPerson/foo-key", Versioned.Version.from(1)); + assertThat(v1.hasData()).isFalse(); + + Versioned v2 = versioned.get("versionedPerson/foo-key", Versioned.Version.from(2)); + assertThat(v2.hasData()).isTrue(); + } + + @Test + void optimisticLockingInsertShouldFail() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + person.setVersion(2); + + assertThatExceptionOfType(OptimisticLockingFailureException.class) + .isThrownBy(() -> this.versionedRepository.save(person)); + } + + @Test + void optimisticLockingUpdateShouldFail() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + + VersionedPerson saved = this.versionedRepository.save(person); + saved.setVersion(2); + saved.setFirstname("baz"); + + assertThatExceptionOfType(OptimisticLockingFailureException.class) + .isThrownBy(() -> this.versionedRepository.save(saved)); + } + + @Test + void shouldDeleteAll() { + + VersionedPerson person = new VersionedPerson(); + person.setId("foo-key"); + person.setFirstname("bar"); + + this.versionedRepository.save(person); + this.versionedRepository.deleteAll(); + + Iterable all = this.versionedRepository.findAll(); + + assertThat(all).isEmpty(); + } + + @Test + void shouldApplyQueryMethod() { + + VersionedPerson walter = new VersionedPerson(); + walter.setId("walter"); + walter.setFirstname("Walter"); + + walter = this.versionedRepository.save(walter); + + VersionedPerson skyler = new VersionedPerson(); + skyler.setId("skyler"); + skyler.setFirstname("Skyler"); + + skyler = this.versionedRepository.save(skyler); + + Iterable all = this.versionedRepository.findByIdStartsWith("walt"); + + assertThat(all).contains(walter).doesNotContain(skyler); + } + + @Test + void loadAndUpdate() { + + SimplePerson person = new SimplePerson(); + person.setId("foo-bar"); + person.setFirstname("bar"); + + this.simpleRepository.save(person); + + SimplePerson versionedPerson = this.simpleRepository.findById("foo-bar").get(); + versionedPerson.setFirstname("baz"); + + this.simpleRepository.save(versionedPerson); + } + + interface VersionedRepository extends CrudRepository { + + List findByIdStartsWith(String prefix); + + } + + interface SimpleRepository extends CrudRepository { + + List findByIdStartsWith(String prefix); + + } + + @Secret(backend = "versioned") + static class VersionedPerson { + + @Id + String id; + + @Version + long version; + + String firstname; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof VersionedPerson)) { + return false; + } + VersionedPerson that = (VersionedPerson) o; + if (version != that.version) { + return false; + } + if (!ObjectUtils.nullSafeEquals(id, that.id)) { + return false; + } + return ObjectUtils.nullSafeEquals(firstname, that.firstname); + } + + @Override + public int hashCode() { + int result = ObjectUtils.nullSafeHashCode(id); + result = 31 * result + (int) (version ^ (version >>> 32)); + result = 31 * result + ObjectUtils.nullSafeHashCode(firstname); + return result; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer(); + sb.append(getClass().getSimpleName()); + sb.append(" [id='").append(id).append('\''); + sb.append(", version=").append(version); + sb.append(", firstname='").append(firstname).append('\''); + sb.append(']'); + return sb.toString(); + } + + } + + @Secret(backend = "versioned") + static class SimplePerson { + + @Id + String id; + + String firstname; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof VersionedPerson)) { + return false; + } + VersionedPerson that = (VersionedPerson) o; + if (!ObjectUtils.nullSafeEquals(id, that.id)) { + return false; + } + return ObjectUtils.nullSafeEquals(firstname, that.firstname); + } + + @Override + public int hashCode() { + int result = ObjectUtils.nullSafeHashCode(id); + result = 31 * result + ObjectUtils.nullSafeHashCode(firstname); + return result; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer(); + sb.append(getClass().getSimpleName()); + sb.append(" [id='").append(id).append('\''); + sb.append(", firstname='").append(firstname).append('\''); + sb.append(']'); + return sb.toString(); + } + + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultRepositoryIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultRepositoryIntegrationTests.java index 58c2c478..5ea98f80 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultRepositoryIntegrationTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/repository/VaultRepositoryIntegrationTests.java @@ -22,7 +22,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Sort; import org.springframework.data.repository.CrudRepository; @@ -35,9 +37,8 @@ import org.springframework.vault.repository.VaultRepositoryIntegrationTests.Vaul import org.springframework.vault.repository.configuration.EnableVaultRepositories; import org.springframework.vault.util.IntegrationTestSupport; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.springframework.data.domain.Sort.Order.asc; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.domain.Sort.Order.*; /** * Integration tests for Vault repositories. @@ -49,7 +50,9 @@ import static org.springframework.data.domain.Sort.Order.asc; class VaultRepositoryIntegrationTests extends IntegrationTestSupport { @Configuration - @EnableVaultRepositories(considerNestedRepositories = true) + @EnableVaultRepositories(considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = VaultRepositoryIntegrationTests.VaultRepository.class, + type = FilterType.ASSIGNABLE_TYPE)) static class VaultRepositoryTestConfiguration extends VaultIntegrationTestConfiguration { } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/repository/convert/MappingVaultConverterUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/repository/convert/MappingVaultConverterUnitTests.java index 08288865..26cebe9e 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/repository/convert/MappingVaultConverterUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/repository/convert/MappingVaultConverterUnitTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.convert.converter.Converter; +import org.springframework.data.annotation.Version; import org.springframework.vault.repository.mapping.VaultMappingContext; import static org.assertj.core.api.Assertions.assertThat; @@ -197,6 +198,40 @@ class MappingVaultConverterUnitTests { assertThat(sink).isEqualTo(expected); } + @Test + void shouldWriteVersionedEntity() { + + VersionedEntity entity = new VersionedEntity(); + entity.setId("heisenberg"); + entity.setUsername("walter"); + entity.setVersion(0); + + SecretDocument expected = new SecretDocument("heisenberg"); + expected.put("username", "walter"); + expected.setVersion(0); + expected.put("_class", entity.getClass().getName()); + + SecretDocument sink = new SecretDocument(); + + this.converter.write(entity, sink); + + assertThat(sink).isEqualTo(expected); + } + + @Test + void shouldReadVersionedEntity() { + + SecretDocument document = new SecretDocument("heisenberg"); + document.put("username", "walter"); + document.setVersion(11); + + VersionedEntity read = this.converter.read(VersionedEntity.class, document); + + assertThat(read.getId()).isEqualTo("heisenberg"); + assertThat(read.getUsername()).isEqualTo("walter"); + assertThat(read.getVersion()).isEqualTo(11); + } + @Test void shouldWriteConvertedEntity() { @@ -309,6 +344,41 @@ class MappingVaultConverterUnitTests { } + static class VersionedEntity { + + String id; + + String username; + + @Version + long version; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + } + static class ExtendedEntity extends SimpleEntity { String location; diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 90719ff2..64f45b1d 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -6,7 +6,7 @@ Mark Paluch; :self-docs-root: https://docs.spring.io/spring-vault/docs/{version}/ :example-root: ../../../../spring-vault-core/src/test/java/org/springframework/vault/documentation -(C) 2016-2021 The original authors. +(C) 2016-2022 The original authors. NOTE: _Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically._ diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 9609ea9c..ed3fedec 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -5,6 +5,8 @@ === What's new in Spring Vault 2.4 * Support for <> for Username/Password, LDAP, Okta, and RADIUS authentication. +* Support of versioned Key/Value secrets engines for Vault repositories. +* <>. [[new-features.2-3-0]] === What's new in Spring Vault 2.3 @@ -12,7 +14,7 @@ * Support for PEM-encoded certificates for keystore and truststore usage. * `ReactiveVaultEndpointProvider` for non-blocking lookup of `VaultEndpoint`. * `VaultKeyValueMetadataOperations` for Key-Value metadata interaction. -* Support for `transform` backend (Enterprise Feature). +* Support for `transform` secrets engine (Enterprise Feature). * Documentation of <>. * Login credentials for Kubernetes and PCF authentication are reloaded for each login attempt. * `SecretLeaseContainer` publishes `SecretLeaseRotatedEvent` instead of `SecretLeaseExpiredEvent` and `SecretLeaseCreatedEvent` on successful secret rotation. @@ -22,7 +24,7 @@ [[new-features.2-2-0]] === What's new in Spring Vault 2.2 -* Support for Key-Value v2 (versioned backend) secrets through `@VaultPropertySource`. +* Support for Key-Value v2 (versioned secrets engine) secrets through `@VaultPropertySource`. * SpEL support in `@Secret`. * Add support for Jetty as reactive HttpClient. * `LifecycleAwareSessionManager` and `ReactiveLifecycleAwareSessionManager` emit now ``AuthenticationEvent``s. @@ -36,7 +38,7 @@ Use `AppRoleAuthentication` instead as recommended by HashiCorp Vault. === What's new in Spring Vault 2.1 * <>, <>, and <> authentication. -* Template API support for versioned and unversioned Key/Value backends and for Vault wrapping operations. +* Template API support for versioned and unversioned Key/Value secrets engines and for Vault wrapping operations. * Support full pull mode in reactive AppRole authentication. * Improved Exception hierarchy for Vault login failures. @@ -51,7 +53,7 @@ Use `AppRoleAuthentication` instead as recommended by HashiCorp Vault. * Support CSR signing, certificate revocation and CRL retrieval. * <>. * RoleId/SecretId unwrapping for <>. -* <> with transit backend-based `BytesKeyGenerator` and `BytesEncryptor`. +* <> with transit secrets engine-based `BytesKeyGenerator` and `BytesEncryptor`. [[new-features.1-1-0]] === What's new in Spring Vault 1.1.0 diff --git a/src/main/asciidoc/reference/vault-repositories.adoc b/src/main/asciidoc/reference/vault-repositories.adoc index 4dac8757..e20ac5fc 100644 --- a/src/main/asciidoc/reference/vault-repositories.adoc +++ b/src/main/asciidoc/reference/vault-repositories.adoc @@ -1,12 +1,16 @@ [[vault.repositories]] = Vault Repositories -Working with `VaultTemplate` and responses mapped to Java classes allows basic data operations like read, write -and delete. Vault repositories apply Spring Data's repository concept on top of Vault. -A Vault repository exposes basic CRUD functionality and supports query derivation with predicates constraining -the Id property, paging and sorting. +Working with `VaultTemplate` and responses mapped to Java classes allows basic data operations like read, write and delete. +Vault repositories apply Spring Data's repository concept on top of Vault. +A Vault repository exposes basic CRUD functionality and supports query derivation with predicates constraining the identifier property, paging and sorting. +Vault repositories use the key/value secrets engine functionality to persist and query data. +As of version 2.4, Spring Vault can use additionally key/value version 2 secrets engine, the actual secrets engine version is discovered during runtime. -NOTE: Read more about Spring Data Repositories in the https://docs.spring.io/spring-data/commons/docs/current/reference/html/#repositories[Spring Data Commons reference documentation]. The reference documentation will give you an introduction to Spring Data repositories. +NOTE: Deletes within versioned key/value secrets engine use the `DELETE` operation. Secrets are not destroyed through `CrudRepository.delete(…)`. + +NOTE: Read more about Spring Data Repositories in the https://docs.spring.io/spring-data/commons/docs/current/reference/html/#repositories[Spring Data Commons reference documentation]. +The reference documentation will give you an introduction to Spring Data repositories. [[vault.repositories.usage]] == Usage @@ -18,7 +22,7 @@ To access domain entities stored in Vault you can leverage repository support th [source,java] ---- @Secret -public class Credentials { +class Credentials { @Id String id; String password; @@ -28,7 +32,8 @@ public class Credentials { ---- ==== -We have a pretty simple domain object here. Note that it has a property named `id` annotated with +We have a pretty simple domain object here. +Note that it has a property named `id` annotated with `org.springframework.data.annotation.Id` and a `@Secret` annotation on its type. Those two are responsible for creating the actual key used to persist the object as JSON inside Vault. @@ -41,22 +46,23 @@ The next step is to declare a repository interface that uses the domain object. ==== [source,java] ---- -public interface CredentialsRepository extends CrudRepository { +interface CredentialsRepository extends CrudRepository { } ---- ==== -As our repository extends `CrudRepository` it provides basic CRUD and query methods. Vault repositories -require Spring Data components. Make sure to include `spring-data-commons` and `spring-data-keyvalue` artifacts in your class path. +As our repository extends `CrudRepository` it provides basic CRUD and query methods. +Vault repositories require Spring Data components. +Make sure to include `spring-data-commons` and `spring-data-keyvalue` artifacts in your class path. -The easiest way to achive this, is by setting up dependency management and adding the artifacts to your `pom.xml`: +The easiest way to achieve this, is by setting up dependency management and adding the artifacts to your `pom.xml`: Then add the following to `pom.xml` dependencies section. .Using the Spring Data BOM ==== -[source, xml, subs="verbatim,attributes"] +[source,xml,subs="verbatim,attributes"] ---- @@ -90,7 +96,6 @@ Then add the following to `pom.xml` dependencies section. ---- ==== - The thing we need in between to glue things together is the according Spring configuration. .JavaConfig for Vault Repositories @@ -99,10 +104,10 @@ The thing we need in between to glue things together is the according Spring con ---- @Configuration @EnableVaultRepositories -public class ApplicationConfig { +class ApplicationConfig { @Bean - public VaultTemplate vaultTemplate() { + VaultTemplate vaultTemplate() { return new VaultTemplate(…); } } @@ -117,7 +122,7 @@ Given the setup above we can go on and inject `CredentialsRepository` into our c ---- @Autowired CredentialsRepository repo; -public void basicCrudOperations() { +void basicCrudOperations() { Credentials creds = new Credentials("heisenberg", "327215", "AAA-GG-SSSS"); rand.setAddress(new Address("308 Negra Arroyo Lane", "Albuquerque", "New Mexico", "87104")); @@ -131,8 +136,8 @@ public void basicCrudOperations() { repo.delete(creds); <4> } ---- -<1> Stores properties of `Credentials` inside Vault Hash with a key pattern `keyspace/id`, -in this case `credentials/heisenberg`, in the generic secret backend. + +<1> Stores properties of `Credentials` inside Vault Hash with a key pattern `keyspace/id`, in this case `credentials/heisenberg`, in the key-value secret secrets engine. <2> Uses the provided id to retrieve the object stored at `keyspace/id`. <3> Counts the total number of entities available within the keyspace _credentials_ defined by `@Secret` on `Credentials`. <4> Removes the key for the given object from Vault. @@ -141,12 +146,10 @@ in this case `credentials/heisenberg`, in the generic secret backend. [[vault.repositories.mapping]] == Object to Vault JSON Mapping -Vault repositories store objects in Vault using JSON as interchange format. Object mapping between JSON and -the entity is done by `VaultConverter`. The converter reads and writes `SecretDocument` that contains the body -from a `VaultResponse`. ``VaultResponse``s are read from Vault and the body is deserialized by -Jackson into a `Map` of `String` and `Object`. -The default `VaultConverter` implementation reads the `Map` with nested values, `List` and `Map` objects and -converts these to entities and vice versa. +Vault repositories store objects in Vault using JSON as interchange format. +Object mapping between JSON and the entity is done by `VaultConverter`. +The converter reads and writes `SecretDocument` that contains the body from a `VaultResponse`. ``VaultResponse``s are read from Vault and the body is deserialized by Jackson into a `Map` of `String` and `Object`. +The default `VaultConverter` implementation reads the `Map` with nested values, `List` and `Map` objects and converts these to entities and vice versa. Given the `Credentials` type from the previous sections the default mapping is as follows: @@ -165,6 +168,7 @@ Given the `Credentials` type from the previous sections the default mapping is a } } ---- + <1> The `_class` attribute is included on root level as well as on any nested interface or abstract types. <2> Simple property values are mapped by path. <3> Properties of complex types are mapped as nested objects. @@ -172,7 +176,7 @@ Given the `Credentials` type from the previous sections the default mapping is a NOTE: The `@Id` property must be mapped to `String`. -[cols="1,2,3", options="header"] +[cols="1,2,3",options="header"] .Default Mapping Rules |=== | Type @@ -208,23 +212,24 @@ of Complex Type You can customize the mapping behavior by registering a `Converter` in `VaultCustomConversions`. Those converters can take care of converting from/to a type such as `LocalDate` as well as `SecretDocument` -whereas the first one is suitable for converting simple properties and the last one complex types to their JSON -representation. The second option offers full control over the resulting `SecretDocument`. Writing objects to `Vault` +whereas the first one is suitable for converting simple properties and the last one complex types to their JSON representation. +The second option offers full control over the resulting `SecretDocument`. +Writing objects to `Vault` will delete the content and re-create the whole entry, so not mapped data will be lost. [[vault.repositories.queries]] == Queries and Query Methods -Query methods allow automatic derivation of simple queries from the method name. Vault has no query engine but -requires direct access of HTTP context paths. Vault query methods translate Vault's API possibilities to queries. -A query method execution lists children under a context path, applies filtering to the Id, optionally limits the -Id stream with offset/limit and applies sorting after fetching the results. +Query methods allow automatic derivation of simple queries from the method name. +Vault has no query engine but requires direct access of HTTP context paths. +Vault query methods translate Vault's API possibilities to queries. +A query method execution lists children under a context path, applies filtering to the Id, optionally limits the Id stream with offset/limit and applies sorting after fetching the results. .Sample Repository Query Method ==== [source,java] ---- -public interface CredentialsRepository extends CrudRepository { +interface CredentialsRepository extends CrudRepository { List findByIdStartsWith(String prefix); } @@ -296,12 +301,13 @@ Here's an overview of the keywords supported for Vault. | `findFirst10ByFirstname`,`findTop5ByFirstname` |=== +[[vault.repositories.sorting.paging]] === Sorting and Paging -Query methods support sorting and paging by selecting in memory a sublist (offset/limit) Id's retrieved from -a Vault context path. Sorting has is not limited to a particular field, unlike query method predicates. -Unpaged sorting is applied after Id filtering and all resulting secrets are fetched from Vault. This way -a query method fetches only results that are also returned as part of the result. +Query methods support sorting and paging by selecting in memory a sublist (offset/limit) Id's retrieved from a Vault context path. +Sorting has is not limited to a particular field, unlike query method predicates. +Unpaged sorting is applied after Id filtering and all resulting secrets are fetched from Vault. +This way a query method fetches only results that are also returned as part of the result. Using paging and sorting requires secret fetching before filtering the Id's which impacts performance. Sorting and paging guarantees to return the same result even if the natural order of Id returned by Vault changes. @@ -311,7 +317,7 @@ Therefore, all Id's are fetched from Vault first, then sorting is applied and af ==== [source,java] ---- -public interface CredentialsRepository extends PagingAndSortingRepository { +interface CredentialsRepository extends PagingAndSortingRepository { List findTop10ByIdStartsWithOrderBySocialSecurityNumberDesc(String prefix); @@ -319,3 +325,62 @@ public interface CredentialsRepository extends PagingAndSortingRepository + +VersionedCredentials concurrent = repo.findById("sample-credentials").get(); <2> + +credentials.setPassword("something-else"); + +repos.save(credentials); <3> + + +concurrent.setPassword("concurrent change"); + +repos.save(concurrent); // throws OptimisticLockingFailureException <4> + +---- + +<1> Obtain a secret by its Id `sample-credentials`. +<2> Obtain a second instance of the secret by its Id `sample-credentials`. +<3> Update the secret and let Vault increment the version. +<4> Update the second instance that uses the previous version. +The operation fails with an `OptimisticLockingFailureException` as the version was incremented in Vault in the meantime. +==== + +NOTE: When deleting versioned secrets, delete by Id deletes the most recent secret. Delete by entity deletes the secret at the provided version.