Adding Authorization to a Serverless Node.js App

Gabe Jackson

Adding Authorization to a Serverless Node.js App

The main benefit of developing a serverless application is that managing servers, balancing load, scaling up and down, and a hundred other things become someone else's problem (🎉). However, securing your serverless application with authorization remains decidedly your problem.

The cloud providers offer some helpful primitives like authentication solutions, access control for their own cloud services, and hooks for you to write your own custom authorization code, but much of the heavy lifting is left up to you. In addition to writing tons (and tons and tons) of JSON, you'll have to figure out the precise baton waves required to orchestrate your authorization service / function / sidecar (/ clowncar) to ensure that everything is wired up correctly ("Resource": "*" ought to do it) and that it's at least as available as the service it's protecting.

Or you could skip all that and use oso, the open source policy engine for authorization:

  • Kiss gigantic JSON documents goodbye and write authorization logic in a declarative language that mirrors the way you would explain the logic to a coworker.

  • Stop worrying about availability and orchestration by adding the oso library as a dependency and deploying it with the rest of your application code.

  • Render authorization decisions quickly with no time-wasting network hops or secondary service latency to contend with.

In this post, we'll show you how oso makes it simple to add extensible, fine-grained authorization to your serverless application.

Starting out

As our canvas, we're going to start with a simple todo app. The app is written for Lambda's Node.js runtime, so we'll be using oso's Node.js library to implement authorization. However, if you wanted to add authorization to code written for a different Lambda runtime, there are oso libraries for Python, Ruby, Java, and Rust, with more coming soon.

The todo app consists of five Lambda functions (fronted by API Gateway) covering the basic CRUD operations on top of a single DynamoDB table. To track ownership, each todo has a creator field that contains a User populated with a few fields from the Lambda event payload: country, sourceIp, and userAgent.

Now that we have the lay of the land, let's fire up our serverless app (sans authorization).

No Authorization — No Code's evil twin

If you don't want to get your hands dirty, the app is running (with authorization in place) at serverless-todo-app.oso.dev. You may substitute that address in every time you see <SERVICE_ENDPOINT> for the remainder of the post.

If you're following along at home, you'll need a few things to get started:

When you're all set up, npm run serverless -- deploy is the magic incantation to coax some distant computers into action. After liftoff is achieved, you can use cURL to interact with your extremely scalable todo app:

$ curl https://<SERVICE_ENDPOINT>/todos
[]
$ curl https://<SERVICE_ENDPOINT>/todos -d '{"text":"my first todo!"}'
{"id":"0cf6cec0-247f-11eb-b64e-4df956b5b3e4","creator":{"country":"US","sourceIp":"1.2.3.4","userAgent":"curl/7.64.1"},"text":"my first todo!","checked":false,"createdAt":1605141365298,"updatedAt":1605141365298}
$ curl -XPUT https://<SERVICE_ENDPOINT>/todos/0cf6cec0-247f-11eb-b64e-4df956b5b3e4 -d '{"text":"my first updated todo!"}'
{"checked":false,"createdAt":1605141365298,"text":"my first updated todo!","creator":{"sourceIp":"1.2.3.4","country":"US","userAgent":"curl/7.64.1"},"id":"0cf6cec0-247f-11eb-b64e-4df956b5b3e4","updatedAt":1605141518919}
$ curl -XDELETE https://<SERVICE_ENDPOINT>/todos/0cf6cec0-247f-11eb-b64e-4df956b5b3e4
{"checked":false,"createdAt":1605141365298,"text":"my first updated todo!","creator":{"sourceIp":"1.2.3.4","country":"US","userAgent":"curl/7.64.1"},"id":"0cf6cec0-247f-11eb-b64e-4df956b5b3e4","updatedAt":1605141518919}
$ curl https://<SERVICE_ENDPOINT>/todos/0cf6cec0-247f-11eb-b64e-4df956b5b3e4
Not Found

Note that if you're hitting our hosted copy at serverless-todo-app.oso.dev, requests to the list endpoint (GET /todos) will return a bunch of existing todos instead of an empty list.

Our battlestation serverless todo app is now fully armed and operational, but extremely vulnerable to rebel attacks unauthorized shenanigans. Let's add some security!

Adding authorization with oso

First, add oso to our project: npm install oso.

Next, create an empty Polar file in the project root: touch policy.polar. Polar is the declarative logic language used to write oso authorization rules,

The machinery of initializing oso and asking it to make an authorization decision is identical across all five Lambdas, so we can wrap it in a function in src/helpers.js:

const { Oso } = require('oso');

const { User } = require('./User');

