Graph-Based Access Control

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Role-based access control stops being simple when groups nest, roles are granted on folders instead of single documents, and the question is no longer "does this user have the role EDITOR" but "may this user write this document, given every group they belong to, directly or indirectly, and every folder above the document". Those are path questions, and a graph database answers them with one pattern instead of a recursive join. This page builds such an authorization service in Spring Boot: the model, the schema, the permission check, the integration with Spring Security, a paged "everything this user can access" query, a decision cache and its invalidation, and a test against a real server.

Problem

Three properties of real permission systems are awkward in a relational schema and natural in a graph:

  • Inherited membership. A user belongs to a team, the team belongs to a department, the department belongs to a company group. A grant to the department reaches every member of every nested team.

  • Inherited resources. A grant on a folder reaches every document below it, at any depth.

  • Explainability. The answer to "why can Alice read this" is a path through the graph, which can be returned to an administrator as it is.

The query that expresses both inheritances is a variable-length path match. The concepts behind it (paths, variable length patterns, bounded traversal) are explained in the Neo4j Reference use cases, whose identity and access section deals with deduplicating identities; the permission model below is a separate, small dataset defined on this page. The Spring Data Neo4j mapping and Neo4jClient basics are in Spring Data Neo4j; Spring Security itself is in Spring Security. Neither is repeated here.

The graph is read mostly: permission checks happen on every request, while memberships and grants change rarely. That shapes the design: a fast, bounded read query; a cache in front of it; and an explicit invalidation on the few writes.

Graph model

(:User)-[:MEMBER_OF]->(:Group)-[:MEMBER_OF]->(:Group)
(:User|:Group)-[:HAS_ROLE]->(:Role)
(:Role)-[:CAN_READ|CAN_WRITE]->(:Resource)
(:Resource)-[:CHILD_OF]->(:Resource)

The notation is the pattern syntax described in Cypher manual — Patterns. A role is a named bundle of grants. A subject (a user or a group) holds roles, a role grants a permission on a resource, and the grant covers the resource and all its descendants. A group that is a MEMBER_OF another group passes its roles on to its own members. The dataset used on this page is small enough to hold in mind:

Element Content

Users

alice, bob, carol, dave

Groups

engineering, backend-team (member of engineering), contractors

Membership

alice in backend-team, bob in engineering, carol in contractors; dave belongs to no group

Roles

reader-eng, editor-design, hr-reader

Role holders

engineering has reader-eng, backend-team has editor-design, dave has hr-reader directly; contractors has no role

Grants

reader-eng CAN_READ on eng; editor-design CAN_WRITE on design; hr-reader CAN_READ on hr

Resources

wiki with children eng and hr; eng has design; design has adr-1; hr has salaries

The permission path from user alice through backend-team and engineering to the roles and down the resource tree

The figure shows why Alice reads adr-1: she is in backend-team, which is in engineering, which holds reader-eng, which can read eng, and adr-1 sits below eng. She also writes adr-1, through the role of her own team on design. Bob reads it through the same role but cannot write it, and Carol, whose only group has no role, reaches nothing.

The mapping uses the standard Spring Data Neo4j annotations. Business keys are the identifiers, so the same data can be reloaded or merged without generated ids. @RelationshipProperties carries the data that belongs to the relationship itself (when a membership started, who granted a permission):

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.RelationshipProperties;
import org.springframework.data.neo4j.core.schema.TargetNode;

@Node("User")
public class AccessUser {

    @Id
    private final String username;
    private final String displayName;

    @Relationship(type = "MEMBER_OF", direction = Relationship.Direction.OUTGOING)
    private final List<Membership> memberships;

    @Relationship(type = "HAS_ROLE", direction = Relationship.Direction.OUTGOING)
    private final List<Role> roles;

    @PersistenceCreator
    public AccessUser(String username, String displayName, List<Membership> memberships, List<Role> roles) {
        this.username = username;
        this.displayName = displayName;
        this.memberships = memberships == null ? new ArrayList<>() : memberships;
        this.roles = roles == null ? new ArrayList<>() : roles;
    }

