Roles & Permissions
The StarterKit includes a database-backed authorization system built on Laravel's native Gate and Policy infrastructure. No external packages (like Spatie) are used.
Overview
Roles and permissions are stored in the database. Roles are assigned to users (or other models) with an optional scope, binding the role to a specific organization, workspace, or team. Permissions are attached to roles. Authorization checks flow through Gate::before, which evaluates platform roles, org-level escalation, and scoped permissions.
Two layers of roles exist:
| Layer | Purpose | Examples |
|---|---|---|
| Platform | Nubos backend administration | nubos:super-admin, nubos:support, nubos:billing |
| Organization | App-level access within an org context | owner, admin, member, team:lead, team:member |
Platform roles are always present. Organization roles depend on the structure chosen during nubos:init.
Database Schema
Four tables power the system:
roles -- Role definitions with scope.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
name | string | Role name, unique per scope |
scope | string | platform, tenant, workspace, team, etc. |
is_system | boolean | System roles cannot be deleted |
permissions -- Permission definitions.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
name | string | Permission key, e.g. members.invite |
group | string (nullable) | Logical grouping for UI, e.g. members |
scope | string | Same scope system as roles |
is_system | boolean | System permissions cannot be deleted |
role_permission -- Which permissions belong to which role.
role_assignments -- Polymorphic pivot that assigns roles to any model with an optional scope.
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
role_id | uuid | FK to roles |
model_type | string | Polymorphic type (e.g. App\Models\User) |
model_id | uuid | Polymorphic ID |
scope_type | string (nullable) | Scope model type (e.g. App\Models\Tenant) |
scope_id | uuid (nullable) | Scope model ID |
Models
Role
use App\Models\Role;
$role = Role::query()->where('name', 'admin')->where('scope', 'tenant')->first();
// Access permissions
$role->permissions; // Collection<Permission>Permission
use App\Models\Permission;
$permission = Permission::query()->where('name', 'members.invite')->first();
// Access roles that have this permission
$permission->roles; // Collection<Role>RoleAssignment
A MorphPivot model with HasUuids that generates UUIDs for the role_assignments table. Used internally by the HasRoles trait.
The HasRoles Trait
The HasRoles trait is applied to the User model by default. It can be applied to any model (e.g. Team) that needs role assignments.
Assigning and Removing Roles
// Platform role (no scope)
$user->assignRole($superAdminRole);
// Scoped role (bound to a specific organization)
$user->assignRole($adminRole, $tenant);
// Remove
$user->removeRole($adminRole, $tenant);Checking Roles and Permissions
// Check role
$user->hasRole('owner', $tenant); // true/false
// Check permission (resolves through assigned roles)
$user->hasPermission('members.invite', $tenant); // true/false
// Get all roles for a scope
$user->rolesFor($tenant); // Collection<Role>
// Get all permissions for a scope (deduplicated)
$user->permissionsFor($tenant); // Collection<Permission>Authorization Flow
Authorization is handled in AppServiceProvider via Gate::before:
Request
-> Middleware sets current org context
-> Controller calls $this->authorize('ability', $model)
-> Gate::before fires:
1. nubos:super-admin? -> grant all
2. owner/admin in any active scope? -> grant all
3. User has permission in any active scope? -> grant
4. null -> fall through to PoliciesThe ScopeResolver determines which scopes are currently active. It is generated by nubos:init based on your organization structure:
| Init Choice | Active Scopes |
|---|---|
| No init | [] (only platform roles work) |
| Team | [current_team] |
| Workspace | [current_workspace] |
| Workspace + Teams | [current_workspace, current_team] |
| Tenant | [current_tenant] |
| Tenant + Teams | [current_tenant, current_team] |
| Tenant + Workspaces | [current_tenant, current_workspace] |
| Tenant + Workspaces + Teams | [current_tenant, current_workspace, current_team] |
Permissions are checked across all active scopes. A user with members.invite on the tenant level can also invite in any workspace or team within that tenant.
Platform Roles
Seeded by RolesAndPermissionsSeeder (always present, runs before nubos:init seeders):
| Role | Permissions |
|---|---|
nubos:super-admin | All (via Gate::before, no explicit permissions needed) |
nubos:support | tenants.view, users.view, users.impersonate |
nubos:billing | billing.view, billing.update, subscriptions.manage |
Organization Roles
Seeded by OrgRolesAndPermissionsSeeder (generated by nubos:init):
Base Roles (always generated)
| Role | Scope | Key Permissions |
|---|---|---|
owner | org type | All org permissions (via Gate::before escalation) |
admin | org type | org.settings.view, members.*, teams.* (if sub-teams) |
member | org type | members.view, teams.view (if sub-teams) |
Team Roles (only with sub-teams)
| Role | Scope | Key Permissions |
|---|---|---|
team:lead | team | team.settings.*, team.members.* |
team:member | team | team.members.view |
Org-level owner and admin roles automatically have access to all team-level operations via the escalation in Gate::before.
Policies
Policies live in domain folders under App\Policies:
App\Policies\Authorization\RolePolicy-- Protects role management. System roles cannot be updated or deleted.App\Policies\Authorization\PermissionPolicy-- Permissions are read-only in the Starter Kit. Create, update, and delete always returnfalse.
Both are registered on their models via the #[UsePolicy] attribute.
Adding Your Own Permissions
To add permissions for your application's domain, create them in a seeder:
use App\Models\Permission;
use App\Models\Role;
// Create the permission
Permission::query()->firstOrCreate(
['name' => 'projects.create', 'scope' => 'tenant'],
['group' => 'projects', 'is_system' => false],
);
// Assign to existing roles
$adminRole = Role::query()->where('name', 'admin')->where('scope', 'tenant')->first();
$adminRole->permissions()->syncWithoutDetaching(
Permission::query()->where('name', 'projects.create')->pluck('id'),
);Then check in your controllers:
class ProjectController extends Controller
{
public function store(StoreProjectRequest $request): RedirectResponse
{
$this->authorize('projects.create');
// ...
}
}The Gate::before in AppServiceProvider will resolve the permission against the user's roles in the current scope.
Seeding
Two seeders handle roles and permissions:
| Seeder | Location | Content |
|---|---|---|
RolesAndPermissionsSeeder | database/seeders/ (always present) | Platform roles and permissions |
OrgRolesAndPermissionsSeeder | database/seeders/ (generated by nubos:init) | Org-specific roles and permissions |
Both are idempotent (firstOrCreate, syncWithoutDetaching) and safe to run multiple times.
The DatabaseSeeder assigns the nubos:super-admin role to the default test user. The generated NubosSeeder assigns org roles to the demo users.
