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 (Cookbook - Role-based Access Control (RBAC) : RedwoodJS Docs), 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.

2 Likes

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

:laughing:

1 Like

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:

https://github.com/redwoodjs/redwood/blob/main/packages/router/src/router.js#L50

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!

3 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

7 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.

5 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.

4 Likes

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

1 Like

@Chris I have been following your example. One question I have is, how do you setup your Team object for a new user? Do you redirect to a page where they can create a team? I noticed you have a name field, so you will need the user input.

Another question I had is why you are returning after creating the db row for a new user inside the getCurrentUser function. Would this not result in different payloads for a new user or existing user?

Any code examples would be very helpful!

@viperfx Erm to the first question yes! there a create a team page, that then assigns them to that team once created.

To what I understand you still want to return a payload when its a new user, so I do so?

1 Like

@Chris Thanks for the quick reply! can you give any hints as to how you handle the redirects? E.g. when a user is made but other details are missing such as a link to a Team.

How do you ensure a team must be created on a user before they visit other pages? Do you perform any global checks?

Regarding the returned, I was specifically pointing out

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

it looks like you are returning early there, and was curious why? it also seems like the return payload would be different there, compared to at the end of the function?

I am also curious how you fill out other fields such as first name, last name etc. Are you collecting these on initial sign up? or are these requested after sign up?

so that’s returned early because redwood just needs something back! but the next one will have all the info. I am sure that it can be improved. Basically, when the user logs in you redirect them to a “pickTeamPage” if the pick team page notices that they don’t have a name etc, then you send them to “choose a name page” then once the data have been filled in you send them to “pick a team”. where they can choose to log into a team or create one.

1 Like

@Chris Thanks! That makes sense.

Basically, when the user logs in you redirect them

This is the part I am unclear about. Where do you do this redirect? Current this is a simple signup flow when signing up via GoTrue library with netlify

const SignupPage = () => {

  const { client } = useAuth()

  const [error, setError] = React.useState(null)

  const onSubmit = (data) => {

    setError(null)

    client

      .signup(data.email, data.password)

      .then((props) => {

        navigate(routes.login())

      })

      .catch((error) => setError(error.message))

  }

This is an example Login flow

const LoginPage = () => {

  const { logIn } = useAuth()

  const [error, setError] = React.useState(null)

  const onSubmit = (data) => {

    setError(null)

    logIn({ email: data.email, password: data.password, remember: true })

      .then(() => navigate(routes.home()))

      .catch((error) => setError(error.message))

  }

There are two questions I have:

  1. On Login: Do you always redirect to a team page? or do you have a backend check to check if a team exists? and then redirect to home for example?
  2. Is there checks on the pickTeamPage itself to handle the redirect to other places?

Navigate them to a page that you then work out that logic on and then on that page send them to where is needed.

for example this is what I do I route them to a pickTeam page


const PickTeam = (): ReactElement => {
  const { currentUser } = useAuth()
 // can do we know there name and or skiped avatar if done not redirect them to page to give details
  if (currentUser.user.onboardingHasName === 'notStarted') {
    return <Redirect to={routes.onboardingUpdateName()} />
  } else if (currentUser.user.onboardingHasAvatar === 'notStarted') {
    return <Redirect to={routes.onboardingUpdateAvatar()} />
  }

// render pick team cell
  return (
    <LoginLayout noPadding>
      <PickTeamCell />
    </LoginLayout>
  )
}
1 Like

@Chris thanks for sharing the code! That makes it super clear. Would love to hear from @dthyresson and others if they consider this good practice.

Chris, with regards to redirecting to the pickTeamPage. How is that handled when a user has a team picked already? or is this more like team selector page? so if it’s teams are empty they can create their first team from this screen, but if they have a team, they can select it and proceed to the application?

So does that mean you have the team slug or name, in all your URLs? such as /{teamSlug}/dashboard/ or do you use cookie and store the current selected team?

Edit

Here is how I implemented so far. Wanted to leave a gist so it’s useful for others.

@esteban_url did you ever set up auth templates that you mentioned before? I just had the same idea and thought it might be useful for people to just start from a RW app with auth already there.

@thedavid would this be something that we could have as separate RW repos for each provider? Perhaps it comes with the provider already setup plus login/signup button components?

I’d be happy to work on something if y’all think it would be helpful!

1 Like

hey @morganmspencer I recently picked that up again, i’m very close to wrapping up the functionalities, would love to hear your ideas and collaborate if you are up for it.

so far I’ve set up user management with netlify. theres a users CRUD. all without a DB at all.

you can check it out here

also it’s deployed here :
https://trailhead-rw.netlify.app/

email: test@admin.com
pasword: test

I’ve stayed away from styling as much as possible so so to focus on getting it to work.

Once I have everything woking maybe there is a way to have it as setup command(?). so that it can be added on top of a new app, that would be ideal.

something like this:

yarn redwood setup auth netlify-user-managemet

otherwise I would just transform this into a template repo.

im open to changing the approach too. I just want to do this once and then keep re-using it on every app :sweat_smile:

1 Like

I was wondering if it would be templated as you mentioned. I feel like it would be best utilized when starting a new project. Maybe there could be flags added to the create-redwood-app command so that it is added on install.

Eg. yarn create redwood-app my-redwood-app --auth-netlify

Actually, it would be cool in general to add some flags to CRWA command that you could chain together. Such as adding Netlify auth and Tailwind with flags. Pretty much adding any of the RW setup commands as flags.

Eg. yarn create redwood-app my-redwood-app --auth-netlify --tailwind --deploy-netlify

1 Like