Property Level Security with Neo4j Enterprise

security-patrol-guard

In Neo4j 3.1 Enterprise edition, we introduced the first wave of security features that are coming down the pipeline. Now you can start off with Administrators, Architects, Publishers and Readers as built in default groups. You can read about their capabilities in the docs.

If you’ve been paying attention, you know Neo4j thrives under it’s dynamic customizability. The developers decided to let us build our own custom Roles and limit their capabilities to a set of Stored Procedures. With this, we can build any kind of access control we want, but let’s go for the jugular and let’s see how we can build property level security for Neo4j.

I’m going to reuse a technique you’ve seen me do here before. Using Roaring Bitmaps saved as a byte array property on each node. In this case, not to keep track of “dislikes” but instead to keep track of which node id and property key combinations a user is allowed to access.

In the previous example, we saved the node ids of people who “disliked” the user as a byte array property, because those relationships were pretty useless other than to filter out people we should no longer show or recommend to the user. That was easy because we could just store node ids which are just numbers and fit nicely into bitmap locations.

In order to store node ids and the properties of those node ids we have access to, we need to do something different. We are going to combine the node id with the property key id that Neo4j assigns each property name by bit shifting our node ids 8 spots and adding our key combined with 0xFF to limit it to 256 properties. If we wanted 512 property keys, we would shift 9 spots and use 0x1FF. For 1024 property keys, 0x3FF, you get the idea… and why do you have so many property keys?

Integer permission = toIntExact((nodeId << 8) | (keys.get(property) & 0xFF));

Now we need to store this permission number in our roaring bitmap and save it as a byte array in our node:

byte[] bytes;
MutableRoaringBitmap userPermissions = new MutableRoaringBitmap();
userPermissions.add(permission);
userPermissions.runOptimize();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
userPermissions.serialize(new DataOutputStream(baos));
bytes = baos.toByteArray();
user.setProperty("permissions", bytes);

When performing a traversal and before deciding what if any node properties to return we can perform a check. We start by getting all the properties of a node, then use that node id and each key in a loop to check the userPermissions roaring bitmap. If the bitmap has that permission id set, we add it to our results. If however we find that we had no permissions to any of the properties of the node, then we don’t add it to our results and skip it like so:

            Node node = db.getNodeById(nodeId);
            Map<String, Object> properties = node.getAllProperties();
            Map<String, Object> filteredProperties = new HashMap<>();
            for (String property : properties.keySet()) {
                Integer permission = toIntExact((nodeId << 8) | (keys.get(property) & 0xFF));
                if (userPermissions.contains(permission)) {
                    filteredProperties.put(key, properties.get(key));
                }
            }
            if (!filteredProperties.isEmpty()) {
                results.add(new MapResult(filteredProperties));
            }

The basic idea works, but how do we integrate it into Neo4j? Well, instead of building unmanaged extensions like I usually do, I’m going to build a set of Cypher Stored Procedures instead. Let’s start with some kind of security Schema. We want Security Users and Security Groups so users can combine their permissions with those of the groups they belong to. We also need a special role for these users that I will call “secured”. The code is below… notice one thing. I am checking the security context of our query and making sure only someone with the admin rights is allowed to run this stored procedure.

    @Description("com.maxdemarzi.generateSecuritySchema() | Creates schema for SecurityUser and SecurityGroup")
    @Procedure(name = "com.maxdemarzi.generateSecuritySchema", mode = Mode.SCHEMA)
    public Stream<StringResult> generateSecuritySchema() throws IOException {
        if ( ktx.securityContext().isAdmin() ) {
            org.neo4j.graphdb.schema.Schema schema = db.schema();
            if (!schema.getConstraints(Labels.SecurityUser).iterator().hasNext()) {
                schema.constraintFor(Labels.SecurityUser)
                        .assertPropertyIsUnique("username")
                        .create();
            }
            if (!schema.getConstraints(Labels.SecurityGroup).iterator().hasNext()) {
                schema.constraintFor(Labels.SecurityGroup)
                        .assertPropertyIsUnique("name")
                        .create();
            }

            db.execute("CALL dbms.security.createRole(\"secured\")");
        }
        return Stream.of(new StringResult("Security Schema Generated"));
    }

Now that we have our schema, let’s go ahead and create a procedure to make a user and add them to our “secured” role. I’m going to hijack the dbms.security.createUser procedure by running it here as well as adding the user to the role and finally creating a SecurityUser node with new empty permissions.

    @Description("com.maxdemarzi.createUserWithPropertyRights(username, password, mustChange) | Creates a User and SecurityUser Node")
    @Procedure(mode = Mode.WRITE)
    public Stream<NodeResult> createUserWithPropertyRights(@Name("username") String username,
                                                           @Name("password") String password,
                                                           @Name("mustChange") boolean mustChange) throws IOException {
         Node user = null;
         if ( ktx.securityContext().isAdmin() ) {
             Map<String, Object> params = new HashMap<>();
             params.put("username", username);
             params.put("password", password);
             params.put("mustChange", mustChange);
             params.put("group", "secured");

             String request = "CALL dbms.security.createUser({username},{password},{mustChange})";
             db.execute(request, params);
             request = "CALL dbms.security.addRoleToUser({group}, {username})";
             db.execute(request, params);
             user = db.createNode(Labels.SecurityUser);
             user.setProperty("username", username);
             createPermissionsProperty(user);
         }
         return Stream.of(new NodeResult(user));
    }

To add a permission to a user we need another procedure. This one takes the username of the user we are assigning the permission, as well as the node and property we want the user to have access to.

    @Description("com.maxdemarzi.addUserPermission(username, node, property) | Gives user access to node.property")
    @Procedure(mode = Mode.WRITE)
    public Stream<StringResult> addUserPermission(@Name("username") String username,
                                                  @Name("node") Node node,
                                                  @Name("property") String property) throws IOException {

        cachePropertyId(property);
        updatePermission(Labels.SecurityUser, "username",  username, node, property, true);
        return Stream.of(new StringResult("User " + username + " permission to node " + node.getId() + " " + property + " added"));
    }

I’ll skip over doing the same to Security Groups as well as assigning a user to a security group. These are just nodes and relationships, not roles or anything special. We are going to build procedures for all queries the secured user is able to do, and then limit the secured users to only be able to run these stored procedures. So for example if we had a procedure like this:

@Description("com.maxdemarzi.connected(label, key, value, relationshipType, depth) | Find connected nodes out to a certain depth")
    @Procedure(name = "com.maxdemarzi.connected")
    public Stream<MapResult> connected(@Name("label") String label,
                                       @Name("key") String key,
                                       @Name("value") Object value,
                                       @Name("relationshipType") String relationshipType,
                                       @Name("depth") Number depth) {
...

We would edit /conf/neo4j.conf and add the following line:

dbms.security.procedures.roles=com.maxdemarzi.connected:secured

This limits any user with the “secured” role to just that procedure, but we could write a bunch of procedures that all shared the same namespace and they would all be allowed.

So there you have it. You can now do Property Level Security with Neo4j Enterprise. You can tweak this to handle relationship properties by adding another bitmap, or simplify it to whole nodes and relationships by skipping the bit-shifting. The magic of Neo4j is that it lets you do whatever you dream up. So if anybody asks “Can Neo4j do X?”, the answer is yeah, just give me a few minutes. Code as always is on github. Once caveat I ran into is that Bolt does not support displaying of byte arrays, so if using the browser, turn off bolt to see the results of “createUserWithPropertyRights”. Enjoy.

Tagged , , , , , ,

Leave a comment