Skip to content

Commit 47c5a15

Browse files
Add integration test using custom DataEncryptionKeyAccessor
We need to ensure that the accessor is able to read the content property from the entity before it is removed/after it is created. This is necessary to have custom key accessors work, so they can store the encryption key somewhere other than the entity itself, for example based on the content id
1 parent 2a141b2 commit 47c5a15

File tree

2 files changed

+264
-2
lines changed

2 files changed

+264
-2
lines changed

spring-content-encryption/src/main/java/internal/org/springframework/content/fragments/EncryptingContentStoreImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ public S unsetContent(S entity, PropertyPath propertyPath, org.springframework.c
130130
throw new StoreAccessException(String.format("Content property %s does not exist", propertyPath.getName()));
131131
}
132132

133-
S newEntity = storeDelegate.unsetContent(entity, propertyPath, params);
133+
var newEntity = cryptoService.clearKeys(entity, propertyPath);
134134

135-
return cryptoService.clearKeys(newEntity, propertyPath);
135+
return storeDelegate.unsetContent(newEntity, propertyPath, params);
136136
}
137137

138138
@Override
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package org.springframework.content.encryption.fs;
2+
3+
import com.github.paulcwarren.ginkgo4j.Ginkgo4jSpringRunner;
4+
import java.util.Collection;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.content.commons.mappingcontext.ContentProperty;
7+
import org.springframework.content.encryption.config.EncryptingContentStoreConfiguration;
8+
import org.springframework.content.encryption.config.EncryptingContentStoreConfigurer;
9+
import internal.org.springframework.content.rest.boot.autoconfigure.ContentRestAutoConfiguration;
10+
import internal.org.springframework.content.s3.boot.autoconfigure.S3ContentAutoConfiguration;
11+
import io.restassured.module.mockmvc.RestAssuredMockMvc;
12+
import jakarta.persistence.Entity;
13+
import jakarta.persistence.GeneratedValue;
14+
import jakarta.persistence.GenerationType;
15+
import jakarta.persistence.Id;
16+
import lombok.Getter;
17+
import lombok.NoArgsConstructor;
18+
import lombok.Setter;
19+
import org.apache.commons.io.IOUtils;
20+
import org.apache.http.HttpStatus;
21+
import org.hamcrest.Matchers;
22+
import org.junit.Test;
23+
import org.junit.runner.RunWith;
24+
import org.springframework.beans.factory.annotation.Autowired;
25+
import org.springframework.boot.SpringApplication;
26+
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
27+
import org.springframework.boot.autoconfigure.SpringBootApplication;
28+
import org.springframework.boot.test.context.SpringBootTest;
29+
import org.springframework.content.commons.annotations.ContentId;
30+
import org.springframework.content.commons.annotations.ContentLength;
31+
import org.springframework.content.commons.annotations.MimeType;
32+
import org.springframework.content.encryption.keys.DataEncryptionKeyAccessor;
33+
import org.springframework.content.encryption.keys.StoredDataEncryptionKey.UnencryptedSymmetricDataEncryptionKey;
34+
import org.springframework.content.encryption.store.EncryptingContentStore;
35+
import org.springframework.content.fs.config.EnableFilesystemStores;
36+
import org.springframework.content.fs.io.FileSystemResourceLoader;
37+
import org.springframework.content.fs.store.FilesystemContentStore;
38+
import org.springframework.context.annotation.Bean;
39+
import org.springframework.context.annotation.Configuration;
40+
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
41+
import org.springframework.data.repository.CrudRepository;
42+
import org.springframework.web.context.WebApplicationContext;
43+
44+
import java.io.FileInputStream;
45+
import java.io.IOException;
46+
import java.nio.file.Files;
47+
import java.util.Optional;
48+
import java.util.UUID;
49+
50+
import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.*;
51+
import static io.restassured.module.mockmvc.RestAssuredMockMvc.given;
52+
import static org.hamcrest.Matchers.*;
53+
import static org.hamcrest.MatcherAssert.assertThat;
54+
55+
@RunWith(Ginkgo4jSpringRunner.class)
56+
@SpringBootTest(classes = CustomKeyAccessorEncryptionIT.Application.class, webEnvironment= SpringBootTest.WebEnvironment.RANDOM_PORT)
57+
public class CustomKeyAccessorEncryptionIT {
58+
59+
@Autowired
60+
private FileRepository repo;
61+
62+
@Autowired
63+
private FileContentStore3 store;
64+
65+
@Autowired
66+
private ContentEncryptionKeyRepository contentEncryptionKeyRepository;
67+
68+
@Autowired
69+
private java.io.File filesystemRoot;
70+
71+
@Autowired
72+
private WebApplicationContext webApplicationContext;
73+
74+
private FsFile f;
75+
76+
{
77+
Describe("Client-side encryption with custom key storage", () -> {
78+
BeforeEach(() -> {
79+
RestAssuredMockMvc.webAppContextSetup(webApplicationContext);
80+
81+
f = repo.save(new FsFile());
82+
});
83+
Context("given content", () -> {
84+
BeforeEach(() -> {
85+
given()
86+
.contentType("text/plain")
87+
.body("Hello Client-side encryption World!")
88+
.when()
89+
.post("/fsFiles/" + f.getId() + "/content")
90+
.then()
91+
.statusCode(HttpStatus.SC_CREATED);
92+
});
93+
It("should be stored encrypted", () -> {
94+
Optional<FsFile> fetched = repo.findById(f.getId());
95+
assertThat(fetched.isPresent(), is(true));
96+
f = fetched.get();
97+
98+
String contents = IOUtils.toString(new FileInputStream(new java.io.File(filesystemRoot, f.getContentId().toString())));
99+
assertThat(contents, is(not("Hello Client-side encryption World!")));
100+
101+
assertThat(contentEncryptionKeyRepository.findById(f.getContentId()).isPresent(), is(true));
102+
});
103+
It("should be retrieved decrypted", () -> {
104+
given()
105+
.header("accept", "text/plain")
106+
.get("/fsFiles/" + f.getId() + "/content")
107+
.then()
108+
.statusCode(HttpStatus.SC_OK)
109+
.assertThat()
110+
.contentType(Matchers.startsWith("text/plain"))
111+
.body(Matchers.equalTo("Hello Client-side encryption World!"));
112+
});
113+
Context("when the content is unset", () -> {
114+
It("it should remove the content and clear the content key", () -> {
115+
f = repo.findById(f.getId()).get();
116+
String contentId = f.getContentId().toString();
117+
118+
given()
119+
.delete("/fsFiles/" + f.getId() + "/content")
120+
.then()
121+
.statusCode(HttpStatus.SC_NO_CONTENT);
122+
123+
f = repo.findById(f.getId()).get();
124+
assertThat(contentEncryptionKeyRepository.findById(UUID.fromString(contentId)).isEmpty(), is(true));
125+
assertThat(new java.io.File(filesystemRoot, contentId).exists(), is(false));
126+
});
127+
});
128+
});
129+
});
130+
}
131+
132+
@Test
133+
public void noop() {}
134+
135+
@SpringBootApplication(exclude={S3ContentAutoConfiguration.class})
136+
@ImportAutoConfiguration(ContentRestAutoConfiguration.class)
137+
@EnableJpaRepositories(considerNestedRepositories = true)
138+
@EnableFilesystemStores
139+
static class Application {
140+
public static void main(String[] args) {
141+
SpringApplication.run(Application.class, args);
142+
}
143+
144+
@Configuration
145+
public static class Config {
146+
147+
@Bean
148+
public java.io.File filesystemRoot() {
149+
try {
150+
return Files.createTempDirectory("").toFile();
151+
} catch (IOException ioe) {}
152+
return null;
153+
}
154+
155+
@Bean
156+
public FileSystemResourceLoader fileSystemResourceLoader() {
157+
return new FileSystemResourceLoader(filesystemRoot().getAbsolutePath());
158+
}
159+
160+
@Bean
161+
public EncryptingContentStoreConfigurer<FileContentStore3> config(ContentEncryptionKeyRepository encryptionKeyRepository) {
162+
return new EncryptingContentStoreConfigurer<FileContentStore3>() {
163+
@Override
164+
public void configure(EncryptingContentStoreConfiguration<FileContentStore3> config) {
165+
config.dataEncryptionKeyAccessor(new EntityStorageDataEncryptionKeyAccessor<>(encryptionKeyRepository));
166+
}
167+
};
168+
}
169+
}
170+
}
171+
172+
public interface FileRepository extends CrudRepository<FsFile, Long> {}
173+
174+
public interface FileContentStore3 extends FilesystemContentStore<FsFile, UUID>, EncryptingContentStore<FsFile, UUID> {}
175+
176+
@Entity
177+
@Getter
178+
@Setter
179+
@NoArgsConstructor
180+
public static class FsFile {
181+
@Id
182+
@GeneratedValue(strategy = GenerationType.AUTO)
183+
private Long id;
184+
185+
private String name;
186+
187+
@ContentId private UUID contentId;
188+
@ContentLength private long contentLength;
189+
@MimeType private String contentMimeType;
190+
}
191+
192+
public interface ContentEncryptionKeyRepository extends CrudRepository<ContentEncryptionKey, UUID> {
193+
194+
195+
}
196+
197+
@Entity
198+
@Getter
199+
@Setter
200+
@NoArgsConstructor
201+
public static class ContentEncryptionKey {
202+
@Id
203+
private UUID contentId;
204+
205+
private String algorithm;
206+
207+
private byte[] encryptionKey;
208+
209+
private byte[] iv;
210+
}
211+
212+
@RequiredArgsConstructor
213+
private static class EntityStorageDataEncryptionKeyAccessor<S> implements DataEncryptionKeyAccessor<S, UnencryptedSymmetricDataEncryptionKey> {
214+
private final ContentEncryptionKeyRepository contentEncryptionKeyRepository;
215+
216+
@Override
217+
public Collection<UnencryptedSymmetricDataEncryptionKey> findKeys(S entity, ContentProperty contentProperty) {
218+
var contentId = (UUID)contentProperty.getContentId(entity);
219+
if(contentId == null) {
220+
return null;
221+
}
222+
return contentEncryptionKeyRepository.findById(contentId).stream()
223+
.map(encryptionKeyEntity -> new UnencryptedSymmetricDataEncryptionKey(
224+
encryptionKeyEntity.getAlgorithm(),
225+
encryptionKeyEntity.getEncryptionKey(),
226+
encryptionKeyEntity.getIv()
227+
))
228+
.toList();
229+
}
230+
231+
@Override
232+
public S setKeys(S entity, ContentProperty contentProperty,
233+
Collection<UnencryptedSymmetricDataEncryptionKey> dataEncryptionKeys
234+
) {
235+
var contentId = (UUID)contentProperty.getContentId(entity);
236+
var maybeDataEncryptionKey = dataEncryptionKeys.stream().findFirst();
237+
238+
if(maybeDataEncryptionKey.isEmpty()) {
239+
contentEncryptionKeyRepository.deleteById(contentId);
240+
return entity;
241+
}
242+
243+
var dataEncryptionKey = maybeDataEncryptionKey.get();
244+
245+
246+
var encryptionKeyEntity = contentEncryptionKeyRepository.findById(contentId)
247+
.orElseGet(() -> {
248+
var contentEncryptionKey = new ContentEncryptionKey();
249+
contentEncryptionKey.setContentId(contentId);
250+
return contentEncryptionKey;
251+
});
252+
253+
encryptionKeyEntity.setAlgorithm(dataEncryptionKey.getAlgorithm());
254+
encryptionKeyEntity.setEncryptionKey(dataEncryptionKey.getKeyData());
255+
encryptionKeyEntity.setIv(dataEncryptionKey.getInitializationVector());
256+
257+
contentEncryptionKeyRepository.save(encryptionKeyEntity);
258+
259+
return entity;
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)