DEV Community

Flor Marshall for fluree

Posted on

Tutorial: To-Do List Generator V2: Identity and Permissions

Issuing Identity and Permissions at the Data layer with Fluree DB

Recap

This tutorial is a follow up to the first tutorial found here on dev.to or on github on the to-do-list branch, where we built a to-do list generator using React and Fluree. If you are not familiar with Fluree, please refer to the first part of this tutorial series to learn the basics.

Getting started

  1. To get started git clone the repo via git clone https://github.com/fluree/version-2-lists-generator.git, or any other preferred method.

  2. cd into the repo and run npm install

  3. then run npm start to locally serve the app in your browser at http://localhost:3000

Fluree

As previously mentioned Fluree is a Web3-capable graph database platform powered by an immutable ledger. The architecture of Fluree gives developers a lot of freedom and control over the way data is managed, specifically the enabling of permissions that go along with data maintenance. Unlike regular dbs, plagued with data-silos and ever changing APIs to manage application-specific permissions and identity, Fluree offers the ability to implement them directly in the data layer.

Identity and Permissions

To fully understand the way identity and permissions work in Fluree, we will need to dive into the different collections and predicates that help facilitate this capability.

User, Auth, Rules, and Roles

_user, _auth, _rule, and _role are built in collections within Fluree that hold the criteria needed to implement user permissions and control identity. Below is a diagram showcasing how each collection is connected to each other by their predicates.

relationship-diagram

A user can have an auth record associated with their identity, that auth record is then governed by the roles associated with it. Each specific role has rules that dictate their access (read and write) to the data. The role that comes straight out of the box in Fluree is ["_role/id","root"], this role has full read and write access to the db. Users can have multiple roles connected to their auth record, and each role can have a different set of rules as well. Also users can have multiple auth records, but multiple users cannot share the same auth record.

It is worth noting that an auth record does not need to be tied to a user, they can be used independently with a collection of your choosing.

Generating Auth records with Public-Private Keys

The way auth records control identity in Fluree are by tying the record to a specific public-private key pair (specifically generated using the secp256k1 elliptic curve). This cryptographic identity enables the user to sign their transactions and queries according to their permissions. There are a number of ways to generate the public-private key/auth id triple, and can be found here in the docs. In this tutorial we will be issuing them using the Admin UI in the sections below.

Signing queries and transactions can be done in different ways found here, in this tutorial we use the @fluree/crypto-utils library.

Integrating identity and permissions into the Application

Now that we have a basic understanding of how identity and permissions can be used within Fluree lets take these concepts and configure them within our application. We will need to refine our schema, add roles and rules, create smart functions, and generate auth records with private-public keys.

Make sure to change the network and db variables located in the appConfig.js to mirror your Fluree network and db namespace, and double-check that you're using the correct port (e.g. :8090).

Schema Changes

In the previous version of this application the _user collection was not used, but now that we need to leverage permissioning we will utilize the _user collection, along with their username predicate. The only other additions are the following predicates: list/listOwner, assignee/user, and task/issuedBy, where all of these predicates reference the _user collection. The list/listOwner predicate references a single user, while theassignee/user predicate is multi: true, meaning it can reference multiple users, given that a list can have more than one assignee. The entire schema can be found hereand transacted to a new Fluree ledger.

Roles and Rules

We have two types of users: list-owners and assignees. In this scenario we do not need to create multiple roles for these users, one role is suitable to control what the different users can see and do. Any difference in how list-owners and assignees can interact with lists and tasks can be permissioned based on how each user is linked to those records in the schema.

We will create an endUser role and utilize the existing root role.

two-roles

We can transact our new role to the DB:

[
  {
    "_id": "_role?endUser",
    "id": "endUser"
  }
]
Enter fullscreen mode Exit fullscreen mode

Now that we have the root role and our additional endUser role we need to create the rules that will govern what the roles can and cannot do. Below is a graphic with the rules associated with each role.

rules-for-each-role

At first glance it doesn't seem like the root user and the endUser differ in what they can and cannot do, but in order to fully grasp how the rules are enforced we need to dive into Smart Functions. Smart functions are functions written in Clojure*, they can be triggered with any query or transaction that a user attached to a certain role issues. These smart functions live in the _fn collection, the _rule collection has a predicate called _rule/fns which is a reference to this collection. So when an action takes place the smart function that is connected to a certain rule is evaluated and will either return true or false depending if the user in question has the permission to execute the desired action.

*Currently, Smart Functions are only usable in Clojure, although we are alpha-testing a version of Fluree that allows for JavaScript-written Smart Functions as well. Stay tuned for updates!

Rules and Smart Functions

