Skip to content

Commit 5ede200

Browse files
committed
feat(security): milestone3 RLS enforcement + milestone4 audit query parity
1 parent a40b4f2 commit 5ede200

File tree

16 files changed

+962
-70
lines changed

16 files changed

+962
-70
lines changed

spring-boot-starter-data-falkordb/src/main/java/org/springframework/boot/autoconfigure/data/falkordb/FalkorDBAutoConfiguration.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1010
import org.springframework.boot.context.properties.EnableConfigurationProperties;
1111
import org.springframework.context.annotation.Bean;
12+
import org.springframework.beans.factory.ObjectProvider;
1213
import org.springframework.data.falkordb.core.DefaultFalkorDBClient;
1314
import org.springframework.data.falkordb.core.FalkorDBClient;
1415
import org.springframework.data.falkordb.core.FalkorDBTemplate;
16+
import org.springframework.data.falkordb.core.query.FalkorDBQueryRewriter;
1517
import org.springframework.data.falkordb.core.mapping.DefaultFalkorDBEntityConverter;
1618
import org.springframework.data.falkordb.core.mapping.DefaultFalkorDBMappingContext;
1719
import org.springframework.data.falkordb.core.mapping.FalkorDBMappingContext;
@@ -96,9 +98,10 @@ public FalkorDBMappingContext falkorDBMappingContext() {
9698
@ConditionalOnMissingBean
9799
@ConditionalOnBean(FalkorDBClient.class)
98100
public FalkorDBTemplate falkorDBTemplate(FalkorDBClient client,
99-
FalkorDBMappingContext mappingContext) {
101+
FalkorDBMappingContext mappingContext,
102+
ObjectProvider<FalkorDBQueryRewriter> queryRewriterProvider) {
100103
DefaultFalkorDBEntityConverter converter = new DefaultFalkorDBEntityConverter(
101104
mappingContext, new EntityInstantiators(), client);
102-
return new FalkorDBTemplate(client, mappingContext, converter);
105+
return new FalkorDBTemplate(client, mappingContext, converter, queryRewriterProvider.getIfAvailable());
103106
}
104107
}

spring-boot-starter-data-falkordb/src/main/java/org/springframework/boot/autoconfigure/data/falkordb/FalkorDBSecurityConfiguration.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import org.springframework.data.falkordb.security.integration.FalkorDBSecurityContextFilter;
1515
import org.springframework.data.falkordb.security.integration.PrivilegeService;
1616
import org.springframework.data.falkordb.security.manager.RBACManager;
17+
import org.springframework.data.falkordb.security.rls.RowLevelSecurityQueryRewriter;
18+
import org.springframework.data.falkordb.core.query.FalkorDBQueryRewriter;
1719
import org.springframework.security.core.Authentication;
1820
import org.springframework.security.core.context.SecurityContextHolder;
1921
import org.springframework.web.filter.OncePerRequestFilter;
@@ -61,6 +63,13 @@ public RBACManager falkorDBRbacManager(FalkorDBTemplate template, FalkorDBSecuri
6163
return new RBACManager(template, properties.getAdminRole());
6264
}
6365

