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
This commit is contained in:
Mark Paluch
2022-05-19 11:33:56 +02:00
parent d7c99a17cb
commit 7556404f3f
13 changed files with 1015 additions and 83 deletions

View File

@@ -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<String, Object> options;
private final @Nullable Map<String, Object> options;
final boolean available;
private final boolean available;
private MountInfo(String path, @Nullable Map<String, Object> options, boolean available) {
this.path = path;

View File

@@ -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;
}
/**

View File

@@ -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<String, Object> 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<String, Object> 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();
}

View File

@@ -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(".")) {

View File

@@ -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<String, VaultKeyValueKeyspaceAccessor> 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> T get(Object id, String keyspace, Class<T> 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> 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<Object> 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<String> 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<String> doList(String keyspace) {
List<String> list = this.vaultOperations.list(keyspace);
VaultKeyValueKeyspaceAccessor accessor = getAccessor(keyspace);
List<String> 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<? extends VaultPersistentEntity<?>, 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<String> 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<String> 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<String> list(String pattern) {
return operations.list(getPathInMount(pattern));
}
@Nullable
@Override
SecretDocument get(String id) {
Versioned<Map<String, Object>> 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());
}
}
}
}

View File

@@ -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<Class<? extends KeyValueEvent>> 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<Class<? extends KeyValueEvent>> 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> 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> 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> T delete(T objectToDelete) {
Class<T> type = (Class<T>) 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);
}
}
}

View File

@@ -77,7 +77,15 @@ class VaultQueryEngine extends QueryEngine<VaultKeyValueAdapter, VaultQuery, Com
}
}
Stream<T> typed = stream.map(it -> getRequiredAdapter().get(it, keyspace, type));
Stream<T> typed = stream.flatMap(it -> {
T result = getRequiredAdapter().get(it, keyspace, type);
if (result == null) {
return Stream.empty();
}
return Stream.of(result);
});
if (comparator != null) {

View File

@@ -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<VersionedPerson> 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<Object> objectVersioned = versioned.get("versionedPerson/foo-key", Versioned.Version.from(1));
assertThat(objectVersioned.hasData()).isTrue();
this.versionedRepository.delete(versionedPerson); // delete v1
Versioned<Object> v1 = versioned.get("versionedPerson/foo-key", Versioned.Version.from(1));
assertThat(v1.hasData()).isFalse();
Versioned<Object> 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<VersionedPerson> 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<VersionedPerson> 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<VersionedPerson, String> {
List<VersionedPerson> findByIdStartsWith(String prefix);
}
interface SimpleRepository extends CrudRepository<SimplePerson, String> {
List<SimplePerson> 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();
}
}
}

View File

@@ -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 {
}

View File

@@ -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;

View File

@@ -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._

View File

@@ -5,6 +5,8 @@
=== What's new in Spring Vault 2.4
* Support for <<vault.authentication.userpass,Username/Password authentication>> for Username/Password, LDAP, Okta, and RADIUS authentication.
* Support of versioned Key/Value secrets engines for Vault repositories.
* <<vault.repositories.optimistic-locking,Optimistic locking support through Vault repositories using versioned Key/Value secrets engines>>.
[[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 <<vault.core.secret-engines,how to use Vault secret backends>>.
* 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
* <<vault.authentication.gcpgce,GCP Compute>>, <<vault.authentication.gcpiam,GCP IAM>>, and <<vault.authentication.azuremsi, Azure>> 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.
* <<vault.authentication.kubernetes,Kubernetes authentication>>.
* RoleId/SecretId unwrapping for <<vault.authentication.approle,AppRole authentication>>.
* <<vault.misc.spring-security,Spring Security integration>> with transit backend-based `BytesKeyGenerator` and `BytesEncryptor`.
* <<vault.misc.spring-security,Spring Security integration>> with transit secrets engine-based `BytesKeyGenerator` and `BytesEncryptor`.
[[new-features.1-1-0]]
=== What's new in Spring Vault 1.1.0

View File

@@ -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<Credentials, String> {
interface CredentialsRepository extends CrudRepository<Credentials, String> {
}
----
====
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"]
----
<dependencyManagement>
<dependencies>
@@ -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<Credentials, String> {
interface CredentialsRepository extends CrudRepository<Credentials, String> {
List<Credentials> 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<Credentials, String> {
interface CredentialsRepository extends PagingAndSortingRepository<Credentials, String> {
List<Credentials> findTop10ByIdStartsWithOrderBySocialSecurityNumberDesc(String prefix);
@@ -319,3 +325,62 @@ public interface CredentialsRepository extends PagingAndSortingRepository<Creden
}
----
====
[[vault.repositories.optimistic-locking]]
== Optimistic Locking
Vaults key/value secrets engine version 2 can maintain versioned secrets.
Spring Vault supports versioning through a version property in the domain model that are annotated with `@Version`.
Using optimistic locking makes sure updates are only applied to secrets with a matching version.
Therefore, the actual value of the version property is added to the update request through the `cas` property.
If another operation altered the secret in the meantime, then an OptimisticLockingFailureException is thrown and the secret isn't updated.
Version properties must be numeric properties such as `int` or `long` and map to the `cas` property when updating secrets.
.Sample Versioned Entity
====
[source,java]
----
@Secret
class VersionedCredentials {
@Id String id;
@Version int version;
String password;
String socialSecurityNumber;
Address address;
}
----
====
The following example shows these features:
.Sample Versioned Entity
====
[source,java]
----
VersionedCredentialsRepository repo = …;
VersionedCredentials credentials = repo.findById("sample-credentials").get(); <1>
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.