It is important to note that smart functions are not limited to use in rules, they can be used in _collection/spec, _predicate/spec, _predicate/txSpec and even in transactions, an example of each can be found here. But for the purpose of demonstrating permissions, all of our smart functions are connected to the rules mentioned above. Now lets create a rule that is connected to a smart function:

[
  {
    "_id": "_rule$fullAccessOwnListData",
    "id": "fullAccessOwnListData",
    "doc": "A _user with this rule can only view and edit their own list data",
    "fns": ["_fn$fullAccessOwnListData"],
    "ops": ["all"],
    "collection": "list",
    "predicates": ["*"]
  },
  {
    "_id": "_fn$fullAccessOwnListData",
    "name": "fullAccessOwnListData?",
    "doc": "A _user can query and edit their own list data",
    "code": "(relationship? (?sid) [\"list/listOwner\" \"_user/auth\"] (?auth_id))"
  }
]
Enter fullscreen mode Exit fullscreen mode

The JSON snippet above is an example of a transaction that issues both a rule and its accompanying fn. Starting with the rule object it includes a temporary id (for the transaction) that will evaluate to a integer once it's in Fluree. It also includes an id, a description, the fns associated with the rule in the form of a temporary id, ops is set to all (meaning this applies to both actions: transact and query), and the collection and its predicates to which this rule is attributed to. In this case it applies to the list collection and all of its predicates.

In short, this rule indicates that the fullAccessOwnListData smart function will be run every time a user with this rule either transacts or queries any data field available to records in the list collection.

The next object is a transaction item that creates the smart function connected to this rule. It uses a temporary id, a name, a description, and the code is in Clojure. Clojure is a Lisp family language developed for the Java Virtual Machine, and is the language Fluree uses to program its core software. For an in-depth look at Clojure's syntax visit Learn X in Y minutes: Clojure, and useful introduction material can be found at Clojure for the Brave and True. Lets break the code down:

