Ready to get started?

Join Adocasts Plus for $8.00/mo, or sign into your account to get access to all of our lessons.

robot mascot smiling

Rolling Our Own Authorization Access Controls

In this lesson, we'll create our own simple authorization access control list. We'll then share this list globally throughout our application by appending it to our HttpContext and sharing it with our Vue application via Inertia.

Published
Jan 31
Duration
6m 55s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Get the Code

Download or explore the source code for this lesson on GitHub

Repository

Ready to get started?

Join Adocasts Plus for $8.00/mo, or sign into your account to get access to all of our lessons.

Join The Discussion! (6 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Commented 1 month ago

    Hi again Tom !

    Sorry I ask a lot of questions 😅

    I was wondering how I could implement a more granular permission system where a user can have different roles at different levels - for example, being an admin at the org level but only a member for a course and an admin for another one within that workspace. First I would have to create another action GetCourseAbilities and create "rules" like we did for GetOrganizationAbilities .

    My problem is to understand how to modify the middleware to get the roleId associated to the course. Since we only get the roleId for the workspace. Should I create a middleware that specifically check the ctx.params to retrieve the course.id and find the user's role like we do in the workspace_middleware ?

    I should have tested it before asking you, but I have analysis paralysis and I'm not sure this is the best and cleaner idea.

    Also I was wondering how we can check more things than just the user's role for an organization's ability. For example, canceling an invite. An admin user can cancel all invites, but a member user cannot remove invites, unless he's the one who made the invite. Tried multiple things here but none of them is working. I think I didn't understand very well how things work 😅

    1

    Please sign in or sign up for free to reply

    1. Commented 1 month ago

      Hi Emilien!

      No worries at all!! 😊

      Yeah, things spiral here quickly depending on your approach. It's a big enough of a topic to be a series of its own, but I'll try to give an overview below.

      Access Determination

      So, the first thing you need to do is figure out how the more granular access will be determined. Will only the creator have permission to edit, update, and delete? Or, maybe the creator can invite others to edit, update, and delete? Essentially, how are the different roles going to be determined at those different levels?

      Creator Only

      If it's just the creator, then that's much easier. You'd want to capture the userId of the user who created the resource. For example, adding a ownerId onto the Course model. Then, you'd handle the logic very similarly to how we allow org members to remove themselves throughout the next two lessons (12.1 & 12.2). By giving priority to allow the action if the ownerId matches the authenticated user's id. When that's the case, you'd want to ignore whatever the role's permission says and allow the action.

      Resource Specific Invitations

      If you want to allow the creator to invite/share with others to collaborate, you'll need a table to track that. This table could look many different ways.

      • It could be a single table per resource, ex: course_user consisting of course_id, user_id, and role_id if there are different levels and not an on/off.

      • A polymorphic table, ex: permission_user consisting of something like entity , entity_id, user_id, and again role_id if needed. Here, entity would be the table/model and entity_id would be the row's id for that table/model. Now, Lucid doesn't support polymorphic relationships but that doesn't mean you couldn't specifically query for what you need. The downside is, that you can't use foreign keys for the entity_id since it'll be dynamic

      • A single table with a nullable column per resource, ex: permission_user consisting of something like course_id, another_id, user_id, and again role_id if needed. This is similar to the polymorphic relationship, but you have a specific column per resource needed. Plus here, is you can keep foreign keys. Downside here, is it can be a lot of columns if a lot of resources need to use it.

      There are many different ways authorization can look beyond these two, but determining what that looks like is the first step.

      Populating Permissions

      When it comes to populating these permissions, again, it's going to depend on just how granular you need to go. For us in this series, things were simple so our approach was simple. The approach covered here was meant mostly for a single set of roles that then trickle down as needed.

      If you're going to be listing courses and each of those courses in the list is going to have varying permissions based on invites/shares, then you might be better off moving the can approach to the row level. For example, allowing something like course.can.edit to determine if the authenticated user can edit the course. This could be done using something like model hooks.

      You could also always reach for a package to help achieve your implementation.

      That was a lot, sorry! Hopefully, that helps at least little though.

      1

      Please sign in or sign up for free to reply

      1. Commented 28 days ago

        I went with the row level with model hooks. Works well, I had to configure the app to make the HttpContext available in the models via the Async Local Storage thing.

        @afterFind()
          static async attachPermissions(workspace: Workspace) {
            const ctx = HttpContext.getOrFail()
            const user = ctx.auth.use('web').user!
        
            const userWorkspaceRole = await db
              .from('task_workspace_users')
              .where('workspace_id', workspace.id)
              .where('user_id', user.id)
              .first()
        
            let roleId
        
            if (userWorkspaceRole) {
              roleId = userWorkspaceRole.role_id
            }
        
            workspace.can = {
              read: roleId === Roles.ADMIN || roleId === Roles.MEMBER,
              edit: roleId === Roles.ADMIN,
              delete: roleId === Roles.ADMIN,
              createBoards: roleId === Roles.ADMIN || roleId === Roles.MEMBER,
              removeMembers: roleId === Roles.ADMIN,
            }
          }
        Copied!

        My only problem is that my tests which are using models with those hooks are not working anymore since the HttpContext is not available outside an HTTP request.

        test('should remove a user from workspace with multiple members', async ({ assert }) => {
            const users = await UserFactory.createMany(2)
            const workspace = await WorkspaceFactory.create()
        
            const board = await BoardFactory.merge({ workspaceId: workspace.id }).create()
            const column = await ColumnFactory.merge({
              boardId: board.id,
              workspaceId: workspace.id,
            }).create()
            await TaskFactory.merge({ columnId: column.id, workspaceId: workspace.id }).createMany(3)
        
            await workspace.related('users').attach(users.map((user) => user.id))
        
            await RemoveWorkspaceUser.handle({
              workspace,
              removeUserId: users[0].id,
            })
        
            await workspace.refresh()
            await workspace.load('users')
        
            assert.equal(workspace.users.length, 1)
            assert.equal(workspace.users[0].id, users[1].id)
        
            const workspaceExists = await Workspace.find(workspace.id)
            assert.isNotNull(workspaceExists)
          })
        Copied!
        1

        Please sign in or sign up for free to reply

        1. Commented 28 days ago

          Yeah, you'll want the HttpContext here to be optional. Then, when you don't have an HttpContext, you'll want to signal to yourself that your permissions haven't been populated; leaving it null there could be a way to do that.

          @afterFind()
          static async attachPermissions(workspace: Workspace) {
            const ctx = HttpContext.get()
          
            if (!ctx) return
          
            // ...
          }
          Copied!

          If the HttpContext isn't optional then your model would also become unusable within commands, jobs, events, and all that fun stuff. In most cases, you shouldn't need permissions on those types of requests. On the off chance you do, that's where you'd want to extract the contents of the hook into a model method, service, or action so you can also use the same logic on an as-needed basis.

          1

          Please sign in or sign up for free to reply

          1. Commented 28 days ago

            Yeah I finally tried to recreate the GetOrganizationAbilities (GetWorkspaceAbilities in my case) action for another ressource. So I created a GetBoardAbilities, then I created a new middleware that checks for the user's permissions for the specific board :

            @inject()
            export default class BoardPermissionsMiddleware {
              async handle(ctx: HttpContext, next: NextFn) {
                const user = ctx.auth.use('web').user!
            
                try {
                  const boardId = ctx.params.boardId
            
                  if (!boardId) {
                    return await next()
                  }
            
                  const board = await Board.findOrFail(boardId)
            
                  const boardUser = await db
                    .from('task_board_users')
                    .where('board_id', boardId)
                    .where('user_id', user.id)
                    .select('role_id as roleId')
                    .first()
            
                  if (!boardUser) {
                    ctx.session.flash('errorBag', "Vous n'êtes pas membre de ce tableau")
                    return ctx.response.redirect().back()
                  }
            
                  const boardRoleId = boardUser.roleId
                  console.log(boardRoleId)
            
                  ctx.can.board = GetBoardAbilities.handle({ roleId: boardRoleId })
            
                  ctx.inertia.share({
                    activeBoard: new BoardDto(board),
                    can: ctx.can,
                  })
                } catch (error) {
                  console.error(error)
                  return ctx.response.redirect().toRoute('boards.index')
                }
            
                return await next()
              }
            }
            Copied!

            This middleware is used just after the WorkspaceMiddleware and if there is no boardId params in the route, it just calls next() so it doesn't do anything on routes unrelated to boards. Then I can access the abilities in the backend via ctx.can.board and in the frontend via inertia shared props can.board.destroy for example. Not sure if it is the best way to do it or the cleanest, but it looks easier to understand for me as I found your way to do ACL pretty easy to understand too.

            1

            Please sign in or sign up for free to reply

            1. Commented 27 days ago

              That's awesome! I'm happy to hear you're making progress with it! I wouldn't worry too much about doing things the best or cleanest way, especially initially! So long as it works for what you need in a way you understand, that's all that matters to keep progress going. You can always refactor down the road if need be. 😊

              1

              Please sign in or sign up for free to reply