Skip to content
SaaS4Builders
Teams

Role Management

Built-in roles and permissions, custom tenant-scoped roles, role assignment rules, and the Spatie Permissions integration for team access control.

SaaS4Builders uses Spatie Permission with its teams feature to provide tenant-scoped role-based access control. The system ships with three built-in roles and supports custom roles that tenants can create to match their organizational structure.


Built-in Roles

Three roles are seeded by default and available to all tenants:

Owner — The user who created the tenant. Has full control over every aspect of the organization: team management, billing, settings, and tenant deletion. There is exactly one owner per tenant. Ownership can only be transferred via the team.transfer_ownership permission.

Admin — A trusted team member with broad management capabilities. Admins can invite and remove members, manage billing and settings, and create custom roles. They cannot delete the tenant or transfer ownership.

Member — A standard team member with minimal permissions. Members can view billing information but cannot make changes to the team, billing, or settings. This is the default role assigned when no other role is specified.


Permissions Matrix

The seeder (backend/database/seeders/RolesAndPermissionsSeeder.php) defines 10 tenant-scoped permissions under the web guard:

PermissionDescriptionOwnerAdminMember
tenant.updateUpdate tenant name and settings
tenant.deleteDelete the tenant permanently
team.inviteSend team invitations
team.removeRemove team members
team.manageGeneral team management
team.transfer_ownershipTransfer ownership to another member
billing.viewView subscription, invoices, usage
billing.manageCreate/cancel subscriptions, manage payment methods
settings.viewView tenant settings
roles.manageCreate, update, delete custom roles

Here is the relevant portion of the seeder that assigns permissions to built-in roles:

backend/database/seeders/RolesAndPermissionsSeeder.php
$ownerRole = Role::findOrCreate('owner', 'web');
$ownerRole->givePermissionTo([
    'tenant.update', 'tenant.delete',
    'team.invite', 'team.remove', 'team.manage', 'team.transfer_ownership',
    'billing.view', 'billing.manage',
    'settings.view', 'roles.manage',
]);

$adminRole = Role::findOrCreate('admin', 'web');
$adminRole->givePermissionTo([
    'tenant.update',
    'team.invite', 'team.remove', 'team.manage',
    'billing.view', 'billing.manage',
    'settings.view', 'roles.manage',
]);

$memberRole = Role::findOrCreate('member', 'web');
$memberRole->givePermissionTo([
    'billing.view',
]);

Custom roles can be assigned any combination of these 10 permissions. When you create a custom role, you select from the same permission pool.


Role Assignment Rules

The ChangeMemberRole action enforces strict business rules when changing a member's role:

  1. Cannot change your own role — prevents users from escalating their own permissions or accidentally locking themselves out. Returns CANNOT_CHANGE_OWN_ROLE.
  2. Cannot change the owner's role — the owner role is permanent. To change who owns the tenant, use ownership transfer. Returns CANNOT_CHANGE_OWNER_ROLE.
  3. Cannot assign the owner role — the "owner" role is never available in the role assignment dropdown. Only ownership transfer can move the owner role. Returns error if attempted.
  4. Requires roles.manage permission — only users with this permission (owners and admins by default) can change other members' roles. Returns INSUFFICIENT_PERMISSIONS.
Ownership transfer is a distinct operation from role changes, gated by the team.transfer_ownership permission. You cannot promote a member to "owner" through the role change endpoint — only through a dedicated ownership transfer flow.

Member Removal Rules

The RemoveMember action has similar restrictions:

  • Cannot remove yourself — use a separate "leave team" flow if needed. Returns CANNOT_REMOVE_SELF.
  • Cannot remove the owner — the owner can only leave by transferring ownership first. Returns CANNOT_REMOVE_OWNER.
  • Requires team.remove permission — only owners and admins can remove members.
  • Hierarchy enforcement — a non-owner cannot remove someone with the same role level. Only the owner can remove an admin.

When a member is removed:

  • Their Spatie roles are cleared within the tenant context
  • They are detached from the tenant via the pivot table
  • If the user has no remaining tenant memberships and is not a platform admin, the user account is deleted (orphan cleanup)
  • A TeamMemberRemoved event is dispatched, triggering seat quantity sync

Custom Tenant-Scoped Roles

Beyond the three built-in roles, tenants can create custom roles with specific permission sets to match their organizational needs.

Creating Custom Roles

Create a custom role via POST /api/v1/tenant/{tenantId}/roles:

{
  "name": "billing-manager",
  "permissions": ["billing.view", "billing.manage"]
}

Validation rules:

  • The role name must be unique within the tenant
  • Reserved names (owner, admin, member) cannot be used for custom roles
  • At least one permission must be assigned
  • Only valid web-guard permissions are accepted

Custom roles are stored in the Spatie roles table with the tenant_id set to the creating tenant's ID. This scopes them to a single tenant — other tenants cannot see or use them.

Updating Custom Roles

Update a role's name or permissions via PATCH /api/v1/tenant/{tenantId}/roles/{roleId}:

