An Introduction to Role-based Access Control in RedwoodJS

This is the deck I presented on the 8/28 Meetup.

Edit: Note this document is soon to be out of date. A new RBAC Cookbook is replacing this content with more examples and details.

An Introduction to Role-based Access Control in RedwoodJS

  • Authentication vs Authorization
  • House and Blog
  • RBAC Demo
  • Identity as a Service
  • How To’s with Code Samples
  • Additional Resources
  • Q&A

Authentication vs Authorization

  • Authentication is the act of validating that users are who they claim to be.
  • Authorization is the process of giving the user permission to access a specific resource or function.
  • In even more simpler terms authentication is the process of verifying oneself, while authorization is the process of verifying what you have access to.

:house: Example

Consider a :house: while you are away on vacation.

You are the owner and have given out :key: keys to your neighbor and a plumber that unlock the :house: :door: door.

You’ve assigned them passcodes to turn off the :rotating_light: alarm that identifies them as either a neighbor or plumber.


:house: Example

Your neighbor can enter the kitchen to get food to feed your :cat: and the your office to water your :cactus: and also use the :toilet:.

The plumber can access the basement to get at the pipes, use the :toilet:, access the laundry or :fork_and_knife:kitchen to fix the sink, but not your office.

Neither of them should be allowed into your :bed: bedroom.


:house: Example

The owner knows who they claim to be and have given them keys.

The passcodes inform what access they have because it says if they are a neighbor or plumber.

If your :house: could enforce RBAC, it needs to know the rules.


:house: Role Matrix

Role Kitchen Basement Office
Neighbor :white_check_mark: :white_check_mark:
Plumber :white_check_mark: :white_check_mark:
Owner :white_check_mark: :white_check_mark: :white_check_mark:
Role Bathroom Bedroom Laundry
Neighbor :white_check_mark:
Plumber :white_check_mark: :white_check_mark:
Owner :white_check_mark: :white_check_mark: :white_check_mark:

Blog Example

In our Blog example anyone can view Posts (authenticated or not). They are public.

  • Authors can write new Posts.
  • Editors can update them.
  • Publishers can write, review, edit and delete Posts.
  • And admins can do it all (and more).

Blog Role Matrix

Role View New Edit Delete Manage Users
Author :white_check_mark: :white_check_mark:
Editor :white_check_mark: :white_check_mark:
Role View New Edit Delete Manage Users
Publisher :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:
Admin :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark: :white_check_mark:

RBAC Demo

https://redwoodblog-with-identity.netlify.app/

  • Author
  • Editor

RedwoodJS Auth and RBAC Support

  • Implement an Identity as a Service/Authentication Provider
  • Define and Assign Roles
  • Set Roles to Current User
  • Enforce Access
  • Secure Web and Api sides
  • Helps to be familiar with Blog Tutorial as well as pages, cells, services, authentication, and routes.

Authentication Provider & Identity as a Service

“Doing authentication correctly is as hard, error-prone, and risky as rolling your own encryption.”

  • Identity as a Service such as Netlify Identity, Auth0, Magic.link, etc.
  • Aims to help developers solve the problem of authentication
  • Manages authentication (and roles) and the complexity associated

Netlify Identity Access Token (JWT) & App Metadata

{
  "exp": 1598628532,
  "sub": "1d271db5-f0cg-21f4-8b43-a01ddd3be294",
  "email": "example+author@example.com",
  "app_metadata": {
    "roles": ["author"]
  },
  "user_metadata": {
    "full_name": "Arthur Author",
  }
}

Set Roles to Current User

import { parseJWT } from "@redwoodjs/api";

export const getCurrentUser = async decoded => {
  return (
    context.currentUser || { ...decoded, roles: parseJWT({ decoded }).roles }
  );
};

RedwoodJS Web-side RBAC

  • Routes
  • NavLinks in a Layout
  • Cells/Components
  • Markup in Page

How to Protect a RedwoodJS Route

import { Router, Route, Private } from "@redwoodjs/router";

const Routes = () => {
  return (
    <Router>
      <Private unauthenticated="home" role="admin">
        <Route path="/admin/users" page={UsersPage} name="users" />
      </Private>
    </Router>
  );
};

How to Protect a RedwoodJS NavLink in a Layout

import { NavLink, Link, routes } from '@redwoodjs/router'
import { useAuth } from '@redwoodjs/auth'

const SidebarLayout = ({ children }) => {
  const { isAuthenticated, hasRole } = useAuth()

  return (
    ...
    {isAuthenticated && hasRole('admin') && (
      <NavLink
        to={routes.users()} className="text-gray-600" activeClassName="text-gray-900"
      >
      Manage Users
    </NavLink>
    ...
   )}
 )
}

How to Protect a RedwoodJS Component

import { useAuth } from "@redwoodjs/auth";

const Post = ({ post }) => {
  const { hasRole } = useAuth();

  return (
    <nav className="rw-button-group">
      {(hasRole("admin") ||
        hasRole("publisher")) && (
          <a
            href="#"
            className="rw-button rw-button-red"
            onClick={() => onDeleteClick(post.id)}
          >
            Delete
          </a>
        ))}
    </nav>
  );
};

