Authorization RBAC with dynamic roles

I would like any suggestions of how we can implement an authorization based on roles / permissions that are different based on the organization / project.

I try to implement the following requirement :

  • A user can be part of different projects.
  • On each project, a user has a role.

For instance, a user is admin of the project A and member of the project B and thus can edit resources under the project A but only view resources under the project B.

I think that this is a common use case but I didn’t find any documentation on how I should implement it. Precisely, when I see the cookbook about the RBAC (https://redwoodjs.com/cookbook/role-based-access-control-rbac), I don’t find a way to manage roles created dynamically.

For instance, if I create an access_token with app_attributes : `

For instance, if I create an access_token with app_attributes :
{ "exp": 1598628532, "sub": "1d271db5-f0cg-21f4-8b43-a01ddd3be294", "email": "example+author@example.com", "app_metadata": { "roles": ["projectA:admin", "projectB:member"] }, "user_metadata": { "full_name": "Arthur Author", } }
How, can I use the roles in the app to protect the routes ?
Can I write something like that :
const Routes = () => { return ( <Router> <Private unauthenticated="home" role="{project}:admin"> <Route path="{project}/admin/users" page={UsersPage} name="users" /> </Private> </Router> ) }

I wonder if I completely miss a point here or even if this use case can be addressed by the RBAC implementation of redwood.
Thank you for your help.

1 Like

Definitely a great question for the one and only @dthyresson!

:laughing:

Hi @abarre … right … so what I think you are describing here is similar to “Team” based access: I am an organization with many users and depending on the what information they are interacting with, they can play different roles and even have specific permissions.

I’m not 100% sure, but I think @Chris has had to implement a solution to a very similar problem, so maybe he can help here, too.

You have a few problems to tackle:

  • define users
  • define roles
  • define projects
  • associate users to projects with roles

You have to figure out how and where to encode this info, how to extract it and how to enforce (both for api/service and web/ui).

No small task.

You may want to keep roles as more coarse-grained: 'user', 'superuser' (maybe there is someone with access to all projects).

Then I think you will have to implement and manage the user -> project -> permission information in a set of tables and implement your own checks that one can inject into requireAuth or elsewhere to enforce those permissions based on the currently acted upon project. Not so bad on the api/service side.

User
Project
UserProject (where a column also has the permission)

On the web side, perhaps you store and record on the decoded user a set of these permissions per project still in user_metadata:

"permissions": {
  "projects": [
    { "alan parsons": ['read', 'edit']},
    { "manhattan": ['admin'] }
   ]
}

I think you’d be forced to protect the routes with basic RBAC but then w/in the page (or maybe layout?) check for what project that page is looking at and check the permissions and don’t render or navigate away to some unauthenticated page.

Probably implement a utility that still uses useAuth but checks the currently acted upon project and those permissions.

What I don’t know is if you can extend Private or the Page Loader to include the logic for that permission check – that would be something very interesting and protect the route.

See:

You’d want some

(isAuthenticated && role && hasRole(role)) && hasPermission(project, 'edit')

check and if not

<Redirect
        to={`${unauthenticatedRoute()}?redirectTo=${window.location.pathname}`}
      />

Curious to see what you come up with!

2 Likes

Hello! yes I have built it all custom roles / permissions / team access

model User {
  id Int @id @default(autoincrement())

  currentTeam   Team? @relation(fields: [currentTeamId], references: [id])
  currentTeamId Int?
  currentRole   TeamMemberRole? @relation(fields: [currentRoleId], references: [id])
  currentRoleId Int?
  email         String  @unique
  issuer        String  @unique

  teamMembers     TeamMember[]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @updatedAt
}

model TeamMember {
  id Int @id @default(autoincrement())
  team   Team? @relation(fields: [teamId], references: [id])
  teamId Int?
  teamMemberRole      TeamMemberRole?      @relation(fields: [teamMemberRoleId], references: [id])
  teamMemberRoleId    Int?
}

model Team {
  id        Int     @id @default(autoincrement())
  name                    String 
  teamMemberRoles         TeamMemberRole[]
  teamMembers             TeamMember[]
}

model TeamMemberRole {
  id              Int                    @id @default(autoincrement())
  name            String                 @unique
  permissions     TeamMemberPermission[]
  teamMembers     TeamMember[]
  teams           Team[]
  users           User[]
  teamInvitations TeamInvitation[]
  uneditable      Boolean                @default(false)
  createdAt       DateTime               @default(now())
  updatedAt       DateTime               @updatedAt
}

model TeamMemberPermission {
  id        Int              @id @default(autoincrement())
  name      String           @unique
  createdAt DateTime         @default(now())
  updatedAt DateTime         @updatedAt
  teamRoles TeamMemberRole[]
}

This is my authfile

import { TeamMemberLogActionTypes } from '@prisma/client'
import { AuthenticationError, ForbiddenError } from '@redwoodjs/api'
import { context } from '@redwoodjs/api/dist/globalContext'


import { db } from './db'

export const getCurrentUser = async (authToken, b) => {
  const mAdmin = new Magic(process.env.MAGICLINK_SECRET)

  const {
    email,
    publicAddress,
    issuer,
  } = await mAdmin.users
    .getMetadataByToken(authToken)
    .catch((e) => console.error(e))

  if (!email || !publicAddress || !issuer) {
    throw new AuthenticationError('Uh, oh')
  }

  const account = await db.user.findOne({
    where: { issuer },
    include: {
      currentRole: true,
      currentTeam: true,
      teamMembers: true,
      onboarding: true,
    },
  })

  if (!account) {
    return await db.user.create({
      data: {
        email,
        publicAddress,
        issuer,
      },
      include: {
        currentRole: true,
        currentTeam: true,
        teamMembers: true,
      },
    })
  }

  const currentUser = {
    auth: {
      issuer: account.issuer,
      publicAddress: account.publicAddress,
      email: account.email,
    },
    user: {
      userId: account.id,l,
      firstName: account.firstName,
      secondName: account.secondName,
      updatedAt: account.updatedAt,
      createdAt: account.createdAt,
    },

    currentRole: account.currentRole,
    currentTeam: account.currentTeam,
    roles: null,
    teamMembers: account.teamMembers,
  }

  if (currentUser.currentRole?.id) {
    const findTeamRole = await db.teamMemberRole.findOne({
      where: { id: currentUser.currentRole?.id },
      include: {
        permissions: true,
      },
    })
    const currentPermissions = findTeamRole.permissions.map((o) => o.name)
    return { ...currentUser, roles: currentPermissions }
  } else {
    return currentUser
  }
}

// Use this function in your services to check that a user is logged in, and
// optionally raise an error if they're not.
export const requireAuth = ({ permission }) => {
  if (!context.currentUser) {
    throw new AuthenticationError('You are not logged into a user.')
  }

  if (
    typeof permission !== 'undefined' &&
    !context.currentUser.roles?.includes(permission)
  ) {
    throw new ForbiddenError(
      `You role does not have access rights to '${permission}'`
    )
  }
}

export const checkTeam = (teamId) => {
  if (teamId !== context.currentUser.currentTeam.id) {
    throw new ForbiddenError("You don't have access to that team!")
  }
}

I can explain more if needed in a bit of a pinch of time right now :slight_smile:
this was for a custom solution with Magic.Links

how to make it dynamic? you attach and detatch the roles as needed from the teamMember. One user can have multiple be part of multiple teams and the connecting table in the middle is teamMember

2 Likes

Thank you so much for starting this conversation @abarre, and for your valuable insights @dthyresson and @Chris

I want to set up a “template” to kickstart projects, already have auth worked out, starting to work on RBAC now. Hopefully I’ll be able to add value to this conversation and maybe redwood as I advance.

3 Likes

Thank you @dthyresson and @Chris for your detailed explanation. I am very impressed.
I will perform a POC in the following days and let you know the result.

2 Likes

Oh, I like this a lot. Keep us posted! And definitely use us as a sounding board for ideas as needed.