paint-brush
Creating a Service for Sensitive Data with Spring and Redisby@alexrum
161 reads

Creating a Service for Sensitive Data with Spring and Redis

by Alexander RumyantsevNovember 5th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This post presents a simplified example of an application that handles sensitive data leveraging Spring and Redis.
featured image - Creating a Service for Sensitive Data with Spring and Redis
Alexander Rumyantsev HackerNoon profile picture


Many companies work with user-sensitive data that can’t be stored permanently due to legal restrictions. Usually, this can happen in fintech companies. The data must not be stored for longer than a predefined time period and preferably should be deleted after it was used for the service purposes. There are multiple possible options to solve this problem. In this post, I would like to present a simplified example of an application that handles sensitive data leveraging Spring and Redis.


Redis is a high-performance NoSQL database. Usually, it is used as an in-memory caching solution because of its speed. However, in this example, we will be using it as the primary datastore. It perfectly fits our problem’s needs and has a good integration with Spring Data.

We will create an application that manages a user's full name and card details (as an example of sensitive data).  Card details will be passed (POST request) to the application as an encrypted string (just a normal string for simplicity). The data will be stored in the DB for 5 minutes only. After the data is read (GET request) it will be automatically deleted.


The app is designed as an internal microservice of the company without public access. The user’s data can be passed from a user-facing service. Card details can then be requested by other internal microservices, ensuring sensitive data is kept secure and inaccessible from external services.

Initialise Spring Boot project

Let’s start creating the project with Spring initializr. We will need Spring Web, Spring Data Redis, Lombok. I also added Spring Boot Actuator as it would definitely be useful in a real microservice.


After initializing the service we should add other dependencies. To be able to delete the data automatically after it has been read we will be using AspectJ. I also added some other dependencies that are helpful for the service and make it look more realistic (for a real-world service you would definitely add some validation for example).


The final build.gradle would look like this:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.3'
    id 'io.spring.dependency-management' version '1.1.6'
    id "io.freefair.lombok" version "8.10.2"
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(22)
    }
}

repositories {
    mavenCentral()
}

ext {
    springBootVersion = '3.3.3'
    springCloudVersion = '2023.0.3'
    dependencyManagementVersion = '1.1.6'
    aopVersion = "1.9.19"
    hibernateValidatorVersion = '8.0.1.Final'
    testcontainersVersion = '1.20.2'
    jacksonVersion = '2.18.0'
    javaxValidationVersion = '3.1.0'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation "org.aspectj:aspectjweaver:${aopVersion}"


    implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}"
    implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
    implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}"

    implementation "jakarta.validation:jakarta.validation-api:${javaxValidationVersion}"
    implementation "org.hibernate:hibernate-validator:${hibernateValidatorVersion}"

    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage'
    }
    testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}"
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

tasks.named('test') {
    useJUnitPlatform()
}


We need to set up a connection to Redis. Spring Data Redis properties in application.yml:

spring:
 data:
   redis:
     host: localhost
     port: 6379


Domain

CardInfo is the data object that we will be working with. To make it more realistic let’s make card details to be passed in the service as encrypted data. We need to decrypt, validate, and then store incoming data. There will be 3 layers in domain:

  • DTO - request level, used in controllers
  • Model - service level, used in business logic
  • Entity - persistent level, used in repositories


DTO is converted to Model and vice versa in CardInfoConverter.

Model is converted to Entity and vice versa in CardInfoEntityMapper.

We use Lombok for convenience.


DTO:

@Builder
@Getter
@ToString(exclude = "cardDetails")
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class CardInfoRequestDto {
   @NotBlank
   private String id;
   @Valid
   private UserNameDto fullName;
   @NotNull
   private String cardDetails;
}


Where UserNameDto:

@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserNameDto {
   @NotBlank
   private String firstName;
   @NotBlank
   private String lastName;
}

Card details here represent an encrypted string and fullName is a separate object that is passed as it is. Notice how cardDetails field is excluded from toString() method. Since the data is sensitive it shouldn’t be accidentally logged.


Model:

@Data
@Builder
public class CardInfo {

   @NotBlank
   private String id;
   @Valid
   private UserName userName;
   @Valid
   private CardDetails cardDetails;
}
@Data
@Builder
public class UserName {

