Skip to content

Commit 7d367ad

Browse files
committed
feat(security): milestone1 roles inheritance, PUBLIC default, create/write split
1 parent 06ec9d0 commit 7d367ad

File tree

9 files changed

+317
-30
lines changed

9 files changed

+317
-30
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ public PrivilegeService falkorDBPrivilegeService(FalkorDBTemplate template, Falk
4545
@Bean
4646
@ConditionalOnMissingBean
4747
public AuthenticationFalkorSecurityContextAdapter falkorDBAuthenticationAdapter(FalkorDBTemplate template,
48-
PrivilegeService privilegeService) {
49-
return new AuthenticationFalkorSecurityContextAdapter(template, privilegeService);
48+
PrivilegeService privilegeService, FalkorDBSecurityProperties properties) {
49+
return new AuthenticationFalkorSecurityContextAdapter(template, privilegeService, properties.getDefaultRole());
5050
}
5151

5252
@Bean

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ public class FalkorDBSecurityProperties {
2020
*/
2121
private String adminRole = "admin";
2222

23+
/**
24+
* Default role included for all users (parity with Python RBAC 'PUBLIC' role).
25+
*/
26+
private String defaultRole = org.springframework.data.falkordb.security.context.FalkorSecurityContext.DEFAULT_DEFAULT_ROLE;
27+
2328
/**
2429
* Whether audit logging is enabled.
2530
*/
@@ -55,6 +60,14 @@ public void setAdminRole(String adminRole) {
5560
this.adminRole = adminRole;
5661
}
5762

63+
public String getDefaultRole() {
64+
return this.defaultRole;
65+
}
66+
67+
public void setDefaultRole(String defaultRole) {
68+
this.defaultRole = defaultRole;
69+
}
70+
5871
public boolean isAuditEnabled() {
5972
return this.auditEnabled;
6073
}

spring-boot-starter-data-falkordb/src/main/java/org/springframework/data/falkordb/security/bootstrap/FalkorDBSecurityBootstrapRunner.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,28 @@ public FalkorDBSecurityBootstrapRunner(FalkorDBTemplate template, FalkorDBSecuri
3434

3535
@Override
3636
public void run(ApplicationArguments args) {
37+
ensureDefaultRole();
3738
ensureAdminRole();
3839
ensureAdminUser();
3940
}
4041

42+
private void ensureDefaultRole() {
43+
String role = this.properties.getDefaultRole();
44+
if (!StringUtils.hasText(role)) {
45+
return;
46+
}
47+
Map<String, Object> params = new HashMap<>();
48+
params.put("name", role);
49+
params.put("description", "Bootstrapped default role");
50+
params.put("createdAt", Instant.now().toString());
51+
52+
String cypher = "MERGE (r:_Security_Role {name: $name}) "
53+
+ "ON CREATE SET r.description = $description, r.immutable = true, r.createdAt = $createdAt "
54+
+ "RETURN r";
55+
56+
this.template.query(cypher, params, FalkorDBClient.QueryResult::records);
57+
}
58+
4159
private void ensureAdminRole() {
4260
String adminRole = this.properties.getAdminRole();
4361
if (!StringUtils.hasText(adminRole)) {

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

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ public class AuthenticationFalkorSecurityContextAdapter {
3535

3636
private final PrivilegeService privilegeService;
3737

38-
public AuthenticationFalkorSecurityContextAdapter(FalkorDBTemplate template, PrivilegeService privilegeService) {
38+
private final String defaultRole;
39+
40+
public AuthenticationFalkorSecurityContextAdapter(FalkorDBTemplate template, PrivilegeService privilegeService,
41+
String defaultRole) {
3942
this.template = template;
4043
this.privilegeService = privilegeService;
44+
this.defaultRole = defaultRole;
4145
}
4246

4347
public FalkorSecurityContext fromAuthentication(Authentication authentication) {
@@ -55,20 +59,20 @@ public FalkorSecurityContext fromAuthentication(Authentication authentication) {
5559
return null;
5660
}
5761

58-
Set<Role> roles = new HashSet<>(user.getRoles());
59-
// Optionally, intersect with Spring Security authorities
6062
Set<String> springAuthorities = extractAuthorityNames(authentication);
61-
roles.removeIf(role -> !springAuthorities.isEmpty() && !springAuthorities.contains(role.getName()));
63+
Set<String> roleNames = extractRoleNames(user, springAuthorities);
64+
65+
// Expand role inheritance from graph if possible
66+
roleNames.addAll(resolveInheritedRoleNames(roleNames));
6267

63-
Set<Privilege> privileges = this.privilegeService.loadPrivileges(username, roles);
68+
Set<Privilege> privileges = this.privilegeService.loadPrivileges(username, roleNames);
6469

65-
return new FalkorSecurityContext(user, roles, privileges);
70+
return new FalkorSecurityContext(user, roleNames, privileges, this.defaultRole);
6671
}
6772

6873
private User loadUserByUsername(String username) {
6974
String cypher = "MATCH (u:_Security_User {username: $username})-[:HAS_ROLE]->(r:_Security_Role) "
70-
+ "OPTIONAL MATCH (r)<-[:GRANTED_TO]-(p:_Security_Privilege) "
71-
+ "RETURN u, collect(DISTINCT r) as roles, collect(DISTINCT p) as privileges";
75+
+ "RETURN u";
7276
return this.template.query(cypher, Collections.singletonMap("username", username), result -> {
7377
for (org.springframework.data.falkordb.core.FalkorDBClient.Record record : result.records()) {
7478
User u = this.template.getConverter().read(User.class, record);
@@ -92,4 +96,50 @@ private Set<String> extractAuthorityNames(Authentication authentication) {
9296
return names;
9397
}
9498

99+
private Set<String> extractRoleNames(User user, Set<String> springAuthorities) {
100+
Set<String> roleNames = new HashSet<>();
101+
if (user != null && user.getRoles() != null && !user.getRoles().isEmpty()) {
102+
for (Role role : user.getRoles()) {
103+
if (role != null && role.getName() != null) {
104+
roleNames.add(role.getName());
105+
}
106+
}
107+
}
108+
// If the user has no graph roles, fall back to Spring authorities as role names
109+
if (roleNames.isEmpty() && springAuthorities != null && !springAuthorities.isEmpty()) {
110+
roleNames.addAll(springAuthorities);
111+
}
112+
// Intersect with Spring authorities if present
113+
if (springAuthorities != null && !springAuthorities.isEmpty()) {
114+
roleNames.removeIf(rn -> !springAuthorities.contains(rn));
115+
}
116+
if (this.defaultRole != null && !this.defaultRole.isBlank()) {
117+
roleNames.add(this.defaultRole);
118+
}
119+
return roleNames;
120+
}
121+
122+
private Set<String> resolveInheritedRoleNames(Set<String> roleNames) {
123+
if (roleNames == null || roleNames.isEmpty()) {
124+
return Collections.emptySet();
125+
}
126+
try {
127+
String cypher = "MATCH (r:_Security_Role) WHERE r.name IN $roleNames "
128+
+ "OPTIONAL MATCH (r)-[:INHERITS_FROM*0..]->(p:_Security_Role) "
129+
+ "RETURN DISTINCT p";
130+
List<Role> roles = this.template.query(cypher,
131+
Collections.singletonMap("roleNames", roleNames), Role.class);
132+
Set<String> inherited = new HashSet<>();
133+
for (Role r : roles) {
134+
if (r != null && r.getName() != null) {
135+
inherited.add(r.getName());
136+
}
137+
}
138+
return inherited;
139+
}
140+
catch (Exception ignored) {
141+
return Collections.emptySet();
142+
}
143+
}
144+
95145
}

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,20 @@ public PrivilegeService(FalkorDBTemplate template, Duration ttl) {
4545
}
4646

4747
public Set<Privilege> loadPrivileges(String username, Set<Role> roles) {
48-
if (username == null || roles == null || roles.isEmpty()) {
48+
if (roles == null || roles.isEmpty()) {
49+
return Collections.emptySet();
50+
}
51+
Set<String> roleNames = new HashSet<>();
52+
for (Role role : roles) {
53+
if (role != null && role.getName() != null) {
54+
roleNames.add(role.getName());
55+
}
56+
}
57+
return loadPrivileges(username, roleNames);
58+
}
59+
60+
public Set<Privilege> loadPrivileges(String username, java.util.Collection<String> roleNames) {
61+
if (username == null || roleNames == null || roleNames.isEmpty()) {
4962
return Collections.emptySet();
5063
}
5164

@@ -54,7 +67,7 @@ public Set<Privilege> loadPrivileges(String username, Set<Role> roles) {
5467
return entry.privileges;
5568
}
5669

57-
Set<Privilege> loaded = loadPrivilegesFromGraph(roles);
70+
Set<Privilege> loaded = loadPrivilegesFromGraph(roleNames);
5871
this.cache.put(username, new CacheEntry(loaded, Instant.now().plus(this.ttl)));
5972
return loaded;
6073
}
@@ -75,14 +88,8 @@ public void invalidateAll() {
7588
this.cache.clear();
7689
}
7790

78-
private Set<Privilege> loadPrivilegesFromGraph(Set<Role> roles) {
79-
Set<String> roleNames = new HashSet<>();
80-
for (Role role : roles) {
81-
if (role != null && role.getName() != null) {
82-
roleNames.add(role.getName());
83-
}
84-
}
85-
if (roleNames.isEmpty()) {
91+
private Set<Privilege> loadPrivilegesFromGraph(java.util.Collection<String> roleNames) {
92+
if (roleNames == null || roleNames.isEmpty()) {
8693
return Collections.emptySet();
8794
}
8895

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

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
package org.springframework.data.falkordb.security.context;
66

7+
import java.util.ArrayDeque;
78
import java.util.Collections;
9+
import java.util.Deque;
810
import java.util.HashSet;
911
import java.util.Objects;
1012
import java.util.Set;
@@ -15,22 +17,51 @@
1517
import org.springframework.data.falkordb.security.model.Privilege;
1618
import org.springframework.data.falkordb.security.model.Role;
1719
import org.springframework.data.falkordb.security.model.User;
20+
import org.springframework.lang.Nullable;
21+
import org.springframework.util.StringUtils;
1822

1923
/**
2024
* Falkor-specific security context used for RBAC checks.
2125
*/
2226
@API(status = API.Status.EXPERIMENTAL, since = "1.0")
2327
public class FalkorSecurityContext {
2428

29+
/**
30+
* Default role name included for all users unless explicitly disabled.
31+
*/
32+
public static final String DEFAULT_DEFAULT_ROLE = "PUBLIC";
33+
2534
private final User user;
2635

2736
private final Set<String> effectiveRoles;
2837

2938
private final Set<Privilege> privileges;
3039

3140
public FalkorSecurityContext(User user, Set<Role> roles, Set<Privilege> privileges) {
41+
this(user, roles, privileges, DEFAULT_DEFAULT_ROLE);
42+
}
43+
44+
public FalkorSecurityContext(User user, Set<Role> roles, Set<Privilege> privileges, @Nullable String defaultRole) {
45+
this.user = Objects.requireNonNull(user, "user must not be null");
46+
this.effectiveRoles = Collections.unmodifiableSet(computeEffectiveRoleNames(roles, defaultRole));
47+
this.privileges = Collections.unmodifiableSet(new HashSet<>(privileges));
48+
}
49+
50+
public FalkorSecurityContext(User user, java.util.Collection<String> effectiveRoleNames, Set<Privilege> privileges) {
51+
this(user, effectiveRoleNames, privileges, DEFAULT_DEFAULT_ROLE);
52+
}
53+
54+
public FalkorSecurityContext(User user, java.util.Collection<String> effectiveRoleNames, Set<Privilege> privileges,
55+
@Nullable String defaultRole) {
3256
this.user = Objects.requireNonNull(user, "user must not be null");
33-
this.effectiveRoles = Collections.unmodifiableSet(extractRoleNames(roles));
57+
Set<String> names = new HashSet<>();
58+
if (effectiveRoleNames != null) {
59+
names.addAll(effectiveRoleNames);
60+
}
61+
if (StringUtils.hasText(defaultRole)) {
62+
names.add(defaultRole);
63+
}
64+
this.effectiveRoles = Collections.unmodifiableSet(names);
3465
this.privileges = Collections.unmodifiableSet(new HashSet<>(privileges));
3566
}
3667

@@ -70,19 +101,41 @@ private boolean resourceMatches(String privilegeResource, String requestedResour
70101
if (privilegeResource == null || requestedResource == null) {
71102
return false;
72103
}
73-
// MVP: exact match; can be extended to patterns later
104+
if ("*".equals(privilegeResource)) {
105+
return true;
106+
}
107+
// Matches any property on a specific resource, e.g. com.foo.Entity.*
108+
if (privilegeResource.endsWith(".*")) {
109+
String prefix = privilegeResource.substring(0, privilegeResource.length() - 2);
110+
return requestedResource.startsWith(prefix + ".");
111+
}
112+
// Default: exact match
74113
return privilegeResource.equals(requestedResource);
75114
}
76115

77-
private Set<String> extractRoleNames(Set<Role> roles) {
116+
private Set<String> computeEffectiveRoleNames(@Nullable Set<Role> roles, @Nullable String defaultRole) {
117+
Set<String> names = new HashSet<>();
118+
if (StringUtils.hasText(defaultRole)) {
119+
names.add(defaultRole);
120+
}
78121
if (roles == null || roles.isEmpty()) {
79-
return Collections.emptySet();
122+
return names;
80123
}
81-
Set<String> names = new HashSet<>();
82-
for (Role role : roles) {
83-
if (role != null && role.getName() != null) {
124+
125+
Set<Role> visited = new HashSet<>();
126+
Deque<Role> stack = new ArrayDeque<>(roles);
127+
while (!stack.isEmpty()) {
128+
Role role = stack.pop();
129+
if (role == null || !visited.add(role)) {
130+
continue;
131+
}
132+
if (StringUtils.hasText(role.getName())) {
84133
names.add(role.getName());
85134
}
135+
Set<Role> parents = role.getParentRoles();
136+
if (parents != null && !parents.isEmpty()) {
137+
stack.addAll(parents);
138+
}
86139
}
87140
return names;
88141
}

src/main/java/org/springframework/data/falkordb/security/repository/SecureFalkorDBRepository.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,16 @@ public class SecureFalkorDBRepository<T, ID>
4949

5050
private final AuditLogger auditLogger;
5151

52+
private final FalkorDBEntityInformation<T, ID> entityInformation;
53+
5254
private final org.springframework.data.falkordb.repository.FalkorDBRepository<T, ID> delegate;
5355

5456
public SecureFalkorDBRepository(FalkorDBOperations operations,
5557
FalkorDBEntityInformation<T, ID> entityInformation,
5658
@org.springframework.lang.Nullable AuditLogger auditLogger) {
5759
this.domainType = entityInformation.getJavaType();
5860
this.metadata = SecurityMetadataUtils.resolveMetadata(this.domainType);
61+
this.entityInformation = entityInformation;
5962
this.delegate = new DelegateRepository<>(operations, entityInformation);
6063
this.auditLogger = auditLogger;
6164
}
@@ -119,17 +122,20 @@ public boolean existsById(ID id) {
119122

120123
@Override
121124
public <S extends T> S save(S entity) {
122-
// MVP: treat all save operations as WRITE
123-
require(Action.WRITE);
125+
Action action = isNewEntity(entity) ? Action.CREATE : Action.WRITE;
126+
require(action);
127+
// Apply WRITE property deny rules to both CREATE and WRITE
124128
validateWrite(entity);
125129
return delegate.save(entity);
126130
}
127131

128132
@Override
129133
public <S extends T> List<S> saveAll(Iterable<S> entities) {
130-
require(Action.WRITE);
131134
List<S> list = new ArrayList<>();
132135
for (S entity : entities) {
136+
Action action = isNewEntity(entity) ? Action.CREATE : Action.WRITE;
137+
require(action);
138+
// Apply WRITE property deny rules to both CREATE and WRITE
133139
validateWrite(entity);
134140
list.add(entity);
135141
}
@@ -214,6 +220,13 @@ public <S extends T, R> R findBy(Example<S> example,
214220

215221
// --- Internal helpers ---
216222

223+
private <S extends T> boolean isNewEntity(S entity) {
224+
if (entity == null) {
225+
return true;
226+
}
227+
return this.entityInformation.isNew(entity);
228+
}
229+
217230
private void require(Action action) {
218231
FalkorSecurityContext ctx = FalkorSecurityContextHolder.getContext();
219232
if (ctx == null) {

0 commit comments

Comments
 (0)