From fb3d405042143d79756dbcbc856eaece92ba2297 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 27 Sep 2026 14:32:20 +0800 Subject: [PATCH] fix(iam): close authorization gaps in core controllers - Organization settings: any member could update the company, including owner_uuid (take ownership) and billing/lifecycle fields, and change the organization 2FA policy. Updates and the 2FA policy now require the owner, the Administrator role or a system admin; owner, Stripe ids, plan, status, trial and type are ignored for non-admin updates (ownership keeps its transfer endpoint). - System-wide 2FA policy save is restricted to system admins. - Admin platform metrics are restricted to system admins; IAM and developer metrics require iam list user / developers list api-key. - Reports resolved to the "fleetbase" service, so no permission ever matched and every report endpoint was open. ReportController now uses the iam service, and direct query execution/export/download require iam execute/export report. - API credentials, webhooks, API events and request logs resolved to resource names (api-credential, webhook-endpoint, ...) that no permission uses, leaving them unguarded. Controllers can now declare $permissionResource, used by Auth when resolving permissions; these map to the Developers schema resources (api-key, webhook, event, log). - Auth::cannotUnlessAdmin() for explicit checks outside AuthorizationGuard. --- .../Internal/v1/AdminMetricsController.php | 13 ++++ .../Internal/v1/ApiCredentialController.php | 5 ++ .../Internal/v1/ApiEventController.php | 5 ++ .../Internal/v1/ApiRequestLogController.php | 5 ++ .../Internal/v1/CompanyController.php | 46 +++++++++++++++ .../v1/DeveloperMetricsController.php | 13 ++++ .../Internal/v1/IamMetricsController.php | 13 ++++ .../Internal/v1/ReportController.php | 23 ++++++++ .../Internal/v1/TwoFaController.php | 12 ++++ .../Internal/v1/WebhookEndpointController.php | 5 ++ .../v1/WebhookRequestLogController.php | 5 ++ src/Support/Auth.php | 38 +++++++++++- src/Traits/HasApiControllerBehavior.php | 16 +++++ tests/Unit/Http/CompanyControllerTest.php | 41 +++++++++++-- tests/Unit/Http/ReportControllerTest.php | 18 ++++++ tests/Unit/Http/TwoFaControllerTest.php | 14 +++++ tests/Unit/Support/AuthSupportTest.php | 59 +++++++++++++++++++ 17 files changed, 325 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AdminMetricsController.php b/src/Http/Controllers/Internal/v1/AdminMetricsController.php index 65404844..0dfafd78 100644 --- a/src/Http/Controllers/Internal/v1/AdminMetricsController.php +++ b/src/Http/Controllers/Internal/v1/AdminMetricsController.php @@ -6,6 +6,7 @@ use Fleetbase\Models\Activity; use Fleetbase\Models\Company; use Fleetbase\Models\User; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -14,6 +15,18 @@ class AdminMetricsController extends Controller { + public function __construct() + { + // Platform-wide figures: system administrators only. + $this->middleware(function ($request, $next) { + if (!Auth::getUserFromSession($request)?->isAdmin()) { + return response()->error('Only system administrators can view platform metrics.', 401); + } + + return $next($request); + }); + } + public function kpi(Request $request, string $slug): JsonResponse { [$currentPeriodStart, $previousPeriodStart] = $this->periodBoundaries(); diff --git a/src/Http/Controllers/Internal/v1/ApiCredentialController.php b/src/Http/Controllers/Internal/v1/ApiCredentialController.php index 4d482114..9e9c64b4 100644 --- a/src/Http/Controllers/Internal/v1/ApiCredentialController.php +++ b/src/Http/Controllers/Internal/v1/ApiCredentialController.php @@ -33,6 +33,11 @@ class ApiCredentialController extends FleetbaseController */ public $service = 'developers'; + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'api-key'; + /** * Create a new API credential record. * diff --git a/src/Http/Controllers/Internal/v1/ApiEventController.php b/src/Http/Controllers/Internal/v1/ApiEventController.php index fcf4eb51..b73ffcb0 100644 --- a/src/Http/Controllers/Internal/v1/ApiEventController.php +++ b/src/Http/Controllers/Internal/v1/ApiEventController.php @@ -19,4 +19,9 @@ class ApiEventController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'event'; } diff --git a/src/Http/Controllers/Internal/v1/ApiRequestLogController.php b/src/Http/Controllers/Internal/v1/ApiRequestLogController.php index bb3ef65f..a2f85852 100644 --- a/src/Http/Controllers/Internal/v1/ApiRequestLogController.php +++ b/src/Http/Controllers/Internal/v1/ApiRequestLogController.php @@ -19,4 +19,9 @@ class ApiRequestLogController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'log'; } diff --git a/src/Http/Controllers/Internal/v1/CompanyController.php b/src/Http/Controllers/Internal/v1/CompanyController.php index 14f42b4d..9a9a5cfd 100644 --- a/src/Http/Controllers/Internal/v1/CompanyController.php +++ b/src/Http/Controllers/Internal/v1/CompanyController.php @@ -32,6 +32,26 @@ class CompanyController extends FleetbaseController */ public $resource = 'company'; + /** + * Company attributes only platform administrators may change through updateRecord(). + */ + private const PLATFORM_MANAGED_FIELDS = ['owner_uuid', 'stripe_customer_id', 'stripe_connect_id', 'plan', 'status', 'trial_ends_at', 'type']; + + public function __construct() + { + parent::__construct(); + + // Organization settings and the organization's 2FA policy: owner, Administrator role or system admin. + $this->middleware(function ($request, $next) { + $company = Company::where('uuid', session('company'))->first(); + if (!$company || !$this->currentUserManagesOrganization($company)) { + return response()->error('Only the organization owner or an Administrator can change organization settings.', 401); + } + + return $next($request); + })->only(['updateRecord', 'saveTwoFactorSettings']); + } + /** * Find an organization visible to the current session company. * @@ -63,6 +83,11 @@ public function updateRecord(Request $request, string $id) try { $input = $this->model->getApiPayloadFromRequest($request); + + // Ownership moves through transferOwnership(); billing and lifecycle fields are platform-managed. + if (!$request->user()?->isAdmin()) { + $input = Arr::except($input, self::PLATFORM_MANAGED_FIELDS); + } $input = $this->model->fillSessionAttributes($input, [], ['updated_by_uuid']); if ($this->model->isColumn('slug')) { @@ -157,6 +182,7 @@ public function saveTwoFactorSettings(Request $request) if (!$company) { return response()->error('No company session found', 401); } + if (isset($twoFaSettings['enabled']) && $twoFaSettings['enabled'] === false) { $twoFaSettings['enforced'] = false; } @@ -261,6 +287,26 @@ private function resolveVisibleCompanyForUsers(string $id, Request $request): ?C ->first(); } + /** + * Whether the session user may manage the organization: platform admins, the owner, + * and members holding the Administrator role in it. + */ + private function currentUserManagesOrganization(Company $company): bool + { + $user = Auth::getUserFromSession(); + if (!$user) { + return false; + } + + if ($user->isAdmin() || $company->owner_uuid === $user->uuid) { + return true; + } + + $companyUser = CompanyUser::where('company_uuid', $company->uuid)->where('user_uuid', $user->uuid)->first(); + + return $companyUser !== null && $companyUser->roles()->where('name', 'Administrator')->exists(); + } + private function resolveVisibleCompany(string $id): ?Company { $sessionCompany = session('company'); diff --git a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php index 6add50a9..4eb3baf1 100644 --- a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php +++ b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php @@ -8,12 +8,25 @@ use Fleetbase\Models\ApiRequestLog; use Fleetbase\Models\WebhookEndpoint; use Fleetbase\Models\WebhookRequestLog; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; class DeveloperMetricsController extends Controller { + public function __construct() + { + // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it. + $this->middleware(function ($request, $next) { + if (Auth::cannotUnlessAdmin('developers list api-key')) { + return response()->error('User is not authorized to list api-key', 401); + } + + return $next($request); + }); + } + public function kpis(Request $request): JsonResponse { [$start, $end, $previousStart, $previousEnd] = $this->periods($request); diff --git a/src/Http/Controllers/Internal/v1/IamMetricsController.php b/src/Http/Controllers/Internal/v1/IamMetricsController.php index 27a75a9d..e8208812 100644 --- a/src/Http/Controllers/Internal/v1/IamMetricsController.php +++ b/src/Http/Controllers/Internal/v1/IamMetricsController.php @@ -11,6 +11,7 @@ use Fleetbase\Models\Role; use Fleetbase\Models\Setting; use Fleetbase\Models\User; +use Fleetbase\Support\Auth; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -21,6 +22,18 @@ class IamMetricsController extends Controller { private const DORMANT_DAYS = 90; + public function __construct() + { + // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it. + $this->middleware(function ($request, $next) { + if (Auth::cannotUnlessAdmin('iam list user')) { + return response()->error('User is not authorized to list user', 401); + } + + return $next($request); + }); + } + public function kpis(Request $request): JsonResponse { $companyUuid = session('company'); diff --git a/src/Http/Controllers/Internal/v1/ReportController.php b/src/Http/Controllers/Internal/v1/ReportController.php index 7cb7f29d..1693292c 100644 --- a/src/Http/Controllers/Internal/v1/ReportController.php +++ b/src/Http/Controllers/Internal/v1/ReportController.php @@ -2,8 +2,10 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Attributes\SkipAuthorizationCheck; use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Models\Report; +use Fleetbase\Support\Auth; use Fleetbase\Support\Reporting\ComputedColumnValidator; use Fleetbase\Support\Reporting\ReportQueryConverter; use Fleetbase\Support\Reporting\ReportQueryErrorHandler; @@ -22,12 +24,30 @@ class ReportController extends FleetbaseController */ public $resource = 'report'; + /** + * The IAM schema service for report permissions (`iam list report`, `iam execute report`, ...). + * + * @var string + */ + public $service = 'iam'; + protected ReportQueryValidator $queryValidator; protected ReportQueryErrorHandler $errorHandler; public function __construct() { parent::__construct(); + + // These endpoints skip AuthorizationGuard (their method names do not map to a schema action). + foreach (['execute' => ['executeQuery'], 'export' => ['exportQuery', 'download']] as $action => $methods) { + $this->middleware(function ($request, $next) use ($action) { + if (Auth::cannotUnlessAdmin("iam {$action} report")) { + return response()->error("User is not authorized to {$action} report", 401); + } + + return $next($request); + })->only($methods); + } $this->queryValidator = new ReportQueryValidator(app(ReportSchemaRegistry::class)); $this->errorHandler = new ReportQueryErrorHandler(); } @@ -238,6 +258,7 @@ public function execute(Request $request, string $id): JsonResponse /** * Execute a query directly without saving as report. */ + #[SkipAuthorizationCheck] public function executeQuery(Request $request): JsonResponse { try { @@ -368,6 +389,7 @@ public function export(Request $request, string $id): JsonResponse /** * Export query results directly without saving as report. */ + #[SkipAuthorizationCheck] public function exportQuery(Request $request): JsonResponse { try { @@ -431,6 +453,7 @@ public function exportQuery(Request $request): JsonResponse /** * Download exported file. */ + #[SkipAuthorizationCheck] public function download(Request $request, string $filename) { try { diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php index f81c7d58..158aa1f9 100644 --- a/src/Http/Controllers/Internal/v1/TwoFaController.php +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -15,6 +15,18 @@ */ class TwoFaController extends Controller { + public function __construct() + { + // The system-wide 2FA policy applies to every organization: system administrators only. + $this->middleware(function ($request, $next) { + if (!Auth::getUserFromSession($request)?->isAdmin()) { + return response()->error('Only system administrators can change the system two-factor policy.', 401); + } + + return $next($request); + })->only('saveSystemConfig'); + } + /** * Save Two-Factor Authentication system wide settings. * diff --git a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php index 46731192..869eb880 100644 --- a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php +++ b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php @@ -37,6 +37,11 @@ class WebhookEndpointController extends FleetbaseController */ public $service = 'developers'; + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'webhook'; + /** * Enables a webhook endpoint. * diff --git a/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php b/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php index f172c7a7..9d4253db 100644 --- a/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php +++ b/src/Http/Controllers/Internal/v1/WebhookRequestLogController.php @@ -19,4 +19,9 @@ class WebhookRequestLogController extends FleetbaseController * @var string */ public $service = 'developers'; + + /** + * The IAM schema resource this controller's permissions use. + */ + public string $permissionResource = 'log'; } diff --git a/src/Support/Auth.php b/src/Support/Auth.php index f5a21a4f..01d81466 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -395,7 +395,7 @@ public static function resolvePermissionsFromRequest(Request $request): Collecti } $service = $controller->getService(); - $resource = str_replace('_', '-', $controller->getResourceSingularName()); + $resource = static::getPermissionResourceFromController($controller); $action = ActionMapper::resolve($request, $resource); // If the resource is not guarded at all @@ -522,12 +522,24 @@ public static function applyDirectivesToQuery($builder, ?Request $request = null public static function getRequiredPermissionNameFromRequest(Request $request): string { $controller = $request->getController(); - $resource = str_replace('_', '-', $controller->getResourceSingularName()); + $resource = static::getPermissionResourceFromController($controller); $action = ActionMapper::resolve($request, $resource); return implode(' ', [$action, $resource]); } + /** + * Resolves the permission resource name for a resource controller. + */ + public static function getPermissionResourceFromController($controller): string + { + if (method_exists($controller, 'getPermissionResourceName')) { + return $controller->getPermissionResourceName(); + } + + return str_replace('_', '-', $controller->getResourceSingularName()); + } + /** * Checks if a resource is guarded by any permissions. * @@ -569,6 +581,28 @@ public static function can(string $permission): bool }); } + /** + * Determines if the current user lacks the specified permission, treating platform + * administrators (and a missing session user) the way AuthorizationGuard does. + * + * For explicit checks in controllers that the guard cannot resolve on its own. + * + * @param string $permission the permission string in the format '{service} {action} {resource}' + */ + public static function cannotUnlessAdmin(string $permission): bool + { + $user = static::getUserFromSession(); + if (!$user) { + return true; + } + + if ($user->isAdmin()) { + return false; + } + + return static::cannot($permission); + } + /** * Determines if the current user lacks the specified permission. * diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 589f35aa..d3395472 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -271,6 +271,22 @@ public function getResourceSingularName(): string return $this->resourceSingularlName; } + /** + * Gets the resource name used in permission names, e.g. "user" in "iam list user". + * + * Defaults to the kebab-cased singular resource name. A controller whose model name + * differs from its IAM schema resource declares `public string $permissionResource` + * (for example ApiCredentialController uses the schema resource "api-key"). + */ + public function getPermissionResourceName(): string + { + if (property_exists($this, 'permissionResource') && !empty($this->permissionResource)) { + return $this->permissionResource; + } + + return str_replace('_', '-', $this->getResourceSingularName()); + } + /** * Gets the service associated with the controller. * diff --git a/tests/Unit/Http/CompanyControllerTest.php b/tests/Unit/Http/CompanyControllerTest.php index 0ecf7ef1..1445168e 100644 --- a/tests/Unit/Http/CompanyControllerTest.php +++ b/tests/Unit/Http/CompanyControllerTest.php @@ -762,17 +762,22 @@ function company_controller_bind_activity(): CompanyControllerActivityFake expect($foreign->getStatusCode())->toBe(404) ->and($foreign->getData(true))->toBe(['errors' => ['Organization not found.']]); + $before = $capsule->getConnection('mysql')->table('companies')->where('uuid', 'company-1')->first(); + $updated = company_controller()->updateRecord(company_controller_request('PUT', [ - 'name' => 'Acme Updated', - 'slug' => 'attempted-slug-change', - 'status' => 'suspended', + 'name' => 'Acme Updated', + 'slug' => 'attempted-slug-change', + 'status' => 'suspended', + 'owner_uuid' => 'attempted-owner-takeover', ]), 'company_public_1'); $record = $capsule->getConnection('mysql')->table('companies')->where('uuid', 'company-1')->first(); + // Ownership and lifecycle status are platform-managed and ignored for non-admin updates. expect($updated['company']->resource->name)->toBe('Acme Updated') ->and($record->name)->toBe('Acme Updated') - ->and($record->status)->toBe('suspended') + ->and($record->status)->toBe($before->status) + ->and($record->owner_uuid)->toBe($before->owner_uuid) ->and($record->slug)->toBe('acme-logistics'); $deleted = company_controller()->deleteRecord('company_public_1', company_controller_request('DELETE')); @@ -1427,3 +1432,31 @@ function company_controller_bind_activity(): CompanyControllerActivityFake ->and($notMember->getStatusCode())->toBe(400) ->and($notMember->getData(true))->toBe(['errors' => ['User selected to leave organization is not a member of this organization.']]); }); + +test('company controller only lets organization managers update settings or the organization 2fa policy', function () { + $capsule = company_controller_fixtures(); + + $registered = collect(company_controller()->getMiddleware()) + ->first(fn ($entry) => ($entry['options']['only'] ?? null) === ['updateRecord', 'saveTwoFactorSettings']); + expect($registered)->not->toBeNull(); + + $run = function (string $user) use ($registered) { + session(['company' => 'company-1', 'user' => $user]); + + return ($registered['middleware'])(company_controller_request('PUT'), fn () => 'allowed'); + }; + + // A plain member (the dispatcher case) is refused. + $refused = $run('member-1'); + expect($refused->getStatusCode())->toBe(401) + ->and($refused->getData(true))->toBe(['errors' => ['Only the organization owner or an Administrator can change organization settings.']]); + + // The owner, a member holding the Administrator role, and system admins are allowed. + expect($run('owner-1'))->toBe('allowed') + ->and($run('admin-1'))->toBe('allowed'); + + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-member-1', + ]); + expect($run('member-1'))->toBe('allowed'); +}); diff --git a/tests/Unit/Http/ReportControllerTest.php b/tests/Unit/Http/ReportControllerTest.php index 7c961ccd..ea410ddf 100644 --- a/tests/Unit/Http/ReportControllerTest.php +++ b/tests/Unit/Http/ReportControllerTest.php @@ -916,3 +916,21 @@ function report_controller_use_query_validator(ReportController $controller, Rep ->and($current->company_uuid)->toBe('company-1') ->and($other)->toBeNull(); }); + +test('report controller maps direct query execution and exports to the iam report permissions', function () { + report_controller_bind(); + + $controller = new ReportController(); + $scoped = collect($controller->getMiddleware())->mapWithKeys(fn ($entry) => [implode(',', $entry['options']['only'] ?? []) => $entry['middleware']]); + + expect($scoped->keys()->all())->toBe(['executeQuery', 'exportQuery,download']) + ->and($controller->getService())->toBe('iam'); + + // No session user: both are refused before reaching the query engine. + session()->flush(); + $execute = ($scoped['executeQuery'])(Request::create('/int/v1/reports/execute-query', 'POST'), fn () => 'allowed'); + $export = ($scoped['exportQuery,download'])(Request::create('/int/v1/reports/export-query', 'POST'), fn () => 'allowed'); + + expect($execute->getData(true))->toBe(['errors' => ['User is not authorized to execute report']]) + ->and($export->getData(true))->toBe(['errors' => ['User is not authorized to export report']]); +}); diff --git a/tests/Unit/Http/TwoFaControllerTest.php b/tests/Unit/Http/TwoFaControllerTest.php index f9e77ba7..176e30f4 100644 --- a/tests/Unit/Http/TwoFaControllerTest.php +++ b/tests/Unit/Http/TwoFaControllerTest.php @@ -469,3 +469,17 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') ->and(app('db')->table('personal_access_tokens')->where('tokenable_id', $user->uuid)->count())->toBe(0); }); + +test('two fa controller restricts the system-wide policy save to system administrators', function () { + two_fa_controller_database(); + + $middleware = two_fa_controller()->getMiddleware(); + expect($middleware)->toHaveCount(1) + ->and($middleware[0]['options']['only'])->toBe(['saveSystemConfig']); + + session()->flush(); + $refused = ($middleware[0]['middleware'])(Request::create('/int/v1/two-fa/config', 'POST'), fn () => 'allowed'); + + expect($refused->getStatusCode())->toBe(401) + ->and($refused->getData(true))->toBe(['errors' => ['Only system administrators can change the system two-factor policy.']]); +}); diff --git a/tests/Unit/Support/AuthSupportTest.php b/tests/Unit/Support/AuthSupportTest.php index 4aa4c7cd..cd425cff 100644 --- a/tests/Unit/Support/AuthSupportTest.php +++ b/tests/Unit/Support/AuthSupportTest.php @@ -221,6 +221,30 @@ public function queryRecord(): void } } +class AuthSupportAliasedResourceController +{ + public string $permissionResource = 'api-key'; + + public function getService(): string + { + return 'developers'; + } + + public function getResourceSingularName(): string + { + return 'api_credential'; + } + + public function getPermissionResourceName(): string + { + return $this->permissionResource; + } + + public function queryRecord(): void + { + } +} + class AuthSupportSkipController { #[SkipAuthorizationCheck] @@ -787,6 +811,41 @@ function auth_support_request(string $method = 'GET', ?string $controllerClass = expect(Auth::resolvePermissionsFromRequest(auth_support_request('GET', AuthSupportSkipController::class))->isEmpty())->toBeTrue(); }); +test('auth support resolves permissions against a controller permission resource alias', function () { + [$admin] = auth_support_fixtures(); + session(['user' => $admin->uuid]); + + app('db')->table('permissions')->insert([ + ['id' => 'permission-list-api-key', 'name' => 'developers list api-key', 'guard_name' => 'sanctum', 'service' => 'developers', 'created_at' => now(), 'updated_at' => now()], + ]); + + $request = auth_support_request('GET', AuthSupportAliasedResourceController::class); + + // Without the alias the model name "api-credential" matches no permission and the guard lets everyone through. + expect(Auth::isResourceGuarded('api-credential'))->toBeFalse() + ->and(Auth::getRequiredPermissionNameFromRequest($request))->toBe('list api-key') + ->and(Auth::resolvePermissionsFromRequest($request)->pluck('id')->all())->toBe(['permission-list-api-key']) + ->and(Auth::getPermissionResourceFromController(app(AuthSupportResourceController::class)))->toBe('user'); +}); + +test('auth support cannotUnlessAdmin lets platform admins through and checks everyone else', function () { + [$admin] = auth_support_fixtures(); + + app('db')->table('permissions')->insert([ + ['id' => 'permission-list-user-admin-check', 'name' => 'iam list user', 'guard_name' => 'sanctum', 'service' => 'iam', 'created_at' => now(), 'updated_at' => now()], + ]); + + session(['user' => $admin->uuid]); + $admin->forceFill(['type' => 'admin'])->save(); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeFalse(); + + $admin->forceFill(['type' => 'user'])->save(); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeTrue(); + + session(['user' => null]); + expect(Auth::cannotUnlessAdmin('iam list user'))->toBeTrue(); +}); + test('auth support filters and applies directives for assigned role and policy subjects', function () { [$admin] = auth_support_fixtures(); session(['user' => $admin->uuid]);