Skip to content

Authorization Middleware

Authorization middleware controls access based on OAuth scopes, user privileges, roles, and user types. These run after authentication to enforce fine-grained access control.

AliasClassPurpose
scopeCheckForAnyScopeOAuth scope (any match)
scopesCheckScopesOAuth scope (all required)
privilegePrivilegeLogin user privilege check
advisor_roleCheckAdvisorRoleAdvisor role + type validation
user_typeUserTypeUser type restriction (advisor/client)
canAuthorizePolicy-based authorization
prevent_privileged_impersonationPreventPrivilegedImpersonationBlock actions during impersonation

File: app/Http/Middleware/CheckForAnyScope.php

Extends Laravel Passport’s scope middleware. Validates that the OAuth token has at least one of the specified scopes.

final class CheckForAnyScope extends \Laravel\Passport\Http\Middleware\CheckForAnyScope
{
/**
* Do not check the scope when using cookie authentication
*/
#[\Override]
public function handle($request, $next, ...$scopes): Response
{
if (Str::isEmptyOrNull($request->bearerToken())) {
return $next($request);
}
return parent::handle($request, $next, ...$scopes);
}
}
  • Extends Laravel Passport - inherits base scope checking logic
  • Skips scope check for cookie auth - if no bearer token, proceeds without validation
  • Delegates to parent for actual scope validation when bearer token exists
// Any of these scopes grants access
Route::middleware('scope:read-data,admin')->group(function () {
Route::get('data', [DataController::class, 'index']);
});
// First-party apps only
Route::middleware('scope:first-party')->group(function () {
Route::get('sensitive', [SensitiveController::class, 'index']);
});
ScopeDescription
first-partyRightCapital’s own applications
household:readRead household data
household:writeModify household data
advisor:readRead advisor data
advisor:writeModify advisor data

File: app/Http/Middleware/CheckScopes.php

Extends Laravel Passport’s scope middleware. Validates that the OAuth token has all of the specified scopes.

final class CheckScopes extends \Laravel\Passport\Http\Middleware\CheckScopes
{
/**
* Do not check the scope when using cookie authentication
*/
#[\Override]
public function handle($request, $next, ...$scopes): Response
{
if (Str::isEmptyOrNull($request->bearerToken())) {
return $next($request);
}
return parent::handle($request, $next, ...$scopes);
}
}
  • Same cookie-auth bypass logic as CheckForAnyScope
  • Requires all specified scopes vs any
// ALL scopes required
Route::middleware('scopes:household:read,household:write')->group(function () {
Route::put('households/{household}', [HouseholdController::class, 'update']);
});
MiddlewareLogicExample
scope:a,ba OR bEither scope grants access
scopes:a,ba AND bBoth scopes required

File: app/Http/Middleware/Privilege.php

Checks login user privileges (stored on advisor model, different from OAuth scopes).

