Building a Multi-tenant SaaS Control Panel for Neo4j Desktop

The introduction of multi-database in Neo4j 4.0 has certainly made the lives of aspiring SaaS vendors. Previously to 4.0, it was only possible to run one instance of a Neo4j database on a server at any time. This meant a lot of effort around standing up Neo4j instances for customers of SaaS services. Now, it’s as simple as running a CREATE DATABASE command against the system database.

The Scenario

So, let’s paint a scenario. Say we have a customer, Vendorpower. VP sell SaaS services to clients that allow them to view and track sales opportunities. As a relatively new company, they still handle the onboarding process of clients and at the moment this involves creating a new server manually every time a customer signs up. Now that they have upgraded to 4.0, they would like to take advantage of Multi-database and automate their processes.

Because the tasks will only ever be run internally, we can build a Graph App that can be run via Neo4j Desktop. The app should be capable of connecting to the Neo4j System database and creating a new database when a subscription is created.

As previously mentioned, I’m a fan of Vue.js so I will be using this in this example. The underlying code will all be Cypher so this can be executed from whichever frontend or backend framework(s) you prefer.

Inception for Graph Databases

Ironically, in order to enable automated multi-tenancy - the first thing to do would be to create a database to hold the client information. For this we could use the default neo4j database but instead I will create one called clients. This database will hold the information on each Customer and their subscriptions. For each :Product, there will be one or more :Script nodes which can be queried to run to create constraints or seed the database with data.

The model for the customers database will look something like this:

Arrows Model

As this will only be run once, I will use cypher-shell. The -d flag allows you to specify which database you query against (without this cypher-shell will fall back to the default database specified in dbms.default_database).

bin/cypher-shell -a bolt://localhost:7687 -u neo4j -p neo -d system

neo4j@system> CREATE DATABASE customers;
neo4j@system> SHOW DATABASES;
+---------------------------------------------------------------------------------------------------+
| name        | address          | role         | requestedStatus | currentStatus | error | default |
+---------------------------------------------------------------------------------------------------+
| "customers" | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | FALSE   |
| "neo4j"     | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | TRUE    |
| "system"    | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | FALSE   |
+---------------------------------------------------------------------------------------------------+

Neo4j requires at least one database, so now that the customer database exists I can drop neo4j.

neo4j@system> DROP DATABASE neo4j;

Next, there will need to be some constraints. Each database will be named after the customer that owns it, so for that I can create a unique constraint on the :Customer name property. The :Product and :Subscription nodes will also require unique constraints on the id property, and we’ll also likely query subscriptions by endDate so I’ll create that too.

neo4j@system> :use customers
neo4j@customers> CREATE CONSTRAINT ON (c:Customer) ASSERT c.name IS UNIQUE;
neo4j@customers> CREATE CONSTRAINT ON (p:Product) ASSERT p.id IS UNIQUE;
neo4j@customers> CREATE CONSTRAINT ON (s:Subscription) ASSERT s.id IS UNIQUE;
neo4j@customers> CREATE INDEX ON :Subscription(endDate);

Creating Products

A subscription to a product will require some setup scripts. For now, I will create a couple of generic scripts to create a constraint and some dummy data along with a :Config node that could hold some global config for that client at a later date.

// Create some generic scripts
CREATE (constraint:Script { cypher: "CREATE CONSTRAINT ON (c:Customer) ASSERT c.id IS UNIQUE" })
CREATE (seed:Script { cypher: "CREATE (c:Customer {id: 1, customer: 'Dummy Customer'})" })
WITH constraint, seed

// Create gold, silver and bronze products
UNWIND [
    { id: 1, name: 'Bronze', price: 9.99 },
    { id: 2, name: 'Silver', price: 14.99 },
    { id: 3, name: 'Gold', price: 28.99 }
] AS row

CREATE (p:Product) SET p += row

// Create links to generic scripts
CREATE (p)-[:REQUIRES_SCRIPT]->(constraint)
CREATE (p)-[:REQUIRES_SCRIPT]->(seed)

// Run a script to create some sort of Config node
CREATE (p)-[:REQUIRES_SCRIPT]->(:Script { cypher: "CREATE (:Config { package: '"+ row.name +"' })" })

Each Product node should be related to the two generic script nodes and an additional relationship to a third :Script node.

Product and Script nodes

Nice! Now the packages are there, we can start on a UI.

Creating our first Customer

First step, let’s create a new project with the vue-cli. Because I’m lazy a fan of open source, I will also install the vue-neo4j package to allow me to quickly connect to Neo4j. This will also let me build in support for Neo4j Desktop and allow me to connect to the current active database.