    public String username() { return username; }
    public String displayName() { return displayName; }
    public List<Membership> memberships() { return memberships; }
    public List<Role> roles() { return roles; }
}

@Node("Group")
public class AccessGroup {

    @Id
    private final String name;

    /** A group that is a member of another group passes its roles on to its own members. */
    @Relationship(type = "MEMBER_OF", direction = Relationship.Direction.OUTGOING)
    private final List<Membership> parents;

    @Relationship(type = "HAS_ROLE", direction = Relationship.Direction.OUTGOING)
    private final List<Role> roles;

    @PersistenceCreator
    public AccessGroup(String name, List<Membership> parents, List<Role> roles) {
        this.name = name;
        this.parents = parents == null ? new ArrayList<>() : parents;
        this.roles = roles == null ? new ArrayList<>() : roles;
    }

    public String name() { return name; }
    public List<Membership> parents() { return parents; }
    public List<Role> roles() { return roles; }
}

@RelationshipProperties
public class Membership {

    private final LocalDate since;

    @TargetNode
    private final AccessGroup group;

    public Membership(LocalDate since, AccessGroup group) {
        this.since = since;
        this.group = group;
    }

    public LocalDate since() { return since; }
    public AccessGroup group() { return group; }
}

See Spring Data Neo4j — reference guide for the mapping annotations, and the mapping sections of Spring Data Neo4j for relationship properties and persistence constructors.

Roles and resources follow the same pattern. The two permission relationships are separate types, so each has its own list and its own relationship-properties class:

@Node("Role")
public class Role {

    @Id
    private final String name;

    @Relationship(type = "CAN_READ", direction = Relationship.Direction.OUTGOING)
    private final List<Grant> reads;

    @Relationship(type = "CAN_WRITE", direction = Relationship.Direction.OUTGOING)
    private final List<Grant> writes;

    @PersistenceCreator
    public Role(String name, List<Grant> reads, List<Grant> writes) {
        this.name = name;
        this.reads = reads == null ? new ArrayList<>() : reads;
        this.writes = writes == null ? new ArrayList<>() : writes;
    }

    public String name() { return name; }
    public List<Grant> reads() { return reads; }
    public List<Grant> writes() { return writes; }
}

@RelationshipProperties
public class Grant {

    private final LocalDate grantedOn;

    @TargetNode
    private final Resource resource;

    public Grant(LocalDate grantedOn, Resource resource) {
        this.grantedOn = grantedOn;
        this.resource = resource;
    }

    public LocalDate grantedOn() { return grantedOn; }
    public Resource resource() { return resource; }
}

@Node("Resource")
public class Resource {

    @Id
    private final String id;
    private final String name;

    /** The parent folder; the root resource has none. */
    @Relationship(type = "CHILD_OF", direction = Relationship.Direction.OUTGOING)
    private final Resource parent;

    @PersistenceCreator
    public Resource(String id, String name, Resource parent) {
        this.id = id;
        this.name = name;
        this.parent = parent;
    }

    public String id() { return id; }
    public String name() { return name; }
    public Resource parent() { return parent; }
}

public interface ResourceRepository extends Neo4jRepository<Resource, String> {
}

See Spring Data Neo4j — reference guide for repositories and @Node. The entities are used to administer the graph and load small neighbourhoods; the permission check itself is a Cypher query, because a derived query cannot express a variable-length path of unknown depth over two relationship families, and a mapped entity would load the whole reachable subgraph just to answer a yes or no.

Schema

Every lookup in the permission query starts from a key (username, id), so each key needs a uniqueness constraint, which also creates the supporting index. Spring Data Neo4j does not create the schema, so the application does it at startup with idempotent IF NOT EXISTS statements:

@Component
public class AccessSchemaInitializer implements ApplicationRunner {

    private static final List<String> STATEMENTS = List.of(
        "CREATE CONSTRAINT user_username IF NOT EXISTS FOR (u:User) REQUIRE u.username IS UNIQUE",
        "CREATE CONSTRAINT group_name IF NOT EXISTS FOR (g:Group) REQUIRE g.name IS UNIQUE",
        "CREATE CONSTRAINT role_name IF NOT EXISTS FOR (r:Role) REQUIRE r.name IS UNIQUE",
        "CREATE CONSTRAINT resource_id IF NOT EXISTS FOR (r:Resource) REQUIRE r.id IS UNIQUE");