final class Privilege
{
public function handle(Request $request, Closure $next, string $privilege): Response
{
$login_user = Auth::getUserOrFail();
if (($login_user->advisor->privileges[$privilege] ?? false) !== true) {
throw new AccessDeniedHttpException('You don't have ' . str_replace('_', ' ', $privilege) . ' privilege.');
}
return $next($request);
}
}
  • Takes single privilege per middleware invocation
  • Uses Auth::getUserOrFail() to get authenticated user
  • Checks advisor->privileges[$privilege] array for true value
  • Underscores in privilege name replaced with spaces in error message

Privileges are stored as a JSON array on the advisor model:

// advisor->privileges = ['manage_users' => true, 'manage_billing' => true, ...]
PrivilegeDescription
manage_usersUser management access
manage_billingBilling and payment access
manage_integrationsThird-party integration access
view_reportsReport viewing access
export_dataData export access
Route::middleware('privilege:manage_users')->group(function () {
Route::resource('users', UserController::class);
});
// Chain multiple privileges (requires all)
Route::middleware(['privilege:manage_billing', 'privilege:view_reports'])->group(function () {
Route::get('billing-reports', [BillingReportController::class, 'index']);
});
{
"message": "You don't have manage users privilege."
}

HTTP Status: 403 Forbidden


File: app/Http/Middleware/UserType.php

Restricts access based on user type using enum comparison.

final class UserType
{
public function handle(Request $request, Closure $next, string $user_type): Response
{
if ($request->user() !== null) {
$type = $request->user()->type;
if ($type !== \RightCapital\Core\Enums\User\UserType::from($user_type)) {
throw new AccessDeniedHttpException('You are not ' . Str::indefiniteArticle($user_type) . " $user_type.");
}
} else {
throw new AuthenticationException('You must log in first.');
}
return $next($request);
}
}
  • Uses RightCapital\Core\Enums\User\UserType enum for comparison
  • Converts middleware parameter to enum via UserType::from($user_type)
  • Uses Str::indefiniteArticle() for grammatically correct error (“a” vs “an”)
// Advisor-only routes
Route::middleware('user_type:advisor')->group(function () {
Route::resource('households', HouseholdController::class);
Route::resource('clients', ClientController::class);
});
// Client-only routes
Route::middleware('user_type:client')->group(function () {
Route::get('my-plan', [ClientPlanController::class, 'show']);
Route::put('my-profile', [ClientProfileController::class, 'update']);
});
// Not an advisor
{
"message": "You are not an advisor."
}
// Not a client
{
"message": "You are not a client."
}
// Not logged in
{
"message": "You must log in first."
}

HTTP Status: 403 Forbidden (user type mismatch) or 401 Unauthorized (not logged in)


File: app/Http/Middleware/CheckAdvisorRole.php

Validates advisor role and organization type via database query.

final class CheckAdvisorRole
{
public function handle(Request $request, Closure $next, string ...$roles_and_types): Response
{
$login_user = Auth::getUserOrFail();
if ($login_user->advisor === null) {
throw new AccessDeniedHttpException('You don't have the permission.');
}
$advisor_role_query = AdvisorRole::where(AdvisorRole::COLUMN_ADVISOR_ID, $login_user->advisor->id);
$check_roles = [];
$advisor_role_query->whereNested(function (Builder $query) use ($roles_and_types, &$check_roles): void {
foreach ($roles_and_types as $role_with_type) {
[$role, $type] = explode('|', $role_with_type);
$query->orWhere(function (Builder $query) use ($role, $type): void {
$query->where(AdvisorRole::COLUMN_ROLE, $role)->where(AdvisorRole::COLUMN_TYPE, $type);
});
$check_roles[] = $role;
}
});
if (!$advisor_role_query->exists()) {
throw new AccessDeniedHttpException('You don't have the ' . implode(' or ', $check_roles) . ' permissions.');
}
return $next($request);
}
}
  • Database query on AdvisorRole table (not in-memory check)
  • Parameter format: ROLE|TYPE - both role and type are required
  • Uses nested OR conditions - any matching role/type combination grants access
  • Uses Auth::getUserOrFail() to get login user
advisor_role:ROLE|TYPE,ROLE|TYPE,...

Both ROLE and TYPE are required in each parameter:

  • ROLE: SUPER_ADMIN, ADMIN, MEMBER, VIEWER
  • TYPE: ORGANIZATION, FEDERATION, ENTERPRISE
// Organization super admin only
Route::middleware('advisor_role:SUPER_ADMIN|ORGANIZATION')->group(function () {
Route::resource('organization/settings', OrgSettingsController::class);
});
// Federation admin OR organization admin
Route::middleware('advisor_role:ADMIN|FEDERATION,ADMIN|ORGANIZATION')->group(function () {
Route::get('admin/dashboard', [AdminDashboardController::class, 'index']);
});
// Not an advisor
{
"message": "You don't have the permission."
}
// No matching role
{
"message": "You don't have the ADMIN or SUPER_ADMIN permissions."
}

HTTP Status: 403 Forbidden


File: Laravel’s built-in Illuminate\Auth\Middleware\Authorize

Uses Laravel’s policy system for model-based authorization.

// Check policy ability on route model
Route::put('households/{household}', [HouseholdController::class, 'update'])
->middleware('can:update,household');
// Check global ability
Route::post('exports', [ExportController::class, 'store'])
->middleware('can:create-export');
// Check with additional parameter
Route::post('households/{household}/reports', [ReportController::class, 'store'])
->middleware('can:create-report,household');
app/Policies/HouseholdPolicy.php
class HouseholdPolicy
{
public function update(User $user, Household $household): bool
{
return $user->advisor_id === $household->advisor_id;
}
public function createReport(User $user, Household $household): bool
{
return $this->update($user, $household)
&& $user->hasFeature('reports');
}
}

File: app/Http/Middleware/PreventPrivilegedImpersonation.php

Blocks all actions when an employee is impersonating a user.

final class PreventPrivilegedImpersonation
{
public function handle(Request $request, Closure $next): Response
{
if (Session::get(SessionEntity::KEY_IMPERSONATOR_EMPLOYEE) !== null
|| Session::get(SessionEntity::KEY_ADMIN_PORTAL_IMPERSONATOR_USER_ID) !== null) {
throw new AccessDeniedHttpException('This action cannot be performed while impersonating.');
}
return $next($request);
}
}
  • Checks session keys for impersonation state (not app('impersonator'))
  • Two types of impersonation detected:
    • SessionEntity::KEY_IMPERSONATOR_EMPLOYEE - Employee impersonation
    • SessionEntity::KEY_ADMIN_PORTAL_IMPERSONATOR_USER_ID - Admin portal impersonation
  • Blocks all routes with this middleware during impersonation (no route name checking)
Route::middleware(['auth', 'prevent_privileged_impersonation'])->group(function () {
// WebAuthn registration (security sensitive)
Route::post('webauthn/registration/initialize', ...);
Route::post('webauthn/registration/finalize', ...);
// Password change
Route::put('password', [PasswordController::class, 'update']);
// API key management
Route::resource('api-keys', ApiKeyController::class);
});
{
"message": "This action cannot be performed while impersonating."
}

HTTP Status: 403 Forbidden

See [impersonation.md](../impersonation/ for more details.


Authorization middleware is typically combined in layers:

Route::group([
'middleware' => ['auth:web', 'scope:first-party']
], function () {
// Level 1: Authenticated + First-party scope
Route::group(['middleware' => 'user_type:advisor'], function () {
// Level 2: Advisor users only
Route::group(['middleware' => 'advisor_role:ADMIN|ORGANIZATION'], function () {
// Level 3: Organization admins only
Route::group(['middleware' => 'prevent_privileged_impersonation'], function () {
// Level 4: Not during impersonation
Route::post('sensitive-action', [SensitiveController::class, 'store']);
});
});
});
});

Request
┌─────────────────┐
│ OAuth Scope? │────No────▶ (skip if cookie auth)
│ (bearer token) │
└─────────────────┘
│ Yes
┌─────────────────┐
│ User Type? │────No────▶ 403
└─────────────────┘
│ Yes
┌─────────────────┐
│ Advisor Role? │────No────▶ 403
│ (DB query) │
└─────────────────┘
│ Yes
┌─────────────────┐
│ Privilege? │────No────▶ 403
└─────────────────┘
│ Yes
┌─────────────────┐
│ Policy Check? │────No────▶ 403
└─────────────────┘
│ Yes
┌─────────────────┐
│ Impersonation │────Yes───▶ 403
│ Session Keys? │
└─────────────────┘
│ No
Continue

MiddlewareLogicUse Case
scope:a,bOROAuth scope check (any), skips for cookie auth
scopes:a,bANDOAuth scope check (all), skips for cookie auth
privilege:pSingleUser privilege check (advisor->privileges)
user_type:advisor-Advisor-only routes (enum comparison)
user_type:client-Client-only routes (enum comparison)
advisor_role:R|TORRole + org type check (DB query)
can:ability,model-Policy-based auth
prevent_privileged_impersonation-Block if session has impersonation keys