66+
@Bean
67+
@ConditionalOnMissingBean
68+
@ConditionalOnProperty(prefix = "spring.data.falkordb.security", name = "query-rewrite-enabled", havingValue = "true")
69+
public FalkorDBQueryRewriter falkorDBQueryRewriter() {
70+
return new RowLevelSecurityQueryRewriter();
71+
}
72+
6473
@Bean
6574
@ConditionalOnMissingBean
6675
public FalkorDBSecurityContextFilter falkorDBSecurityContextFilter(

spring-boot-starter-data-falkordb/src/main/java/org/springframework/boot/autoconfigure/data/falkordb/FalkorDBSecurityProperties.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ public class FalkorDBSecurityProperties {
3030
*/
3131
private boolean auditEnabled = true;
3232

33+
/**
34+
* Whether the core template should attempt query-time row-level security enforcement by
35+
* rewriting generated Cypher queries.
36+
*
37+
* Disabled by default (opt-in).
38+
*/
39+
private boolean queryRewriteEnabled = false;
40+
3341
/**
3442
* Time-to-live for cached privileges per user.
3543
*/
@@ -76,6 +84,14 @@ public void setAuditEnabled(boolean auditEnabled) {
7684
this.auditEnabled = auditEnabled;
7785
}
7886

87+
public boolean isQueryRewriteEnabled() {
88+
return this.queryRewriteEnabled;
89+
}
90+
91+
public void setQueryRewriteEnabled(boolean queryRewriteEnabled) {
92+
this.queryRewriteEnabled = queryRewriteEnabled;
93+
}
94+
7995
public Duration getPrivilegeCacheTtl() {
8096
return this.privilegeCacheTtl;
8197
}

src/main/java/org/springframework/data/falkordb/core/FalkorDBTemplate.java

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
import org.apiguardian.api.API;
3434

3535
import org.springframework.data.domain.Sort;
36+
import org.springframework.data.falkordb.core.query.FalkorDBQueryRewriter;
37+
import org.springframework.data.falkordb.core.query.RewrittenQuery;
38+
import org.springframework.lang.Nullable;
3639
import org.springframework.data.falkordb.core.mapping.DefaultFalkorDBEntityConverter;
3740
import org.springframework.data.falkordb.core.mapping.DefaultFalkorDBPersistentEntity;
3841
import org.springframework.data.falkordb.core.mapping.FalkorDBEntityConverter;
@@ -56,14 +59,22 @@ public class FalkorDBTemplate implements FalkorDBOperations {
5659

5760
private final FalkorDBEntityConverter entityConverter;
5861

62+
private final @Nullable FalkorDBQueryRewriter queryRewriter;
63+
5964
public FalkorDBTemplate(FalkorDBClient falkorDBClient, FalkorDBMappingContext mappingContext,
6065
FalkorDBEntityConverter entityConverter) {
66+
this(falkorDBClient, mappingContext, entityConverter, null);
67+
}
68+
69+
public FalkorDBTemplate(FalkorDBClient falkorDBClient, FalkorDBMappingContext mappingContext,
70+
FalkorDBEntityConverter entityConverter, @Nullable FalkorDBQueryRewriter queryRewriter) {
6171
Assert.notNull(falkorDBClient, "FalkorDBClient must not be null");
6272
Assert.notNull(mappingContext, "FalkorDBMappingContext must not be null");
6373
Assert.notNull(entityConverter, "FalkorDBEntityConverter must not be null");
6474

6575
this.falkorDBClient = falkorDBClient;
6676
this.mappingContext = mappingContext;
77+
this.queryRewriter = queryRewriter;
6778

6879
// If the entity converter is DefaultFalkorDBEntityConverter and doesn't have a
6980
// client,
@@ -165,14 +176,9 @@ public <T> Optional<T> findById(Object id, Class<T> clazz) {
165176
String primaryLabel = getPrimaryLabel(persistentEntity);
166177

167178
String cypher = "MATCH (n:" + primaryLabel + ") WHERE id(n) = $id RETURN n";
168-
Map<String, Object> parameters = Collections.singletonMap("id", id);
179+
Map<String, Object> parameters = Collections.singletonMap("id", normalizeInternalId(id));
169180

170-
return this.falkorDBClient.query(cypher, parameters, result -> {
171-
for (FalkorDBClient.Record record : result.records()) {
172-
return Optional.of(this.entityConverter.read(clazz, record));
173-
}
174-
return Optional.empty();
175-
});
181+
return queryForObject(cypher, parameters, clazz);
176182
}
177183

178184
@Override
@@ -191,15 +197,10 @@ public <T> List<T> findAllById(Iterable<?> ids, Class<T> clazz) {
191197
String primaryLabel = getPrimaryLabel(persistentEntity);
192198

193199
String cypher = "MATCH (n:" + primaryLabel + ") WHERE id(n) IN $ids RETURN n";
194-
Map<String, Object> parameters = Collections.singletonMap("ids", idList);
200+
Map<String, Object> parameters = Collections.singletonMap("ids",
201+
idList.stream().map(this::normalizeInternalId).collect(Collectors.toList()));
195202

196-
return this.falkorDBClient.query(cypher, parameters, result -> {
197-
List<T> entities = new ArrayList<>();
198-
for (FalkorDBClient.Record record : result.records()) {
199-
entities.add(this.entityConverter.read(clazz, record));
200-
}
201-
return entities;
202-
});
203+
return query(cypher, parameters, clazz);
203204
}
204205

205206
@Override
@@ -211,13 +212,7 @@ public <T> List<T> findAll(Class<T> clazz) {
211212

212213
String cypher = "MATCH (n:" + primaryLabel + ") RETURN n";
213214

214-
return this.falkorDBClient.query(cypher, Collections.emptyMap(), result -> {
215-
List<T> entities = new ArrayList<>();
216-
for (FalkorDBClient.Record record : result.records()) {
217-
entities.add(this.entityConverter.read(clazz, record));
218-
}
219-
return entities;
220-
});
215+
return query(cypher, Collections.emptyMap(), clazz);
221216
}
222217

223218
@Override
@@ -238,13 +233,7 @@ public <T> List<T> findAll(Class<T> clazz, Sort sort) {
238233
cypher.append(orderBy);
239234
}
240235

241-
return this.falkorDBClient.query(cypher.toString(), Collections.emptyMap(), result -> {
242-
List<T> entities = new ArrayList<>();
243-
for (FalkorDBClient.Record record : result.records()) {
244-
entities.add(this.entityConverter.read(clazz, record));
245-
}
246-
return entities;
247-
});
236+
return query(cypher.toString(), Collections.emptyMap(), clazz);
248237
}
249238

250239
@Override
@@ -256,7 +245,9 @@ public <T> long count(Class<T> clazz) {
256245

257246
String cypher = "MATCH (n:" + primaryLabel + ") RETURN count(n) as count";
258247

259-
return this.falkorDBClient.query(cypher, Collections.emptyMap(), result -> {
248+
RewrittenQuery rq = maybeRewrite(cypher, Collections.emptyMap(), clazz);
249+
250+
return this.falkorDBClient.query(rq.getCypher(), rq.getParameters(), result -> {
260251
for (FalkorDBClient.Record record : result.records()) {
261252
Object count = record.get("count");
262253
return (count instanceof Number) ? ((Number) count).longValue() : 0L;
@@ -274,9 +265,11 @@ public <T> boolean existsById(Object id, Class<T> clazz) {
274265
String primaryLabel = getPrimaryLabel(persistentEntity);
275266

276267
String cypher = "MATCH (n:" + primaryLabel + ") WHERE id(n) = $id RETURN count(n) > 0 as exists";
277-
Map<String, Object> parameters = Collections.singletonMap("id", id);
268+
Map<String, Object> parameters = Collections.singletonMap("id", normalizeInternalId(id));
278269

279-
return this.falkorDBClient.query(cypher, parameters, result -> {
270+
RewrittenQuery rq = maybeRewrite(cypher, parameters, clazz);
271+
272+
return this.falkorDBClient.query(rq.getCypher(), rq.getParameters(), result -> {
280273
for (FalkorDBClient.Record record : result.records()) {
281274
Object exists = record.get("exists");
282275
return (exists instanceof Boolean) ? (Boolean) exists : false;
@@ -294,7 +287,7 @@ public <T> void deleteById(Object id, Class<T> clazz) {
294287
String primaryLabel = getPrimaryLabel(persistentEntity);
295288

296289
String cypher = "MATCH (n:" + primaryLabel + ") WHERE id(n) = $id DELETE n";
297-
Map<String, Object> parameters = Collections.singletonMap("id", id);
290+
Map<String, Object> parameters = Collections.singletonMap("id", normalizeInternalId(id));
298291

299292
this.falkorDBClient.query(cypher, parameters);
300293
}
@@ -315,7 +308,8 @@ public <T> void deleteAllById(Iterable<?> ids, Class<T> clazz) {
315308
String primaryLabel = getPrimaryLabel(persistentEntity);
316309

317310
String cypher = "MATCH (n:" + primaryLabel + ") WHERE id(n) IN $ids DELETE n";
318-
Map<String, Object> parameters = Collections.singletonMap("ids", idList);
311+
Map<String, Object> parameters = Collections.singletonMap("ids",
312+
idList.stream().map(this::normalizeInternalId).collect(Collectors.toList()));
319313

320314
this.falkorDBClient.query(cypher, parameters);
321315
}
@@ -338,7 +332,9 @@ public <T> List<T> query(String cypher, Map<String, Object> parameters, Class<T>
338332
Assert.notNull(parameters, "Parameters must not be null");
339333
Assert.notNull(clazz, "Class must not be null");
340334

341-
return this.falkorDBClient.query(cypher, parameters, result -> {
335+
RewrittenQuery rq = maybeRewrite(cypher, parameters, clazz);
336+
337+
return this.falkorDBClient.query(rq.getCypher(), rq.getParameters(), result -> {
342338
List<T> entities = new ArrayList<>();
343339
for (FalkorDBClient.Record record : result.records()) {
344340
entities.add(this.entityConverter.read(clazz, record));
@@ -353,7 +349,9 @@ public <T> Optional<T> queryForObject(String cypher, Map<String, Object> paramet
353349
Assert.notNull(parameters, "Parameters must not be null");
354350
Assert.notNull(clazz, "Class must not be null");
355351

356-
return this.falkorDBClient.query(cypher, parameters, result -> {
352+
RewrittenQuery rq = maybeRewrite(cypher, parameters, clazz);
353+
354+
return this.falkorDBClient.query(rq.getCypher(), rq.getParameters(), result -> {
357355
for (FalkorDBClient.Record record : result.records()) {
358356
return Optional.of(this.entityConverter.read(clazz, record));
359357
}
@@ -368,7 +366,15 @@ public <T> T query(String cypher, Map<String, Object> parameters,
368366
Assert.notNull(parameters, "Parameters must not be null");
369367
Assert.notNull(resultMapper, "Result mapper must not be null");
370368

371-
return this.falkorDBClient.query(cypher, parameters, resultMapper);
369+
RewrittenQuery rq = maybeRewrite(cypher, parameters, null);
370+
return this.falkorDBClient.query(rq.getCypher(), rq.getParameters(), resultMapper);
371+
}
372+
373+
private RewrittenQuery maybeRewrite(String cypher, Map<String, Object> parameters, @Nullable Class<?> domainType) {
374+
if (this.queryRewriter == null) {
375+
return RewrittenQuery.of(cypher, parameters);
376+
}
377+
return this.queryRewriter.rewrite(cypher, parameters, domainType);
372378
}
373379

374380
/**
@@ -389,14 +395,29 @@ public FalkorDBMappingContext getMappingContext() {
389395
return this.mappingContext;
390396
}
391397

398+
private Object normalizeInternalId(Object id) {
399+
if (id instanceof Long) {
400+
Long l = (Long) id;
401+
if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) {
402+
return l.intValue();
403+
}
404+
}
405+
return id;
406+
}
407+
392408
private String getPrimaryLabel(DefaultFalkorDBPersistentEntity<?> persistentEntity) {
393409
// Get the primary label from the @Node annotation
394410
Node nodeAnnotation = persistentEntity.getType().getAnnotation(Node.class);
395411
if (nodeAnnotation != null) {
396412
if (!nodeAnnotation.primaryLabel().isEmpty()) {
397413
return nodeAnnotation.primaryLabel();
398414
}
399-
else if (nodeAnnotation.labels().length > 0) {
415+
// Note: @AliasFor is not applied when reading the annotation via plain reflection.
416+
// We therefore need to check both value() and labels().
417+
if (nodeAnnotation.value().length > 0) {
418+
return nodeAnnotation.value()[0];
419+
}
420+
if (nodeAnnotation.labels().length > 0) {
400421
return nodeAnnotation.labels()[0];
401422
}
402423
}

src/main/java/org/springframework/data/falkordb/core/mapping/DefaultFalkorDBEntityConverter.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,12 @@ public final void write(final Object source, final Map<String, Object> sink) {
203203
*/
204204
private Object convertValueForFalkorDB(final Object value, final FalkorDBPersistentProperty property) {
205205
if (value instanceof LocalDateTime) {
206-
// Convert LocalDateTime to ISO string format that FalkorDB
207-
// can handle. Using ISO_LOCAL_DATE_TIME format.
206+
// Convert LocalDateTime to ISO string format that FalkorDB can handle.
208207
return ((LocalDateTime) value).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
209208
}
209+
if (value instanceof java.time.Instant) {
210+
return DateTimeFormatter.ISO_INSTANT.format((java.time.Instant) value);
211+
}
210212

211213
// Apply intern() function for low-cardinality string properties
212214
if (property != null && property.isInterned() && value instanceof String) {
@@ -352,7 +354,14 @@ else if (targetType == String.class) {
352354
return LocalDateTime.parse(strValue, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
353355
}
354356
catch (Exception ex) {
355-
// If parsing fails, return null
357+
return null;
358+
}
359+
}
360+
if (targetType == java.time.Instant.class) {
361+
try {
362+
return java.time.Instant.parse(strValue);
363+
}
364+
catch (Exception ex) {
356365
return null;
357366
}
358367
}
@@ -661,6 +670,11 @@ private Object saveEntityAndGetId(Object entity, FalkorDBPersistentEntity<?> per
661670
org.springframework.data.falkordb.core.schema.Node nodeAnn = persistentEntity.getType()
662671
.getAnnotation(org.springframework.data.falkordb.core.schema.Node.class);
663672
if (nodeAnn != null) {
673+
for (String label : nodeAnn.value()) {
674+
if (label != null && !label.isEmpty() && !label.equals(primaryLabel)) {
675+
labels.add(label);
676+
}
677+
}
664678
for (String label : nodeAnn.labels()) {
665679
if (label != null && !label.isEmpty() && !label.equals(primaryLabel)) {
666680
labels.add(label);
@@ -780,7 +794,10 @@ private String getPrimaryLabel(FalkorDBPersistentEntity<?> entity) {
780794
if (!nodeAnnotation.primaryLabel().isEmpty()) {
781795
return nodeAnnotation.primaryLabel();
782796
}
783-
else if (nodeAnnotation.labels().length > 0) {
797+
if (nodeAnnotation.value().length > 0) {
798+
return nodeAnnotation.value()[0];
799+
}
800+
if (nodeAnnotation.labels().length > 0) {
784801
return nodeAnnotation.labels()[0];
785802
}
786803
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright (c) 2025 FalkorDB Ltd.
3+
*/
4+
5+
package org.springframework.data.falkordb.core.query;
6+
7+
import java.util.Map;
8+
9+
import org.springframework.lang.Nullable;
10+
11+
/**
12+
* Hook to allow rewriting or augmenting Cypher queries prior to execution.
13+
*
14+
* Intended use: row-level security / policy enforcement.
15+
*/
16+
@FunctionalInterface
17+
public interface FalkorDBQueryRewriter {
18+
19+
RewrittenQuery rewrite(String cypher, Map<String, Object> parameters, @Nullable Class<?> domainType);
20+
21+
}

0 commit comments

Comments
 (0)