{
  "name": "finance-manager",
  "permissions": ["billing.view", "billing.manage", "settings.view"]
}

Restrictions:

  • Built-in roles (owner, admin, member) cannot be renamed
  • Reserved names cannot be used as the new name
  • Permission changes take effect immediately for all users with that role (Spatie's cache is cleared)

Deleting Custom Roles

Delete a custom role via DELETE /api/v1/tenant/{tenantId}/roles/{roleId}.

Behavior:

  • Built-in roles cannot be deleted
  • All users currently assigned the deleted role are automatically reassigned to the member role
  • The role record and its permission associations are removed
When you delete a custom role, all affected users are automatically reassigned to the member role. No user is ever left without a role in the tenant.

Spatie Permissions Integration

Team Scoping

The Spatie Permission package is configured with teams => true and team_foreign_key => 'tenant_id'. This means:

  • The roles table has a tenant_id column
  • Built-in roles have tenant_id = NULL (global templates, available to all tenants)
  • Custom roles have tenant_id set (scoped to the creating tenant)
  • The model_has_roles table uses team_id (which maps to tenant_id) to scope role assignments
  • Role queries resolve both global (tenant_id IS NULL) and tenant-specific roles

Role Resolution with ResolvesTeamRole

Actions that need to check a user's role or permissions use the ResolvesTeamRole trait, which wraps all checks in TeamContext::run() for safe scoping:

backend/app/Domain/Team/Support/ResolvesTeamRole.php
trait ResolvesTeamRole
{
    private function getCurrentRole(Tenant $tenant, User $user): ?string
    {
        return TeamContext::run($tenant->id, function () use ($user): ?string {
            $user->load('roles');

            $role = $user->roles->first();

            return $role?->name;
        });
    }

    private function userCan(Tenant $tenant, User $user, string $permission): bool
    {
        return TeamContext::run($tenant->id, function () use ($user, $permission): bool {
            $user->load('roles', 'permissions');

            return $user->hasPermissionTo($permission, 'web');
        });
    }
}

The getCurrentRole() method explicitly reloads the roles relationship within TeamContext to ensure proper Spatie team scoping — this is important because cached role relationships may reference a different tenant context.


API Endpoints

Role Management

All role management endpoints require the roles.manage permission and tenant context middleware (auth:sanctum, tenant.resolve, tenant.member, onboarding.complete).

MethodPathDescription
GET/api/v1/tenant/{tenantId}/rolesList all roles (built-in + custom) with permissions and user counts
GET/api/v1/tenant/{tenantId}/roles/permissionsList all available web-guard permission keys
POST/api/v1/tenant/{tenantId}/rolesCreate a custom role
PATCH/api/v1/tenant/{tenantId}/roles/{roleId}Update a role's name and/or permissions
DELETE/api/v1/tenant/{tenantId}/roles/{roleId}Delete a custom role (reassigns users to "member")

Role response structure:

{
  "id": 4,
  "name": "billing-manager",
  "guard_name": "web",
  "tenant_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
  "is_builtin": false,
  "permissions": ["billing.view", "billing.manage"],
  "users_count": 3,
  "created_at": "2026-03-20T10:00:00.000000Z",
  "updated_at": "2026-03-20T10:00:00.000000Z"
}

Member Role Endpoints

These endpoints manage role assignment for individual team members:

MethodPathDescription
GET/api/v1/tenant/{tenantId}/team/rolesList assignable roles (simplified: id + name)
PATCH/api/v1/tenant/{tenantId}/team/members/{memberId}/roleChange a member's role

Change role request:

{
  "role_id": 3
}

The role_id must reference a valid web-guard role that is either global (tenant_id IS NULL) or belongs to the current tenant. The owner role cannot be assigned through this endpoint.


Frontend Integration

The frontend provides composables and components for role management:

Composables

  • useTenantRoles() — Fetches the list of roles for the current tenant via useAuthenticatedAsyncData, including both built-in and custom roles
  • useTenantRoleActions() — Mutation composable for createRole(), updateRole(), deleteRole(), and fetchPermissions()
  • useTeamMembers() — Provides role-related computed properties (canChangeRoles, isManager) and the enrichMember() function that adds canRoleBeChanged flags

Components

  • TeamRoleSelector — A dropdown (USelect) for selecting a role, with role names translated via useRoleLabel()
  • TeamMemberCard — Displays the role badge (read-only for owners, dropdown selector for changeable roles)

Types

The frontend defines rich types for role management:

frontend/features/core/team/types.ts
type TenantRole = {
  id: number
  name: string
  guardName: string
  tenantId: string | null
  isBuiltin: boolean
  permissions: string[]
  usersCount: number
  createdAt: string
  updatedAt: string
}

type CreateTenantRoleInput = {
  name: string
  permissions: string[]
}

type UpdateTenantRoleInput = {
  name?: string
  permissions?: string[]
}

The ManageableRole type ('admin' | 'member') restricts the roles available for assignment — owner is never an option in the UI.