diff --git a/spring-data-couchbase/pom.xml b/spring-data-couchbase/pom.xml index 3186819ce..62f7c1ce6 100644 --- a/spring-data-couchbase/pom.xml +++ b/spring-data-couchbase/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 @@ -32,6 +33,8 @@ 1.5.25 4.8.1 4.0.3 + 2.13.4 + 3.1.0 @@ -115,6 +118,13 @@ true + + com.fasterxml.jackson.datatype + jackson-datatype-joda + ${jackson-joda} + test + + org.testcontainers testcontainers @@ -217,6 +227,13 @@ test + + com.couchbase.client + couchbase-encryption + ${couchbase.encryption} + test + + @@ -303,7 +320,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..7cc90f52d 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,13 +28,18 @@ 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.convert.PropertyValueConverterRegistrar; +import org.springframework.data.convert.SimplePropertyValueConversions; import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.core.convert.CouchbaseCustomConversions; +import org.springframework.data.couchbase.core.convert.CouchbasePropertyValueConverterFactory; +import org.springframework.data.couchbase.core.convert.CryptoConverter; import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; import org.springframework.data.couchbase.core.convert.translation.TranslationService; @@ -149,7 +155,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(); } @@ -160,7 +168,6 @@ public ClusterEnvironment couchbaseClusterEnvironment() { * @param builder the builder that can be customized. */ protected void configureEnvironment(final ClusterEnvironment.Builder builder) { - } @Bean(name = BeanNames.COUCHBASE_TEMPLATE) @@ -269,6 +276,7 @@ public MappingCouchbaseConverter mappingCouchbaseConverter(CouchbaseMappingConte CouchbaseCustomConversions couchbaseCustomConversions) { MappingCouchbaseConverter converter = new MappingCouchbaseConverter(couchbaseMappingContext, typeKey()); converter.setCustomConversions(couchbaseCustomConversions); + couchbaseMappingContext.setSimpleTypeHolder(couchbaseCustomConversions.getSimpleTypeHolder()); return converter; } @@ -280,8 +288,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; @@ -306,12 +314,26 @@ public CouchbaseMappingContext couchbaseMappingContext(CustomConversions customC * * @return ObjectMapper */ + private ObjectMapper couchbaseObjectMapper() { + return couchbaseObjectMapper(cryptoManager()); + } + + /** + * Creates a {@link ObjectMapper} for the jsonSerializer of the ClusterEnvironment + * + * @param cryptoManager + * @return ObjectMapper + */ - public ObjectMapper couchbaseObjectMapper() { - ObjectMapper mapper = new 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 +342,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 +363,7 @@ CouchbaseCallbackTransactionManager couchbaseTransactionManager(CouchbaseClientF TransactionTemplate couchbaseTransactionTemplate(CouchbaseCallbackTransactionManager couchbaseTransactionManager) { return new TransactionTemplate(couchbaseTransactionManager); } + /** * The default TransactionalOperator. * @@ -376,14 +399,43 @@ protected boolean autoIndexCreation() { /** * Register custom Converters in a {@link CustomConversions} object if required. These {@link CustomConversions} will * be registered with the {@link #mappingCouchbaseConverter(CouchbaseMappingContext, CouchbaseCustomConversions)} )} - * and {@link #couchbaseMappingContext(CustomConversions)}. Returns an empty {@link CustomConversions} instance by - * default. + * and {@link #couchbaseMappingContext(CustomConversions)}. * * @return must not be {@literal null}. */ @Bean(name = BeanNames.COUCHBASE_CUSTOM_CONVERSIONS) public CustomConversions customConversions() { - return new CouchbaseCustomConversions(Collections.emptyList()); + return customConversions(cryptoManager()); + } + + /** + * Register custom Converters in a {@link CustomConversions} object if required. These {@link CustomConversions} will + * be registered with the {@link #mappingCouchbaseConverter(CouchbaseMappingContext, CouchbaseCustomConversions)} )} + * and {@link #couchbaseMappingContext(CustomConversions)}. + * + * @param cryptoManager + * @return must not be {@literal null}. + */ + public CustomConversions customConversions(CryptoManager cryptoManager) { + List newConverters = new ArrayList(); + CustomConversions customConversions = CouchbaseCustomConversions.create(configurationAdapter -> { + SimplePropertyValueConversions valueConversions = new SimplePropertyValueConversions(); + valueConversions.setConverterFactory(new CouchbasePropertyValueConverterFactory(cryptoManager)); + valueConversions.setValueConverterRegistry(new PropertyValueConverterRegistrar().buildRegistry()); + configurationAdapter.setPropertyValueConversions(valueConversions); + configurationAdapter.registerConverters(newConverters); + }); + return customConversions; + } + + @Bean + protected CryptoManager cryptoManager() { + return null; + } + + @Bean + protected CryptoConverter cryptoConverter(CryptoManager cryptoManager) { + return cryptoManager == null ? null : new CryptoConverter(cryptoManager); } /** 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..0017c03ea 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 @@ -18,10 +18,15 @@ import java.util.Collections; +import com.couchbase.client.java.query.QueryScanConsistency; 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.CouchbaseDocument; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.data.mapping.model.EntityInstantiators; /** @@ -29,6 +34,7 @@ * * @author Michael Nitschinger * @author Mark Paluch + * @author Michael Reiche */ public abstract class AbstractCouchbaseConverter implements CouchbaseConverter, InitializingBean { @@ -93,39 +99,70 @@ 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(Object value) { + public Object convertForWriteIfNeeded(CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor, + boolean processValueConverter) { + Object value = accessor.getProperty(prop, prop.getType()); if (value == null) { return null; } + if (processValueConverter && conversions.hasValueConverter(prop)) { + CouchbaseDocument encrypted = (CouchbaseDocument) conversions.getPropertyValueConversions() + .getValueConverter(prop) + .write(value, new CouchbaseConversionContext(prop, (MappingCouchbaseConverter) this, accessor)); + return encrypted; + } + Class targetClass = this.conversions.getCustomWriteTarget(value.getClass()).orElse(null); - return this.conversions.getCustomWriteTarget(value.getClass()) // - .map(it -> (Object) this.conversionService.convert(value, it)) // + boolean canConvert = targetClass == null ? false + : this.conversionService.canConvert(new TypeDescriptor(prop.getField()), TypeDescriptor.valueOf(targetClass)); + if (canConvert) { + return this.conversionService.convert(value, new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(targetClass)); + } + + 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; + } - /* TODO needed later + /** + * 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 convertToCouchbaseType(Object value, TypeInformation typeInformation) { + public Object convertForWriteIfNeeded(Object value) { if (value == null) { return null; } - + return this.conversions.getCustomWriteTarget(value.getClass()) // .map(it -> (Object) this.conversionService.convert(value, it)) // .orElseGet(() -> Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value); - - } - - @Override - public Object convertToCouchbaseType(String source) { - return source; + } - */ @Override public Class getWriteClassFor(Class clazz) { return this.conversions.getCustomWriteTarget(clazz).orElse(clazz); } + + @Override + public CustomConversions getConversions() { + return conversions; + } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java new file mode 100644 index 000000000..657af10c0 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java @@ -0,0 +1,66 @@ +/* + * 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 org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link CouchbaseConverter}. + * + * @author Michael Reiche + * @since 5.0 + */ +public class CouchbaseConversionContext implements ValueConversionContext { + + private final CouchbasePersistentProperty persistentProperty; + private final MappingCouchbaseConverter couchbaseConverter; + private final ConvertingPropertyAccessor propertyAccessor; + + public CouchbaseConversionContext(CouchbasePersistentProperty persistentProperty, + MappingCouchbaseConverter couchbaseConverter, ConvertingPropertyAccessor accessor) { + + this.persistentProperty = persistentProperty; + this.couchbaseConverter = couchbaseConverter; + this.propertyAccessor = accessor; + } + + @Override + public CouchbasePersistentProperty getProperty() { + return persistentProperty; + } + + @Override + public T write(@Nullable Object value, TypeInformation target) { + return (T) ValueConversionContext.super.write(value, target); + } + + @Override + public T read(@Nullable Object value, TypeInformation target) { + return ValueConversionContext.super.read(value, target); + } + + public MappingCouchbaseConverter getConverter() { + return couchbaseConverter; + } + + public ConvertingPropertyAccessor getAccessor() { + return propertyAccessor; + } +} 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..8b5f06e5d 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 @@ -16,12 +16,14 @@ package org.springframework.data.couchbase.core.convert; +import org.springframework.data.convert.CustomConversions; import org.springframework.data.convert.EntityConverter; import org.springframework.data.convert.EntityReader; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; 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 +39,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, boolean processValueConverter); + /** * Return the Class that would actually be stored for a given Class. * @@ -63,9 +76,10 @@ public interface CouchbaseConverter */ Alias getTypeAlias(TypeInformation info); - // TODO needed later - // CouchbaseTypeMapper getMapper(); - // Object convertToCouchbaseType(Object source, TypeInformation typeInformation); - // - // Object convertToCouchbaseType(String source); + /** + * return the conversions + * + * @return conversions + */ + CustomConversions getConversions(); } 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..099d7feb6 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 @@ -16,11 +16,35 @@ package org.springframework.data.couchbase.core.convert; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; +import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.convert.PropertyValueConversions; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.PropertyValueConverterRegistrar; +import org.springframework.data.convert.SimplePropertyValueConversions; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.util.Assert; + +import com.couchbase.client.java.encryption.annotation.Encrypted; /** * Value object to capture custom conversion. @@ -32,6 +56,7 @@ * @author Oliver Gierke * @author Mark Paluch * @author Subhashni Balakrishnan + * @Michael Reiche * @see org.springframework.data.convert.CustomConversions * @see SimpleTypeHolder * @since 2.0 @@ -55,11 +80,249 @@ public class CouchbaseCustomConversions extends org.springframework.data.convert } /** - * Create a new instance with a given list of converters. + * Create a new {@link CouchbaseCustomConversions} instance registering the given converters. + * + * @param converters must not be {@literal null}. + */ + public CouchbaseCustomConversions(List converters) { + this(CouchbaseConverterConfigurationAdapter.from(converters)); + } + + /** + * Create a new {@link CouchbaseCustomConversions} given {@link CouchbaseConverterConfigurationAdapter}. * - * @param converters the list of custom converters. + * @param conversionConfiguration must not be {@literal null}. */ - public CouchbaseCustomConversions(final List converters) { - super(STORE_CONVERSIONS, converters); + protected CouchbaseCustomConversions(CouchbaseConverterConfigurationAdapter conversionConfiguration) { + super(conversionConfiguration.createConverterConfiguration()); + } + + /** + * Functional style {@link org.springframework.data.convert.CustomConversions} creation giving users a convenient way + * of configuring store specific capabilities by providing deferred hooks to what will be configured when creating the + * {@link org.springframework.data.convert.CustomConversions#CustomConversions(ConverterConfiguration) instance}. + * + * @param configurer must not be {@literal null}. + */ + public static CouchbaseCustomConversions create(Consumer configurer) { + CouchbaseConverterConfigurationAdapter adapter = new CouchbaseConverterConfigurationAdapter(); + configurer.accept(adapter); + return new CouchbaseCustomConversions(adapter); + } + + @Override + public boolean hasValueConverter(PersistentProperty property) { + if (property.findAnnotation(Encrypted.class) != null) { + return true; + } + return super.hasValueConverter(property); + } + + /** + * {@link CouchbaseConverterConfigurationAdapter} encapsulates creation of + * {@link org.springframework.data.convert.CustomConversions.ConverterConfiguration} with CouchbaseDB specifics. + */ + public static class CouchbaseConverterConfigurationAdapter { + + /** + * List of {@literal java.time} types having different representation when rendered + */ + private static final Set> JAVA_DRIVER_TIME_SIMPLE_TYPES = new HashSet<>( + Arrays.asList(LocalDate.class, LocalTime.class, LocalDateTime.class)); + + private boolean useNativeDriverJavaTimeCodecs = false; + private final List customConverters = new ArrayList<>(); + private final PropertyValueConversions internalValueConversion = PropertyValueConversions.simple(it -> {}); + private PropertyValueConversions propertyValueConversions = internalValueConversion; + + /** + * Create a {@link CouchbaseConverterConfigurationAdapter} using the provided {@code converters} and our own codecs + * for JSR-310 types. + * + * @param converters must not be {@literal null}. + * @return + */ + public static CouchbaseConverterConfigurationAdapter from(List converters) { + + Assert.notNull(converters, "Converters must not be null"); + + CouchbaseConverterConfigurationAdapter converterConfigurationAdapter = new CouchbaseConverterConfigurationAdapter(); + converterConfigurationAdapter.registerConverters(converters); + return converterConfigurationAdapter; + } + + /** + * Add a custom {@link Converter} implementation. + * + * @param converter must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverter(Converter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + customConverters.add(converter); + return this; + } + + /** + * Gateway to register property specific converters. + * + * @param configurationAdapter must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter configurePropertyConversions( + Consumer> configurationAdapter) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry"); + + PropertyValueConverterRegistrar propertyValueConverterRegistrar = new PropertyValueConverterRegistrar(); + configurationAdapter.accept(propertyValueConverterRegistrar); + + ((SimplePropertyValueConversions) valueConversions()) + .setValueConverterRegistry(propertyValueConverterRegistrar.buildRegistry()); + return this; + } + + /** + * Add a custom {@link ConverterFactory} implementation. + * + * @param converterFactory must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverterFactory(ConverterFactory converterFactory) { + + Assert.notNull(converterFactory, "ConverterFactory must not be null"); + customConverters.add(converterFactory); + return this; + } + + /** + * Add {@link Converter converters}, {@link ConverterFactory factories}, {@link ConverterBuilder.ConverterAware + * converter-aware objects}, and {@link GenericConverter generic converters}. + * + * @param converters must not be {@literal null} nor contain {@literal null} values. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverters(Collection converters) { + + Assert.notNull(converters, "Converters must not be null"); + Assert.noNullElements(converters, "Converters must not be null nor contain null values"); + + customConverters.addAll(converters); + return this; + } + + /** + * Add a custom/default {@link PropertyValueConverterFactory} implementation used to serve + * {@link PropertyValueConverter}. + * + * @param converterFactory must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerPropertyValueConverterFactory( + PropertyValueConverterFactory converterFactory) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry"); + + ((SimplePropertyValueConversions) valueConversions()).setConverterFactory(converterFactory); + return this; + } + + /** + * Optionally set the {@link PropertyValueConversions} to be applied during mapping. + *

+ * Use this method if {@link #configurePropertyConversions(Consumer)} and + * {@link #registerPropertyValueConverterFactory(PropertyValueConverterFactory)} are not sufficient. + * + * @param valueConversions must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter setPropertyValueConversions( + PropertyValueConversions valueConversions) { + + Assert.notNull(valueConversions, "PropertyValueConversions must not be null"); + this.propertyValueConversions = valueConversions; + return this; + } + + PropertyValueConversions valueConversions() { + + if (this.propertyValueConversions == null) { + this.propertyValueConversions = internalValueConversion; + } + + return this.propertyValueConversions; + } + + ConverterConfiguration createConverterConfiguration() { + + if (hasDefaultPropertyValueConversions() + && propertyValueConversions instanceof SimplePropertyValueConversions svc) { + svc.init(); + } + + if (!useNativeDriverJavaTimeCodecs) { + return new ConverterConfiguration(STORE_CONVERSIONS, this.customConverters, convertiblePair -> true, + this.propertyValueConversions); + } + + /* + * We need to have those converters using UTC as the default ones would go on with the systemDefault. + */ + List converters = new ArrayList<>(STORE_CONVERTERS.size() + 3); + converters.add(DateToUtcLocalDateConverter.INSTANCE); + converters.add(DateToUtcLocalTimeConverter.INSTANCE); + converters.add(DateToUtcLocalDateTimeConverter.INSTANCE); + converters.addAll(STORE_CONVERTERS); + + StoreConversions storeConversions = StoreConversions.of(new SimpleTypeHolder(JAVA_DRIVER_TIME_SIMPLE_TYPES, + SimpleTypeHolder.DEFAULT /* CouchbaseSimpleTypes.HOLDER */), converters); + + return new ConverterConfiguration(storeConversions, this.customConverters, convertiblePair -> { + + // Avoid default registrations + + if (JAVA_DRIVER_TIME_SIMPLE_TYPES.contains(convertiblePair.getSourceType()) + && Date.class.isAssignableFrom(convertiblePair.getTargetType())) { + return false; + } + + return true; + }, this.propertyValueConversions); + } + + private enum DateToUtcLocalDateTimeConverter implements Converter { + INSTANCE; + + @Override + public LocalDateTime convert(Date source) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(source.getTime()), ZoneId.of("UTC")); + } + } + + private enum DateToUtcLocalTimeConverter implements Converter { + INSTANCE; + + @Override + public LocalTime convert(Date source) { + return DateToUtcLocalDateTimeConverter.INSTANCE.convert(source).toLocalTime(); + } + } + + private enum DateToUtcLocalDateConverter implements Converter { + INSTANCE; + + @Override + public LocalDate convert(Date source) { + return DateToUtcLocalDateTimeConverter.INSTANCE.convert(source).toLocalDate(); + } + } + + private boolean hasDefaultPropertyValueConversions() { + return propertyValueConversions == internalValueConversion; + } + } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java new file mode 100644 index 000000000..ef6c70592 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java @@ -0,0 +1,85 @@ +/* + * 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.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.mapping.PersistentProperty; + +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.encryption.annotation.Encrypted; + +/** + * Accept the Couchbase @Encrypted annotation in addition to @ValueConverter + * + * @author Michael Reiche + */ +public class CouchbasePropertyValueConverterFactory implements PropertyValueConverterFactory { + + CryptoManager cryptoManager; + Map>, PropertyValueConverter> converterCache = new HashMap<>(); + + public CouchbasePropertyValueConverterFactory(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public > PropertyValueConverter getConverter( + PersistentProperty property) { + PropertyValueConverter valueConverter = PropertyValueConverterFactory.super.getConverter(property); + if (valueConverter != null) { + return valueConverter; + } + Encrypted encryptedAnn = property.findAnnotation(Encrypted.class); + if (encryptedAnn != null) { + Class cryptoConverterClass = CryptoConverter.class; + return getConverter((Class>) cryptoConverterClass); + } else { + return null; + } + } + + @Override + public > PropertyValueConverter getConverter( + Class> converterType) { + + PropertyValueConverter converter = converterCache.get(converterType); + if (converter != null) { + return (PropertyValueConverter) converter; + } + + if (CryptoConverter.class.isAssignableFrom(converterType)) { + converter = new CryptoConverter(cryptoManager); + } else { + try { + Constructor constructor = converterType.getConstructor(); + converter = (PropertyValueConverter) constructor.newInstance(); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + converterCache.put((Class>) converter.getClass(), converter); + return (PropertyValueConverter) converter; + + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java new file mode 100644 index 000000000..8b16468d7 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java @@ -0,0 +1,276 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.util.Assert; + +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.core.error.InvalidArgumentException; +import com.couchbase.client.java.json.JsonArray; +import com.couchbase.client.java.json.JsonObject; +import com.couchbase.client.java.json.JsonValue; + +/** + * Encrypt/Decrypted properties annotated with + * + * @author Michael Reiche + */ +public class CryptoConverter implements + PropertyValueConverter>> { + + CryptoManager cryptoManager; + + public CryptoConverter(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public Object read(CouchbaseDocument value, ValueConversionContext> context) { + byte[] decrypted = cryptoManager().decrypt(value.export()); + if (decrypted == null) { + return null; + } + // it's decrypted to byte[]. Now figure out how to convert to the property type. + return coerceToValueRead(decrypted, (CouchbaseConversionContext) context); + } + + @Override + public CouchbaseDocument write(Object value, ValueConversionContext> context) { + CouchbaseConversionContext ctx = (CouchbaseConversionContext) context; + CouchbasePersistentProperty property = ctx.getProperty(); + byte[] plainText = coerceToBytesWrite(property, ctx.getAccessor(), ctx); + Map encrypted = cryptoManager().encrypt(plainText, CryptoManager.DEFAULT_ENCRYPTER_ALIAS); + return new CouchbaseDocument().setContent(encrypted); + } + + private Object coerceToValueRead(byte[] decrypted, CouchbaseConversionContext context) { + CouchbasePersistentProperty property = context.getProperty(); + + CustomConversions cnvs = context.getConverter().getConversions(); + ConversionService svc = context.getConverter().getConversionService(); + Class type = property.getType(); + + String decryptedString = new String(decrypted); + if ("null".equals(decryptedString)) { + return null; + } + /* this what we would do if we could use a JsonParser with a beanPropertyTypeRef + final JsonParser plaintextParser = p.getCodec().getFactory().createParser(plaintext); + plaintextParser.setCodec(p.getCodec()); + + return plaintextParser.readValueAs(beanPropertyTypeRef); + */ + + if (!cnvs.isSimpleType(type) && !type.isArray()) { + JsonObject jo = JsonObject.fromJson(decryptedString); + CouchbaseDocument source = new CouchbaseDocument().setContent(jo); + return context.getConverter().read(property.getTypeInformation(), source); + } else { + String jsonString = "{\"" + property.getFieldName() + "\":" + decryptedString + "}"; + try { + CouchbaseDocument decryptedDoc = new CouchbaseDocument().setContent(JsonObject.fromJson(jsonString)); + return context.getConverter().getPotentiallyConvertedSimpleRead(decryptedDoc.get(property.getFieldName()), + property); + } catch (InvalidArgumentException | ConverterNotFoundException | ConversionFailedException e) { + throw new RuntimeException(decryptedString, e); + } + } + } + + private byte[] coerceToBytesWrite(CouchbasePersistentProperty property, ConvertingPropertyAccessor accessor, + CouchbaseConversionContext context) { + byte[] plainText; + CustomConversions cnvs = context.getConverter().getConversions(); + + Class sourceType = property.getType(); + Class targetType = cnvs.getCustomWriteTarget(property.getType()).orElse(null); + Object value = context.getConverter().getPotentiallyConvertedSimpleWrite(property, accessor, false); + if (value == null) { // null + plainText = "null".getBytes(StandardCharsets.UTF_8); + } else if (value.getClass().isArray()) { // array + JsonArray ja; + if (value.getClass().getComponentType().isPrimitive()) { + ja = jaFromPrimitiveArray(value); + } else { + ja = jaFromObjectArray(value, context.getConverter()); + } + plainText = ja.toBytes(); + } else if (cnvs.isSimpleType(sourceType)) { // simpleType + String plainString = value != null ? value.toString() : null; + if ((sourceType == String.class || targetType == String.class) || sourceType == Character.class + || sourceType == char.class || Enum.class.isAssignableFrom(sourceType) + || Locale.class.isAssignableFrom(sourceType)) { + // TODO use jackson serializer here + plainString = "\"" + plainString.replaceAll("\"", "\\\"") + "\""; + } + plainText = plainString.getBytes(StandardCharsets.UTF_8); + } else { // an entity + plainText = JsonObject.fromJson(context.read(value).toString().getBytes(StandardCharsets.UTF_8)).toBytes(); + } + return plainText; + } + + CryptoManager cryptoManager() { + Assert.notNull(cryptoManager, + "cryptoManager needed to encrypt/decrypt but it is null. Override needed for cryptoManager() method of " + + AbstractCouchbaseConverter.class.getName()); + return cryptoManager; + } + + JsonArray jaFromObjectArray(Object value, MappingCouchbaseConverter converter) { + CustomConversions cnvs = converter.getConversions(); + ConversionService svc = converter.getConversionService(); + JsonArray ja = JsonArray.ja(); + for (Object o : (Object[]) value) { + ja.add(coerceToJson(o, cnvs, svc)); + } + return ja; + } + + JsonArray jaFromPrimitiveArray(Object value) { + Class component = value.getClass().getComponentType(); + JsonArray jArray; + if (Long.TYPE.isAssignableFrom(component)) { + jArray = ja_long((long[]) value); + } else if (Integer.TYPE.isAssignableFrom(component)) { + jArray = ja_int((int[]) value); + } else if (Double.TYPE.isAssignableFrom(component)) { + jArray = ja_double((double[]) value); + } else if (Float.TYPE.isAssignableFrom(component)) { + jArray = ja_float((float[]) value); + } else if (Boolean.TYPE.isAssignableFrom(component)) { + jArray = ja_boolean((boolean[]) value); + } else if (Short.TYPE.isAssignableFrom(component)) { + jArray = ja_short((short[]) value); + } else if (Byte.TYPE.isAssignableFrom(component)) { + jArray = ja_byte((byte[]) value); + } else if (Character.TYPE.isAssignableFrom(component)) { + jArray = ja_char((char[]) value); + } else { + throw new RuntimeException("unhandled primitive array: " + component.getName()); + } + return jArray; + } + + JsonArray ja_long(long[] array) { + JsonArray ja = JsonArray.ja(); + for (long t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_int(int[] array) { + JsonArray ja = JsonArray.ja(); + for (int t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_double(double[] array) { + JsonArray ja = JsonArray.ja(); + for (double t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_float(float[] array) { + JsonArray ja = JsonArray.ja(); + for (float t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_boolean(boolean[] array) { + JsonArray ja = JsonArray.ja(); + for (boolean t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_short(short[] array) { + JsonArray ja = JsonArray.ja(); + for (short t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_byte(byte[] array) { + JsonArray ja = JsonArray.ja(); + for (byte t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_char(char[] array) { + JsonArray ja = JsonArray.ja(); + for (char t : array) { + ja.add(String.valueOf(t)); + } + return ja; + } + + Object coerceToJson(Object o, CustomConversions cnvs, ConversionService svc) { + if (o != null && o.getClass() == Optional.class) { + o = ((Optional) o).isEmpty() ? null : ((Optional) o).get(); + } + Optional> clazz; + if (o == null) { + o = JsonValue.NULL; + } else if ((clazz = cnvs.getCustomWriteTarget(o.getClass())).isPresent()) { + o = svc.convert(o, clazz.get()); + } else if (JsonObject.checkType(o)) { + // The object is of an acceptable type + } else if (Number.class.isAssignableFrom(o.getClass())) { + if (o.toString().contains(".")) { + o = ((Number) o).doubleValue(); + } else { + o = ((Number) o).longValue(); + } + } else if (Character.class.isAssignableFrom(o.getClass())) { + o = ((Character) o).toString(); + } else if (Enum.class.isAssignableFrom(o.getClass())) { + o = ((Enum) o).name(); + } else { // punt + o = o.toString(); + } + return o; + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DateConverters.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DateConverters.java index d1352944b..2222d075f 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DateConverters.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DateConverters.java @@ -16,7 +16,7 @@ package org.springframework.data.couchbase.core.convert; -import static java.time.ZoneId.*; +import static java.time.ZoneId.systemDefault; import java.time.Instant; import java.util.ArrayList; @@ -26,6 +26,7 @@ import java.util.List; import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.joda.time.LocalDate; import org.joda.time.LocalDateTime; import org.springframework.core.convert.converter.Converter; @@ -204,7 +205,7 @@ public enum NumberToDateTimeConverter implements Converter { @Override public DateTime convert(Number source) { - return source == null ? null : new DateTime(source.longValue()); + return source == null ? null : new DateTime(source.longValue(), DateTimeZone.UTC); } } 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 0a4db7604..0733b8b52 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,6 +19,7 @@ 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; @@ -33,9 +34,10 @@ 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.convert.PropertyValueConverter; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbaseList; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; @@ -53,6 +55,7 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.callback.EntityCallbacks; @@ -72,6 +75,10 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.encryption.annotation.Encrypted; +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. @@ -91,7 +98,6 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter implem * @see #TYPEKEY_SYNCGATEWAY_COMPATIBLE */ public static final String TYPEKEY_DEFAULT = DefaultCouchbaseTypeMapper.DEFAULT_TYPE_KEY; - /** * A "type key" (the name of the field that will hold type information) that is compatible with Sync Gateway (which * doesn't allows underscores). @@ -149,6 +155,7 @@ public MappingCouchbaseConverter( // the conversions Service gets them in afterPropertiesSet() CustomConversions customConversions = new CouchbaseCustomConversions(Collections.emptyList()); this.setCustomConversions(customConversions); + // Don't rely on setSimpleTypeHolder being called in afterPropertiesSet() - some integration tests do not use it // if the mappingContext does not have the SimpleTypes, it will not know that they have converters, then it will // try to access the fields of the type and (maybe) fail with InaccessibleObjectException ((CouchbaseMappingContext) mappingContext).setSimpleTypeHolder(customConversions.getSimpleTypeHolder()); @@ -241,13 +248,11 @@ protected R read(final TypeInformation type, final CouchbaseDocument sour CouchbasePersistentEntity entity = (CouchbasePersistentEntity) mappingContext .getRequiredPersistentEntity(typeToUse); + if (source.containsKey("encbooleans")) + System.err.println(source); return read(entity, source, parent); } - private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { - return property.isAnnotationPresent(IdPrefix.class) || property.isAnnotationPresent(IdSuffix.class); - } - /** * Read an incoming {@link CouchbaseDocument} into the target entity. * @@ -266,20 +271,29 @@ protected R read(final CouchbasePersistentEntity entity, final CouchbaseD final R instance = instantiator.createInstance(entity, provider); final ConvertingPropertyAccessor accessor = getPropertyAccessor(instance); - entity.doWithProperties(new PropertyHandler() { + entity.doWithProperties(new PropertyHandler<>() { @Override public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { if (!doesPropertyExistInSource(prop) || entity.isConstructorArgument(prop) || isIdConstructionProperty(prop) || prop.isAnnotationPresent(N1qlJoin.class)) { return; } + Object obj = prop == entity.getIdProperty() && parent == null ? source.getId() : getValueInternal(prop, source, instance, entity); + 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()) + || source.containsKey(maybeMangle(property)); } private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { @@ -398,10 +412,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") + protected 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) { @@ -415,7 +444,7 @@ public void write(final Object source, final CouchbaseDocument target) { typeMapper.writeType(type, target); } - writeInternal(source, target, type, true); + writeInternalRoot(source, target, type, true, null); if (target.getId() == null) { throw new MappingException("An ID property is needed, but not found/could not be generated on this entity."); } @@ -426,11 +455,12 @@ 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. + * @param property will be null for the root */ @SuppressWarnings("unchecked") - protected void writeInternal(final Object source, CouchbaseDocument target, final TypeInformation typeHint, - boolean withId) { + protected void writeInternalRoot(final Object source, CouchbaseDocument target, TypeInformation typeHint, + boolean withId, CouchbasePersistentProperty property) { if (source == null) { return; } @@ -442,7 +472,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, property); return; } @@ -451,7 +481,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, property); addCustomTypeKeyIfNecessary(typeHint, source, target); } @@ -487,8 +517,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; } @@ -508,6 +538,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) { @@ -521,6 +552,7 @@ protected void writeInternal(final Object source, final CouchbaseDocument target } else { target.setId(id); } + } entity.doWithAssociations(new AssociationHandler() { @@ -530,11 +562,16 @@ 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); } } }); + if (prop != null && conversions.hasValueConverter(prop)) { // whole entity is encrypted + Map propertyConverted = (Map) conversions.getPropertyValueConversions() + .getValueConverter(prop).write(source, new CouchbaseConversionContext(prop, this, accessor)); + target.setContent(JsonObject.from(propertyConverted)); + } } private void writeToTargetDocument(final CouchbaseDocument target, final CouchbasePersistentEntity entity, @@ -576,10 +613,10 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { return; } - if (!conversions.isSimpleType(propertyObj.getClass())) { - writePropertyInternal(propertyObj, target, prop, false); + if (!conversions.isSimpleType(prop.getType())) { + writePropertyInternal(propertyObj, target, prop, accessor); } else { - writeSimpleInternal(propertyObj, target, prop.getFieldName()); + writeSimpleInternal(prop, accessor, target, prop.getFieldName()); } } } @@ -587,15 +624,15 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { } /** - * Helper method to write a property into the target document. + * Helper method to write a non-simple property into the target document. * * @param source the source object. * @param target the target document. * @param prop the property information. */ @SuppressWarnings("unchecked") - private void writePropertyInternal(final Object source, final CouchbaseDocument target, - final CouchbasePersistentProperty prop, boolean withId) { + protected void writePropertyInternal(final Object source, final CouchbaseDocument target, + final CouchbasePersistentProperty prop, final ConvertingPropertyAccessor accessor) { if (source == null) { return; } @@ -603,34 +640,31 @@ 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); - target.put(name, collectionDoc); + CouchbaseList collectionDoc = createCollection(asCollection(source), valueType, prop, accessor); + putMaybeEncrypted(target, prop, collectionDoc, accessor); return; } if (valueType.isMap()) { CouchbaseDocument mapDoc = createMap((Map) source, prop); - target.put(name, mapDoc); + putMaybeEncrypted(target, prop, mapDoc, accessor); + return; + } + + if (conversions.hasValueConverter(prop)) { // property is encrypted + putMaybeEncrypted(target, prop, source, accessor); 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; } Optional> basicTargetType = conversions.getCustomWriteTarget(source.getClass()); if (basicTargetType.isPresent()) { - basicTargetType.ifPresent(it -> { target.put(name, conversionService.convert(source, it)); }); @@ -643,9 +677,18 @@ 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); + target.put(maybeMangle(prop), propertyDoc); + } + + private void putMaybeEncrypted(CouchbaseDocument target, CouchbasePersistentProperty prop, Object value, + ConvertingPropertyAccessor accessor) { + if (conversions.hasValueConverter(prop)) { // property is encrypted + value = conversions.getPropertyValueConversions().getValueConverter(prop).write(value, + new CouchbaseConversionContext(prop, this, accessor)); + } + target.put(maybeMangle(prop), value); } /** @@ -659,7 +702,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); } /** @@ -667,12 +710,11 @@ 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) { - for (Map.Entry entry : source.entrySet()) { + private CouchbaseDocument writeMapInternal(final Map source, final CouchbaseDocument target, + TypeInformation type, CouchbasePersistentProperty prop) { + for (Map.Entry entry : source.entrySet()) { Object key = entry.getKey(); Object val = entry.getValue(); @@ -680,14 +722,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, prop); target.put(simpleKey, embeddedDoc); } } else { @@ -702,12 +744,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); } /** @@ -715,12 +757,10 @@ 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) { - TypeInformation componentType = type == null ? null : type.getComponentType(); + final TypeInformation type, CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { for (Object element : source) { Class elementType = element == null ? null : element.getClass(); @@ -729,11 +769,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, prop); target.put(embeddedDoc); } @@ -784,7 +823,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. */ @@ -792,8 +831,51 @@ 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); + } + target.put(maybeMangle(source), result); + } + public Object getPotentiallyConvertedSimpleWrite(final Object value) { - return convertForWriteIfNeeded(value); + return convertForWriteIfNeeded(value); // cannot access annotations + } + + /** + * This does process PropertyValueConversions + * + * @param value + * @param accessor + * @return + */ + @Deprecated + public Object getPotentiallyConvertedSimpleWrite(final CouchbasePersistentProperty value, + ConvertingPropertyAccessor accessor) { + return convertForWriteIfNeeded(value, accessor, true); // can access annotations + } + + /** + * This does process PropertyValueConversions + * + * @param value + * @param accessor + * @param processValueConverter + * @return + */ + public Object getPotentiallyConvertedSimpleWrite(final CouchbasePersistentProperty value, + ConvertingPropertyAccessor accessor, boolean processValueConverter) { + return convertForWriteIfNeeded(value, accessor, processValueConverter); // can access annotations } /** @@ -845,7 +927,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)) { @@ -855,7 +937,34 @@ 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.getType()); // type does not have annotations + } + } + + /** + * 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") + public R readValue(Object value, CouchbasePersistentProperty prop, Object parent, boolean noDecrypt) { + Class rawType = prop.getType(); + if (conversions.hasValueConverter(prop) && !noDecrypt) { + return (R) conversions.getPropertyValueConversions().getValueConverter(prop).read(value, + new CouchbaseConversionContext(prop, this, null)); + } else 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,7 +1069,23 @@ public CouchbasePropertyValueProvider(final CouchbaseDocument source, @SuppressWarnings("unchecked") public R getPropertyValue(final CouchbasePersistentProperty property) { String expression = property.getSpelExpression(); - Object value = expression != null ? evaluator.evaluate(expression) : source.get(property.getFieldName()); + String maybeFieldName = maybeMangle(property); + Object value = expression != null ? evaluator.evaluate(expression) : source.get(maybeFieldName); + boolean noDecrypt = false; + + // handle @Encrypted FROM_UNENCRYPTED. Just accept them as-is. + if (property.findAnnotation(Encrypted.class) != null) { + if (value == null && !maybeFieldName.equals(property.getFieldName()) + && property.findAnnotation(Encrypted.class).migration().equals(Encrypted.Migration.FROM_UNENCRYPTED)) { + value = source.get(property.getFieldName()); + noDecrypt = true; + } else if (value != null + && !((value instanceof CouchbaseDocument) && (((CouchbaseDocument) value)).containsKey("kid"))) { + noDecrypt = true; + // TODO - should we throw an exception, or just ignore the problem of not being encrypted with noDecrypt=true? + throw new RuntimeException("should have been encrypted, but is not " + maybeFieldName); + } + } if (property == entity.getIdProperty() && parent == null) { return readValue(source.getId(), property.getTypeInformation(), source); @@ -968,9 +1093,22 @@ public R getPropertyValue(final CouchbasePersistentProperty property) { if (value == null) { return null; } + return readValue(value, property, source, noDecrypt); + } + } - return readValue(value, property.getTypeInformation(), source); + String maybeMangle(PersistentProperty property) { + Assert.notNull(property, "property"); + if (!conversions.hasValueConverter(property)) { + return ((CouchbasePersistentProperty) property).getFieldName(); } + PropertyValueConverter propertyValueConverter = conversions.getPropertyValueConversions() + .getValueConverter((CouchbasePersistentProperty) property); + CryptoManager cryptoManager = propertyValueConverter != null && propertyValueConverter instanceof CryptoConverter + ? ((CryptoConverter) propertyValueConverter).cryptoManager() + : null; + String fname = ((CouchbasePersistentProperty) property).getFieldName(); + return cryptoManager != null ? cryptoManager.mangle(fname) : fname; } /** 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..b154366a9 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 @@ -18,6 +18,7 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -26,6 +27,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; +import org.springframework.util.Base64Utils; /** * Out of the box conversions for java dates and calendars. @@ -50,6 +52,12 @@ private OtherConverters() {} converters.add(StringToBigInteger.INSTANCE); converters.add(BigDecimalToString.INSTANCE); converters.add(StringToBigDecimal.INSTANCE); + converters.add(ByteArrayToString.INSTANCE); + converters.add(StringToByteArray.INSTANCE); + converters.add(CharArrayToString.INSTANCE); + converters.add(StringToCharArray.INSTANCE); + converters.add(ClassToString.INSTANCE); + converters.add(StringToClass.INSTANCE); return converters; } @@ -113,4 +121,70 @@ public BigDecimal convert(String source) { return source == null ? null : new BigDecimal(source); } } + + @WritingConverter + public enum ByteArrayToString implements Converter { + INSTANCE; + + @Override + public String convert(byte[] source) { + return source == null ? null : Base64Utils.encodeToString(source); + } + } + + @ReadingConverter + public enum StringToByteArray implements Converter { + INSTANCE; + + @Override + public byte[] convert(String source) { + return source == null ? null : Base64Utils.decode(source.getBytes(StandardCharsets.UTF_8)); + } + } + + @WritingConverter + public enum CharArrayToString implements Converter { + INSTANCE; + + @Override + public String convert(char[] source) { + return source == null ? null : new String(source) ; + } + } + + @ReadingConverter + public enum StringToCharArray implements Converter { + INSTANCE; + + @Override + public char[] convert(String source) { + return source == null ? null : source.toCharArray(); + } + } + + + @WritingConverter + public enum ClassToString implements Converter, String> { + INSTANCE; + + @Override + public String convert(Class source) { + return source == null ? null : source.getClass().getName() ; + } + } + + @ReadingConverter + public enum StringToClass implements Converter> { + INSTANCE; + + @Override + public Class convert(String source) { + try { + return source == null ? null : Class.forName(source); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + } + } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java index cdd47d28c..0204764c5 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java @@ -16,8 +16,15 @@ package org.springframework.data.couchbase.core.mapping; -import static java.util.stream.Collectors.*; +import static java.util.stream.Collectors.toSet; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; import java.util.stream.Stream; import org.springframework.data.mapping.model.SimpleTypeHolder; @@ -35,4 +42,39 @@ public abstract class CouchbaseSimpleTypes { private CouchbaseSimpleTypes() {} + public static final Set> AUTOGENERATED_ID_TYPES; + + static { + Set> classes = new HashSet<>(); + classes.add(String.class); + classes.add(BigInteger.class); + AUTOGENERATED_ID_TYPES = Collections.unmodifiableSet(classes); + + Set> simpleTypes = new HashSet<>(); + simpleTypes.add(Pattern.class); + simpleTypes.add(UUID.class); + simpleTypes.add(Instant.class); + + COUCHBASE_SIMPLE_TYPES = Collections.unmodifiableSet(simpleTypes); + } + + private static final Set> COUCHBASE_SIMPLE_TYPES; + + public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(COUCHBASE_SIMPLE_TYPES, true) { + + @Override + public boolean isSimpleType(Class type) { + + if (type.isEnum()) { + return true; + } + + if (type.getName().startsWith("java.time")) { + return false; + } + + return super.isSimpleType(type); + } + }; + } 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..dc761dd32 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,6 +15,7 @@ */ package org.springframework.data.couchbase.domain; +import com.couchbase.client.java.encryption.annotation.Encrypted; import org.springframework.data.couchbase.core.mapping.Document; @Document diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java new file mode 100644 index 000000000..93422d642 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020-2021 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 org.springframework.data.couchbase.core.mapping.Document; + +import com.couchbase.client.java.encryption.annotation.Encrypted; + +@Document +public class AddressWithEncStreet extends Address { + + private @Encrypted String encStreet; + + public AddressWithEncStreet() {} + + public String getEncStreet() { + return encStreet; + } + + public void setEncStreet(String encStreet) { + this.encStreet = encStreet; + } + +} diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java new file mode 100644 index 000000000..11bc775a8 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java @@ -0,0 +1,105 @@ +/* + * 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.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; + +import com.couchbase.client.java.encryption.annotation.Encrypted; +import com.couchbase.client.java.query.QueryScanConsistency; + +/** + * UserEncrypted entity for tests + * + * @author Michael Nitschinger + * @author Michael Reiche + */ + +@Document +public class TestEncrypted implements Serializable { + + public String id; + @Encrypted + public byte[] encString={1,2,3,4}; + + public TestEncrypted() { + } + + public TestEncrypted(final String id) { + this(); + this.id = id; + } + + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + + public String toString(){ + StringBuffer sb=new StringBuffer(); + sb.append("encString: "+encToString()); + return sb.toString(); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public void initSimpleTypes(){ + + } + + @Override public boolean equals(Object o){ + if(o == null || o.getClass() != getClass()){ + return false; + } + TestEncrypted other = (TestEncrypted) o; + //return this.encString == other.encString; + if(other.encString == null && this.encString != null) + return false; + return other.encString.equals(this.encString); + } + + public String encToString(){ + StringBuffer sb = new StringBuffer(); + for(byte c:encString){ + if(!sb.isEmpty()) + sb.append(","); + sb.append(c); + } + return sb.toString(); + } +} 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..37a0720e4 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java @@ -0,0 +1,260 @@ +/* + * 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.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; + +import com.couchbase.client.java.encryption.annotation.Encrypted; +import com.couchbase.client.java.query.QueryScanConsistency; + +/** + * 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._class = "abstractuser"; + this.subtype = AbstractingTypeMapper.Type.USER; + } + + public String _class; // cheat a little so that will work with Java SDK + + @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; + } + + static DateTime NOW_DateTime = DateTime.now(DateTimeZone.UTC); + static Date NOW_Date = Date.from(Instant.now()); + @Version protected long version; + + @Encrypted(migration = Encrypted.Migration.FROM_UNENCRYPTED) public String encryptedField; + + @Encrypted public boolean encboolean; + @Encrypted public boolean[] encbooleans; + @Encrypted public Boolean encBoolean; + @Encrypted public Boolean[] encBooleans; + + @Encrypted public long enclong; + @Encrypted public long[] enclongs; + @Encrypted public Long encLong; + @Encrypted public Long[] encLongs; + + @Encrypted public short encshort; + @Encrypted public short[] encshorts; + @Encrypted public Short encShort; + @Encrypted public Short[] encShorts; + + @Encrypted public int encinteger; + @Encrypted public int[] encintegers; + @Encrypted public Integer encInteger; + @Encrypted public Integer[] encIntegers; + + @Encrypted public byte encbyte; + public byte[] plainbytes; + @Encrypted public byte[] encbytes; + @Encrypted public Byte encByte; + @Encrypted public Byte[] encBytes; + + @Encrypted public float encfloat; + @Encrypted public float[] encfloats; + @Encrypted public Float encFloat; + @Encrypted public Float[] encFloats; + + @Encrypted public double encdouble; + @Encrypted public double[] encdoubles; + @Encrypted public Double encDouble; + @Encrypted public Double[] encDoubles; + + @Encrypted public char encchar='x'; // need to initialize as char(0) is not legal + @Encrypted public char[] encchars; + @Encrypted public Character encCharacter; + @Encrypted public Character[] encCharacters; + + @Encrypted public String encString; + @Encrypted public String[] encStrings; + + @Encrypted public Date encDate; + @Encrypted public Date[] encDates; + + @Encrypted public Locale encLocal; + @Encrypted public Locale[] encLocales; + + @Encrypted public QueryScanConsistency encEnum; + @Encrypted public QueryScanConsistency[] encEnums; + + @Encrypted public Class clazz; + + @Encrypted public BigInteger encBigInteger; + @Encrypted public BigDecimal encBigDecimal; + @Encrypted public UUID encUUID; + @Encrypted public DateTime encDateTime; + + @Encrypted public Address encAddress = new Address(); + + public Date plainDate; + public DateTime plainDateTime; + + public List nicknames; + + public Address homeAddress = null; + public List addresses = new ArrayList<>(); + + public String getLastname() { + return lastname; + } + + public long getVersion() { + return version; + } + + public void setId(String id) { + this.id = id; + } + + 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(AddressWithEncStreet address) { + this.addresses.add(address); + } + + public UserEncrypted withClass(String _class) { + this._class = _class; + return this; + } + + @Override + public int hashCode() { + return Objects.hash(getId(), firstname, lastname); + } + + public void initSimpleTypes() { + + encboolean = false; + encbooleans = new boolean[] { true, false }; + encBoolean = true; + encBooleans = new Boolean[] { true, false }; + + enclong = 1; + enclongs = new long[] { 1, 2 }; + encLong = Long.valueOf(1); + encLongs = new Long[] { Long.valueOf(1), Long.valueOf(2) }; + + encshort = 1; + encshorts = new short[] { 3, 4 }; + encShort = 5; + encShorts = new Short[] { 6, 7 }; + + encinteger = 1; + encintegers = new int[] { 2, 3 }; + encInteger = 4; + encIntegers = new Integer[] { 5, 6 }; + + encbyte = 32; + encbytes = new byte[] { 1, 2, 3, 4 }; + plainbytes = new byte[] { 1, 2, 3, 4 }; + encByte = 48; + encBytes = new Byte[] { 4, 5, 6, 7 }; + + encfloat = 1; + encfloats = new float[] { 1, 2 }; + encFloat = Float.valueOf("1.1"); + encFloats = new Float[] { encFloat }; + + encdouble = 1.2; + encdoubles = new double[] { 3.4, 5.6 }; + encDouble = 7.8; + encDoubles = new Double[] { 9.10, 11.12 }; + + encchar = 'a'; + encchars = new char[] { 'b', 'c', 'd' }; + encCharacter = 'a'; + encCharacters = new Character[] { 'a', 'b' }; + + encString = "myString"; + encStrings = new String[] { "myString" }; + + encDate = NOW_Date; + encDates = new Date[] { NOW_Date }; + + encLocal = Locale.US; + encLocales = new Locale[] { Locale.US }; + + encEnum = QueryScanConsistency.NOT_BOUNDED; + encEnums = new QueryScanConsistency[] { QueryScanConsistency.NOT_BOUNDED }; + + encBigInteger = new BigInteger("123"); + encBigDecimal = new BigDecimal("456"); + + encUUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + //clazz = String.class; + + encDateTime = NOW_DateTime; + + encAddress = new Address(); + + plainDate = NOW_Date; + plainDateTime = NOW_DateTime; + + nicknames = List.of("Happy", "Sleepy"); + + } +} 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..2326ccc84 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java @@ -0,0 +1,417 @@ +/* + * 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.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.Assert.assertNull; +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.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.couchbase.CouchbaseClientFactory; +import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.domain.Address; +import org.springframework.data.couchbase.domain.AddressWithEncStreet; +import org.springframework.data.couchbase.domain.TestEncrypted; +import org.springframework.data.couchbase.domain.UserEncrypted; +import org.springframework.data.couchbase.domain.UserEncryptedRepository; +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.encryption.AeadAes256CbcHmacSha512Provider; +import com.couchbase.client.encryption.DefaultCryptoManager; +import com.couchbase.client.encryption.Keyring; +import com.couchbase.client.java.env.ClusterEnvironment; +import com.couchbase.client.java.json.JsonObject; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.joda.JodaModule; + +/** + * 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 CouchbaseClientFactory clientFactory; + + @Autowired CouchbaseTemplate couchbaseTemplate; + + @BeforeEach + public void beforeEach() { + super.beforeEach(); + List users = couchbaseTemplate.findByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + for (UserEncrypted user : users) { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + try { // may have also used upperCased-id + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + couchbaseTemplate.removeByQuery(UserEncrypted.class).all(); + couchbaseTemplate.findByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + } + + @Test + void javaSDKEncryption() { + + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void saveAndFindByTestId() { + TestEncrypted user = new TestEncrypted(UUID.randomUUID().toString()); + user.initSimpleTypes(); + couchbaseTemplate.save(user); + TestEncrypted writeSpringReadSpring = couchbaseTemplate.findById(TestEncrypted.class).one(user.id); + System.err.println(user); + System.err.println(writeSpringReadSpring); + assertEquals(user.toString(), writeSpringReadSpring.toString()); + + TestEncrypted writeSpringReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.id).contentAs(TestEncrypted.class); + writeSpringReadSDK.setId(user.id); + assertEquals(user.toString(), writeSpringReadSDK.toString()); + + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + TestEncrypted writeSDKReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId().toUpperCase()).contentAs(TestEncrypted.class); + writeSDKReadSDK.setId(user.getId()); + assertEquals(user.toString(), writeSDKReadSDK.toString()); + + TestEncrypted writeSDKReadSpring = couchbaseTemplate.findById(TestEncrypted.class).one(user.getId().toUpperCase()); + writeSDKReadSpring.setId(user.getId()); + + assertEquals(user.toString(), writeSDKReadSpring.toString()); + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSpring_readSpring() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSpring_readSpring", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.initSimpleTypes(); + // save the user with spring + assertFalse(userEncryptedRepository.existsById(user.getId())); + userEncryptedRepository.save(user); + // read the user with Spring + Optional writeSpringReadSpring = userEncryptedRepository.findById(user.getId()); + assertTrue(writeSpringReadSpring.isPresent()); + writeSpringReadSpring.ifPresent(u -> assertEquals(user, u)); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSpring_readSDK() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSpring_readSDK", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.initSimpleTypes(); + // save the user with spring + assertFalse(userEncryptedRepository.existsById(user.getId())); + userEncryptedRepository.save(user); + + // read user with SDK + UserEncrypted writeSpringReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAs(UserEncrypted.class); + writeSpringReadSDK.setId(user.getId()); + writeSpringReadSDK.setVersion(user.getVersion()); + assertEquals(user, writeSpringReadSDK); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSDK_readSpring() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSDK_readSpring", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.initSimpleTypes(); + + // save the user with the SDK + assertFalse(userEncryptedRepository.existsById(user.getId().toUpperCase())); + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + + Optional writeSDKReadSpring = userEncryptedRepository.findById(user.getId().toUpperCase()); + assertTrue(writeSDKReadSpring.isPresent()); + writeSDKReadSpring.get().setId(user.getId()); + writeSDKReadSpring.get().setVersion(user.getVersion()); + writeSDKReadSpring.ifPresent(u -> assertEquals(user, u)); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSDK_readSDK() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSDK_readSDK", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.clazz = String.class; // not supported by SDK, but not support by UserEncrypted.toString() either. + user.initSimpleTypes(); + // write the user with the SDK + assertFalse(userEncryptedRepository.existsById(user.getId().toUpperCase())); + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + + // read the user with the SDK + UserEncrypted writeSDKReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId().toUpperCase()).contentAs(UserEncrypted.class); + writeSDKReadSDK.setId(user.getId()); + writeSDKReadSDK.setVersion(user.getVersion()); + assertEquals(user.clazz, writeSDKReadSDK.clazz); + writeSDKReadSDK.clazz = null; // null these out as UserEncrypted.toString() doesn't support them. + user.clazz = null; + assertEquals(user, writeSDKReadSDK); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void testFromMigration() { + boolean cleanAfter = true; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "testFromMigration", "l", + "migrating from unencrypted"); + JsonObject jo = JsonObject.jo(); + jo.put("firstname", user.getFirstname()); + jo.put("lastname", user.getLastname()); + jo.put("encryptedField", user.encryptedField); + jo.put("_class", user._class); + + // save it unencrypted + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId(), jo); + JsonObject migration = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAsObject(); + assertEquals("migrating from unencrypted", migration.get("encryptedField")); + assertNull(migration.get(CryptoManager.DEFAULT_ENCRYPTER_ALIAS + "encryptedField")); + + // it will be retrieved successfully + Optional found = userEncryptedRepository.findById(user.getId()); + assertTrue(found.isPresent()); + user.setVersion(found.get().getVersion()); + found.ifPresent(u -> assertEquals(user, u)); + // save it encrypted + UserEncrypted saved = userEncryptedRepository.save(user); + // it will be retrieved successfully + Optional foundEnc = userEncryptedRepository.findById(user.getId()); + assertTrue(foundEnc.isPresent()); + user.setVersion(foundEnc.get().getVersion()); + foundEnc.ifPresent(u -> assertEquals(user, u)); + + // retrieve it without decrypting + JsonObject encrypted = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAsObject(); + assertEquals("myKey", + ((JsonObject) encrypted.get(CryptoManager.DEFAULT_ENCRYPTED_FIELD_NAME_PREFIX + "encryptedField")).get("kid")); + assertNull(encrypted.get("encryptedField")); + + if (cleanAfter) { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + try { // may have also used upperCased-id + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @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 + public ObjectMapper couchbaseObjectMapper(CryptoManager cryptoManager) { + ObjectMapper om = super.couchbaseObjectMapper(cryptoManager); + om.registerModule(new JodaModule()); + return om; + } + + @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() { + Map keyMap = new HashMap(); + keyMap.put("myKey", new byte[64] /* all zeroes */); + Keyring keyring = Keyring.fromMap(keyMap); + AeadAes256CbcHmacSha512Provider provider = AeadAes256CbcHmacSha512Provider.builder().keyring(keyring) + /*.securityProvider(secProvider)*/.build(); + return new WrappingCryptoManager(DefaultCryptoManager.builder().decrypter(provider.decrypter()) + .defaultEncrypter(provider.encrypterForKey("myKey")).build()); + } + + public class WrappingCryptoManager implements CryptoManager { + CryptoManager cryptoManager; + + public WrappingCryptoManager(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public Map encrypt(byte[] plaintext, String encrypterAlias) { + Map encryptedNode = cryptoManager.encrypt(plaintext, encrypterAlias); + return encryptedNode; + } + + @Override + public byte[] decrypt(Map encryptedNode) { + byte[] result = cryptoManager.decrypt(encryptedNode); + return result; + } + + private String toBytes(byte[] plaintext) { + StringBuffer sb = new StringBuffer(); + for (byte b : plaintext) { + sb.append(b); + sb.append(" "); + } + return sb.toString(); + } + + private boolean cmp(byte[] a, byte[] b) { + if (a.length != b.length) + return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) + return false; + } + return true; + } + + byte[] canned_sdk_encbytes = { 34, 65, 81, 73, 68, 66, 65, 61, 61, 34 }; + byte[] canned_spring_encbytes = { 91, 49, 44, 50, 44, 51, 44, 52, 93 }; + } + + } +}