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