Skip to content

Commit 238e1e0

Browse files
committed
feat(security): milestone5 scoped context and impersonation session
1 parent 5ede200 commit 238e1e0

File tree

10 files changed

+522
-4
lines changed

10 files changed

+522
-4
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Spring Data FalkorDB provides JPA-style object-graph mapping for [FalkorDB](http
2121
- **💳 Transaction Support**: Built on Spring's robust transaction management
2222
- **⚡ High Performance**: Leverages FalkorDB's speed with the official JFalkorDB Java client
2323
- **🌐 RESP Protocol**: Uses the reliable RESP protocol for communication
24+
- **🔐 RBAC (Experimental)**: Optional security-aware repositories with `@Secured` and row-level filtering
2425

2526
## 📦 Installation
2627

@@ -94,6 +95,26 @@ dependencies {
9495
}
9596
```
9697

98+
## 🔐 RBAC / Security (Experimental)
99+
100+
Spring Data FalkorDB includes an experimental RBAC layer that can enforce action checks (`READ`, `WRITE`, `CREATE`, `DELETE`) and optional row-level security (`@RowLevelSecurity`).
101+
102+
When using the Spring Boot starter, enable it via:
103+
104+
```properties
105+
spring.data.falkordb.security.enabled=true
106+
```
107+
108+
For non-web usage, you can set a thread-local context scope:
109+
110+
```java
111+
try (var scope = FalkorSecurityContextHolder.withContext(ctx)) {
112+
// repository calls here are authorized using ctx
113+
}
114+
```
115+
116+
The starter also exposes a `FalkorDBSecuritySession` bean for loading contexts from the graph and admin-only impersonation.
117+
97118
## 🏃 Quick Start
98119

99120
### 1. Entity Mapping

spring-boot-starter-data-falkordb/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,40 @@ public class PersonController {
103103
| `spring.data.falkordb.database` | Database/graph name **(required)** | - |
104104
| `spring.data.falkordb.repositories.enabled` | Enable/disable repository auto-configuration | `true` |
105105

106+
## RBAC / Security (Optional)
107+
108+
Enable security-aware repositories and RBAC integration:
109+
110+
```properties
111+
spring.data.falkordb.security.enabled=true
112+
```
113+
114+
When enabled, repositories are created using `SecureFalkorDBRepository` and will enforce `@Secured` / `@RowLevelSecurity` metadata.
115+
116+
### Scoped context
117+
118+
For non-web usage (tests, batch jobs), you can set the current `FalkorSecurityContext` using a try-with-resources scope:
119+
120+
```java
121+
try (var scope = FalkorSecurityContextHolder.withContext(ctx)) {
122+
// any repository calls here use ctx
123+
}
124+
```
125+
126+
### Impersonation (admin-only)
127+
128+
The starter exposes a `FalkorDBSecuritySession` bean that can load a user context from the graph and optionally impersonate:
129+
130+
```java
131+
@Autowired FalkorDBSecuritySession session;
132+
133+
try (var scope = session.impersonate("alice")) {
134+
// runs as alice
135+
}
136+
```
137+
138+
Impersonation requires the current thread context to contain the configured admin role (default: `admin`).
139+
106140
> **Note:** The `@EnableFalkorDBRepositories` annotation is **optional**. Repositories are automatically
107141
> enabled by the starter. Use `spring.data.falkordb.repositories.enabled=false` to disable them.
108142

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.springframework.data.falkordb.security.manager.RBACManager;
1717
import org.springframework.data.falkordb.security.rls.RowLevelSecurityQueryRewriter;
1818
import org.springframework.data.falkordb.core.query.FalkorDBQueryRewriter;
19+
import org.springframework.data.falkordb.security.session.FalkorDBSecuritySession;
1920
import org.springframework.security.core.Authentication;
2021
import org.springframework.security.core.context.SecurityContextHolder;
2122
import org.springframework.web.filter.OncePerRequestFilter;
@@ -63,6 +64,12 @@ public RBACManager falkorDBRbacManager(FalkorDBTemplate template, FalkorDBSecuri
6364
return new RBACManager(template, properties.getAdminRole());
6465
}
6566

67+
@Bean
68+
@ConditionalOnMissingBean
69+
public FalkorDBSecuritySession falkorDBSecuritySession(FalkorDBTemplate template, FalkorDBSecurityProperties properties) {
70+
return new FalkorDBSecuritySession(template, properties.getAdminRole(), properties.getDefaultRole());
71+
}
72+
6673
@Bean
6774
@ConditionalOnMissingBean
6875
@ConditionalOnProperty(prefix = "spring.data.falkordb.security", name = "query-rewrite-enabled", havingValue = "true")

spring-boot-starter-data-falkordb/src/main/java/org/springframework/data/falkordb/security/integration/AuthenticationFalkorSecurityContextAdapter.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ public FalkorSecurityContext fromAuthentication(Authentication authentication) {
7171
}
7272

7373
private User loadUserByUsername(String username) {
74-
String cypher = "MATCH (u:_Security_User {username: $username})-[:HAS_ROLE]->(r:_Security_Role) "
75-
+ "RETURN u";
74+
String cypher = "MATCH (u:_Security_User {username: $username}) RETURN u as n, id(u) as nodeId";
7675
return this.template.query(cypher, Collections.singletonMap("username", username), result -> {
7776
for (org.springframework.data.falkordb.core.FalkorDBClient.Record record : result.records()) {
7877
User u = this.template.getConverter().read(User.class, record);

spring-boot-starter-data-falkordb/src/main/java/org/springframework/data/falkordb/security/integration/PrivilegeService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ private Set<Privilege> loadPrivilegesFromGraph(java.util.Collection<String> role
9494
}
9595

9696
String cypher = "MATCH (r:_Security_Role)<-[:GRANTED_TO]-(p:_Security_Privilege) "
97-
+ "WHERE r.name IN $roleNames RETURN p";
97+
+ "WHERE r.name IN $roleNames RETURN p as n, id(p) as nodeId";
9898
return new HashSet<>(this.template.query(cypher,
9999
Collections.singletonMap("roleNames", roleNames), Privilege.class));
100100
}

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

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ private Object convertValueForFalkorDB(final Object value, final FalkorDBPersist
209209
if (value instanceof java.time.Instant) {
210210
return DateTimeFormatter.ISO_INSTANT.format((java.time.Instant) value);
211211
}
212+
if (value instanceof Enum<?>) {
213+
return ((Enum<?>) value).name();
214+
}
212215

213216
// Apply intern() function for low-cardinality string properties
214217
if (property != null && property.isInterned() && value instanceof String) {
@@ -349,6 +352,16 @@ else if (targetType == String.class) {
349352
// Handle String conversions
350353
if (value instanceof String) {
351354
String strValue = (String) value;
355+
if (targetType.isEnum()) {
356+
try {
357+
@SuppressWarnings({ "unchecked", "rawtypes" })
358+
Object enumValue = Enum.valueOf((Class<? extends Enum>) targetType, strValue);
359+
return enumValue;
360+
}
361+
catch (Exception ex) {
362+
return null;
363+
}
364+
}
352365
if (targetType == LocalDateTime.class) {
353366
try {
354367
return LocalDateTime.parse(strValue, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
@@ -1193,7 +1206,7 @@ private Map<String, Object> extractRelationshipProperties(Object relatedEntity)
11931206
*/
11941207
private Object extractValueFromNodeObject(FalkorDBClient.Record record, String propertyName) {
11951208
try {
1196-
Object nodeObj = record.get("n");
1209+
Object nodeObj = findNodeObject(record);
11971210
if (nodeObj == null) {
11981211
return null;
11991212
}
@@ -1212,6 +1225,60 @@ private Object extractValueFromNodeObject(FalkorDBClient.Record record, String p
12121225
}
12131226
}
12141227

1228+
private Object findNodeObject(FalkorDBClient.Record record) {
1229+
if (record == null) {
1230+
return null;
1231+
}
1232+
// Preferred alias used by repository queries
1233+
Object n = safeRecordGet(record, "n");
1234+
if (n != null) {
1235+
return n;
1236+
}
1237+
// Fall back to first node-like object in the record.
1238+
try {
1239+
for (String key : record.keys()) {
1240+
Object v = safeRecordGet(record, key);
1241+
if (isNodeLike(v)) {
1242+
return v;
1243+
}
1244+
}
1245+
}
1246+
catch (Exception ignored) {
1247+
return null;
1248+
}
1249+
return null;
1250+
}
1251+
1252+
private Object safeRecordGet(FalkorDBClient.Record record, String key) {
1253+
try {
1254+
return record.get(key);
1255+
}
1256+
catch (Exception ex) {
1257+
return null;
1258+
}
1259+
}
1260+
1261+
private boolean isNodeLike(Object value) {
1262+
if (value == null) {
1263+
return false;
1264+
}
1265+
Class<?> type = value.getClass();
1266+
try {
1267+
type.getMethod("getProperty", String.class);
1268+
return true;
1269+
}
1270+
catch (NoSuchMethodException ignored) {
1271+
// ignore
1272+
}
1273+
try {
1274+
type.getMethod("getProperties");
1275+
return true;
1276+
}
1277+
catch (NoSuchMethodException ignored) {
1278+
return false;
1279+
}
1280+
}
1281+
12151282
private Object tryGetPropertyMethod(Object nodeObj, String propertyName) {
12161283
try {
12171284
java.lang.reflect.Method getPropertyMethod = nodeObj.getClass().getMethod("getProperty", String.class);

src/main/java/org/springframework/data/falkordb/security/context/FalkorSecurityContextHolder.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
@API(status = API.Status.EXPERIMENTAL, since = "1.0")
1313
public final class FalkorSecurityContextHolder {
1414

15+
@FunctionalInterface
16+
public interface Scope extends AutoCloseable {
17+
@Override
18+
void close();
19+
}
20+
1521
private static final ThreadLocal<FalkorSecurityContext> CONTEXT = new ThreadLocal<>();
1622

1723
private FalkorSecurityContextHolder() {
@@ -29,4 +35,21 @@ public static void clearContext() {
2935
CONTEXT.remove();
3036
}
3137

38+
/**
39+
* Set the given context for the current thread and return a scope that restores
40+
* the previous context when closed.
41+
*/
42+
public static Scope withContext(FalkorSecurityContext context) {
43+
final FalkorSecurityContext previous = getContext();
44+
setContext(context);
45+
return () -> {
46+
if (previous == null) {
47+
clearContext();
48+
}
49+
else {
50+
setContext(previous);
51+
}
52+
};
53+
}
54+
3255
}

0 commit comments

Comments
 (0)