    private final Neo4jClient client;

    public AccessSchemaInitializer(Neo4jClient client) {
        this.client = client;
    }

    @Override
    public void run(ApplicationArguments args) {
        STATEMENTS.forEach(statement -> client.query(statement).run());
    }
}

See Cypher manual — Managing constraints and Spring Boot — Using the ApplicationRunner. Schema statements run outside a data transaction, which is why this is a separate runner and not part of a @Transactional service. A larger project would move the same statements into a migration tool; the trade-offs are in Indexes and Constraints.

Writes

Administrative changes are a handful of MERGE statements. They are idempotent, so a retried request cannot create a second membership, and each publishes an event that the cache layer (below) listens to after the commit:

public record AccessChanged(String reason) {
}

@Service
public class AccessAdminService {

    private final Neo4jClient client;
    private final ApplicationEventPublisher events;

    public AccessAdminService(Neo4jClient client, ApplicationEventPublisher events) {
        this.client = client;
        this.events = events;
    }

    @Transactional
    public void addUserToGroup(String username, String group) {
        client.query("""
                MATCH (u:User {username: $username}), (g:Group {name: $group})
                MERGE (u)-[m:MEMBER_OF]->(g)
                  ON CREATE SET m.since = date()
                """)
            .bind(username).to("username")
            .bind(group).to("group")
            .run();
        events.publishEvent(new AccessChanged("member added: " + username + " to " + group));
    }

    @Transactional
    public void removeUserFromGroup(String username, String group) {
        client.query("""
                MATCH (:User {username: $username})-[m:MEMBER_OF]->(:Group {name: $group})
                DELETE m
                """)
            .bind(username).to("username")
            .bind(group).to("group")
            .run();
        events.publishEvent(new AccessChanged("member removed: " + username + " from " + group));
    }

    @Transactional
    public void nestGroup(String child, String parent) {
        client.query("""
                MATCH (c:Group {name: $child}), (p:Group {name: $parent})
                MERGE (c)-[m:MEMBER_OF]->(p)
                  ON CREATE SET m.since = date()
                """)
            .bind(child).to("child")
            .bind(parent).to("parent")
            .run();
        events.publishEvent(new AccessChanged("group nested: " + child + " in " + parent));
    }

    @Transactional
    public void grantRole(String role, Permission permission, String resourceId) {
        // the relationship type comes from a closed enum, never from user input
        client.query("""
                MATCH (r:Role {name: $role}), (res:Resource {id: $resourceId})
                MERGE (r)-[g:%s]->(res)
                  ON CREATE SET g.grantedOn = date()
                """.formatted(permission.grantType()))
            .bind(role).to("role")
            .bind(resourceId).to("resourceId")
            .run();
        events.publishEvent(new AccessChanged("grant: " + role + " " + permission + " on " + resourceId));
    }
}

See Cypher manual — MERGE and Spring Framework — Using @Transactional. A relationship type cannot be a Cypher parameter, so grantRole builds the statement from the Permission enum defined in the next section; building it from a request string would be a Cypher injection hole.

Queries and analytics

The permission check

The enum fixes the two permission levels and the relationship pattern each one accepts. A write grant implies read access, so reading accepts both types:

public enum Permission {

    READ("CAN_READ|CAN_WRITE", "CAN_READ"),
    WRITE("CAN_WRITE", "CAN_WRITE");

    private final String checkTypes;
    private final String grantType;

    Permission(String checkTypes, String grantType) {
        this.checkTypes = checkTypes;
        this.grantType = grantType;
    }

    /** Relationship types accepted when checking this permission. */
    public String checkTypes() { return checkTypes; }

    /** The single relationship type created when granting this permission. */
    public String grantType() { return grantType; }
}

See Cypher manual — Patterns for the A|B relationship type alternation used in the pattern strings. The check is an existential subquery over a variable-length path in three stages: climb the membership chain from the user, take any role the user or an ancestor group holds, and match a grant on the requested resource or any of its ancestors:

