Add support for RevisionRepository.

Vault repositories can now implement RevisionRepository to access older secret revisions.

See gh-593
This commit is contained in:
Mark Paluch
2022-05-20 09:54:00 +02:00
parent 7556404f3f
commit 3d0b909b5a
10 changed files with 603 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.Versioned;
/**
* Vault database exchange object containing data before/after it's exchanged with Vault.

View File

@@ -268,6 +268,14 @@ public class VaultKeyValueAdapter extends AbstractKeyValueAdapter {
}
public VaultConverter getConverter() {
return this.vaultConverter;
}
public VaultOperations getVaultOperations() {
return this.vaultOperations;
}
static abstract class VaultKeyValueKeyspaceAccessor {
private final KeyValueDelegate.MountInfo mountInfo;

View File

@@ -28,6 +28,8 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.repository.convert.VaultConverter;
import org.springframework.vault.repository.mapping.VaultMappingContext;
/**
@@ -177,4 +179,12 @@ public class VaultKeyValueTemplate extends KeyValueTemplate {
}
}
public VaultConverter getConverter() {
return execute(adapter -> ((VaultKeyValueAdapter) adapter).getConverter());
}
public VaultOperations getVaultOperations() {
return execute(adapter -> ((VaultKeyValueAdapter) adapter).getVaultOperations());
}
}

View File

@@ -19,10 +19,15 @@ import org.springframework.data.keyvalue.core.KeyValueOperations;
import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery;
import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory;
import org.springframework.data.repository.core.EntityInformation;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.RepositoryComposition;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;
import org.springframework.data.repository.core.support.RepositoryFragment;
import org.springframework.data.repository.history.RevisionRepository;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.parser.AbstractQueryCreator;
import org.springframework.vault.repository.core.MappingVaultEntityInformation;
import org.springframework.vault.repository.core.VaultKeyValueTemplate;
import org.springframework.vault.repository.mapping.VaultPersistentEntity;
import org.springframework.vault.repository.query.VaultQueryCreator;
@@ -54,12 +59,35 @@ public class VaultRepositoryFactory extends KeyValueRepositoryFactory {
this.operations = keyValueOperations;
}
@Override
protected RepositoryComposition.RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata,
KeyValueOperations operations) {
RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata, operations);
if (RevisionRepository.class.isAssignableFrom(metadata.getRepositoryInterface())
&& operations instanceof VaultKeyValueTemplate) {
VaultKeyValueTemplate template = (VaultKeyValueTemplate) operations;
VaultPersistentEntity<?> entity = (VaultPersistentEntity<?>) this.operations.getMappingContext()
.getRequiredPersistentEntity(metadata.getDomainType());
EntityInformation<?, String> entityInformation = getEntityInformation(metadata.getDomainType());
VaultRevisionRepository<?> repository = new VaultRevisionRepository<>(entityInformation,
entity.getKeySpace(), template);
return fragments.append(RepositoryFragment.implemented(repository));
}
return fragments;
}
@Override
@SuppressWarnings("unchecked")
public <T, ID> EntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
VaultPersistentEntity<T> entity = (VaultPersistentEntity<T>) this.operations.getMappingContext()
.getPersistentEntity(domainClass);
.getRequiredPersistentEntity(domainClass);
return new MappingVaultEntityInformation<>(entity);
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 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.support;
import java.time.Instant;
import java.util.Optional;
import org.springframework.data.history.RevisionMetadata;
import org.springframework.vault.support.Versioned;
/**
* @author Mark Paluch
*/
public class VaultRevisionMetadata implements RevisionMetadata<Integer> {
private final Versioned.Metadata metadata;
public VaultRevisionMetadata(Versioned<?> versioned) {
this(versioned.getRequiredMetadata());
}
public VaultRevisionMetadata(Versioned.Metadata metadata) {
this.metadata = metadata;
}
@Override
public Optional<Integer> getRevisionNumber() {
return Optional.of(metadata.getVersion().getVersion());
}
@Override
public Optional<Instant> getRevisionInstant() {
return Optional.of(metadata.getCreatedAt());
}
@Override
public <T> T getDelegate() {
return (T) metadata;
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 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.support;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.history.Revision;
import org.springframework.data.history.Revisions;
import org.springframework.data.repository.core.EntityInformation;
import org.springframework.data.repository.history.RevisionRepository;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.vault.core.VaultKeyValueMetadataOperations;
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.SecretDocument;
import org.springframework.vault.repository.convert.VaultConverter;
import org.springframework.vault.repository.core.VaultKeyValueTemplate;
import org.springframework.vault.support.VaultMetadataResponse;
import org.springframework.vault.support.Versioned;
/**
* Vault-based {@link RevisionRepository} providing revision metadata for versioned
* secrets.
*
* @author Mark Paluch
* @since 2.4
*/
public class VaultRevisionRepository<T> implements RevisionRepository<T, String, Integer> {
private final EntityInformation<T, String> metadata;
private final String keyspacePath;
private final VaultVersionedKeyValueOperations operations;
private final VaultKeyValueMetadataOperations metadataOperations;
private final VaultConverter converter;
public VaultRevisionRepository(EntityInformation<T, String> metadata, String keyspace,
VaultKeyValueTemplate keyValueTemplate) {
Assert.notNull(metadata, "EntityInformation must not be null");
Assert.notNull(keyValueTemplate, "VaultKeyValueTemplate must not be null");
this.metadata = metadata;
this.converter = keyValueTemplate.getConverter();
VaultOperations vaultOperations = keyValueTemplate.getVaultOperations();
KeyValueDelegate delegate = new KeyValueDelegate(vaultOperations);
KeyValueDelegate.MountInfo mountInfo = delegate.getMountInfo(keyspace);
if (!mountInfo.isAvailable()) {
throw new IllegalStateException("Mount not available under " + keyspace);
}
if (!delegate.isVersioned(keyspace)) {
throw new IllegalStateException("Mount under " + keyspace + " is not versioned");
}
this.keyspacePath = keyspace.substring(mountInfo.getPath().length());
this.operations = vaultOperations.opsForVersionedKeyValue(mountInfo.getPath());
this.metadataOperations = this.operations.opsForKeyValueMetadata();
}
@Override
public Optional<Revision<Integer, T>> findLastChangeRevision(String id) {
Assert.notNull(id, "Identifier must not be null");
return toRevision(operations.get(getPath(id)), id);
}
@Override
public Revisions<Integer, T> findRevisions(String id) {
VaultMetadataResponse metadata = metadataOperations.get(getPath(id));
if (metadata == null) {
return Revisions.none();
}
return Revisions.of(collectRevisions(id, metadata.getVersions()));
}
private List<Revision<Integer, T>> collectRevisions(String id, List<Versioned.Metadata> versions) {
List<Revision<Integer, T>> revisions = new ArrayList<>();
for (Versioned.Metadata version : versions) {
Versioned<Map<String, Object>> versioned = operations.get(getPath(id), version.getVersion());
if (versioned == null) {
continue;
}
T entity = versioned.hasData() ? converter.read(this.metadata.getJavaType(), createDocument(id, versioned))
: null;
revisions.add(Revision.of(new VaultRevisionMetadata(versioned), entity));
}
return revisions;
}
@Override
public Page<Revision<Integer, T>> findRevisions(String id, Pageable pageable) {
if (pageable.isUnpaged()) {
return new PageImpl<>(Collections.emptyList());
}
VaultMetadataResponse metadata = metadataOperations.get(getPath(id));
if (metadata == null || pageable.getOffset() > metadata.getVersions().size()) {
return Page.empty(pageable);
}
List<Versioned.Metadata> versions = metadata.getVersions();
int toIndex = Math.min(versions.size(), Math.toIntExact(pageable.getOffset() + pageable.getPageSize()));
List<Versioned.Metadata> metadataPage = versions.subList(Math.toIntExact(pageable.getOffset()), toIndex);
List<Revision<Integer, T>> revisions = collectRevisions(id, metadataPage);
return new PageImpl<>(revisions, pageable, versions.size());
}
@Override
public Optional<Revision<Integer, T>> findRevision(String id, Integer revisionNumber) {
Assert.notNull(id, "Identifier must not be null");
Assert.notNull(revisionNumber, "Revision number must not be null");
return toRevision(operations.get(getPath(id), Versioned.Version.from(revisionNumber)), id);
}
private Optional<Revision<Integer, T>> toRevision(@Nullable Versioned<Map<String, Object>> versioned, String id) {
if (versioned == null) {
return Optional.empty();
}
T entity = versioned.hasData() ? converter.read(metadata.getJavaType(), createDocument(id, versioned)) : null;
return Optional.of(Revision.of(new VaultRevisionMetadata(versioned), entity));
}
private String getPath(String id) {
return keyspacePath + "/" + id;
}
private SecretDocument createDocument(String id, Versioned<Map<String, Object>> versioned) {
return new SecretDocument(id, versioned.getVersion().getVersion(), versioned.getRequiredData());
}
}

View File

@@ -361,8 +361,8 @@ public class VaultCertificateRequest {
/**
* Configure the key format.
* @param format the key format to use. Can be {@code pem}, {@code der}, or
* {@code pkcs8}
* @param privateKeyFormat the key format to use. Can be {@code pem}, {@code der},
* or {@code pkcs8}
* @return {@code this} {@link VaultCertificateRequestBuilder}.
* @since 2.4
*/

View File

@@ -30,7 +30,9 @@ 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.history.Revisions;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.history.RevisionRepository;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.ObjectUtils;
@@ -105,6 +107,23 @@ class VaultKv2RepositoryIntegrationTests extends IntegrationTestSupport {
assertThat(this.versionedRepository.findById("foo-key")).contains(saved);
}
@Test
void shouldReportRevisions() {
VersionedPerson person = new VersionedPerson();
person.setId("foo-key");
person.setFirstname("bar");
VersionedPerson saved = this.versionedRepository.save(person);
saved.setFirstname("baz");
this.versionedRepository.save(saved);
Revisions<Integer, VersionedPerson> revisions = this.versionedRepository.findRevisions(person.getId());
assertThat(revisions).hasSize(2);
}
@Test
void loadAndUpdateVersioned() {
@@ -228,7 +247,8 @@ class VaultKv2RepositoryIntegrationTests extends IntegrationTestSupport {
this.simpleRepository.save(versionedPerson);
}
interface VersionedRepository extends CrudRepository<VersionedPerson, String> {
interface VersionedRepository
extends CrudRepository<VersionedPerson, String>, RevisionRepository<VersionedPerson, String, Integer> {
List<VersionedPerson> findByIdStartsWith(String prefix);

View File

@@ -0,0 +1,261 @@
/*
* Copyright 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.support;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
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.Configuration;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.history.Revision;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
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.repository.configuration.EnableVaultRepositories;
import org.springframework.vault.repository.core.MappingVaultEntityInformation;
import org.springframework.vault.repository.core.VaultKeyValueTemplate;
import org.springframework.vault.repository.mapping.Secret;
import org.springframework.vault.repository.mapping.VaultPersistentEntity;
import org.springframework.vault.support.VaultMount;
import org.springframework.vault.util.IntegrationTestSupport;
import static org.assertj.core.api.Assertions.*;
/**
* @author Mark Paluch
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = VaultRevisionRepositoryIntegrationTests.VaultRepositoryTestConfiguration.class)
class VaultRevisionRepositoryIntegrationTests extends IntegrationTestSupport {
@Configuration
@EnableVaultRepositories()
static class VaultRepositoryTestConfiguration extends VaultIntegrationTestConfiguration {
}
@Autowired
VaultTemplate vaultTemplate;
@Autowired
VaultKeyValueTemplate keyValueTemplate;
@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 shouldReportNoRevisions() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
assertThat(repository.findRevision("foo", 1)).isEmpty();
assertThat(repository.findRevisions("foo")).isEmpty();
assertThat(repository.findRevisions("foo", Pageable.ofSize(2).withPage(1))).isEmpty();
}
@Test
void shouldFindRevisionMetadata() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
prepareVersions();
assertThat(repository.findRevision("foo", 1)).get().satisfies(rev -> {
assertThat(rev.getEntity().getPassword()).isEqualTo("password-v1");
assertThat(rev.getRequiredRevisionInstant()).isNotNull();
assertThat(rev.getRequiredRevisionNumber()).isEqualTo(1);
});
assertThat(repository.findRevision("foo", 5)).isEmpty();
}
@Test
void shouldFindLatestRevision() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
prepareVersions();
assertThat(repository.findLastChangeRevision("foo")).get().satisfies(rev -> {
assertThat(rev.getEntity().getPassword()).isEqualTo("password-v4");
assertThat(rev.getRequiredRevisionInstant()).isNotNull();
assertThat(rev.getRequiredRevisionNumber()).isEqualTo(4);
});
assertThat(repository.findRevision("foo", 5)).isEmpty();
}
@Test
void shouldFindRevisionMetadatas() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
prepareVersions();
assertThat(repository.findRevisions("foo")).hasSize(4);
}
@Test
void shouldFindPagedMetadatas() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
prepareVersions();
Page<Revision<Integer, VersionedPerson>> page1 = repository.findRevisions("foo",
Pageable.ofSize(3).withPage(0));
Page<Revision<Integer, VersionedPerson>> page2 = repository.findRevisions("foo", page1.nextPageable());
Page<Revision<Integer, VersionedPerson>> page3 = repository.findRevisions("foo", page2.nextPageable());
assertThat(page1).hasSize(3);
assertThat(page1.getTotalElements()).isEqualTo(4);
assertThat(page2).hasSize(1);
assertThat(page2.getTotalElements()).isEqualTo(4);
assertThat(page3).isEmpty();
}
@Test
void shouldFindOutOfBoundsPagedMetadatas() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
prepareVersions();
assertThat(repository.findRevisions("foo", Pageable.ofSize(10).withPage(10))).isEmpty();
assertThat(repository.findRevisions("foo", Pageable.ofSize(4).withPage(1))).isEmpty();
}
@Test
void shouldFindDeletedRevisionMetadata() {
VaultRevisionRepository<VersionedPerson> repository = getRepository();
VersionedPerson v1 = new VersionedPerson("foo", 0, "password-v1");
VersionedPerson v2 = new VersionedPerson("foo", 1, "password-v2");
VersionedPerson v3 = new VersionedPerson("foo", 2, "password-v3");
VersionedPerson v4 = new VersionedPerson("foo", 3, "password-v4");
keyValueTemplate.insert(v1);
keyValueTemplate.update(v2);
keyValueTemplate.update(v3);
keyValueTemplate.update(v4);
keyValueTemplate.delete(v2);
assertThat(repository.findRevision("foo", 1)).get().satisfies(rev -> {
assertThat(rev.getEntity()).isNull();
assertThat(rev.getRequiredRevisionInstant()).isNotNull();
assertThat(rev.getRequiredRevisionNumber()).isEqualTo(1);
});
}
@SuppressWarnings("rawtypes")
private VaultRevisionRepository<VersionedPerson> getRepository() {
VaultPersistentEntity<?> entity = keyValueTemplate.getConverter().getMappingContext()
.getRequiredPersistentEntity(VersionedPerson.class);
return new VaultRevisionRepository<>(new MappingVaultEntityInformation(entity), "versioned/versionedPerson",
keyValueTemplate);
}
private void prepareVersions() {
VersionedPerson v1 = new VersionedPerson("foo", 0, "password-v1");
VersionedPerson v2 = new VersionedPerson("foo", 1, "password-v2");
VersionedPerson v3 = new VersionedPerson("foo", 2, "password-v3");
VersionedPerson v4 = new VersionedPerson("foo", 3, "password-v4");
keyValueTemplate.insert(v1);
keyValueTemplate.update(v2);
keyValueTemplate.update(v3);
keyValueTemplate.update(v4);
}
@Secret(backend = "versioned")
static class VersionedPerson {
@Id
String id;
@Version
int version;
String password;
public VersionedPerson() {
}
public VersionedPerson(String id, int version, String password) {
this.id = id;
this.version = version;
this.password = password;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public int getVersion() {
return version;
}
public void setVersion(int version) {
this.version = version;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
}

View File

@@ -384,3 +384,43 @@ The operation fails with an `OptimisticLockingFailureException` as the version w
====
NOTE: When deleting versioned secrets, delete by Id deletes the most recent secret. Delete by entity deletes the secret at the provided version.
[[vault.repositories.revision-repository]]
== Accessing versioned secrets
Key/Value version 2 secrets engine maintains versions of secrets that can be accessed by implementing https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/repository/history/RevisionRepository.html[`RevisionRepository`] in your Vault repository interface declaration.
Revision repositories define lookup methods to obtain revisions for a particular identifier.
Identifiers must be `String`.
.Implementing `RevisionRepository`
====
[source,java]
----
interface RevisionCredentialsRepository extends CrudRepository<Credentials, String>,
RevisionRepository<Credentials, String, Integer> <1>
{
}
----
<1> The first type parameter (`Credentials`) denotes the entity type, the second (`String`) denotes the type of the id property, and the last one (`Integer`) is the type of the revision number. Vault supports only `String` identifiers and `Integer` revision numbers.
====
=== Usage
You can now use the methods from `RevisionRepository` to query the revisions of the entity, as the following example shows:
.Using `RevisionRepository`
====
[source,java]
----
RevisionCredentialsRepository repo = …;
Revisions<Integer, Credentials> revisions = repo.findRevisions("my-secret-id");
Page<Revision<Integer, Credentials>> firstPageOfRevisions = repo.findRevisions("my-secret-id", Pageable.ofSize(4));
----
====