   private String firstName;
   private String lastName;
}


CardInfo is the same as CardInfoRequestDto except cardDetails (converted in CardInfoEntityMapper). CardDetails now is a decrypted object that has two sensitive fields: pan (card number) and CVV (security number):

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"pan", "cvv"})
public class CardDetails {
   @NotBlank
   private String pan;
   private String cvv;
}

See again that we excluded sensitive pan and cvv fields from toString() method.


Entity:

@Getter
@Setter
@ToString(exclude = "cardDetails")
@NoArgsConstructor
@AllArgsConstructor
@Builder
@RedisHash
public class CardInfoEntity {

   @Id
   private String id;
   private String cardDetails;
   private String firstName;
   private String lastName;
}

In order Redis creates hash key of an entity one needs to add @RedisHash annotation along with @Id annotation.


This is how dto -> model conversion happens:

public CardInfo toModel(@NonNull CardInfoRequestDto dto) {
   final UserNameDto userName = dto.getFullName();
   return CardInfo.builder()
           .id(dto.getId())
           .userName(UserName.builder()
                   .firstName(ofNullable(userName).map(UserNameDto::getFirstName).orElse(null))
                   .lastName(ofNullable(userName).map(UserNameDto::getLastName).orElse(null))
                   .build())
           .cardDetails(getDecryptedCardDetails(dto.getCardDetails()))
           .build();
}

private CardDetails getDecryptedCardDetails(@NonNull String cardDetails) {
   try {
       return objectMapper.readValue(cardDetails, CardDetails.class);
   } catch (IOException e) {
       throw new IllegalArgumentException("Card details string cannot be transformed to Json object", e);
   }
}

In this case, for simplicity, method getDecryptedCardDetails just maps string to CardDetails object. In a real application one would have the decryption logic here.

Repository

Spring Data is used to create Repository. The card info in the service is retrieved by id, so there is no need to define custom methods and the code looks like this:

@Repository
public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> {
}


Redis Configuration

We need the entity to be stored only for 5 minutes. To achieve this, we have to set up TTL (time to leave). We can do it by introducing a field in CardInfoEntity and adding the annotation @TimeToLive on top. It can also be achieved by adding the value to @RedisHash: @RedisHash(timeToLive = 5*60).


Both ways have some flaws. In the first case, we have to introduce a field that doesn’t relate to business logic. In the second case, the value is hardcoded. There is another option: implement KeyspaceConfiguration. With this approach we can use property in application.yml to set ttl and if needed other Redis properties.


@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RedisConfiguration {
   private final RedisKeysProperties properties;

   @Bean
   public RedisMappingContext keyValueMappingContext() {
       return new RedisMappingContext(
               new MappingConfiguration(new IndexConfiguration(), new CustomKeyspaceConfiguration()));
   }

   public class CustomKeyspaceConfiguration extends KeyspaceConfiguration {

       @Override
       protected Iterable<KeyspaceSettings> initialConfiguration() {
           return Collections.singleton(customKeyspaceSettings(CardInfoEntity.class, CacheName.CARD_INFO));
       }

       private <T> KeyspaceSettings customKeyspaceSettings(Class<T> type, String keyspace) {
           final KeyspaceSettings keyspaceSettings = new KeyspaceSettings(type, keyspace);
           keyspaceSettings.setTimeToLive(properties.getCardInfo().getTimeToLive().toSeconds());
           return keyspaceSettings;
       }
   }

   @NoArgsConstructor(access = AccessLevel.PRIVATE)
   public static class CacheName {
       public static final String CARD_INFO = "cardInfo";
   }
}

To make Redis delete entities with TTL one has to add enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP to @EnableRedisRepositories annotation. I introduced CacheName class to use constants as entity names and to reflect that there can be multiple entities that can be configured differently if needed.


TTL value is taken from RedisKeysProperties object:

@Data
@Component
@ConfigurationProperties("redis.keys")
@Validated
public class RedisKeysProperties {
   @NotNull
   private KeyParameters cardInfo;

   @Data
   @Validated
   public static class KeyParameters {
       @NotNull
       private Duration timeToLive;
   }
}

Here there is only cardInfo but there can be other entities.


TTL properties in application.yml:

redis:
 keys:
   cardInfo:
     timeToLive: PT5M


Controller

