Skip to content

Commit 106cb61

Browse files
committed
feat: implement end-to-end dynamic keyPrefix for complete tenant data isolation
The keyPrefix attribute in @IndexingOptions now controls both the index configuration AND the actual key storage location for documents. This provides complete multi-tenant data isolation where each tenant's data is stored with different key prefixes. Changes: - Add resolveDynamicKeyspace() to RedisJSONKeyValueAdapter for @document entities - Add resolveDynamicKeyspace() to RedisEnhancedKeyValueAdapter for @RedisHash entities - Update put(), get(), delete() methods to use dynamic keyspace resolution - Update Product entity to use dynamic keyPrefix with SpEL expressions - Add comprehensive testTenantSearchIsolation() test for end-to-end verification - Update multi-tenant documentation to reflect complete isolation support Example usage: @IndexingOptions( indexName = "products_#{@tenantService.getCurrentTenant()}_idx", keyPrefix = "#{@tenantService.getCurrentTenant()}:products:" ) For tenant "acme": - Index: products_acme_idx - Keys stored as: acme:products:<id>
1 parent 6508492 commit 106cb61

File tree

5 files changed

+196
-37
lines changed

5 files changed

+196
-37
lines changed

demos/roms-multitenant/src/main/java/com/redis/om/multitenant/domain/Product.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,22 @@
1414
import lombok.NoArgsConstructor;
1515

