A bear playing hopscotch

Introducing the Oso Drive Node.js Sample App

Val Karpov

We recently shipped a Node.js sample apps repo with 2 apps demonstrating how to use Oso with Node.js. The first is a simple "Hello, World!" app that's a minimum viable example of making an Oso request. The second is a more substantial API called Oso Drive that replicates parts of Google Drive's authorization model with Oso.

Oso Drive is a simplified file sharing API. The fundamental data structures are files and folders: files belong to folders, and folders can belong to other folders. But there are also users and organizations, and whether a user can read or write a particular file depends on which folder the file is in, what organization the user is part of, and what roles the user has in their organization. In this blog post, you'll learn how to implement these authorization patterns in Oso using Oso Drive as an example.

Getting Started With Oso Drive

Oso Drive is a standalone Express app that exposes a simplified file management API that stores files and folders in memory.

The /api folder contains 1 file for each of the API endpoints:

  • POST /createFile: creates a new file
  • POST /createFolder: creates a new folder
  • GET /readFile: reads an existing file
  • GET /readFilder: reads an existing folder
  • PUT /updateFile: updates an existing file

To get started running Oso Drive, clone the sample apps repo from GitHub, and run npm install from the oso-drive directory. If you haven't already, create a free Oso Cloud account and set up an API key. Then copy the .env.example file to .env and update the OSO_CLOUD_API_KEY entry to your Oso cloud API key. Your .env file should look like the following:

OSO_CLOUD_API_KEY=e_49gxomitted_from_placeholder

Once your .env file is set up, run npm run seed. The seed script will add the Oso Drive policy to your Oso Cloud environment, and create some sample files and folders that you can use for testing. Note that the seed script will overwrite any existing policy you have in Oso Cloud, so don't run this script on an Oso Cloud environment that contains production data.

Once npm run seed is done, run npm start to start the API server. You should then be able to interact with the Oso Drive API using curl, Postman, or any other HTTP client. This blog post uses curl for ease of copy/paste. For example, the following curl command shows the output of trying to read file test.txt as the user Peter.

$ curl -H "authorization: Peter" 'http://localhost:3000/readFile?id=test.txt'
{
  "file": {
    "id": "test.txt",
    "content": "hello world"
  },
  "users": {}
}

For simplicity, the Oso Drive app reads the user name directly the authorization header.In a production application, you would use a JWT or some other access token pattern.

Node.js Authorization Based on Organization Roles

Oso Drive supports permissions based on the roles a user has on an organization. This is often the first kind of Role-Based Access Control (RBAC) that people encounter. Organizations have two roles: member and admin.

resource Organization {
    roles = ["admin", "member"];

    "member" if "admin";
}

Folders have an organization relationship, which means that each folder belongs to at most one organization. You can define a policy that grants the writer role to an organization's admins on any folder in the organization as follows.

resource Folder {
    permissions = ["read", "write"];
    roles = ["reader", "writer"];
    relations = {
        folder: Folder,
        organization: Organization, # A folder belongs to at most 1 organization
        owner: User
    };

    # Grant "writer" role if the user is an "admin" on this folder's organization
    "writer" if "admin" on "organization";
}

For example, the user Bill has the admin role on the Initech organization.That means Bill can update the file tps-reports/tps-report.txt:

$ curl -H 'content-type:application/json' -X PUT -d '{"id":"tps-reports/tps-report.txt", "content":"Needs work"}' -H "authorization: Bill" http://localhost:3000/updateFile
{
  "file": {
    "id": "tps-reports/tps-report.txt",
    "folder": "tps-reports",
    "content": "Needs work"
  }
}

On the other hand, the user Samir is a member of the Initech organization, but not an admin. That means Samir cannot update the file tps-reports/tps-report.txt.

$ curl -H 'content-type:application/json' -X PUT -d '{"id":"tps-reports/tps-report.txt", "content":"Hi Peter"}' -H "authorization: Samir" http://localhost:3000/updateFile
{"message":"Not authorized"}

Node.js Authorization Based on Files and Folders

Files are private by default in Oso Drive. For example, the user Tom can't read the file tps-reports/tps-report.txt.

$ curl -H "authorization: Tom" 'http://localhost:3000/readFile?id=tps-reports/tps-report.txt'
{
  "message": "Not authorized"
}

The Oso Drive API determines whether a user can read a particular file based on whether the user has the read permission on that file. The following is the relevant code from /api/readFile.js that uses Oso Cloud to determine whether a user can read a particular file.

const authorized = await oso.authorize(
  { type: 'User', id: userId },
  'read',
  { type: 'File', id }
);
if (!authorized) {
  return res.status(401).json({ message: 'Not authorized' });
}

Similarly, a user can only update a file if they have the write permission on that file.

Here is the relevant code in /api/updateFile.js.

  const authorized = await oso.authorize(
    { type: 'User', id: userId },
    'write',
    { type: 'File', id }
  );
  if (!authorized) {
    return res.status(401).json({ message: 'Not authorized' });
  }