module.exports.may = async (user, action, resource) => {
  const oso = new Oso();
  oso.registerClass(Date);
  oso.registerClass(User);
  await oso.loadFile('policy.polar');
  return oso.isAllowed(user, action, resource);
};

We initialize oso, register the built-in Date object and our User class (both of which we're going to use in our policy), load our Polar file, and then ask oso if the loaded policy permits user to perform action on resource.

In each Lambda, we'll call our helper and return a 403 Forbidden if the user is not authorized to perform the action on the resource in question:

diff --git a/src/todos/update.js b/src/todos/update.js
index 86fff46..a5222a3 100644
--- a/src/todos/update.js
+++ b/src/todos/update.js
@@ -5,9 +5,10 @@ const { getTodo, updateTodo } = require('../db');
-const { error, success } = require('../helpers');
+const { error, may, success } = require('../helpers');

 module.exports.update = async (event, _context, cb) => {
   try {
-    const _user = User.fromEvent(event);
+    const user = User.fromEvent(event);
     const { id } = event.pathParameters;
     const todo = await getTodo(id);

-    // TODO: authorize access.
+    const authorized = await may(user, 'update', todo);
+    if (!authorized) return error(cb, { statusCode: 403 });

Or if we just want to authorize an action generally (as opposed to authorizing an action on specific resources):

// src/todos/list.js

// ...

const authorized = await may(user, 'list');
if (!authorized) return error(cb, { statusCode: 403 });

// ...

Once we've added those two lines to all of our Lambdas, we're now enforcing authorization!

Click here to see a full diff of adding oso to the project.

If you redeploy the app at this point (npm run serverless -- deploy), every request will 403 because oso is deny-by-default. We haven't added any rules to our policy file yet, so in oso's view of the world no one is authorized to do anything.

This is obviously a bit too secure, so let's sketch out our authorization requirements and write some Polar code.

Writing declarative authorization logic

Because of its critical role in application security, authorization logic has a higher bar for readability and auditability than regular old business logic. Polar was designed with readability as a first-class feature.

We're going to create five authorization rules, one for each Lambda. First, we'll write the rule in prose, and then we'll show the corresponding Polar code.

  • Any user is allowed to list todos:
allow(_: User, "list", _);
  • Any user is allowed to create a new todo:
allow(_: User, "create", _);
  • A user is allowed to view a specific todo if they are in the same country as the todo's creator:
allow(user: User, "view", todo) if
    user.country = todo.creator.country;
  • A user is allowed to update a todo if their IP address and user agent match those of the todo's creator:
allow(user: User, "update", todo) if
    user.sourceIp = todo.creator.sourceIp
    and user.userAgent = todo.creator.userAgent;
  • A user is allowed to delete a todo if they're allowed to update it and the todo was created within the last 5 minutes:
allow(user: User, "delete", todo) if
    allow(user, "update", todo)
    and ((new Date().getTime() - todo.createdAt) / (60 * 1000)) < 5;

These rules show off a few of oso's strengths. Implementing fine-grained attribute-based access control (ABAC) is simple when we can write rules directly over application data (in this case, our User class and the structured todo data). The rules are also composable and flexible — instead of duplicating logic in the delete rule, we simply asked if the user was allowed to update the todo and then extended it with an additional time-based check. And, finally, we did some math to determine if five minutes has elapsed since the todo's creation. We could have written a function to calculate the same thing in our Lambda code, but it's a calculation that's only relevant in an authorization context. By writing it here, we maintain the separation of concerns between our authorization and business logic.

Once we've added those rules to our policy file, we can redeploy and interact with our newly secured app:

$ curl --user-agent "007" https://<SERVICE_ENDPOINT>/todos -d '{"text":"Do something nice for Moneypenny"}'
{"id":"9d8b9b02-3175-4211-a8fb-8645d1f70a11","creator":{"country":"US","sourceIp":"67.244.40.223","userAgent":"007"},"text":"Do something nice for Moneypenny","checked":false,"createdAt":1605211750276,"updatedAt":1605211750276}
$ curl --user-agent "Goldfinger" -XPUT https://<SERVICE_ENDPOINT>/todos/9d8b9b02-3175-4211-a8fb-8645d1f70a11 -d '{"text":"Retire, you putz!"}'
Can't do that, boss

Conclusion

We used oso to quickly add fine-grained authorization to our serverless app. We leveraged the app's existing data model to express our authorization logic in a few concise stanzas instead of commingling it with business logic in a tangle of nested if / else statements. And we did it all with a minimal application footprint and no external service dependencies.

A fun extension would be hooking up Cognito for richer user data than the Lambda event object provides, but we'll leave that as an exercise for you, dear reader. Until next time!

Get updates from oso.

We won't spam you. Ever.