(relationship? (?sid) [\"list/listOwner\" \"_user/auth\"] (?auth_id))
Enter fullscreen mode Exit fullscreen mode

There are a subset of Clojure functions that can be used in the code, but in this tutorial all of our smart functions use the relationship? function. The syntax for the relationship? function is as follows (function startSubject path endSubject).

In our implementation, this maps to the following:

  • function: relationship?
  • startSubject: (?sid)
    • (?sid) is a context-dependent function which refers to the specific subject id being queried/transacted in the context of the current function evaluation.
  • path: [\"list/listOwner\" \"_user/auth\"]
    • The path can be a single predicate or a vector of predicates that potentially connect the two subjects. Essentially, for the relationship? smart function to be satisfied, we use this schema-defined path to evaluate whether a relationship exists between two subjects. In our example, if the data links a list to the user accessing the list, via the path of list/listOwner, then we can understand the user accessing the list is the owner and should be entitled to specific permissions.
  • endSubject: (?auth_id)
    • (?auth_id) is also a context-dependent function and refers to the specific auth record (i.e. public cryptographic identity) that is querying/transacting against the data at the time of the smart function evaluation.

The whole code evaluates if the relationship between the subject id and the auth record id return true, given the path. In this example in order for a listOwner to view their list data, their id must have a connection to the auth record id, via the path [list/listOwner _user/auth] that bridges that connection within the data & the schema that models it.

Not all rules may need a custom smart function as the one above, in some cases a user's ability to query and transact certain data should always be true. That means there does not have to be a startSubject or endSubject evaluated through a path. For a rule to always evaluate to true we simply set the _rule/fns to "fns": [[ "_fn/name","true"]], the same can be done if a rule must always evaluate to false.

{
  "_id": "_rule$canViewAssignees",
  "id": "canViewAssignees",
  "doc": "A _user with this rule can view all assignee data",
  "fns": [["_fn/name", "true"]],
  "ops": ["query"],
  "collection": "assignee",
  "predicates": ["*"]
}
Enter fullscreen mode Exit fullscreen mode

The transaction above is a rule where all users can view assignee data, so this rule will always evaluate to true.

We won't go over every rule and their smart functions connected to the endUser role, however they can all be found here and transacted to the Fluree ledger.

Auth records and public-private keys

Now that the roles and rules are transacted into the ledger it is time we create auth records for our users and connect them to public-private keys in order for us to sign queries and transaction, as well as see it in the app UI.

The sample schema provided here has users with nested auth records, like the example below:

[
  {
    "_id": "_user$6",
    "username": "rootUser",
    "auth": [
      {
        "_id": "_auth",
        "id": "Tf3VDot4jSKHFcLY8HSPsuf2yA5YBnRsEPU",
        "roles": [["_role/id", "root"]]
      }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

This auth id is already tied to a public-private key pair, in the sense that it is derived directly from an secp256k1 public key. This auth record just needs to be transacted, along with the rest of the sample data. The gif below shows how to generate an auth record with private-public keys through the admin UI, by selecting transact on the left navigation bar, then pressing the Generate Keys button above the code editor.

generating public private keys

This will prompt a modal to appear with the Public Key, Private Key, and Auth Id. You can either transact the auth record within the modal (remembering to make the appropriate role changes if necessary), then connect the auth id to a user in a separate transaction, or you can copy the auth object, and nest it into a user transaction similar to the one above. But be sure to save the public and private keys externally before closing the modal. In this tutorial there have six users in our sample data and their public-private keys and auth ids are kept in the usersAuth.js. Once the sample data is fully transacted, we can now move on to issuing signed queries and transactions.

Signing Queries and Transactions

The marriage between issuing permissions and generating private-public keys takes place when users sign queries and transactions. The signature proves that whoever is issuing the query or transaction has access to the private key, and so on successful submission it validates their access to view the data or make changes. There are a few ways to go about signing, in this tutorial we use the NPM package, @fluree/crypto-utils, but other methods can be found here.

Signing Queries

The query below uses the signQuery function. It takes a private key, param, queryType, host, and db as parameters (defined below), then it returns an object with keys: header, method and body, which can be sent to the /query endpoint (or other possible endpoints such as /multi-query, history, and block). This specific query can be found here, it is triggered on load and defaults to the rootUser on the tab component. Each tab option is a different user with different permissions. The lists view changes given their identity.

import { signQuery } from '@fluree/crypto-utils';
import usersAuth from './data/usersAuth';

const fetchListData = {
  select: [
    '*',
    {
      tasks: [
        '*',
        {
          assignedTo: ['*'],
        },
      ],
    },
  ],
  from: 'list',
  opts: {
    compact: true,
    orderBy: ['ASC', '_id'],
  },
};
const privateKey = selectedUser.privateKey;
const queryType = 'query';
const host = 'localhost';
const db = `${network}/${database}`;
const param = JSON.stringify(fetchListData);
let signed = signQuery(privateKey, param, queryType, host, db);
fetch(`${baseURL}/query`, signed) //fetch issues the request to the given url and takes the output of signedQuery.
  .then((res) => res.json())
  .then((res) => {
    setLists(res);
  })
  .catch((err) => {
    if (/not found/.test(err.message)) {
      return console.log("this didn't work");
    }
  });
Enter fullscreen mode Exit fullscreen mode

Signing Transactions

These are two example transactions, they both use the signTransaction function, which takes an auth id, db, expire, fuel, private key, tx, and optional deps. It is similar to the query version, but the output of signTransaction is then placed within the body of the POST request. Please refer to deleteTaskFromFluree and editTaskProps for their full implementation.

import { signTranaction } from '@fluree/crypto-utils';
import usersAuth from './data/usersAuth';

let deleteTaskFromFluree = () => {
  const privateKey = selectedUser.privateKey;
  const auth = selectedUser.authId;
  const db = `${network}/${database}`;
  const expire = Date.now() + 1000;
  const fuel = 100000;
  const nonce = 1;
  const tx = JSON.stringify([
    {
      _id: chosenTask._id,
      _action: 'delete',
    },
  ]);

  let signedCommandOne = signTransaction(
    auth,
    db,
    expire,
    fuel,
    nonce,
    privateKey,
    tx
  );
  const fetchOpts = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(signedCommandOne),
  };

  fetch(`${baseURL}command`, fetchOpts).then((res) => {
    return;
  });
};
Enter fullscreen mode Exit fullscreen mode
let editTaskProps = () => {
  const privateKey = selectedUser.privateKey;
  const auth = selectedUser.authId;
  const db = `${network}/${database}`;
  const expire = Date.now() + 1000;
  const fuel = 100000;
  const nonce = 1;
  const tx = JSON.stringify(taskChangeTransact);
  let signedCommandTwo = signTransaction(
    auth,
    db,
    expire,
    fuel,
    nonce,
    privateKey,
    tx
  );

  const fetchOpts = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(signedCommandTwo),
  };
  fetch(`${baseURL}command`, fetchOpts).then((res) => {
    return;
  });
};
Enter fullscreen mode Exit fullscreen mode

Wrap up

At its core identity and permissions within Fluree are simply data, but with security in the data layer power and scalability are not at the mercy of firewalls and APIs. It's time to move away from application-centricity and embrace data-centricity.

To learn more about architecture, infrastructure, and identity, visit the Fluree guides section. And for other repo examples visit our developer hub.

Oldest comments (0)