So how can you give a user permission to read or write a file? The simplest way is to give the user a role on that file. This is an example of a more granular form of RBAC: resource-specific roles. Oso Drive's authorization policy allows you to give a user a reader role on a particular file that lets them read that file, and a corresponding writer role.

resource File {
  permissions = ["read""write"];
  roles = ["reader""writer"];

  "read" if "write";
  "read" if "reader"; # Give a user the "reader" role to let them read a file
  "write" if "writer";
}

The seed script gives the user Michael the writer role on the tps-reports/tps-report.txt file. So the user Michael can read tps-reports/tps-reports.txt:

$ curl -H "authorization: Michael" 'http://localhost:3000/readFile?id=tps-reports/tps-report.txt'
{
  "file": {
    "id": "tps-reports/tps-report.txt",
    "folder": "tps-reports",
    "content": "TODO: write TPS report"
  },
  "users": {
    "Michael": [
      "writer"
    ]
  }
}

Files also inherit roles from the folder they belong to. Files have a folder relation, and role if role on folder; means that a user with the reader role on a folder also gets the reader role for every file in that folder.

resource Folder {
    permissions = ["read", "write"];
    roles = ["reader", "writer"];
    relations = {
        folder: Folder
    };

    role if role on "folder";

    "read" if "write";
    "read"  if "reader";
    "write" if "writer";
}

resource File {
    permissions = ["read", "write"];
    roles = ["reader", "writer"];
    relations = {
        folder: Folder # A file optionally belongs to a folder
    };

    role if role on "folder"; # Inherit roles from the file's folder

    "read" if "write";
    "read"  if "reader";
    "write" if "writer";
}

The seed script gives the user Bob the reader role on the folder tps-reports, and also indicates that the file "tps-reports/tps-report.txt" is in the folder "tps-reports". So that means the user Bob can read the file tps-reports/tps-report.txt, even though that user doesn't explicitly have the reader role on that particular file.

$ curl -H "authorization: Bob" 'http://localhost:3000/readFile?id=tps-reports/tps-report.txt'
{
  "file": {
    "id": "tps-reports/tps-report.txt",
    "folder": "tps-reports",
    "content": "TODO: write TPS report"
  },
  "users": {
    "Michael": [
      "writer"
    ]
  }
}

The /api/createFile endpoint sets the file's folder using the following code.

await oso.tell(
  'has_relation', // Fact type
  { type: 'File', id: file.id }, // Resource
  'folder', // Relation
  { type: 'Folder', id: file.folder } // Actor
);

Node.js Authorization Based on Ownership

Relationship-based access control (ReBAC) is an authorization pattern where permissions are derived from relationships between resources, rather than just roles on a resource. Ownership is a common example of ReBAC: the user that created a file or folder should be able to write to that file or folder. Below is how you can represent that files have an owner, and the owner can write to that file.

resource File {
    permissions = ["read", "write"];
    roles = ["reader", "writer"];
    relations = {
        folder: Folder,
        owner: User
    };

    "writer" if "owner"; # File owner can always write to the file

    "read" if "write";
    "read"  if "reader";
    "write" if "writer";
}

Oso Drive sets the file's owner when the file is created in /api/createFile. Below is the code that sets the file's owner.

await oso.tell(
  'has_relation', // Fact type
  { type: 'File', id: file.id }, // Resource
  'owner', // Relation
  { type: 'User', id: req.headers.authorization } // Actor
);

Node.js Authorization Based on Attributes

Users can also have permissions on a file based on the file's attributes, or even the folder's attributes. Attributes are any arbitrary properties associated with an actor or resource, and authorization that incorporates attributes is known, appropriately, as Attribute-Based Access Control (ABAC).

For example, in Oso Drive, files have a simple is_public attribute: if a file has is_public set, anyone can read that file. The test.txt file is a public file, which is why Peter was able to read it way back at the beginning of the post.

has_permission(_user: User, "read", file: File) if
    is_public(file);

The is_readable_by_org attribute grants the "reader" role on a folder to any member of the folder's organization. The organization matches Organization part ensures that the folder has an associated organization.

has_role(user: User, "reader", folder: Folder) if
    organization matches Organization and
    is_readable_by_org(folder) and
    has_role(user, "member", organization);

Oso Drive also has an is_readable_by_org attribute for files. Implementing this attribute for folders is trickier, because files don't have an organization relation, so you need to check both the folder and the organization.

has_role(user: User, "reader", file: File) if
    organization matches Organization and
    folder matches Folder and
    is_readable_by_org(folder) and
    has_relation(file, "folder", folder) and # Does file belong to right folder?
    has_role(user, "member", organization); # Is this user a member of this org?

Try Authorization in Node.js for Yourself

Oso Drive demonstrates how to implement a complex authorization model in Oso Cloud with Node.js, including role-based, relationship-based, and attribute-based access control. Oso Drive's authorization model includes recursive relationships, role propagation, and other features that would make this model difficult to implement in Node.js code. Oso Cloud makes implementing RBAC, ReBAC, ABAC, and recursive structures much easier. Clone Oso's Node.js sample apps repo and try the Oso Drive sample app for yourself!

Want us to remind you?
We'll email you before the event with a friendly reminder.

Write your first policy