@Repository
public class PermissionQueries {

    private static final String CHECK = """
        MATCH (u:User {username: $username})
        RETURN EXISTS {
            MATCH (u)-[:MEMBER_OF*0..10]->(:User|Group)-[:HAS_ROLE]->(:Role)-[:%s]->(:Resource)
                  <-[:CHILD_OF*0..20]-(:Resource {id: $resourceId})
        } AS allowed
        """;

    private final Neo4jClient client;

    public PermissionQueries(Neo4jClient client) {
        this.client = client;
    }

    @Transactional(readOnly = true)
    public boolean isAllowed(String username, Permission permission, String resourceId) {
        return client.query(CHECK.formatted(permission.checkTypes()))
            .bind(username).to("username")
            .bind(resourceId).to("resourceId")
            .fetchAs(Boolean.class)
            .mappedBy((typeSystem, record) -> record.get("allowed").asBoolean())
            .one()
            .orElse(false);
    }
}
  • MEMBER_OF*0..10 includes length zero, so a role held directly by the user counts, and the upper bound limits the traversal on a cyclic or very deep membership structure. The same applies to CHILD_OF*0..20 on the resource side.

  • The pattern is read from both ends: the grant node is found by walking up from the requested resource, which is cheap because the resource is bound by its unique id.

  • EXISTS stops at the first match instead of enumerating every path, and a missing user produces no row at all, which orElse(false) turns into a deny: the default answer is "no".

  • The username and resource id are parameters. Only the closed Permission enum is formatted into the statement.

Explaining a decision

An administrator’s question "why can she read this" is the same pattern returning the path instead of a boolean:

public record AccessExplanation(List<String> subjects, String role, String grantedOn, String permission) {
}

@Transactional(readOnly = true)
public Optional<AccessExplanation> explain(String username, Permission permission, String resourceId) {
    return client.query("""
            MATCH p = (u:User {username: $username})-[:MEMBER_OF*0..10]->(holder:User|Group)
                      -[:HAS_ROLE]->(role:Role)-[g:%s]->(grant:Resource)
                      <-[:CHILD_OF*0..20]-(:Resource {id: $resourceId})
            RETURN [s IN nodes(p) WHERE s:User OR s:Group | coalesce(s.username, s.name)] AS subjects,
                   role.name AS role, grant.id AS grantedOn, type(g) AS permission
            ORDER BY length(p)
            LIMIT 1
            """.formatted(permission.checkTypes()))
        .bind(username).to("username")
        .bind(resourceId).to("resourceId")
        .fetchAs(AccessExplanation.class)
        .mappedBy((typeSystem, record) -> new AccessExplanation(
            record.get("subjects").asList(Value::asString),
            record.get("role").asString(),
            record.get("grantedOn").asString(),
            record.get("permission").asString()))
        .one();
}

See Cypher manual — Shortest paths for the related path functions, and Cypher manual — List functions for nodes(). ORDER BY length(p) returns the shortest path, and because a write grant also allows reading, that is not always the path through a read role. For Alice and adr-1 the explanation lists alice and backend-team, the role editor-design and the CAN_WRITE grant on design (four hops), not the six-hop path through engineering and reader-eng that the figure also shows. An administrator who wants every reason, not only the shortest one, drops the LIMIT 1.

Everything a user can access, paged

A file browser needs the inverse question: which resources may this user see. The query collects the distinct grant points reachable through the user’s subjects and then walks down from each of them. Sorting by the unique id gives stable pages:

public record AccessibleResource(String id, String name) {
}

