diff --git a/README.adoc b/README.adoc index 6089f8800..2d2975894 100644 --- a/README.adoc +++ b/README.adoc @@ -200,6 +200,13 @@ The generated documentation is available from `target/site/reference/html/index. popd ---- +=== Intellij Issue with Importing pom.xml + +There is an issue in Intellij that prevents it from importing modules when one of the module +directories has the same name as the project directory. The work-around is to create a new module (any name, any type will suffice). +When Intellij creates the new module, it will also recognize the existing modules. Once the new module is +created, it can be deleted and Intellij will now recognize the existing modules. + The generated documentation is available from `target/site/reference/html/index.html`. == Examples diff --git a/spring-data-couchbase/pom.xml b/spring-data-couchbase/pom.xml index 3186819ce..24703019e 100644 --- a/spring-data-couchbase/pom.xml +++ b/spring-data-couchbase/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 @@ -217,6 +218,13 @@ test + + com.couchbase.client + couchbase-encryption + 3.1.0 + test + + @@ -303,7 +311,9 @@ target/generated-test-sources - org.springframework.data.couchbase.repository.support.CouchbaseAnnotationProcessor + + org.springframework.data.couchbase.repository.support.CouchbaseAnnotationProcessor + diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java index ba37fffd1..6e380315c 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java @@ -18,8 +18,9 @@ import static com.couchbase.client.java.ClusterOptions.clusterOptions; -import java.util.Collections; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.springframework.beans.factory.config.BeanDefinition; @@ -27,6 +28,7 @@ import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; +import org.springframework.core.convert.converter.GenericConverter; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.data.convert.CustomConversions; import org.springframework.data.couchbase.CouchbaseClientFactory; @@ -35,6 +37,7 @@ import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.core.convert.CouchbaseCustomConversions; import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; +import org.springframework.data.couchbase.core.convert.OtherConverters; import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; @@ -149,7 +152,9 @@ public ClusterEnvironment couchbaseClusterEnvironment() { if (!nonShadowedJacksonPresent()) { throw new CouchbaseException("non-shadowed Jackson not present"); } - builder.jsonSerializer(JacksonJsonSerializer.create(couchbaseObjectMapper())); + CryptoManager cryptoManager = cryptoManager(); + builder.jsonSerializer(JacksonJsonSerializer.create(couchbaseObjectMapper(cryptoManager))); + builder.cryptoManager(cryptoManager); configureEnvironment(builder); return builder.build(); } @@ -280,8 +285,8 @@ public MappingCouchbaseConverter mappingCouchbaseConverter(CouchbaseMappingConte @Bean public TranslationService couchbaseTranslationService() { final JacksonTranslationService jacksonTranslationService = new JacksonTranslationService(); + jacksonTranslationService.setObjectMapper(couchbaseObjectMapper(cryptoManager())); jacksonTranslationService.afterPropertiesSet(); - // for sdk3, we need to ask the mapper _it_ uses to ignore extra fields... JacksonTransformers.MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return jacksonTranslationService; @@ -308,10 +313,25 @@ public CouchbaseMappingContext couchbaseMappingContext(CustomConversions customC */ public ObjectMapper couchbaseObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); + return couchbaseObjectMapper(cryptoManager()); + } + + /** + * Creates a {@link ObjectMapper} for the jsonSerializer of the ClusterEnvironment + * + * @param cryptoManager + * @return ObjectMapper + */ + + ObjectMapper mapper; + + public ObjectMapper couchbaseObjectMapper(CryptoManager cryptoManager) { + if (mapper != null) { + return mapper; + } + mapper = new ObjectMapper(); // or use the one from the Java SDK (?) JacksonTransformers.MAPPER mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JsonValueModule()); - CryptoManager cryptoManager = null; if (cryptoManager != null) { mapper.registerModule(new EncryptionModule(cryptoManager)); } @@ -320,7 +340,7 @@ public ObjectMapper couchbaseObjectMapper() { /** * The default blocking transaction manager. It is an implementation of CallbackPreferringTransactionManager - * CallbackPreferrringTransactionmanagers do not play well with test-cases that rely + * CallbackPreferringTransactionManagers do not play well with test-cases that rely * on @TestTransaction/@BeforeTransaction/@AfterTransaction * * @param clientFactory @@ -341,6 +361,7 @@ CouchbaseCallbackTransactionManager couchbaseTransactionManager(CouchbaseClientF TransactionTemplate couchbaseTransactionTemplate(CouchbaseCallbackTransactionManager couchbaseTransactionManager) { return new TransactionTemplate(couchbaseTransactionManager); } + /** * The default TransactionalOperator. * @@ -379,11 +400,28 @@ protected boolean autoIndexCreation() { * and {@link #couchbaseMappingContext(CustomConversions)}. Returns an empty {@link CustomConversions} instance by * default. * + * @param cryptoManagerOptional optional cryptoManager. Make varargs for backwards compatibility. * @return must not be {@literal null}. */ @Bean(name = BeanNames.COUCHBASE_CUSTOM_CONVERSIONS) - public CustomConversions customConversions() { - return new CouchbaseCustomConversions(Collections.emptyList()); + public CustomConversions customConversions(CryptoManager... cryptoManagerOptional) { + assert (cryptoManagerOptional == null || cryptoManagerOptional.length <= 1); + CryptoManager cryptoManager = cryptoManagerOptional != null && cryptoManagerOptional.length == 1 + ? cryptoManagerOptional[0] + : null; + List newConverters = new ArrayList(); + // the cryptoConverters take an argument, so they cannot be created in the + // static block of CouchbaseCustomConversions. And they must be set before the super() constructor + // in CouchbaseCustomerConversions + if (cryptoManager != null) { + newConverters.addAll(OtherConverters.getCryptoConverters(cryptoManager)); + } + return new CouchbaseCustomConversions(newConverters); + } + + @Bean + protected CryptoManager cryptoManager() { + return null; } /** diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java index b5231ef19..91b817bc6 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java @@ -20,8 +20,11 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.GenericConversionService; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.data.mapping.model.EntityInstantiators; /** @@ -29,6 +32,7 @@ * * @author Michael Nitschinger * @author Mark Paluch + * @author Michael Reiche */ public abstract class AbstractCouchbaseConverter implements CouchbaseConverter, InitializingBean { @@ -93,6 +97,36 @@ public void afterPropertiesSet() { conversions.registerConvertersIn(conversionService); } + /** + * This convertForWriteIfNeeded takes a property and accessor so that the annotations can be accessed (ie. @Encrypted) + * + * @param prop the property to be converted to the class that would actually be stored. + * @param accessor the property accessor + * @return + */ + @Override + public Object convertForWriteIfNeeded(CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { + Object value = accessor.getProperty(prop, prop.getType()); + if (value == null) { + return null; + } + + Object result = this.conversions.getCustomWriteTarget(prop.getType()) // + .map(it -> this.conversionService.convert(value, new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(it))) // + .orElseGet(() -> Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value); + + return result; + + } + + /** + * This convertForWriteIfNeed takes only the value to convert. It cannot access the annotations of the Field being + * converted. + * + * @param value the value to be converted to the class that would actually be stored. + * @return + */ @Override public Object convertForWriteIfNeeded(Object value) { if (value == null) { diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java index 23e7b20a3..968916980 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java @@ -22,6 +22,7 @@ import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.mapping.Alias; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.data.util.TypeInformation; /** @@ -37,13 +38,24 @@ public interface CouchbaseConverter /** * Convert the value if necessary to the class that would actually be stored, or leave it as is if no conversion - * needed. + * needed. This method cannot access the annotations of the field. * * @param value the value to be converted to the class that would actually be stored. * @return the converted value (or the same value if no conversion necessary). */ Object convertForWriteIfNeeded(Object value); + /** + * Convert the value if necessary to the class that would actually be stored, or leave it as is if no conversion + * needed. This method can access the annotations of the field. + * + * @param source the property to be converted to the class that would actually be stored. + * @param accessor the property accessor + * @return the converted value (or the same value if no conversion necessary). + */ + Object convertForWriteIfNeeded(final CouchbasePersistentProperty source, + final ConvertingPropertyAccessor accessor); + /** * Return the Class that would actually be stored for a given Class. * diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java index ba265d4e4..5806a0e9c 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java @@ -22,6 +22,8 @@ import org.springframework.data.mapping.model.SimpleTypeHolder; +import com.couchbase.client.core.encryption.CryptoManager; + /** * Value object to capture custom conversion. *

@@ -32,6 +34,7 @@ * @author Oliver Gierke * @author Mark Paluch * @author Subhashni Balakrishnan + * @Michael Reiche * @see org.springframework.data.convert.CustomConversions * @see SimpleTypeHolder * @since 2.0 @@ -42,6 +45,18 @@ public class CouchbaseCustomConversions extends org.springframework.data.convert private static final List STORE_CONVERTERS; + private CryptoManager cryptoManager; + + /** + * Expose the CryptoManager used by a DecryptingReadingConverter or EncryptingWritingConverter, if any. There can only + * be one. MappingCouchbaseConverter needs it. + * + * @return cryptoManager + */ + public CryptoManager getCryptoManager() { + return cryptoManager; + } + static { List converters = new ArrayList<>(); @@ -61,5 +76,31 @@ public class CouchbaseCustomConversions extends org.springframework.data.convert */ public CouchbaseCustomConversions(final List converters) { super(STORE_CONVERSIONS, converters); + for (Object c : converters) { + if (c instanceof DecryptingReadingConverter) { + CryptoManager foundCryptoManager = ((DecryptingReadingConverter) c).cryptoManager; + if (foundCryptoManager == null) { + throw new RuntimeException(("DecryptingReadingConverter must have a cryptoManager")); + } else { + if (cryptoManager != null && this.cryptoManager != cryptoManager) { + throw new RuntimeException( + "all DecryptingReadingConverters and EncryptingWringConverters must use " + " a single CryptoManager"); + } + } + cryptoManager = foundCryptoManager; + } + if (c instanceof EncryptingWritingConverter) { + CryptoManager foundCryptoManager = ((EncryptingWritingConverter) c).cryptoManager; + if (foundCryptoManager == null) { + throw new RuntimeException(("EncryptingWritingConverter must have a cryptoManager")); + } else { + if (cryptoManager != null && this.cryptoManager != cryptoManager) { + throw new RuntimeException( + "all DecryptingReadingConverters and EncryptingWringConverters must use " + " a single CryptoManager"); + } + } + cryptoManager = foundCryptoManager; + } + } } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java new file mode 100644 index 000000000..38edf49a2 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java @@ -0,0 +1,71 @@ +/* + * 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.data.couchbase.core.convert; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalGenericConverter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; + +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.encryption.annotation.Encrypted; + +/** + * Use the cryptoManager to decrypt a field + * + * @author Michael Reiche + */ +@ReadingConverter +public class DecryptingReadingConverter implements ConditionalGenericConverter { + + CryptoManager cryptoManager; + ConversionService conversionService; + + public DecryptingReadingConverter(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + @Override + public Set getConvertibleTypes() { + Set convertiblePairs = new HashSet<>(); + Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, + BigInteger.class, BigDecimal.class, Boolean.class }; + for (Class clazz : clazzes) { + convertiblePairs.add(new ConvertiblePair(CouchbaseDocument.class, clazz)); + } + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + return source == null? null : new String(cryptoManager.decrypt(((CouchbaseDocument) source).getContent())); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return targetType.hasAnnotation(Encrypted.class); + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/Encrypted.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/Encrypted.java new file mode 100644 index 000000000..fe10c2908 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/Encrypted.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-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.data.couchbase.core.convert; + +import java.util.Map; + +/** + * + * @author Michael Reiche + */ +public class Encrypted { + Map encryptionMap; + + public Encrypted(Map encryptionMap){ + this.encryptionMap = encryptionMap; + } + + Map getEncryptionMap(){ + return encryptionMap; + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java new file mode 100644 index 000000000..b7ed1d3e2 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-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.data.couchbase.core.convert; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConditionalGenericConverter; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.convert.WritingConverter; + +import com.couchbase.client.core.encryption.CryptoManager; + +/** + * Use the cryptomManager to encrypt properties + * + * @author Michael Reiche + */ +@WritingConverter +public class EncryptingWritingConverter implements ConditionalGenericConverter { + + CryptoManager cryptoManager; + + public EncryptingWritingConverter(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public Set getConvertibleTypes() { + + Set convertiblePairs = new HashSet<>(); + Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, + BigInteger.class, BigDecimal.class, Boolean.class }; + for (Class clazz : clazzes) { + convertiblePairs.add(new ConvertiblePair(clazz, String.class)); + } + return convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (source == null) { + return null; + } + com.couchbase.client.java.encryption.annotation.Encrypted ann = sourceType + .getAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); + Map result = new HashMap<>(); + result.putAll(cryptoManager.encrypt(source.toString().getBytes(), ann.encrypter())); + return new Encrypted(result); + } + + @Override + public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + return sourceType.hasAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java index e480f9c6d..c1b03131d 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java @@ -19,9 +19,11 @@ import static org.springframework.data.couchbase.core.mapping.id.GenerationStrategy.UNIQUE; import static org.springframework.data.couchbase.core.mapping.id.GenerationStrategy.USE_ATTRIBUTES; +import java.beans.Transient; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -33,9 +35,11 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.core.CollectionFactory; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.DefaultConversionService; -import org.springframework.data.annotation.Transient; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; +import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbaseList; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; @@ -72,6 +76,9 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.json.JsonObject; + /** * A mapping converter for Couchbase. The converter is responsible for reading from and writing to entities and * converting it into a consumable database representation. @@ -118,6 +125,11 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter implem */ private @Nullable EntityCallbacks entityCallbacks; + /** + * CryptoManager for en/decryption + */ + private @Nullable CryptoManager cryptoManager; + public MappingCouchbaseConverter() { this(new CouchbaseMappingContext(), null); } @@ -155,6 +167,23 @@ public MappingCouchbaseConverter( spELContext = new SpELContext(CouchbaseDocumentPropertyAccessor.INSTANCE); } + /** + * Get the cryptoManager used in conversions. We need to be able to mangle a property name. + */ + @Override + public void setCustomConversions(CustomConversions customConversions) { + super.setCustomConversions(customConversions); + if (customConversions instanceof CouchbaseCustomConversions) { + this.cryptoManager = ((CouchbaseCustomConversions) customConversions).getCryptoManager(); + } + } + + @Override + public void afterPropertiesSet() { + super.afterPropertiesSet(); + + } + /** * Returns a collection from the given source object. * @@ -277,8 +306,15 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { accessor.setProperty(prop, obj); } + /** + * doesPropertyExistInSource. It could have getFieldName() or mangled(getFieldName()) + * + * @param property + * @return + */ private boolean doesPropertyExistInSource(final CouchbasePersistentProperty property) { - return property.isIdProperty() || source.containsKey(property.getFieldName()); + return property.isIdProperty() || source.containsKey(property.getFieldName()) + || (cryptoManager != null && source.containsKey(cryptoManager.mangle(property.getFieldName()))); } private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { @@ -397,10 +433,25 @@ private Object getPotentiallyConvertedSimpleRead(final Object value, final Class throw new MappingException("Unable to create class from " + value.toString()); } } - return target.isAssignableFrom(value.getClass()) ? value : conversionService.convert(value, target); } + /** + * Potentially convert simple values like ENUMs. + * + * @param value the value to convert. + * @param target the target persistent property which may have an Encrypt annotation + * @return the potentially converted object. + */ + @SuppressWarnings("unchecked") + private Object getPotentiallyConvertedSimpleRead(Object value, final CouchbasePersistentProperty target) { + if (value == null || target == null) { + return value; + } + // this call to convert takes TypeDescriptors - the target type descriptor may have an Encrypt annotation. + return conversionService.convert(value, TypeDescriptor.forObject(value), new TypeDescriptor(target.getField())); + } + @Override public void write(final Object source, final CouchbaseDocument target) { if (source == null) { @@ -414,7 +465,8 @@ public void write(final Object source, final CouchbaseDocument target) { typeMapper.writeType(type, target); } - writeInternal(source, target, type, true); + // CouchbasePersistentEntity entity = mappingContext.getPersistentEntity(source.getClass()); + writeInternalRoot(source, target, type, true); if (target.getId() == null) { throw new MappingException("An ID property is needed, but not found/could not be generated on this entity."); } @@ -425,10 +477,10 @@ public void write(final Object source, final CouchbaseDocument target) { * * @param source the source object. * @param target the target document. - * @param typeHint the type information for the source. + * @param withId write out with the id. */ @SuppressWarnings("unchecked") - protected void writeInternal(final Object source, CouchbaseDocument target, final TypeInformation typeHint, + protected void writeInternalRoot(final Object source, CouchbaseDocument target, TypeInformation typeHint, boolean withId) { if (source == null) { return; @@ -441,7 +493,7 @@ protected void writeInternal(final Object source, CouchbaseDocument target, fina } if (Map.class.isAssignableFrom(source.getClass())) { - writeMapInternal((Map) source, target, ClassTypeInformation.MAP); + writeMapInternal((Map) source, target, ClassTypeInformation.MAP, null); return; } @@ -450,7 +502,7 @@ protected void writeInternal(final Object source, CouchbaseDocument target, fina } CouchbasePersistentEntity entity = mappingContext.getPersistentEntity(source.getClass()); - writeInternal(source, target, entity, withId); + writeInternalEntity(source, target, entity, withId, null); addCustomTypeKeyIfNecessary(typeHint, source, target); } @@ -486,8 +538,8 @@ private String convertToString(Object propertyObj) { * @param entity the persistent entity to convert from. * @param withId one of the top-level properties is the id for the document */ - protected void writeInternal(final Object source, final CouchbaseDocument target, - final CouchbasePersistentEntity entity, boolean withId) { + protected void writeInternalEntity(final Object source, final CouchbaseDocument target, + final CouchbasePersistentEntity entity, boolean withId, CouchbasePersistentProperty prop) { if (source == null) { return; } @@ -507,6 +559,7 @@ protected void writeInternal(final Object source, final CouchbaseDocument target target.setExpiration((int) (entity.getExpiryDuration().getSeconds())); + // write all the entity.properties to the target. Does not write the id or version. writeToTargetDocument(target, entity, accessor, idProperty, versionProperty, prefixes, suffixes, idAttributes); if (idProperty != null && target.getId() == null) { @@ -520,6 +573,7 @@ protected void writeInternal(final Object source, final CouchbaseDocument target } else { target.setId(id); } + } entity.doWithAssociations(new AssociationHandler() { @@ -529,7 +583,7 @@ public void doWithAssociation(final Association ass Class type = inverseProp.getType(); Object propertyObj = accessor.getProperty(inverseProp, type); if (null != propertyObj) { - writePropertyInternal(propertyObj, target, inverseProp, false); + writePropertyInternal(propertyObj, target, inverseProp, accessor); } } }); @@ -576,9 +630,9 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { } if (!conversions.isSimpleType(propertyObj.getClass())) { - writePropertyInternal(propertyObj, target, prop, false); + writePropertyInternal(propertyObj, target, prop, accessor); } else { - writeSimpleInternal(propertyObj, target, prop.getFieldName()); + writeSimpleInternal(prop, accessor, target, prop.getFieldName()); } } } @@ -594,7 +648,7 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { */ @SuppressWarnings("unchecked") private void writePropertyInternal(final Object source, final CouchbaseDocument target, - final CouchbasePersistentProperty prop, boolean withId) { + final CouchbasePersistentProperty prop, final ConvertingPropertyAccessor accessor) { if (source == null) { return; } @@ -602,28 +656,23 @@ private void writePropertyInternal(final Object source, final CouchbaseDocument String name = prop.getFieldName(); TypeInformation valueType = ClassTypeInformation.from(source.getClass()); TypeInformation type = prop.getTypeInformation(); - if (valueType.isCollectionLike()) { - CouchbaseList collectionDoc = createCollection(asCollection(source), prop); + CouchbaseList collectionDoc = createCollection(asCollection(source), valueType, prop, accessor); + // TODO needs to handle enc target.put(name, collectionDoc); return; } if (valueType.isMap()) { CouchbaseDocument mapDoc = createMap((Map) source, prop); + // TODO needs to handle enc target.put(name, mapDoc); return; } if (valueType.getType().equals(java.util.Optional.class)) { - if (source == null) - return; - Optional o = (Optional) source; - if (o.isPresent()) { - writeSimpleInternal(o.get(), target, prop.getFieldName()); - } else { - writeSimpleInternal(null, target, prop.getFieldName()); - } + Optional o = (Optional) source; + writeSimpleInternal(o.map(s -> prop).orElse(null), accessor, target, prop.getFieldName()); return; } @@ -642,9 +691,19 @@ private void writePropertyInternal(final Object source, final CouchbaseDocument CouchbasePersistentEntity entity = isSubtype(prop.getType(), source.getClass()) ? mappingContext.getRequiredPersistentEntity(source.getClass()) - : mappingContext.getRequiredPersistentEntity(type); - writeInternal(source, propertyDoc, entity, false); - target.put(name, propertyDoc); + : mappingContext.getRequiredPersistentEntity(prop); + writeInternalEntity(source, propertyDoc, entity, false, prop); + com.couchbase.client.java.encryption.annotation.Encrypted ann = prop + .findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); + if (ann != null) { + JsonObject jo = JsonObject.from(propertyDoc.getContent()); + Map encMap = new HashMap(); + encMap.putAll(cryptoManager.encrypt(jo.toBytes(), ann.encrypter())); + CouchbaseDocument mapDoc = writeMapInternal(encMap, new CouchbaseDocument(), prop.getTypeInformation(), prop); + target.put(cryptoManager.mangle(name), mapDoc); + } else { + target.put(name, propertyDoc); + } } /** @@ -658,7 +717,7 @@ private CouchbaseDocument createMap(final Map map, final Couchba Assert.notNull(map, "Given map must not be null!"); Assert.notNull(prop, "PersistentProperty must not be null!"); - return writeMapInternal(map, new CouchbaseDocument(), prop.getTypeInformation()); + return writeMapInternal(map, new CouchbaseDocument(), prop.getTypeInformation(), prop); } /** @@ -666,11 +725,10 @@ private CouchbaseDocument createMap(final Map map, final Couchba * * @param source the source object. * @param target the target document. - * @param type the type information for the document. * @return the written couchbase document. */ private CouchbaseDocument writeMapInternal(final Map source, final CouchbaseDocument target, - final TypeInformation type) { + TypeInformation type, CouchbasePersistentProperty prop) { for (Map.Entry entry : source.entrySet()) { Object key = entry.getKey(); Object val = entry.getValue(); @@ -679,14 +737,14 @@ private CouchbaseDocument writeMapInternal(final Map source, fin String simpleKey = key.toString(); if (val == null || conversions.isSimpleType(val.getClass())) { - writeSimpleInternal(val, target, simpleKey); + writeSimpleInternal(val, target, simpleKey); // this is an entry in a map, cannot have an annotation } else if (val instanceof Collection || val.getClass().isArray()) { - target.put(simpleKey, writeCollectionInternal(asCollection(val), - new CouchbaseList(conversions.getSimpleTypeHolder()), type.getMapValueType())); + target.put(simpleKey, + writeCollectionInternal(asCollection(val), new CouchbaseList(conversions.getSimpleTypeHolder()), + prop.getTypeInformation(), prop, getPropertyAccessor(val))); } else { CouchbaseDocument embeddedDoc = new CouchbaseDocument(); - TypeInformation valueTypeInfo = type.isMap() ? type.getMapValueType() : ClassTypeInformation.OBJECT; - writeInternal(val, embeddedDoc, valueTypeInfo, false); + writeInternalRoot(val, embeddedDoc, prop.getTypeInformation(), false); target.put(simpleKey, embeddedDoc); } } else { @@ -701,12 +759,12 @@ private CouchbaseDocument writeMapInternal(final Map source, fin * Helper method to create the underlying collection/list. * * @param collection the collection to write. - * @param prop the property information. * @return the created couchbase list. */ - private CouchbaseList createCollection(final Collection collection, final CouchbasePersistentProperty prop) { - return writeCollectionInternal(collection, new CouchbaseList(conversions.getSimpleTypeHolder()), - prop.getTypeInformation()); + private CouchbaseList createCollection(final Collection collection, final TypeInformation type, + CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { + return writeCollectionInternal(collection, new CouchbaseList(conversions.getSimpleTypeHolder()), type, prop, + accessor); } /** @@ -714,11 +772,11 @@ private CouchbaseList createCollection(final Collection collection, final Cou * * @param source the source object. * @param target the target document. - * @param type the type information for the document. * @return the created couchbase list. */ private CouchbaseList writeCollectionInternal(final Collection source, final CouchbaseList target, - final TypeInformation type) { + final TypeInformation type, CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { + TypeInformation componentType = type == null ? null : type.getComponentType(); for (Object element : source) { @@ -728,11 +786,10 @@ private CouchbaseList writeCollectionInternal(final Collection source, final target.put(getPotentiallyConvertedSimpleWrite(element)); } else if (element instanceof Collection || elementType.isArray()) { target.put(writeCollectionInternal(asCollection(element), new CouchbaseList(conversions.getSimpleTypeHolder()), - componentType)); + type, prop, accessor)); } else { - CouchbaseDocument embeddedDoc = new CouchbaseDocument(); - writeInternal(element, embeddedDoc, componentType, false); + writeInternalRoot(element, embeddedDoc, prop.getTypeInformation(), false); target.put(embeddedDoc); } @@ -783,7 +840,7 @@ private Object readCollection(final TypeInformation targetType, final Couchba /** * Write the given source into the couchbase document target. * - * @param source the source object. + * @param source the source object. This does not have access to annotaions. * @param target the target document. * @param key the key of the object. */ @@ -791,8 +848,41 @@ private void writeSimpleInternal(final Object source, final CouchbaseDocument ta target.put(key, getPotentiallyConvertedSimpleWrite(source)); } + /** + * Write the given source into the couchbase document target. + * + * @param source the source persistent property. This has access to annotations. + * @param target the target document. + * @param key the key of the object. + */ + private void writeSimpleInternal(final CouchbasePersistentProperty source, + final ConvertingPropertyAccessor accessor, final CouchbaseDocument target, final String key) { + Object result = getPotentiallyConvertedSimpleWrite(source, accessor); + if (result instanceof Optional) { + Optional optional = (Optional) result; + result = optional.orElse(null); + } + // For FLE, write as "encrypted$"... + if (source.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null) { + if (result instanceof Encrypted) { + CouchbaseDocument mapDoc = writeMapInternal(((Encrypted) result).getEncryptionMap(), new CouchbaseDocument(), + ClassTypeInformation.MAP, null); + target.put(cryptoManager.mangle(key), mapDoc); + } else { + throw new RuntimeException("field annotation with @Encrypted could not be encrypted " + source.getField()); + } + } else { + target.put(key, result); + } + } + public Object getPotentiallyConvertedSimpleWrite(final Object value) { - return convertForWriteIfNeeded(value); + return convertForWriteIfNeeded(value); // cannot access annotations + } + + public Object getPotentiallyConvertedSimpleWrite(final CouchbasePersistentProperty value, + ConvertingPropertyAccessor accessor) { + return convertForWriteIfNeeded(value, accessor); // can access annotations } /** @@ -844,7 +934,7 @@ public void setEntityCallbacks(EntityCallbacks entityCallbacks) { * @return the converted object. */ @SuppressWarnings("unchecked") - private R readValue(Object value, TypeInformation type, Object parent) { + private R readValue(Object value, TypeInformation type, Object parent) { Class rawType = type.getType(); if (conversions.hasCustomReadTarget(value.getClass(), rawType)) { @@ -854,7 +944,43 @@ private R readValue(Object value, TypeInformation type, Object parent) { } else if (value instanceof CouchbaseList) { return (R) readCollection(type, (CouchbaseList) value, parent); } else { - return (R) getPotentiallyConvertedSimpleRead(value, rawType); + return (R) getPotentiallyConvertedSimpleRead(value, type.getClass()); // type does not have annotations + } + } + + TranslationService translationService = new JacksonTranslationService(); + + /** + * Helper method to read the value based on the PersistentProperty + * + * @param value the value to convert. + * @param prop the persistent property - will have annotations (i.e. Encrypt for FLE) + * @param parent the optional parent. + * @param the target type. + * @return the converted object. + */ + @SuppressWarnings("unchecked") + private R readValue(Object value, CouchbasePersistentProperty prop, Object parent) { + Class rawType = prop.getType(); + + if (prop.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null + && !conversions.isSimpleType(rawType)) { + TreeMap exported = ((CouchbaseDocument) value).export(); // should be couchbase document (?) + byte[] result = cryptoManager.decrypt(exported); + CouchbaseDocument converted = new CouchbaseDocument(); + Object readEntity = read(prop.getTypeInformation().getType(), + (CouchbaseDocument) translationService.decode(new String(result), converted)); + return (R) readEntity; + } + if (conversions.hasCustomReadTarget(value.getClass(), rawType)) { + TypeInformation ti = ClassTypeInformation.from(value.getClass()); + return (R) conversionService.convert(value, ti.toTypeDescriptor(), new TypeDescriptor(prop.getField())); + } else if (value instanceof CouchbaseDocument) { + return (R) read(prop.getTypeInformation(), (CouchbaseDocument) value, parent); + } else if (value instanceof CouchbaseList) { + return (R) readCollection(prop.getTypeInformation(), (CouchbaseList) value, parent); + } else { + return (R) getPotentiallyConvertedSimpleRead(value, prop);// passes PersistentProperty with annotations } } @@ -960,6 +1086,11 @@ public CouchbasePropertyValueProvider(final CouchbaseDocument source, public R getPropertyValue(final CouchbasePersistentProperty property) { String expression = property.getSpelExpression(); Object value = expression != null ? evaluator.evaluate(expression) : source.get(property.getFieldName()); + if (value == null + && property.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null + && cryptoManager != null) { + value = source.get(cryptoManager.mangle(property.getFieldName())); + } if (property == entity.getIdProperty() && parent == null) { return (R) source.getId(); @@ -967,8 +1098,7 @@ public R getPropertyValue(final CouchbasePersistentProperty property) { if (value == null) { return null; } - - return readValue(value, property.getTypeInformation(), source); + return readValue(value, property, source); } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java index 8e95fdb6e..a7a0503cf 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java @@ -23,7 +23,9 @@ import java.util.List; import java.util.UUID; +import com.couchbase.client.core.encryption.CryptoManager; import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.GenericConverter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; @@ -43,7 +45,6 @@ private OtherConverters() {} */ public static Collection> getConvertersToRegister() { List> converters = new ArrayList>(); - converters.add(UuidToString.INSTANCE); converters.add(StringToUuid.INSTANCE); converters.add(BigIntegerToString.INSTANCE); @@ -54,6 +55,19 @@ private OtherConverters() {} return converters; } + /** + * Returns the crypto converters to be registered. + * + * @param cryptoManager to use for encrypting and decrypting + * @return the list of converters to register. + */ + public static Collection getCryptoConverters(CryptoManager cryptoManager) { + List converters = new ArrayList<>(); + converters.add(new EncryptingWritingConverter(cryptoManager)); + converters.add(new DecryptingReadingConverter(cryptoManager)); + return converters; + } + @WritingConverter public enum UuidToString implements Converter { INSTANCE; diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseList.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseList.java index d7cd4b8a1..777e0fe11 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseList.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseList.java @@ -202,7 +202,7 @@ private void verifyValueType(final Object value) { } throw new IllegalArgumentException( - "Attribute of type " + clazz.getCanonicalName() + "can not be stored and must be converted."); + "Attribute of type " + clazz.getCanonicalName() + " can not be stored and must be converted."); } /** diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java index 59b5313b2..5b5f5d6d5 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java @@ -15,10 +15,16 @@ */ package org.springframework.data.couchbase.domain; +import com.couchbase.client.java.encryption.annotation.Encrypted; import org.springframework.data.couchbase.core.mapping.Document; +import java.io.Serializable; + @Document -public class Address extends ComparableEntity { +public class Address extends ComparableEntity implements Serializable { + + @Encrypted + private String encStreet; private String street; private String city; @@ -36,6 +42,14 @@ public void setStreet(String street) { this.street = street; } + public String getEncStreet() { + return encStreet; + } + + public void setEncStreet(String encStreet) { + this.encStreet = encStreet; + } + public String getCity() { return city; } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Config.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Config.java index 209b90bdc..51f15bcf5 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Config.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Config.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors + * Copyright 2012-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. @@ -220,6 +220,7 @@ public MappingCouchbaseConverter mappingCouchbaseConverter(CouchbaseMappingConte @Bean(name = "couchbaseTranslationService") public TranslationService couchbaseTranslationService() { final JacksonTranslationService jacksonTranslationService = new JacksonTranslationService(); + jacksonTranslationService.setObjectMapper(couchbaseObjectMapper(cryptoManager())); jacksonTranslationService.afterPropertiesSet(); // for sdk3, we need to ask the mapper _it_ uses to ignore extra fields... diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java new file mode 100644 index 000000000..48fb356f7 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-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.data.couchbase.domain; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import com.couchbase.client.java.encryption.annotation.Encrypted; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.Transient; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; + +/** + * UserEncrypted entity for tests + * + * @author Michael Nitschinger + * @author Michael Reiche + */ + +@Document +@TypeAlias(AbstractingTypeMapper.Type.ABSTRACTUSER) +public class UserEncrypted extends AbstractUser implements Serializable { + + public UserEncrypted(){ + this.subtype = AbstractingTypeMapper.Type.USER; + } + + public UserEncrypted( final String lastname, final String encryptedField) { + this(); + this.id = UUID.randomUUID().toString(); + this.lastname = lastname; + this.encryptedField = encryptedField; + } + + @PersistenceConstructor + public UserEncrypted(final String id, final String firstname, final String lastname) { + this(); + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + } + + public UserEncrypted(final String id, final String firstname, final String lastname, final String encryptedField) { + this(); + this.id = id; + this.firstname = firstname; + this.lastname = lastname; + this.encryptedField = encryptedField; + } + + @Version protected long version; + @Transient protected String transientInfo; + @CreatedBy protected String createdBy; + @CreatedDate protected long createdDate; + @LastModifiedBy protected String lastModifiedBy; + @LastModifiedDate protected long lastModifiedDate; + @Encrypted public String encryptedField; + @Encrypted public Integer encInteger=1; + @Encrypted public Long encLong=Long.valueOf(1); + @Encrypted public Boolean encBoolean = Boolean.TRUE; + + List nicknames = List.of("Happy", "Sleepy"); + Address homeAddress = new Address(); + List
addresses = new ArrayList<>(); + + @Encrypted + Address encAddress = new Address(); + + public String getLastname() { + return lastname; + } + + public long getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(long createdDate) { + this.createdDate = createdDate; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public long getLastModifiedDate() { + return lastModifiedDate; + } + + public String getLastModifiedBy() { + return lastModifiedBy; + } + + + public String getEncryptedField() { + return encryptedField; + } + + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + public void setHomeAddress(Address address){ + this.homeAddress = address; + } + + public void setEncAddress(Address address){ + this.encAddress = address; + } + + public void addAddress(Address address){ + this.addresses.add(address); + } + @Override + public int hashCode() { + return Objects.hash(getId(), firstname, lastname); + } + + public String getTransientInfo() { + return transientInfo; + } + + public void setTransientInfo(String something) { + transientInfo = something; + } + +} diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncryptedRepository.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncryptedRepository.java new file mode 100644 index 000000000..2d9bfd20f --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncryptedRepository.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-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.data.couchbase.domain; + +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.couchbase.repository.Collection; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.data.couchbase.repository.Query; +import org.springframework.data.couchbase.repository.ScanConsistency; +import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.couchbase.client.java.json.JsonArray; +import com.couchbase.client.java.query.QueryScanConsistency; + +/** + * User Repository for tests + * + * @author Michael Nitschinger + * @author Michael Reiche + */ +@Repository +@ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) +public interface UserEncryptedRepository extends CouchbaseRepository { + + List findByFirstname(String firstname); + + List findByFirstnameIgnoreCase(String firstname); + + Stream findByLastname(String lastname); + + List findByFirstnameIn(String... firstnames); + + List findByFirstnameIn(JsonArray firstnames); + + List findByFirstnameAndLastname(String firstname, String lastname); + + @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and firstname = $1 and lastname = $2") + List getByFirstnameAndLastname(String firstname, String lastname); + + @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and (firstname = $first or lastname = $last)") + List getByFirstnameOrLastname(@Param("first") String firstname, @Param("last") String lastname); + + List findByIdIsNotNullAndFirstnameEquals(String firstname); + + List findByVersionEqualsAndFirstnameEquals(Long version, String firstname); + + @Query("#{#n1ql.selectEntity}|#{#n1ql.filter}|#{#n1ql.bucket}|#{#n1ql.scope}|#{#n1ql.collection}") + @Scope("thisScope") + @Collection("thisCollection") + List spelTests(); + + // simulate a slow operation + @Cacheable("mySpringCache") + default List getByFirstname(String firstname) { + try { + Thread.sleep(1000 * 5); + } catch (InterruptedException ie) {} + return findByFirstname(firstname); + } + + @Override + UserEncrypted save(UserEncrypted user); +} diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java new file mode 100644 index 000000000..bc17cb86b --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java @@ -0,0 +1,221 @@ +/* + * Copyright 2012-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.data.couchbase.repository; + +import static com.couchbase.client.core.util.CbCollections.mapOf; +import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import com.couchbase.client.encryption.AeadAes256CbcHmacSha512Provider; +import com.couchbase.client.encryption.Decrypter; +import com.couchbase.client.encryption.DefaultCryptoManager; +import com.couchbase.client.encryption.Encrypter; +import com.couchbase.client.encryption.EncryptionResult; +import com.couchbase.client.encryption.Keyring; +import com.couchbase.client.java.encryption.annotation.Encrypted; +import com.couchbase.client.java.encryption.databind.jackson.EncryptionModule; +import com.couchbase.client.java.json.JsonArray; +import com.couchbase.client.java.json.JsonObject; +import com.couchbase.client.java.json.JsonValue; +import com.couchbase.client.java.json.JsonValueModule; +import com.couchbase.client.java.query.QueryOptions; +import com.couchbase.client.java.query.QueryScanConsistency; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.query.N1QLExpression; +import org.springframework.data.couchbase.core.query.N1QLQuery; +import org.springframework.data.couchbase.domain.Address; +import org.springframework.data.couchbase.domain.PersonValueRepository; +import org.springframework.data.couchbase.domain.User; +import org.springframework.data.couchbase.domain.UserEncrypted; +import org.springframework.data.couchbase.domain.UserEncryptedRepository; +import org.springframework.data.couchbase.domain.UserRepository; +import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; +import org.springframework.data.couchbase.util.ClusterAwareIntegrationTests; +import org.springframework.data.couchbase.util.ClusterType; +import org.springframework.data.couchbase.util.IgnoreWhen; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import com.couchbase.client.core.deps.io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.core.env.SecurityConfig; +import com.couchbase.client.java.env.ClusterEnvironment; +import org.testcontainers.shaded.org.bouncycastle.asn1.isismtt.x509.AdditionalInformationSyntax; + +/** + * Repository KV tests + * + * @author Michael Nitschinger + * @author Michael Reiche + */ +@SpringJUnitConfig(CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.Config.class) +@IgnoreWhen(clusterTypes = ClusterType.MOCKED) +public class CouchbaseRepositoryFieldLevelEncryptionIntegrationTests extends ClusterAwareIntegrationTests { + + @Autowired + UserEncryptedRepository userEncryptedRepository; + + @Autowired PersonValueRepository personValueRepository; + @Autowired CouchbaseTemplate couchbaseTemplate; + + @BeforeEach + public void beforeEach() { + super.beforeEach(); + couchbaseTemplate.removeByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + couchbaseTemplate.findByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + } + + @Test + void javaSDKEncryption() { + + } + + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void saveAndFindById() { + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "saveAndFindById", "l", "hello"); + Address address = new Address(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(address); + // cannot set encrypted fields within encrypted objects (i.e. setEncAddress()) + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + assertFalse(userEncryptedRepository.existsById(user.getId())); + userEncryptedRepository.save(user); + Optional found = userEncryptedRepository.findById(user.getId()); + assertTrue(found.isPresent()); + System.err.println(found.get()); + found.ifPresent(u -> assertEquals(user, u)); + assertTrue(userEncryptedRepository.existsById(user.getId())); + //userEncryptedRepository.delete(user); + } + + @Configuration + @EnableCouchbaseRepositories("org.springframework.data.couchbase") + static class Config extends AbstractCouchbaseConfiguration { + + @Override + public String getConnectionString() { + return connectionString(); + } + + @Override + public String getUserName() { + return config().adminUsername(); + } + + @Override + public String getPassword() { + return config().adminPassword(); + } + + @Override + public String getBucketName() { + return bucketName(); + } + + @Override + protected void configureEnvironment(ClusterEnvironment.Builder builder) { + if (config().isUsingCloud()) { + builder.securityConfig( + SecurityConfig.builder().trustManagerFactory(InsecureTrustManagerFactory.INSTANCE).enableTls(true)); + } + CryptoManager cryptoManager = cryptoManager(); + builder.cryptoManager(cryptoManager).build(); + } + + @Override + protected CryptoManager cryptoManager() { + + Decrypter decrypter = new Decrypter() { + @Override + public String algorithm() { + return "myAlg"; + } + + @Override + public byte[] decrypt(EncryptionResult encrypted) { + return Base64.getDecoder().decode(encrypted.getString("ciphertext")); + } + }; + + Encrypter encrypter = new Encrypter() { + @Override + public EncryptionResult encrypt(byte[] plaintext) { + return EncryptionResult + .fromMap(mapOf("alg", "myAlg", "ciphertext", Base64.getEncoder().encodeToString(plaintext))); + } + }; + Map keyMap = new HashMap(); + keyMap.put("myKey", + new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, }); + Keyring keyring = Keyring.fromMap(keyMap); + // Provider secProvider; + AeadAes256CbcHmacSha512Provider provider = AeadAes256CbcHmacSha512Provider.builder().keyring(keyring) + /*.securityProvider(secProvider)*/.build(); + return DefaultCryptoManager.builder().decrypter(provider.decrypter()) + .defaultEncrypter(provider.encrypterForKey("myKey")).build(); + } + + byte[] hmacMe(String cbc_secret_key, String cbc_api_message) { + try { + return hmac("hmacSHA256", cbc_secret_key.getBytes("utf-8"), cbc_api_message.getBytes("utf-8")); + } catch (UnsupportedEncodingException | NoSuchAlgorithmException | InvalidKeyException ue) { + return null; + } + } + + static byte[] hmac(String algorithm, byte[] key, byte[] message) + throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(key, algorithm)); + return mac.doFinal(message); + } + + } + +}