How to Protect Markup in a RedwoodJS Page

import { useAuth } from "@redwoodjs/auth";
import SidebarLayout from "src/layouts/SidebarLayout";

const SettingsPage = () => {
  const { isAuthenticated, userMetadata, hasRole } = useAuth();

  return (
    {isAuthenticated && (
      <div className="ml-4 flex-shrink-0">
        {hasRole("admin") && (
          <a
            href={`https://app.netlify.com/sites/${process.env.SITE_NAME}/identity/${userMetadata.id}`}
            target="_blank"
            rel="noreferrer"
          >
            Edit on Netlify
          </a>
        )}
      </div>
    )}
  )}
}

RedwoodJS Api-side RBAC


How to Protect a RedwoodJS Service

import { db } from "src/lib/db";
import { requireAuth } from "src/lib/auth";

const CREATE_POST_ROLES = ["admin", "author", "publisher"];

export const createPost = ({ input }) => {
  requireAuth({ role: CREATE_POST_ROLES });

  return db.post.create({
    data: {
      ...input,
      authorId: context.currentUser.sub,
      publisherId: context.currentUser.sub
    }
  });
};

How to Protect a RedwoodJS Function

import { requireAuth } from "src/lib/auth";

export const handler = async (event, context) => {
  try {
    requireAuth({ role: "admin" });

    return {
      statusCode: 200,
      body: JSON.stringify({
        data: "Permitted"
      })
    };
  } catch {
    return {
      statusCode: 401
    };
  }
};

How to Default Roles on Signup using Netlify Identity Triggers

// api/src/functions/identity-signup,js
export const handler = async (req, _context) => {
  const body = JSON.parse(req.body);

  const eventType = body.event;
  const user = body.user;
  const email = user.email;

  let roles = [];

  if (eventType === "signup") {
    if (email.includes("+author")) {
      roles.push("author");
    }

    if (email.includes("+editor")) {
      roles.push("editor");
    }

    if (email.includes("+publisher")) {
      roles.push("publisher");
    }

    return {
      statusCode: 200,
      body: JSON.stringify({ app_metadata: { roles: roles } })
    };
  } else {
    return {
      statusCode: 200
    };
  }
};

Additional Resources

4 Likes

Thanks for the thorough writeup @dthyresson. Super helpful :slight_smile:

A few questions:

<Private unauthenticated="home" role="admin"> With this, I guess you’re redirected to the home page if you’re not authenticated. But what if you are authenticated, but not authorized, then what happens?

{isAuthenticated && hasRole('admin') Is it necessary to check both isAuthenticated and hasRole? Can you have a role, but not be authenticated?

<nav className="rw-button-group">
      {hasRole("admin") ||
        (hasRole("publisher") && (
          <a
            href="#"
            className="rw-button rw-button-red"
            onClick={() => onDeleteClick(post.id)}
          >
            Delete
          </a>
        ))}
    </nav>

Shouldn’t it be (hasRole("admin") || hasRole("publisher"))? As it stands I don’t think an admin will be able to delete posts.

Great questions!

FYI - All this info is going into a Cookbook for the docs site. So will be sure to make these clarifications and keep be honest!

The Cookbook PR is draft so please comment there as well as I think once that goes live, I’ll remove this topic or edit to say to use Cookbook instead.

I am almost done surrounding the code examples with some supporting text.

@peterp and I discussed whether or not to have an unauthenticated as well as another property with forbidden or not_permitted to state the name of the route to be sent to if not authorized.

But for brevity, we decide to use the same unauthenticated property.

You could declare a new route and page like “Forbidden” to show why one cannot access (in the example below) admin or settings.

This can have custom content like “Only admins can see this”.

<Router>

  <Private unauthenticated="forbidden" role="admin">
    <Route path="/settings" page={SettingsPage} name="settings" />
    <Route path="/admin" page={AdminPage} name="sites" />
  </Private>

  <Route notfound page={NotFoundPage} />
  <Route path="/forbidden" page={ForbiddenPage} name="forbidden" />
</Router>

I can definitely see why one would want the options of one route if unauthenticated and another if not in the role – you can read my and Peter’s exchange [here](Implement Role-based Authorization · Issue #806 · redwoodjs/redwood · GitHub.

When adding hasRole to the private route, do you think there should be an option to define a forbidden or not_permitted page (similar to the unauthenticated) to route when the user is authenticated, but not allowed? Or simply re-use the unauthenticated page for now?

Right - I actually have implemented hasRole=(["admin", "publisher"]) or hasRole("admin") for v17 and had to back into some code and that’s a typo.

If the cookbook gets out after v17 the examples will use the list.

No – if there is no currentUser then hasRole is false:

v16

  hasRole = (role: string): boolean => {
    return this.state.currentUser?.roles?.includes(role) || false
  }

next release

 hasRole = (role: string | string[]): boolean => {
    if (
      typeof role !== 'undefined' &&
      this.state.currentUser &&
      this.state.currentUser.roles
    ) {
      if (typeof role === 'string') {
        return this.state.currentUser.roles?.includes(role) || false
      }

      if (Array.isArray(role)) {
        return this.state.currentUser.roles.some((r) => role.includes(r))
      }
    }

    return false
  }
2 Likes