@Transactional(readOnly = true)
public Page<AccessibleResource> accessibleResources(String username, Pageable pageable) {
    var rows = client.query("""
            MATCH (u:User {username: $username})-[:MEMBER_OF*0..10]->(:User|Group)
                  -[:HAS_ROLE]->(:Role)-[:CAN_READ|CAN_WRITE]->(grant:Resource)
            WITH DISTINCT grant
            MATCH (r:Resource)-[:CHILD_OF*0..20]->(grant)
            WITH DISTINCT r
            ORDER BY r.id
            SKIP $skip LIMIT $limit
            RETURN r.id AS id, r.name AS name
            """)
        .bind(username).to("username")
        .bind(pageable.getOffset()).to("skip")
        .bind(pageable.getPageSize()).to("limit")
        .fetchAs(AccessibleResource.class)
        .mappedBy((typeSystem, record) -> new AccessibleResource(
            record.get("id").asString(), record.get("name").asString()))
        .all()
        .stream()
        .toList();

    long total = client.query("""
            MATCH (u:User {username: $username})-[:MEMBER_OF*0..10]->(:User|Group)
                  -[:HAS_ROLE]->(:Role)-[:CAN_READ|CAN_WRITE]->(grant:Resource)
            WITH DISTINCT grant
            MATCH (r:Resource)-[:CHILD_OF*0..20]->(grant)
            RETURN count(DISTINCT r) AS total
            """)
        .bind(username).to("username")
        .fetchAs(Long.class)
        .mappedBy((typeSystem, record) -> record.get("total").asLong())
        .one()
        .orElse(0L);

    return new PageImpl<>(rows, pageable, total);
}

See Cypher manual — SKIP and Spring Data — Paging and sorting. SKIP pages degrade on very deep pages, because the server still computes the skipped rows; for large trees switch to keyset pagination (WHERE r.id > $after with LIMIT) and return the last id as the next cursor.

Spring Data Neo4j can also run such queries when they are declared with @Query on a repository and return a Page with a countQuery; the Neo4jClient form above keeps both statements next to each other and returns a small record instead of a mapped entity tree. Because it is a read-only analysis of the stored graph, no GDS projection is needed. If a catalogue grows to millions of resources, the same result can be materialised by a scheduled job as described in Calling GDS from Spring Boot and Scheduling and ShedLock.

Service and API

Cached decisions

Every secured request runs the permission check, and the graph changes rarely, so the decision is cached. The cached bean is separate from the caller, because Spring’s cache proxy does not intercept a call from the same class. The cache key is the full question:

@Service
public class PermissionService {

    private final PermissionQueries queries;

    public PermissionService(PermissionQueries queries) {
        this.queries = queries;
    }

    @Cacheable(cacheNames = "graph-permissions", key = "#username + '|' + #permission + '|' + #resourceId")
    public boolean isAllowed(String username, Permission permission, String resourceId) {
        return queries.isAllowed(username, permission, resourceId);
    }
}

See Spring Framework — Cache annotations and the cache providers in Caching. The cache is a bounded Caffeine cache with a short expiry that acts as a safety net, never as the invalidation mechanism:

spring:
  cache:
    cache-names: graph-permissions
    caffeine:
      spec: maximumSize=50000,expireAfterWrite=5m

See Spring Boot — Caching. A membership or grant change can affect many users at once (adding a group to another group changes every member), so invalidation clears the whole cache. It runs after the commit; evicting before the commit would let a concurrent request repopulate the cache with the old graph:

@Component
public class PermissionCacheInvalidator {

    private final CacheManager cacheManager;

    public PermissionCacheInvalidator(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
    public void onAccessChanged(AccessChanged event) {
        var cache = cacheManager.getCache("graph-permissions");
        if (cache != null) {
            cache.clear();
        }
    }
}

See Spring Framework — Transaction-bound events. A local cache only invalidates the instance that handled the change. In a scaled-out service, either use a shared cache (the Redis provider in Caching) or publish the AccessChanged event to a topic and clear each instance’s cache from a consumer, as described in Messaging with Kafka; the five-minute expiry bounds how long a missed message can keep a stale decision.

Wiring the decision into Spring Security

The flow of a protected call is short; the proxy created by method security calls the graphAuthz bean before the controller method runs:

sequenceDiagram participant C as Client participant P as Method security proxy participant A as graphAuthz bean participant S as PermissionService (cache) participant N as Neo4j C->>P: GET /documents/adr-1 P->>A: canRead(authentication, "adr-1") A->>S: isAllowed(alice, READ, adr-1) alt cache miss S->>N: EXISTS variable-length path N-->>S: true end S-->>A: true A-->>P: true P->>C: controller method runs, 200

The bean is an ordinary component named graphAuthz; SpEL reaches it with the @ prefix:

@Component("graphAuthz")
public class GraphAuthz {