I won’t concentrate too much on design, the functionality is way more important to me. Let’s just pretend that the Vendorpower staff don’t have a great eye for design. Neo4j favours Semantic UI for UI, so I will use the Semantic UI Vue package to speed things up a bit.

vue create vendorpower
cd vendorpower

npm i --save vue-neo4j semantic-ui-vue semantic-ui-css
npm run serve

Once it has done it’s thing, there should be a barebones project running on :8080. The next step is to include the two dependencies in main.js.

src/main.js
import Vue from 'vue'
import SuiVue from 'semantic-ui-vue'
import VueNeo4j from 'vue-neo4j'
import App from './App.vue'

Vue.config.productionTip = false

import 'semantic-ui-css/semantic.min.css';

Vue.use(SuiVue)
Vue.use(VueNeo4j)

new Vue({
  render: h => h(App),
}).$mount('#app')

Now, I can use the <sui-*> components in my templates and interact with neo4j in components using this.$neo4j. vue-neo4j comes with a login component, so I will use that to create a quick login form.

In App.vue, I’ll add a data() function that sets the a driver to false by default, then use this to power the <vue-neo4j-connect> component. When you connect to an active database, the callback should overwrite the driver and display the UI.

The component provides a list of Projects and Databases plus a button to connect to the Active Graph. This requires the Neo4j Desktop API which is injected into graph apps - for development purposes you’ll need to put Neo4j Desktop into development mode to get this to work.

Enabling Development mode in Neo4j Desktop

Enable development mode, set initial entry point to localhost:8080 and root path to /

To do this, click the Settings icon in Neo4j Desktop and tick Enable development mode at the bottom of the pane. The Entry point should be the address that the vue project is running at, and the root path should be /. After this, you should see Development App at the top of your Neo4j Desktop screen next to Neo4j Browser.


The App.vue component should look something like this:

src/App.vue
<template>
  <div id="app" class="ui container">
    <vue-neo4j-connect
      v-if="!driver"
      :onConnect="onConnect"
      :showActive="true"
    />
    <div v-else>
      <h1 is="sui-header">Welcome!</h1>
      Connected to {{driver}}!
    </div>
  </div>
</template>

<script>
export default {
  name: 'app',
  data: () => ({ driver: false, }),
  methods: {
    onConnect(driver) {
      this.driver = driver
    },
  },
}
</script>

…and give you a screen like this once connected:

Connected!

Creating a Customer

A simple form is required to create a new customer - for this, we need the list of packages from the customers database. This query is best placed in the mounted function on the component. If you were using the vanilla drivers, you would specify the database name when you create a new session but because I’m using the vue-neo4j package I will specify this as the third argument on this.$neo4j.run():

src/components/CreateCustomer.vue
export default {
  name: 'CreateCustomer',
  data: () => ({ loading: true, products: [], }),
  mounted() {
    this.$neo4j.run(`
      MATCH (p:Product)
      RETURN p.id AS id, 
        p.name AS name, 
        [ (p)-[:REQUIRES_SCRIPT]->(s) | s.cypher ] AS scripts
    `, {}, { database: 'customers' })
      .then(res => {
        this.products = res.records.map(row => ({
          value: row.get('id'),
          text: row.get('name'),
          scripts: row.get('scripts'),
        }))

        this.loading = false
      })
  },
}

Next, a template using the semantic-ui-vue components to create a form which holds the customer name and a dropdown for the packages.

src/components/CreateCustomer.vue
<template>
    <div>
      <sui-form v-if="!loading" @submit.prevent="handleSubmit">
        <h2 is="sui-header">Create Customer</h2>
        <sui-form-field>
          <label>Name</label>
          <sui-input v-model="name" />
        </sui-form-field>
        <sui-form-field>
          <label>Package</label>
          <sui-dropdown
            placeholder="Package"
            selection
            :options="products"
            v-model="product"
          />
        </sui-form-field>
        <sui-button primary>Submit</sui-button>
      </sui-form>
    </div>
</template>

When this form is submitted, the UI will need to:

  1. Create new :Customer and :Subscription nodes in the customers database
  2. Create a new Neo4j database and a native user to connect to it
  3. Ensure that the customer’s database can only be accessed by this user using the new 4.0 Fine Grained Access Controls

In an ideal world you would send an automated onboarding email emails to the customer with their username and password but I will leave that for now.

Restricting Access