Let’s add API to the service to be able to store and access the data by HTTP.

@RestController
@RequiredArgsConstructor
@RequestMapping( "/api/cards")
public class CardController {
   private final CardService cardService;
   private final CardInfoConverter cardInfoConverter;

   @PostMapping
   @ResponseStatus(CREATED)
   public void createCard(@Valid @RequestBody CardInfoRequestDto cardInfoRequest) {
       cardService.createCard(cardInfoConverter.toModel(cardInfoRequest));
   }

   @GetMapping("/{id}")
   public ResponseEntity<CardInfoResponseDto> getCard(@PathVariable("id") String id) {
       return ResponseEntity.ok(cardInfoConverter.toDto(cardService.getCard(id)));
   }
}


Auto deletion with AOP

We want the entity to be deleted right after it was successfully read with a GET request. It can be done with AOP and AspectJ. We need to create Spring Bean and annotate it with @Aspect.


@Aspect
@Component
@RequiredArgsConstructor
@ConditionalOnExpression("${aspect.cardRemove.enabled:false}")
public class CardRemoveAspect {
   private final CardInfoRepository repository;

   @Pointcut("execution(* com.cards.manager.controllers.CardController.getCard(..)) && args(id)")
   public void cardController(String id) {
   }

   @AfterReturning(value = "cardController(id)", argNames = "id")
   public void deleteCard(String id) {
       repository.deleteById(id);
   }
}

@Pointcut defines in what place the logic is applied. Or in other words, what triggers the logic to execute. deleteCard method is where the logic is defined. It deletes cardInfo entity by id using CardInfoRepository. @AfterReturning annotation means that the method should run after a successful return from the method that is defined in the value attribute.


I also annotated the class with @ConditionalOnExpression to be able to switch on/off this functionality from properties.


Testing

We will write web tests using MockMvc and Testcontainers.


Testcontainers initializer for Redis:

public abstract class RedisContainerInitializer {
   private static final int PORT = 6379;
   private static final String DOCKER_IMAGE = "redis:6.2.6";

   private static final GenericContainer REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(DOCKER_IMAGE))
           .withExposedPorts(PORT)
           .withReuse(true);

   static {
       REDIS_CONTAINER.start();
   }

   @DynamicPropertySource
   static void properties(DynamicPropertyRegistry registry) {
       registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
       registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(PORT));
   }
}

With @DynamicPropertySource we can set properties from the started Redis Docker container. Afterwards, the properties will be read by the app to set up a connection to Redis.


Here are basic tests for POST and GET requests:

public class CardControllerTest extends BaseTest {

   private static final String CARDS_URL = "/api/cards";
   private static final String CARDS_ID_URL = CARDS_URL + "/{id}";

   @Autowired
   private CardInfoRepository repository;

   @BeforeEach
   public void setUp() {
       repository.deleteAll();
   }

   @Test
   public void createCard_success() throws Exception {
       final CardInfoRequestDto request = aCardInfoRequestDto().build();


       mockMvc.perform(post(CARDS_URL)
                       .contentType(APPLICATION_JSON)
                       .content(objectMapper.writeValueAsBytes(request)))
               .andExpect(status().isCreated())
       ;
       assertCardInfoEntitySaved(request);
   }

   @Test
   public void getCard_success() throws Exception {
       final CardInfoEntity entity = aCardInfoEntityBuilder().build();
       prepareCardInfoEntity(entity);

       mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.id", is(entity.getId())))
               .andExpect(jsonPath("$.cardDetails", notNullValue()))
               .andExpect(jsonPath("$.cardDetails.cvv", is(CVV)))
       ;
   }
}


And the test to check auto deletion with AOP:

@Test
@EnabledIf(
       expression = "${aspect.cardRemove.enabled}",
       loadContext = true
)
public void getCard_deletedAfterRead() throws Exception {
   final CardInfoEntity entity = aCardInfoEntityBuilder().build();
   prepareCardInfoEntity(entity);

   mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
           .andExpect(status().isOk());
   mockMvc.perform(get(CARDS_ID_URL, entity.getId()))
           .andExpect(status().isNotFound())
   ;
}

I annotated this test with @EnabledIf as AOP logic can be switched off in properties and the annotation determines whether the test should be run.


The source code of the full version of this service is available on GitHub