    private final PermissionService permissions;

    public GraphAuthz(PermissionService permissions) {
        this.permissions = permissions;
    }

    public boolean canRead(Authentication authentication, String resourceId) {
        return check(authentication, Permission.READ, resourceId);
    }

    public boolean canWrite(Authentication authentication, String resourceId) {
        return check(authentication, Permission.WRITE, resourceId);
    }

    private boolean check(Authentication authentication, Permission permission, String resourceId) {
        if (authentication == null || !authentication.isAuthenticated()) {
            return false;
        }
        return permissions.isAllowed(authentication.getName(), permission, resourceId);
    }
}

@RestController
@RequestMapping("/documents")
public class DocumentController {

    private final DocumentService documents;
    private final PermissionQueries queries;

    public DocumentController(DocumentService documents, PermissionQueries queries) {
        this.documents = documents;
        this.queries = queries;
    }

    @GetMapping("/{id}")
    @PreAuthorize("@graphAuthz.canRead(authentication, #id)")
    public DocumentView read(@PathVariable String id) {
        return documents.read(id);
    }

    @PutMapping("/{id}")
    @PreAuthorize("@graphAuthz.canWrite(authentication, #id)")
    public DocumentView update(@PathVariable String id, @RequestBody DocumentUpdate update) {
        return documents.update(id, update);
    }

    @GetMapping
    public Page<AccessibleResource> mine(Authentication authentication, Pageable pageable) {
        return queries.accessibleResources(authentication.getName(), pageable);
    }
}

@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {
}

See Spring Security — Method Security and the @PreAuthorize section of Spring Security. #id is the method parameter and needs the parameter names to be compiled in, which the Spring Boot Maven and Gradle plugins already do. The listing endpoint is not annotated: it takes the user from the Authentication and returns only what the query allows, so no per-row check is needed. Returning 404 instead of 403 for an unreadable id avoids revealing that a resource exists; the conventions are in REST APIs.

The same decision can be made at the request level, before the controller is even resolved, with a custom AuthorizationManager. This keeps all rules in the filter chain and is the better fit when many endpoints share the path variable:

@Component
public class GraphAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    private final PermissionService permissions;

    public GraphAuthorizationManager(PermissionService permissions) {
        this.permissions = permissions;
    }

    @Override
    public AuthorizationResult authorize(Supplier<? extends Authentication> authentication,
                                         RequestAuthorizationContext context) {
        var auth = authentication.get();
        var id = context.getVariables().get("id");
        if (auth == null || !auth.isAuthenticated() || id == null) {
            return new AuthorizationDecision(false);
        }
        var permission = "GET".equals(context.getRequest().getMethod()) ? Permission.READ : Permission.WRITE;
        return new AuthorizationDecision(permissions.isAllowed(auth.getName(), permission, id));
    }
}

