In part 1, we have an introduction to RBAC and why it's the best design for authorisation in most projects. We saw how authorisation is not one-size-fits-all and there are actually several different scenarios we have to cover, and that not all projects have all scenarios. In this article we'll cover the first 3 scenarios:
Scenario 1: Application-wide permissions
Scenario 2: Permissions vary by group
Scenario 3: Permissions vary by group, with hierarchies
In part 1 we also mentioned the can()
function but didn't talk about how to write it. We'll be covering that here.
Scenario 1: Application-wide permissions
This is the "hello world" of authorisation, and unfortunately the only scenario most tutorials cover. In multi-tenant systems it's often also one of the least-used, generally reserved for admin-level permissions such as cancelling orders or banning users. However it's also the simplest so it's a good starting point for understanding the other scenarios.
First off, we need a set of site roles. Because it's a finite set, it's best to use an enum
. As with any TypeScript enum, it's important to define the values too, otherwise you'll get the mappings such as ADMIN
= 0
which makes things much harder to audit and debug. If you're writing in JavaScript use a plain object instead, since this is what the enum
gets transpiled to anyway.
enum SiteRole = {
ADMIN = "site.admin",
MODERATOR = "site.moderator",
MEMBER = "site.member"
}
The rest of the application uses permissions however, so let's define them now:
enum SitePermission = {
ENABLE_USER = "site.enableUser",
DISABLE_USER = "site.disableUser",
VIEW_DOCUMENTS = "site.viewDocuments"
}
Now we create a map of roles to permissions. While you can define this mapping using a database table, unless you really need the permissions map to be user-definable it's better to hard-code it:
export const SitePermissionsByRole: Record<SiteRole, SitePermission[]> = {
// Admins can do anything
[SiteRole.ADMIN]: Object.values(SitePermission),
[SiteRole.MODERATOR]: [SitePermission.ENABLE_USER, SitePermission.DISABLE_USER, SitePermission.VIEW_DOCUMENTS],
[SiteRole.MEMBER]: [SitePermission.VIEW_DOCUMENTS]
}
We need to store the user's site roles somewhere. Depending on how your application is architected this could be a field in the user
table or it might be a separate table. For simplicity we'll just assume it's a field on the user
table here.
id | name | site_roles |
gary | Gary | [SiteRole.ADMIN] |
max | Max | [SiteRole.MODERATOR] |
In part 1, we had a getPermissions()
function. Here's what it might look like:
interface UserPermissions {
user: User
site: SitePermission[]
}
function getPermissions(user: User): UserPermissions {
return {
user
site: getSitePermissions(user)
}
}
// More likely this will be in a helper class, but for simplicity we've
// made it a function here
function getSitePermissions(user: User): SitePermission[] {
// uniq() ensures that we don't get duplicates in the array, since multiple
// roles may confer the same permission
//
// We use flatMap() instead of map() because we get an array of permissions
// for each role, and since we have an array of roles, we would otherwise
// have an array of arrays. flatMap() concatenates all the inner arrays
// together so that we get a simple array of permissions.
return uniq(user.siteRoles.flatMap((role) => SitePermissionsByRole[role]))
}
So now we can get the array of site permissions by calling getPermissions(user)
. But the raw permissions are a little unwieldy to use, so we wrap them in a can()
function:
function getPermissions(user: User): UserPermissions {
return {
user
site: getSitePermissions(user)
}
}
function getCan(permissions: UserPermissions): (permission: SitePermission) => boolean {
return (permission) => {
if (Object.values(SitePermission).includes(permission)) {
return hasSitePermission(permissions, permission)
} else {
// We don't recognise this permission - in a real implementation
// you'd probably want to log an error as well
return false
}
}
}
function hasSitePermission(permissions: UserPermission, permission: SitePermission): boolean {
return permissions.site.includes(permission)
}
We now obtain our can()
function, and use it to decide whether the user is allowed to view documents or not:
// This is unchanged from the example in part 1
const apolloServer = new ApolloServer({
// Build the request context object that's passed to all of the
// resolver functions
context: async ({ req }) => {
// req.user was added by passport or some other code
const user = req.user
const permissions = await authService.getPermissions(user)
return {
user,
can: authService.getCan(permissions)
}
}
})
class MyResolver {
Query: {
async getDocuments(_, args, context) {
if (!context.can(SitePermission.VIEW_DOCUMENTS)) {
throw new ForbiddenError("I can't let you do that, Dave")
}
return [
{ id: "doc1", title: "2001 A Space Odyssey" }
]
}
}
}
We've split generating the permissions
and the can()
function into two steps because quite often we also need the raw permissions for other purposes, such as generating database filters (scenario 5) which we cover in part 3.
Scenario 2: Permissions vary by group
This is a common scenario in multi-tenant systems, where users are grouped into teams, projects or teams and they have a set of roles for each group. For example, Sally might have the roles of ADMIN
of "Team 1" and AUDITOR
and SUBMITTER
in "Team 2".
Let's say this is our set of team roles:
enum TeamRole = {
ADMIN = "admin",
AUDITOR = "auditor",
SUBMITTER = "submitter",
GUEST = "guest"
}
We need a database table for the user roles; let's call it team_role
:
user_id | team_id | role |
sallysmith | team1 | admin |
sallysmith | team2 | auditor |
sallysmith | team2 | submitter |
When Sally logs in, we retrieve her roles from the database and return them as an object of arrays, keyed by the team ID:
async function getTeamRoles(userId) {
const roles = await entityManager.findBy({ userId })
const rolesByTeamId = {}
for (const teamRole of roles) {
if (!rolesByTeamId[teamRole.teamId]) {
rolesByTeamId[teamRole.teamId] = []
}
rolesByTeamId[teamRole.teamId].push(teamRole.role)
}
return rolesByTeamId
}
So given the rows above, await getTeamRoles("sallysmith")
would return
{
team1: ["admin"],
team2: ["auditor", "submitter"]
}
Let's define some team permissions:
enum TeamPermission {
VIEW_SETTINGS = "team.viewSettings",
UPDATE_SETTINGS = "team.updateSettings",
VIEW_DOCUMENTS = "team.viewDocuments",
VIEW_FULL_DOCUMENT = "team.viewFullDocument",
VIEW_DOCUMENT_SUMMARY = "team.viewDocumentSummary",
CREATE_DOCUMENT = "team.createDocument",
UPDATE_DOCUMENT = "team.updateDocument"
}
Note how the permissions are fairly fine-grained. Each conceptual action in your application should generally have its own permission. Next, we map the roles to permissions, the same as we did for the site roles:
const TEAM_ROLE_PERMISSIONS = {
// Admins get all permissions
[TeamRole.ADMIN]: TeamPermission.values(),
[TeamRole.GUEST]: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
[TeamRole.AUDITOR]: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY,
TeamPermission.VIEW_FULL_DOCUMENT
],
[TeamRole.SUBMITTER]: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY,
TeamPermission.VIEW_FULL_DOCUMENT
TeamPermission.CREATE_DOCUMENT,
TeamPermission.UPDATE_DOCUMENT
]
}
If you're using TypeScript, change the first line to
const TEAM_ROLE_PERMISSIONS: Record<TeamRole, readonly TeamPermission[]> = {
TypeScript will then warn you if you create a new role but forget to define what permissions it grants.
Now that we have our permissions map, we can map Sally's roles:
function getTeamPermissions(teamRoles) {
const permissionsByTeamId = {}
for (const [teamId, roles] of Object.entries(teamRoles)) {
const permissions = roles.flatMap(role => TEAM_ROLE_PERMISSIONS[role])
permissionsByTeamId[teamId] = unique(permissions)
}
return permissionsByTeamId
}
Putting it all together, the getPermissions(userId)
function might look something like this:
async function getUserPermissions(userId) {
const teamRoles = await getTeamRoles(userId)
return {
userId: userId,
teams: getTeamPermissions(teamRoles)
}
}
This function returns a promise to an object that looks like this:
{
userId: "user1",
teams: {
team1: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
team2: [
TeamPermission.VIEW_DOCUMENT_SUMMARY
]
}
}
We include the userId because it's very convenient to have that available, in particular for scenario 3. We store the team permissions under the teams
key just like the site permissions are stored under the site
key - just add the code from each scenario that your application needs.
And our can() function. We're including the code from scenario 1 here, but feel free to delete it if your application doesn't need any site permissions:
function getCan(permissions: UserPermissions): (permission: SitePermission | TeamPermission, teamId?: string) => boolean {
return (permission) => {
// This is why we make the enum values globally unique, because we
// have a single function that handles them all.
if (Object.values(SitePermission).includes(permission)) {
return hasSitePermission(permissions, permission)
} else if (Object.values(TeamPermission).includes(permission) {
return hasTeamPermission(permissions, permission, teamId)
} else {
// We don't recognise this permission - in a real implementation
// you'd probably want to log an error as well
return false
}
}
}
function hasSitePermission(permissions: UserPermissions, permission: SitePermission): boolean {
return permissions.site.includes(permission)
}
function hasTeamPermission(permissions: UserPermission, permission: TeamPermission, teamId?: string) {
return teamId && (permissions.teams[teamId] ?? []).includes(permission)
}
We'd then extend the resolver in the original example to understand teams:
class MyResolver {
Query: {
async getDocuments(_, { teamId }, context) {
if (!context.can(TeamPermission.VIEW_DOCUMENTS, teamId)) {
throw new ForbiddenError("I can't let you do that, Dave")
}
return [{ id: "book1", title: "2001 A Space Odyssey" })
}
}
}
You'll notice that the returned array of documents is still hard-coded and is the same for every team. How to safely list objects is covered in part 3 (scenario 5).
Scenario 3: Permissions vary by group, with hierarchies
In some projects, user groups are hierarchical. For example, teams in your application may belong to an organisation. While users can only access documents belonging to the teams they are a member of, there are likely organisation-wide objects such as templates they need access to, and certain organisation-level roles such as auditor and admin may require access to team-level objects.
Scenario 3A: Providing permissions to upstream objects
This is a very common scenario. Users in an organisation's teams often require basic access to objects owned by the organisation, for example templates or details about the organisation such as its name and settings.
To handle this scenario, we can create an organisation role called OrgRole.MEMBER
. We have two options:
Explicitly add it to all users that join the organisation, that is we add
OrgRole.MEMBER
to the user's roles in our database during the signup process. Use this when the relationship between users and their organisations is "strong", for example they are employees of the organisation. The disadvantage of this approach is that you have to remember to add and remove theOrgRole.MEMBER
roles when users change teams.Implicitly add this role when calculating permissions. The
OrgRole.MEMBER
role is not stored in the database and is not visible to any other parts of the application. Instead we look up all the teams the user has a role in to determine which organisations the user should haveOrgRole.MEMBER
in. Use this when users are likely to move between teams of different organisations and do not conceptually belong to the team's organisation, for example the organisation is primarily used for billing purposes. The disadvantage of this approach is that it involves "magic" - someone looking at your code will not immediately understand how users are getting the permissions conferred byOrgRole.MEMBER
.
If you're explicitly adding and removing OrgRole.MEMBER
when users change teams, you can skip to the next scenario. The code below is for when you're implicitly providing this role based on the ownership of the teams the user is in:
async function getUserPermissions(userId) {
const teamRoles = await getTeamRoles(userId)
const orgRoles = await getOrgRoles(userId, teamRoles)
const teamPermissions = getTeamPermissions(teamRoles)
const orgPermissions = getOrgPermissions(orgRoles)
return {
organizations: orgPermissions,
teams: teamPermissions
}
}
// Returns { org1: [ OrgPermission.VIEW ], ... }
async function getOrgRoles(userId, teamRoles) {
const orgRoles = await getExplicitOrgRoles(userId)
const userTeamIds = Object.keys(teamRoles)
const userTeams = await entityManager.findBy(Team, { id: { $in: userTeamIds } })
for (const team of userTeams) {
// We could end up with multiple instances of OrgRole.MEMBER
// for the same role, but this object is only used internally
// and the duplicates won't affect the permissions that are
// returned by getOrgPermissions()
orgRoles[team.organizationId] = [
...orgRoles[team.organizationId] ?? [],
OrgRole.MEMBER
]
}
return orgRoles
}
function getExplicitOrgRoles(userId) {
// The same as getTeamRoles() in scenario 2, just for
// organisations
}
function getOrgPermissions(orgRoles) {
// The same as getTeamPermissions() in scenario 2, just for
// organisations
}
When we call getUserPermissions()
back now, we receive an object like:
{
userId: "user1",
organizations: {
organization1: [
OrgPermissions.VIEW_INFO
]
},
teams: {
team1: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
team2: [
TeamPermission.VIEW_DOCUMENT_SUMMARY
]
}
}
Our can() function now looks like this:
function getCan(permissions: UserPermissions): (permission: TeamPermission | OrgPermission, id?: string) => boolean {
return (permission) => {
// To keep this short we have not included the site permissions code from
// scenario 1
if (Object.values(OrgPermission).includes(permission)) {
return hasOrgPermission(permissions, permission)
} else if (Object.values(TeamPermission).includes(permission) {
return hasTeamPermission(permissions, permission, teamId)
} else {
return false
}
}
}
function hasOrgPermission(permissions: UserPermission, permission: OrgPermission, orgId?: string) {
return orgId && (permissions.organizations[orgId] ?? []).includes(permission)
}
function hasTeamPermission(permissions: UserPermission, permission: TeamPermission, teamId?: string) {
return teamId && (permissions.teams[teamId] ?? []).includes(permission)
}
We could then create a resolver that returns an organisation's details like this:
class OrgResolver {
Query: {
async getOrganizationInfo(_, { organizationId }, ctx) {
if (!can(OrgPermission.VIEW_INFO, organizationId)) {
throw new ForbiddenError("You do not have permission to view this organisation")
}
return this.entityManager.findOneBy(Organization, { id: organizationId })
}
}
}
Our resolver doesn't need any complex code to work out whether the user should be allowed to view this organisation based on team memberships or other factors. It can simply see if the user has the VIEW_INFO
permission for the requested organisation.
Scenario 3B: Providing permissions to downstream objects
This scenario is for when a role at an upper level (eg organisation) should provide access to lower-level resources (eg team). For example, an organisation admin might have the ability to see and edit documents in the organisation's teams.
To implement this, we need to first define the permissions provided by the roles at each level:
// This is the same as in scenario 1, just renamed
const TEAM_PERMISSIONS_BY_TEAM_ROLE = {
[TeamRole.ADMIN]: TeamPermission.values(),
[TeamRole.GUEST]: [
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
// ...
}
// This maps organisation roles to permissions in all of
// organisation's teams
const TEAM_PERMISSIONS_BY_ORG_ROLE = {
// A organisation admin can do anything a team admin can do
[OrgRole.ADMIN]: TEAM_PERMISSIONS_BY_TEAM_ROLE[TeamRole.ADMIN]
}
And the TypeScript version:
// Use this if you want to define team permissions for every org role
const TEAM_PERMISSIONS_BY_ORG_ROLE: Record<OrgRole, readonly TeamPermission[]> = {
// Or use this if only some org roles confer team permissions
const TEAM_PERMISSIONS_BY_ORG_ROLE: Partial<Record<OrgRole, readonly TeamPermission[]>> = {
We then modify our getPermissions()
function from scenario 2A to fetch both the user's org and team roles:
async function getUserPermissions(userId) {
const teamRoles = await getTeamRoles(userId)
const orgRoles = await getOrgRoles(userId, teamRoles)
const teamPermissionsFromTeamRoles = getTeamPermissions(teamRoles)
const teamPermissionsFromOrgRoles = await getTeamPermissionsFromOrgRoles(orgRoles)
const teamPermissions = combineTeamPermissions(teamPermissionsFromTeamRoles, teamPermissionsFromOrgRoles)
const orgPermissions = getOrgPermissions(orgRoles)
return {
organizations: orgPermissions,
teams: teamPermissions
}
}
async function getTeamPermissionsFromOrgRoles(orgRoles) {
const orgIds = Object.keys(orgRoles)
const orgTeams = await entityManager.findBy({ organizationId: { $in: orgIds } })
const permissionsByTeamId = {}
for (const team of orgTeams) {
const teamOrgRoles = orgRoles[team.organizationId] ?? []
const permissions = teamOrgRoles.flatMap(
orgRole => TEAM_PERMISSIONS_BY_ORG_ROLE[orgRole])
permissionsByTeamId[team.id] = unique(permissions)
}
return permissionsByTeamId
}
function combinePermissions(permissionsFromTeamRoles, permissionsFromOrgRoles) {
const teamPermissions = { ...permissionsFromTeamRoles }
// Combine the direct team permissions with the ones provided
// by the user's organisation roles
for (const [teamId, permissions] of Object.entries(permissionsFromOrgRoles)) {
teamPermissions[teamId] = unique([
...teamPermissions[teamId] ?? [],
...permissions
])
}
return teamPermissions
}
This approach works really well with scenario 3A - in many cases users should be able to see the basic details or list all of the teams in their organisations. By having the implicit OrgRole.MEMBER
role confer TeamPermission.VIEW_INFO
, the user will be able to view the basic information of all those teams without writing any special resolver code. The resulting permissions object might looks something like this:
{
organizations: {
organization1: [
OrgPermissions.VIEW_INFO
]
},
teams: {
team1: [
TeamPermission.VIEW_INFO,
TeamPermission.LIST_DOCUMENTS,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
team2: [
TeamPermission.VIEW_INFO,
TeamPermission.VIEW_DOCUMENT_SUMMARY
],
team3: [
TeamPermission.VIEW_INFO
]
}
}
The code that receives and uses this object doesn't need to have any understanding of the relationship between organisations and teams. It doesn't know why this user has the VIEW_INFO
permission in team3
, only that they do. As an example of this - we did not even need to modify the can()
function from scenario 3A. This simplicity is really beneficial in maintaining a permissions system that is understandable and bug-free.
Now it's time for part 3, where we cover object-level permissions and database retrieval. For a complete, working app, check out the example app on Github.