1616
/**
17-
* Product entity with dynamic tenant-aware indexing.
17+
* Product entity with dynamic tenant-aware indexing and storage isolation.
1818
*
19-
* <p>The index name is resolved at runtime using SpEL expressions
19+
* <p>Both the index name and key prefix are resolved at runtime using SpEL expressions
2020
* that reference the TenantService bean to get the current tenant context.
21-
* This allows creating separate indexes per tenant dynamically.
22-
*
23-
* <p>Note: The keyPrefix is intentionally left blank to use the default
24-
* keyspace from Spring Data Redis. All tenants share the same data storage
25-
* but can have different search indexes. For true data isolation, implement
26-
* a custom repository or use tenant-aware key generation.
21+
* This provides complete tenant isolation for both search indexes and data storage.
2722
*
2823
* <p>Example for tenant "acme":
2924
* <ul>
3025
* <li>Index name: products_acme_idx
31-
* <li>Storage keyspace: com.redis.om.multitenant.domain.Product:
26+
* <li>Storage keyspace: acme:products:
27+
* </ul>
28+
*
29+
* <p>Example for tenant "globex":
30+
* <ul>
31+
* <li>Index name: products_globex_idx
32+
* <li>Storage keyspace: globex:products:
3233
* </ul>
3334
*/
3435
@Data
@@ -37,7 +38,8 @@
3738
@Builder
3839
@Document
3940
@IndexingOptions(
40-
indexName = "products_#{@tenantService.getCurrentTenant()}_idx", creationMode = IndexCreationMode.SKIP_IF_EXIST
41+
indexName = "products_#{@tenantService.getCurrentTenant()}_idx",
42+
keyPrefix = "#{@tenantService.getCurrentTenant()}:products:", creationMode = IndexCreationMode.SKIP_IF_EXIST
4143
)
4244
public class Product {
4345

demos/roms-multitenant/src/test/java/com/redis/om/multitenant/RomsMultitenantApplicationTests.java

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
import java.util.List;
66

7+
import org.junit.jupiter.api.AfterEach;
8+
import org.junit.jupiter.api.BeforeEach;
79
import org.junit.jupiter.api.Test;
810
import org.springframework.beans.factory.annotation.Autowired;
911
import org.springframework.boot.test.context.SpringBootTest;
12+
import org.springframework.data.redis.core.StringRedisTemplate;
1013
import org.springframework.test.context.DynamicPropertyRegistry;
1114
import org.springframework.test.context.DynamicPropertySource;
1215
import org.testcontainers.junit.jupiter.Container;
@@ -41,6 +44,21 @@ static void redisProperties(DynamicPropertyRegistry registry) {
4144
@Autowired
4245
private ProductRepository productRepository;
4346

47+
@Autowired
48+
private StringRedisTemplate redisTemplate;
49+
50+
@BeforeEach
51+
void setUp() {
52+
// Reset to default tenant before each test
53+
tenantService.setCurrentTenant("default");
54+
}
55+
56+
@AfterEach
57+
void tearDown() {
58+
// Clean up tenant context after each test
59+
tenantService.clearTenant();
60+
}
61+
4462
@Test
4563
void contextLoads() {
4664
assertThat(tenantService).isNotNull();
@@ -69,42 +87,121 @@ void testMultiTenantIndexCreation() {
6987
}
7088

7189
@Test
72-
void testTenantSpecificIndexCreation() {
90+
void testTenantSpecificIndexAndKeyspaceCreation() {
7391
// This test verifies that:
7492
// 1. Different indexes can be created for different tenants
7593
// 2. The index names are dynamically resolved based on tenant context
76-
//
77-
// Note: Full search isolation requires storing documents with tenant-prefixed keys,
78-
// which requires custom repository implementations or interceptors beyond
79-
// the scope of basic @IndexingOptions support.
94+
// 3. The keyspace is also dynamically resolved for tenant isolation
8095

8196
// Create index for tenant A
8297
tenantService.setCurrentTenant("test_tenant_a");
8398
indexer.createIndexFor(Product.class);
8499
String indexNameA = indexer.getIndexName(Product.class);
100+
String keyspaceA = indexer.getKeyspaceForEntityClass(Product.class);
85101

86102
// Create index for tenant B
87103
tenantService.setCurrentTenant("test_tenant_b");
88104
indexer.createIndexFor(Product.class);
89105
String indexNameB = indexer.getIndexName(Product.class);
106+
String keyspaceB = indexer.getKeyspaceForEntityClass(Product.class);
90107

91108
// Verify that different tenants get different index names
92109
assertThat(indexNameA).isEqualTo("products_test_tenant_a_idx");
93110
assertThat(indexNameB).isEqualTo("products_test_tenant_b_idx");
94111
assertThat(indexNameA).isNotEqualTo(indexNameB);
95112

96-
// Verify the index name changes dynamically based on current tenant
113+
// Verify that different tenants get different keyspaces
114+
assertThat(keyspaceA).isEqualTo("test_tenant_a:products:");
115+
assertThat(keyspaceB).isEqualTo("test_tenant_b:products:");
116+
assertThat(keyspaceA).isNotEqualTo(keyspaceB);
117+
118+
// Verify the index name and keyspace change dynamically based on current tenant
97119
tenantService.setCurrentTenant("test_tenant_a");
98120
assertThat(indexer.getIndexName(Product.class)).isEqualTo("products_test_tenant_a_idx");
121+
assertThat(indexer.getKeyspaceForEntityClass(Product.class)).isEqualTo("test_tenant_a:products:");
99122

100123
tenantService.setCurrentTenant("test_tenant_b");
101124
assertThat(indexer.getIndexName(Product.class)).isEqualTo("products_test_tenant_b_idx");
125+
assertThat(indexer.getKeyspaceForEntityClass(Product.class)).isEqualTo("test_tenant_b:products:");
126+
}
127+
128+
@Test
129+
void testTenantSearchIsolation() {
130+
// This test verifies complete tenant isolation:
131+
// - Data saved by tenant A is only visible to tenant A
132+
// - Data saved by tenant B is only visible to tenant B
133+
// - Documents are stored with tenant-prefixed keys
134+
135+
// Setup: Create indexes for both tenants
136+
tenantService.setCurrentTenant("acme");
137+
indexer.createIndexFor(Product.class);
138+
139+
tenantService.setCurrentTenant("globex");
140+
indexer.createIndexFor(Product.class);
141+
142+
// Save product as tenant "acme"
143+
tenantService.setCurrentTenant("acme");
144+
Product acmeProduct = Product.builder()
145+
.name("Acme Widget")
146+
.category("Widgets")
147+
.price(29.99)
148+
.active(true)
149+
.build();
150+
acmeProduct = productRepository.save(acmeProduct);
151+
String acmeProductId = acmeProduct.getId();
152+
153+
// Verify the key is stored with tenant prefix
154+
Boolean acmeKeyExists = redisTemplate.hasKey("acme:products:" + acmeProductId);
155+
assertThat(acmeKeyExists).isTrue();
156+
157+
// Save product as tenant "globex"
158+
tenantService.setCurrentTenant("globex");
159+
Product globexProduct = Product.builder()
160+
.name("Globex Gadget")
161+
.category("Gadgets")
162+
.price(49.99)
163+
.active(true)
164+
.build();
165+
globexProduct = productRepository.save(globexProduct);
166+
String globexProductId = globexProduct.getId();
167+
168+
// Verify the key is stored with tenant prefix
169+
Boolean globexKeyExists = redisTemplate.hasKey("globex:products:" + globexProductId);
170+
assertThat(globexKeyExists).isTrue();
171+
172+
// Verify search isolation: tenant "acme" should only see their products
173+
tenantService.setCurrentTenant("acme");
174+
List<Product> acmeProducts = productRepository.findByCategory("Widgets");
175+
assertThat(acmeProducts).hasSize(1);
176+
assertThat(acmeProducts.get(0).getName()).isEqualTo("Acme Widget");
177+
178+
// Acme should NOT see Globex's products
179+
List<Product> acmeGadgets = productRepository.findByCategory("Gadgets");
180+
assertThat(acmeGadgets).isEmpty();
181+
182+
// Verify search isolation: tenant "globex" should only see their products
183+
tenantService.setCurrentTenant("globex");
184+
List<Product> globexProducts = productRepository.findByCategory("Gadgets");
185+
assertThat(globexProducts).hasSize(1);
186+
assertThat(globexProducts.get(0).getName()).isEqualTo("Globex Gadget");
187+
188+
// Globex should NOT see Acme's products
189+
List<Product> globexWidgets = productRepository.findByCategory("Widgets");
190+
assertThat(globexWidgets).isEmpty();
191+
192+
// Cleanup
193+
tenantService.setCurrentTenant("acme");
194+
productRepository.delete(acmeProduct);
195+
196+
tenantService.setCurrentTenant("globex");
197+
productRepository.delete(globexProduct);
102198
}
103199

104200
@Test
105201
void testProductSaveAndSearchWithDefaultTenant() {
106202
// Use default tenant (set at application startup)
107203
tenantService.setCurrentTenant("default");
204+
indexer.createIndexFor(Product.class);
108205

109206
// Save a product
110207
Product product = Product.builder()
@@ -116,6 +213,10 @@ void testProductSaveAndSearchWithDefaultTenant() {
116213
product = productRepository.save(product);
117214
assertThat(product.getId()).isNotNull();
118215

216+
// Verify the key is stored with tenant prefix
217+
Boolean keyExists = redisTemplate.hasKey("default:products:" + product.getId());
218+
assertThat(keyExists).isTrue();
219+
119220
// Search should find the product when using the same tenant context
120221
List<Product> found = productRepository.findByCategory("TestCategory");
121222
assertThat(found).hasSize(1);

docs/content/modules/ROOT/pages/multi-tenant-support.adoc

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public class TenantController {
6767

6868
=== SpEL-Based Index Names
6969

70-
Use Spring Expression Language for dynamic index naming. Here's a working example from the `roms-multitenant` demo:
70+
Use Spring Expression Language for dynamic index naming and keyspace (key prefix) resolution. Here's a working example from the `roms-multitenant` demo:
7171

7272
[source,java]
7373
----
@@ -79,6 +79,7 @@ Use Spring Expression Language for dynamic index naming. Here's a working exampl
7979
@Document
8080
@IndexingOptions(
8181
indexName = "products_#{@tenantService.getCurrentTenant()}_idx",
82+
keyPrefix = "#{@tenantService.getCurrentTenant()}:products:",
8283
creationMode = IndexCreationMode.SKIP_IF_EXIST
8384
)
8485
public class Product {
@@ -148,22 +149,18 @@ public class TenantService {
148149
}
149150
----
150151

151-
This pattern creates tenant-specific indexes dynamically:
152+
This pattern provides complete tenant isolation with both separate indexes and separate data storage:
152153

153-
* For tenant "acme": Index `products_acme_idx`
154-
* For tenant "globex": Index `products_globex_idx`
154+
* For tenant "acme":
155+
** Index: `products_acme_idx`
156+
** Keys stored as: `acme:products:<id>`
157+
* For tenant "globex":
158+
** Index: `products_globex_idx`
159+
** Keys stored as: `globex:products:<id>`
155160

156-
[IMPORTANT]
161+
[NOTE]
157162
====
158-
The `indexName` attribute supports SpEL expressions that are evaluated dynamically at runtime. However, the `keyPrefix` attribute affects only the index configuration (what key prefix the index searches), not the actual key storage by Spring Data Redis.
159-
160-
All documents are stored using Spring Data Redis's static keyspace (typically the fully qualified class name). For true data isolation where each tenant's data is stored with different keys, you would need to implement a custom `KeyspaceResolver` or use a tenant-aware ID strategy.
161-
162-
The dynamic index naming feature is primarily useful for:
163-
164-
* Creating separate search indexes per tenant (all searching the same keyspace)
165-
* Environment-specific indexes (dev, staging, production)
166-
* Region-specific or data-center-specific indexes
163+
Both `indexName` and `keyPrefix` attributes support SpEL expressions that are evaluated dynamically at runtime. The `keyPrefix` attribute controls both the index configuration (what key prefix the index searches) AND the actual key storage location for documents. This provides complete tenant data isolation.
167164
====
168165

169166
=== Environment-Based Tenant Configuration

redis-om-spring/src/main/java/com/redis/om/spring/RedisEnhancedKeyValueAdapter.java

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,22 @@ private static String sanitizeKeyspace(String keyspace) {
175175
@Override
176176
public Object put(Object id, Object item, String keyspace) {
177177
RedisData rdo;
178+
// Resolve dynamic keyspace from @IndexingOptions if present
179+
String resolvedKeyspace = resolveDynamicKeyspace(item.getClass(), keyspace);
180+
178181
if (item instanceof RedisData redisData) {
179182
rdo = redisData;
180183
} else {
181184
String idAsString = validateKeyForWriting(id, item);
182-
byte[] redisKey = createKey(sanitizeKeyspace(keyspace), idAsString);
185+
byte[] redisKey = createKey(sanitizeKeyspace(resolvedKeyspace), idAsString);
183186
auditor.processEntity(redisKey, item);
184187
embedder.processEntity(item);
185188

186189
rdo = new RedisData();
187190
converter.write(item, rdo);
188191
rdo.setId(idAsString);
192+
// Override the keyspace in RedisData with the resolved dynamic keyspace
193+
rdo.setKeyspace(sanitizeKeyspace(resolvedKeyspace));
189194
}
190195

191196
redisOperations.executePipelined((RedisCallback<Object>) connection -> {
@@ -216,7 +221,9 @@ public Object put(Object id, Object item, String keyspace) {
216221
public <T> T get(Object id, String keyspace, Class<T> type) {
217222

218223
String stringId = asStringValue(id);
219-
String stringKeyspace = sanitizeKeyspace(keyspace);
224+
// Resolve dynamic keyspace from @IndexingOptions if present
225+
String resolvedKeyspace = resolveDynamicKeyspace(type, keyspace);
226+
String stringKeyspace = sanitizeKeyspace(resolvedKeyspace);
220227

221228
byte[] binId = createKey(stringKeyspace, stringId);
222229

@@ -364,7 +371,9 @@ public <T> List<T> getAllOf(String keyspace, Class<T> type, long offset, int row
364371
@Override
365372
public <T> T delete(Object id, String keyspace, Class<T> type) {
366373
String stringId = asStringValue(id);
367-
String stringKeyspace = sanitizeKeyspace(keyspace);
374+
// Resolve dynamic keyspace from @IndexingOptions if present
375+
String resolvedKeyspace = resolveDynamicKeyspace(type, keyspace);
376+
String stringKeyspace = sanitizeKeyspace(resolvedKeyspace);
368377

369378
T o = get(stringId, stringKeyspace, type);
370379

@@ -418,6 +427,8 @@ private long extractNumDocs(Map<String, Object> info) {
418427
*/
419428
@Override
420429
public boolean contains(Object id, String keyspace) {
430+
// Note: contains() doesn't have type parameter, so we can't resolve dynamic keyspace here
431+
// The caller should ensure the correct keyspace is passed
421432
Boolean exists = redisOperations.execute((RedisCallback<Boolean>) connection -> connection.keyCommands().exists(
422433
toBytes(getKey(keyspace, asStringValue(id)))));
423434

@@ -652,6 +663,27 @@ public byte[] createKey(String keyspace, String id) {
652663
return toBytes(keyspace.endsWith(":") ? keyspace + id : keyspace + ":" + id);
653664
}
654665

666+
/**
667+
* Resolves the dynamic keyspace from @IndexingOptions if present.
668+
* If the entity class has @IndexingOptions with a SpEL keyPrefix expression,
669+
* the expression is evaluated at runtime to determine the actual keyspace.
670+
*
671+
* @param entityClass the entity class to check for @IndexingOptions
672+
* @param defaultKeyspace the default keyspace to use if no dynamic keyspace is configured
673+
* @return the resolved keyspace (either dynamic or default)
674+
*/
675+
private String resolveDynamicKeyspace(Class<?> entityClass, String defaultKeyspace) {
676+
if (entityClass == null) {
677+
return defaultKeyspace;
678+
}
679+
// Use RediSearchIndexer to resolve dynamic keyspace from @IndexingOptions
680+
String dynamicKeyspace = indexer.getKeyspaceForEntityClass(entityClass);
681+
if (dynamicKeyspace != null && !dynamicKeyspace.isBlank()) {
682+
return dynamicKeyspace;
683+
}
684+
return defaultKeyspace;
685+
}
686+
655687
/**
656688
* Container holding update information like fields to remove from the Redis
657689
* Hash.

0 commit comments

Comments
 (0)