Team permissions

This guide covers how to use the permissions package in multi-tenant applications where users belong to teams. You will learn how to:

  • Create a TeamMember model that carries team-scoped roles
  • Authorize actions against the team member instead of the user
  • Combine team permissions with token scoping

Overview

In many applications, a user's permissions depend on which team they are acting in. A user might be an admin in one team and a viewer in another. The same permission definitions apply everywhere, but the roles are assigned per team membership rather than globally on the user.

The permissions package handles this through entity composition. Instead of assigning roles directly to the User model, you create a TeamMember model that represents a user's membership in a specific team. This model gets the withRoles and optionally withPermissions mixins. When authorizing, you load the team member for the current request and pass it to Bouncer as the entity.

Setup

You need a team_members table to represent membership, a team_member_roles pivot table to link members to roles, and a TeamMember model with the withRoles mixin applied.

  1. Create the migrations

    Run the following commands to generate migration files for the team members table and the pivot table.

    node ace make:migration team_members
    node ace make:migration team_member_roles
  2. Define the team_members table

    Open the generated team_members migration and define the table schema. The unique constraint on team_id and user_id ensures a user can only be a member of the same team once.

    database/migrations/xxxx_create_team_members.ts
    import { BaseSchema } from '@adonisjs/lucid/schema'
    
    export default class extends BaseSchema {
      protected tableName = 'team_members'
    
      async up() {
        this.schema.createTable(this.tableName, (table) => {
          table.increments('id')
          table.integer('team_id').notNullable().unsigned()
          table.integer('user_id').notNullable().unsigned()
          table.unique(['team_id', 'user_id'])
          table.timestamp('created_at')
          table.timestamp('updated_at')
        })
      }
    
      async down() {
        this.schema.dropTable(this.tableName)
      }
    }
  3. Define the team_member_roles pivot table

    Open the generated team_member_roles migration and define the pivot table schema. This table links team members to their roles within a specific team.

    database/migrations/xxxx_create_team_member_roles.ts
    import { BaseSchema } from '@adonisjs/lucid/schema'
    
    export default class extends BaseSchema {
      protected tableName = 'team_member_roles'
    
      async up() {
        this.schema.createTable(this.tableName, (table) => {
          table.increments('id')
          table.integer('team_member_id').notNullable().unsigned()
          table.integer('role_id').notNullable().unsigned()
          table.unique(['team_member_id', 'role_id'])
        })
      }
    
      async down() {
        this.schema.dropTable(this.tableName)
      }
    }
  4. Run the migrations

    node ace migration:run
  5. Create the TeamMember model

    Apply withRoles to the TeamMember model. This gives each team member their own set of role assignments, independent of other teams.

    app/models/team_member.ts
    import { TeamMemberSchema } from '#database/schema'
    import { compose } from '@adonisjs/core/helpers'
    import { withRoles } from '@adonisplus/permissions'
    import Role from '#models/role'
    
    export default class TeamMember extends compose(
      TeamMemberSchema,
      withRoles({
        roleModel: () => Role,
        pivotTable: 'team_member_roles',
        pivotForeignKey: 'team_member_id',
      })
    ) {}

    The Role model is shared across all teams. An "editor" role means the same thing everywhere. What changes is which team members have that role.

Assigning roles to team members

When a user joins a team, create a TeamMember record and assign roles to it.

import TeamMember from '#models/team_member'
import Role from '#models/role'
import { permissions } from '#start/permissions'

// Add user to team
const member = await TeamMember.create({
  teamId: team.id,
  userId: user.id,
})

// Assign roles
const editor = await Role.findByOrFail('name', 'editor')
await member.assignRole(editor)

The same user can have different roles in different teams.

// User is an editor in Team A
const memberA = await TeamMember.create({ teamId: teamA.id, userId: user.id })
await memberA.assignRole(editorRole)

// User is an admin in Team B
const memberB = await TeamMember.create({ teamId: teamB.id, userId: user.id })
await memberB.assignRole(adminRole)

Authorizing in controllers

Load the team member for the current request and pass it to Bouncer as the entity. The team member's getPermissions() method returns permissions from its assigned roles, which the generated abilities use for authorization.

app/controllers/products_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { Bouncer } from '@adonisjs/bouncer'
import { permissions } from '#start/permissions'
import TeamMember from '#models/team_member'

const permissionAbilities = permissions.abilities()

export default class ProductsController {
  async store({ auth, params, response }: HttpContext) {
    const user = auth.getUserOrFail()

    const member = await TeamMember.query()
      .where('teamId', params.teamId)
      .where('userId', user.id)
      .firstOrFail()

    // Create a bouncer instance for this team member
    const bouncer = new Bouncer(member, permissionAbilities)
    await bouncer.authorize('product.create')

    // Team member has permission, proceed
  }
}

The key difference from global authorization is that you create a new Bouncer instance with the team member as the entity, rather than using ctx.bouncer (which is bound to the authenticated user).

Token scoping with teams

When using API tokens, pass the token abilities as the second argument to narrow the team member's permissions to what the token allows.

app/controllers/products_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { Bouncer } from '@adonisjs/bouncer'
import { permissions } from '#start/permissions'
import TeamMember from '#models/team_member'

const permissionAbilities = permissions.abilities()

export default class ProductsController {
  async store({ auth, params }: HttpContext) {
    const user = auth.getUserOrFail()
    const tokenAbilities = user.currentAccessToken?.abilities

    const member = await TeamMember.query()
      .where('teamId', params.teamId)
      .where('userId', user.id)
      .firstOrFail()

    const bouncer = new Bouncer(member, permissionAbilities)
    await bouncer.authorize('product.create', tokenAbilities)
  }
}

The effective permissions are the intersection of three sets: what the team member's roles grant, and what the token allows. If the team member has product.create and billing.refund through their roles but the token only allows product.create, the billing.refund check will fail.

Terms & License Agreement