@Configuration
class WebSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, GraphAuthorizationManager graph) throws Exception {
        return http
            .authorizeHttpRequests(requests -> requests
                .requestMatchers("/documents/{id}").access(graph)
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

See Spring Security — Authorize HttpServletRequests. Use one mechanism per endpoint, not both: the annotation keeps the rule beside the code it protects and works for any service method, while the manager is a single place that cannot be forgotten on a new controller.

Integration test

The test starts a real Neo4j server with the plugins used by the other pages of this section (see Calling GDS from Spring Boot for the image tag and plugin settings), loads the dataset of this page and checks allow and deny across inherited groups and nested resources. The general test setup is in Unit and Integration Testing:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Testcontainers
@SpringBootTest
class GraphAccessControlIT {

    @Container
    @ServiceConnection
    static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1")
        .withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");

    private static final String DATASET = """
        CREATE (alice:User {username: 'alice', displayName: 'Alice'}),
               (bob:User {username: 'bob', displayName: 'Bob'}),
               (carol:User {username: 'carol', displayName: 'Carol'}),
               (dave:User {username: 'dave', displayName: 'Dave'}),
               (eng:Group {name: 'engineering'}),
               (backend:Group {name: 'backend-team'}),
               (contractors:Group {name: 'contractors'}),
               (readerEng:Role {name: 'reader-eng'}),
               (editorDesign:Role {name: 'editor-design'}),
               (hrReader:Role {name: 'hr-reader'}),
               (wiki:Resource {id: 'wiki', name: 'Wiki'}),
               (engDocs:Resource {id: 'eng', name: 'Engineering'}),
               (design:Resource {id: 'design', name: 'Design'}),
               (adr1:Resource {id: 'adr-1', name: 'ADR 1'}),
               (hr:Resource {id: 'hr', name: 'HR'}),
               (salaries:Resource {id: 'salaries', name: 'Salaries'}),
               (alice)-[:MEMBER_OF]->(backend), (backend)-[:MEMBER_OF]->(eng),
               (bob)-[:MEMBER_OF]->(eng), (carol)-[:MEMBER_OF]->(contractors),
               (eng)-[:HAS_ROLE]->(readerEng), (backend)-[:HAS_ROLE]->(editorDesign),
               (dave)-[:HAS_ROLE]->(hrReader),
               (readerEng)-[:CAN_READ]->(engDocs), (editorDesign)-[:CAN_WRITE]->(design),
               (hrReader)-[:CAN_READ]->(hr),
               (engDocs)-[:CHILD_OF]->(wiki), (hr)-[:CHILD_OF]->(wiki),
               (design)-[:CHILD_OF]->(engDocs), (adr1)-[:CHILD_OF]->(design),
               (salaries)-[:CHILD_OF]->(hr)
        """;

    @Autowired
    Neo4jClient client;
    @Autowired
    PermissionService permissions;
    @Autowired
    PermissionQueries queries;
    @Autowired
    AccessAdminService admin;
    @Autowired
    SecuredDocuments secured;
    @Autowired
    CacheManager cacheManager;

    @BeforeEach
    void loadDataset() {
        client.query("MATCH (n) DETACH DELETE n").run();
        client.query(DATASET).run();
        cacheManager.getCache("graph-permissions").clear();
    }

    @ParameterizedTest
    @CsvSource({
        "alice, READ,  adr-1,    true",   // backend-team -> engineering -> reader-eng on eng
        "alice, WRITE, adr-1,    true",   // backend-team -> editor-design on design
        "alice, WRITE, eng,      false",  // the write grant starts at design, below eng
        "alice, READ,  wiki,     false",  // grants never flow up the resource tree
        "alice, READ,  salaries, false",
        "bob,   READ,  design,   true",
        "bob,   WRITE, adr-1,    false",
        "carol, READ,  adr-1,    false",  // contractors holds no role
        "dave,  READ,  salaries, true",   // direct role hr-reader on hr
        "dave,  WRITE, salaries, false",
        "dave,  READ,  adr-1,    false",
        "ghost, READ,  adr-1,    false"   // unknown user
    })
    void inheritedGroupsAndNestedResources(String user, Permission permission, String resource, boolean expected) {
        assertThat(queries.isAllowed(user, permission, resource)).isEqualTo(expected);
    }

    @Test
    void explainsTheInheritedPath() {
        var explanation = queries.explain("alice", Permission.READ, "adr-1").orElseThrow();

        // the shortest path is the write grant of Alice's own team, which also allows reading
        assertThat(explanation.subjects()).containsExactly("alice", "backend-team");
        assertThat(explanation.role()).isEqualTo("editor-design");
        assertThat(explanation.grantedOn()).isEqualTo("design");
        assertThat(explanation.permission()).isEqualTo("CAN_WRITE");
    }

    @Test
    void listsEverythingAUserCanAccessInPages() {
        var first = queries.accessibleResources("alice", PageRequest.of(0, 2));

        assertThat(first.getTotalElements()).isEqualTo(3);
        assertThat(first.getContent()).extracting(AccessibleResource::id).containsExactly("adr-1", "design");
        assertThat(queries.accessibleResources("alice", PageRequest.of(1, 2)).getContent())
            .extracting(AccessibleResource::id).containsExactly("eng");
        assertThat(queries.accessibleResources("dave", PageRequest.of(0, 10)).getContent())
            .extracting(AccessibleResource::id).containsExactly("hr", "salaries");
        assertThat(queries.accessibleResources("carol", PageRequest.of(0, 10)).getContent()).isEmpty();
    }

    @Test
    void membershipChangeInvalidatesCachedDecisions() {
        assertThat(permissions.isAllowed("carol", Permission.READ, "adr-1")).isFalse();   // now cached

        admin.addUserToGroup("carol", "engineering");

        assertThat(permissions.isAllowed("carol", Permission.READ, "adr-1")).isTrue();

        admin.removeUserFromGroup("alice", "backend-team");

        assertThat(permissions.isAllowed("alice", Permission.READ, "adr-1")).isFalse();
        assertThat(permissions.isAllowed("alice", Permission.WRITE, "adr-1")).isFalse();
    }

    @Test
    @WithMockUser("bob")
    void methodSecurityAllowsReadsAndRejectsWritesForBob() {
        assertThat(secured.read("adr-1")).isNotNull();
        assertThatThrownBy(() -> secured.update("adr-1", new DocumentUpdate("new text")))
            .isInstanceOf(AccessDeniedException.class);
    }
}

The expected rows follow directly from the dataset: Alice’s two paths, Bob’s read-only inheritance, Carol’s group without a role, and Dave’s direct role. The explain assertion pins the shortest path, and the paged test pins the sort order and the total (explain and accessibleResources are the methods of PermissionQueries shown above). The Testcontainers and @ServiceConnection wiring is described in Spring Boot — Testcontainers, and the security annotations of the last test in Spring Security — Testing Method Security. SecuredDocuments is a small test-scope bean (or the real DocumentService) whose methods carry the same @PreAuthorize("@graphAuthz.canRead(authentication, #id)") style annotations as the controller, which lets the test exercise method security without an HTTP layer. The start-up cost of the container is shared across the tests of the class; sharing one container across classes is covered in Unit and Integration Testing.

Pitfalls and variations

  • Unbounded traversals. A variable-length pattern without an upper bound on a cyclic membership graph is expensive, and a bound that is too low silently denies deep structures. Choose the bounds from the real maximum depth, and add a constraint in the administrative service that rejects a nestGroup call that would create a cycle.

  • Never use internal ids in the API. Resources are identified by their business id. If an internal identifier is ever exposed, it is elementId(), never id(), which is deprecated and not stable.

  • Deny rules are not part of this model. A path match can only add permissions. An explicit deny (for example "all of engineering except contractors") is modelled with a separate DENY relationship type and a check that returns false if a deny path exists, evaluated before the allow path; keep the semantics simple and documented, because precedence rules are where authorization systems become impossible to reason about.

  • Stale decisions. The cache is only as fresh as the invalidation. Every code path that writes a MEMBER_OF, HAS_ROLE, CAN_READ, CAN_WRITE or CHILD_OF relationship, including data imports and manual Cypher, must publish AccessChanged or accept the five-minute expiry. Keep the expiry short for the same reason.

  • Cache key and authentication. The key uses the authenticated name. If tokens carry the identity instead (see Spring Security for JWT resource servers), map the token subject to User.username once, in the Authentication, and use that one name everywhere.

  • Fail closed. A database outage must not turn into an allow. GraphAuthz lets the exception propagate, so a failing check ends as a 5xx, never a 200; do not catch it and return true.

  • Observe denials. Count allowed and denied decisions and time the Neo4j query with Micrometer, see Metrics and Observability; a sudden rise in denials is often a missing membership after a deployment.

  • Large fan-out. A grant on the root of a very large tree makes the "accessible resources" query return nearly everything. For such users prefer the check query per item shown, or materialise the effective permissions with a scheduled job.

  • Spring Authorization Server. If tokens are issued by your own server, the graph can also supply the claims at issue time; the token flow itself is in Spring Security Authorization Server.