All databases are made available to all users by default. In order to ensure that a customer’s database is only accessible by one user, we’ll need to create a public role that will be assigned to all users. Each time a database is created, access to the database by the public role will need to be explicitly denied. Then, a specific user (in this case with the same name) will be created and assigned to a role which has been explicitly allowed access to the database of the same name.

First, the new role will be created. Let’s call it customer. Then, for good measure, deny it access to the customers database.

neo4j@system> CREATE ROLE customer;
0 rows available after 27 ms, consumed after another 0 ms

neo4j@system> DENY ACCESS ON DATABASE customers TO customer;
0 rows available after 199 ms, consumed after another 0 ms

Now, when the form is submitted a handler function should execute the steps above. For this, I will create a new async method called handleSubmit which will run the queries in. An async function will allow me to await the response of each query and append a message to a confirmations array.

export default {
  name: 'CreateCustomer',
  // ...
  methods: {
    async handleSubmit() {
      this.confirmations = []
      this.creating = true

      // The next few lines of code will go here...

      this.creating = false
  }
}

Because we’ll need to await for a few of the cypher statements to run, I have defined it as an async function. I have defined a boolean to hold the creating state, then the array of confirmation messages will be displayed on screen - we could also use this to display a progress bar. Now, let’s add some scripts:

// Create Customer in Customers database
const createCustomer = await this.$neo4j.run(`
  MATCH (p:Product {id: $product})
  MERGE (c:Customer { name: $name })
  CREATE (s:Subscription { id: $name + '-' + $product + '-' + date(), startDate: date(), endDate: date()+duration('P365D') })
  MERGE (c)-[:HAS_SUBSCRIPTION]->(s)-[:FOR_PRODUCT]->(p)

`, { product: this.product, name: this.name }, { database: 'customers' })

This script finds the product node with the corresponding ID, merges the customer node into the database by it’s name and then creates a subscription node expiring in a years time.

const system = this.$neo4j.getSession({ database: 'system' })
const { name, password } = this

// Create Database
await system.run(`
  CREATE DATABASE ${name}
`)

this.confirmations.push(`Database ${name} created`)

Next, create a new database for the client in the system database. In order to do this, I have created a system variable holding a session to the system database.

// Deny access to everyone
await system.run(`
  DENY ACCESS ON DATABASE ${name} TO customer
`)
this.confirmations.push(`Access denied to ${name} for members with customer role`)

Because this database should only be accessed by the current customer, we need to explicitly deny access to all other customers. Because all SaaS customer users will assigned the customer role, the role can be denied access to all databases as they’re created by default. Without this, any customer could access the other databases.

// Create user
await system.run(`
  CREATE USER ${name} SET PASSWORD $password CHANGE NOT REQUIRED
`, { password: this.password })

this.confirmations.push(`The user ${name} has been created with password ${password}`)

Then, create a user with the same name of the database. Appending CHANGE NOT REQUIRED to the command will ensure that the user doesn’t receive a prompt to change their password once they’ve logged in.

// Assign generic role to user
await system.run(`
  GRANT ROLE customer to ${name}
`)

this.confirmations.push(`Assigned ${name} to customer role`)

…and assign them to the customer role - automatically denying them access to all other client databases.

// Create unique role for database
await system.run(`
  CREATE ROLE ${name}
`)

this.confirmations.push(`New role ${name} created`)

By assigning a custom role to the user (in this case, with the same name), they will have explicit access to read and write on their own database.

// Grant unique role access to the new database
await system.run(`
  GRANT ACCESS ON DATABASE ${name} to ${name}
`)

this.confirmations.push(`Role ${name} granted access to ${name}`)

This command allows the user to access the database.

// Grant name management 
await system.run(`
  GRANT NAME MANAGEMENT ON DATABASE ${name} to ${name}
`)

this.confirmations.push(`Role ${name} granted management privileges to ${name}`)

Granting name management to the role will allow the user to create new labels, relationship types and properties in their own database.

// Grant write access (privilege to create labels, relationship types, and property names) to the database
await system.run(`
  GRANT WRITE ON GRAPH ${name} to ${name}
`)

this.confirmations.push(`Role ${name} granted write access to ${name}`)

Then granting write privileges will allow them to write data to their database.

// Assign unique role to user
await system.run(`
  GRANT ROLE ${name} to ${name}
`)

this.confirmations.push(`Assigned ${name} to ${name} role`)

Finally, the user should be assigned the role.

// Seed Data
const customerDriver = new neo4j.driver('bolt://localhost:7687', neo4j.auth.basic(name, password))
const customerSession = customerDriver.session({ database: name })

await this.products.find(p => p.value == this.product)
  .scripts
  .map(async cypher => await customerSession.run(cypher))

this.confirmations.push(`Seed scripts run against database ${name}`)

Now that the database has been set up, it needs to be seeded with some data. Because I loaded the scripts when the form was mounted, I can just run these one by one against a new instance of the driver. If this is successful there should be a constraint created and a single :Config node in the database.

 

The end result

 

NB: The nice thing about this being an internal application is that I can be quite gung-ho with the security concerns. Obviously, you might want to be a bit more careful in a production environment.

But that’s it, a quick test of the UI with the client name foo shows all of the confirmations displaying properly. If I check in the system database, I can see that the user and role have been created.

neo4j@system> SHOW DATABASES;
+---------------------------------------------------------------------------------------------------------+
| name              | address          | role         | requestedStatus | currentStatus | error | default |
+---------------------------------------------------------------------------------------------------------+
| "system"          | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | FALSE   |
| "customers"       | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | FALSE   |
| "foo"             | "localhost:7687" | "standalone" | "online"        | "online"      | ""    | FALSE   |
+---------------------------------------------------------------------------------------------------------+

neo4j@system> SHOW ROLES;
+-------------------------------+
| role              | isBuiltIn |
+-------------------------------+
| "admin"           | TRUE      |
| "publisher"       | TRUE      |
| "editor"          | TRUE      |
| "reader"          | TRUE      |
| "architect"       | TRUE      |
| "clients"         | FALSE     |
| "foo"             | FALSE     |
+-------------------------------+

neo4j@system> SHOW USER foo PRIVILEGES;
+------------------------------------------------------------------------------------------------+
| access    | action   | resource         | graph       | segment           | role       | user  |
+------------------------------------------------------------------------------------------------+
| "DENIED"  | "access" | "database"       | "customers" | "database"        | "customer" | "foo" |
| "DENIED"  | "access" | "database"       | "foo"       | "database"        | "customer" | "foo" |
| "GRANTED" | "write"  | "all_properties" | "foo"       | "NODE(*)"         | "foo"      | "foo" |
| "GRANTED" | "write"  | "all_properties" | "foo"       | "RELATIONSHIP(*)" | "foo"      | "foo" |
| "GRANTED" | "access" | "database"       | "foo"       | "database"        | "foo"      | "foo" |
+------------------------------------------------------------------------------------------------+

We can confirm the access using a new session in cypher shell. There should be one :Config node created via the seed script that includes some information about the user’s subscription and a :Customer record with the name property Dummy Customer.

bin/cypher-shell -a bolt://localhost:7687 -u foo -p bar -d foo
nopwd@nopwd> MATCH (n) RETURN count(n);
+-------------------------+
| labels(n)    | count(n) |
+-------------------------+
| ["Config"]   | 1        |
| ["Customer"] | 1        |
+-------------------------+

Deploying the App

You can deploy applications remotely via npm but as this is only an internal application this would be overkill. The easiest way to deploy the app is to use npm’s build and pack commands. The build command uses vue-cli’s build command to create a dist/ folder full of assets that can then be deployed. The pack command then uses the files in the dist folder to create a .tgz file that can be easily distributed.

$ npm run build
> vendorpower@0.1.0 build /Users/adam/projects/vendorpower
> vue-cli-service build

  File                                   Size              Gzipped

  dist/js/chunk-vendors.86cb0ed7.js      1106.54 KiB       288.39 KiB
  dist/js/app.cb7ef089.js                7.05 KiB          2.72 KiB
  dist/css/chunk-vendors.0c3fd141.css    603.02 KiB        100.55 KiB
  dist/css/app.a1b2901f.css              0.02 KiB          0.04 KiB


$ npm pack
$ ls *.tgz
vendorpower-0.1.0.tgz

The result of this process will be vendorpower-0.1.0.tgz. This can be installed as an application in Neo4j Desktop either by copying and pasting the location of the file (prefixed by file:///) into the Graph App URL textbox at the bottom of the Graph Applications pane, or by dragging and dropping the zip file into the textbox.

Once it has been installed, navigate to the project and click Add Application next to Neo4j Browser to make the application available for that project.

Conclusion

The example is a little tongue in cheek but hopefully it demonstrates the basics of creating an application that interacts successfully the system database. Neo4j 4.0 provides many new features that bring it in line with other databases. Add to that the benefits of storing your data in a graph and the opportunities are practically endless. Now SaaS vendors can easily fire up new Neo4j databases for their clients while making sure that the data is safe and secure.

ALl of the code for this blog post is up on github.

Have you tried multi-database yet? Let me know how you get on in the Neo4j Community forum.