From 9a98911168cca765aa50db30e5dd15c843f18e12 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 23:34:19 +0800 Subject: [PATCH 1/3] feat: keep driver, customer and contact accounts out of the console Driver, customer and contact login accounts are now owned by their FleetOps profile instead of being managed in IAM. This adds the core pieces that model depends on. - User: MANAGED_TYPES, isManagedAccount(), isStaffAccount(), canAccessConsole(), canHoldConsoleSession(), and staff()/managed() scopes. - Console access: login (password and token reuse) refuses all managed types. Customers keep the customer_login_not_allowed code, drivers and contacts get console_access_not_allowed. Session restore, bootstrap, 2FA verify, verify-email tokens and impersonation refuse drivers and contacts. Customers are still allowed there because the customer portal runs inside the console on those endpoints. - Freeing identifiers: a soft-deleted user's email and phone move to meta.deleted_identity, so they can be reused. Restoring the user puts them back only if no other account has taken them. - Promotion: creating or inviting a team member whose email or phone belongs to a managed account in the organization promotes that account to a `user` with the chosen role and sends a join invite, instead of creating a duplicate. Accepting any IAM invite promotes a managed account and asks it to set a console password. --- .../Internal/v1/AuthController.php | 21 +++- .../Internal/v1/TwoFaController.php | 11 ++ .../Internal/v1/UserController.php | 105 ++++++++++++++++++ src/Models/User.php | 68 ++++++++++++ src/Observers/UserObserver.php | 48 ++++++++ src/Support/Auth.php | 31 ++++++ .../Http/AuthControllerLoginBootstrapTest.php | 38 +++++++ tests/Unit/Http/TwoFaControllerTest.php | 21 ++++ tests/Unit/Http/UserControllerTest.php | 64 +++++++++++ tests/Unit/Observers/UserObserverTest.php | 35 ++++++ 10 files changed, 436 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 56faac1f..32e3e4fe 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -62,8 +62,8 @@ public function login(LoginRequest $request) $tokenOwner instanceof User && ($tokenOwner->email === $identity || $tokenOwner->phone === $identity) ) { - if ($tokenOwner->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($tokenOwner)) { + return $denied; } return response()->json([ @@ -83,8 +83,8 @@ public function login(LoginRequest $request) $query->where('email', $identity)->orWhere('phone', $identity); })->first(); - if ($user && $user->type === 'customer') { - return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + if ($denied = Auth::denyConsoleLogin($user)) { + return $denied; } // If the user exists but has no password set (e.g. SSO-invited or provisioned @@ -138,7 +138,7 @@ public function session(Request $request) $session = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($request) { $user = $request->user(); - if (!$user) { + if (!$user || !$user->canHoldConsoleSession()) { return null; } @@ -196,6 +196,11 @@ public function bootstrap(Request $request) { $user = $request->user(); $token = $request->bearerToken(); + + if ($denied = Auth::denyConsoleSession($user)) { + return $denied; + } + $cacheKey = "auth_bootstrap_{$user->uuid}_{$token}"; // Cache for 5 minutes @@ -491,7 +496,7 @@ public function verifyEmail(Request $request) $user->activate(); // If authenticate is set, generate and return a token - if ($authenticate) { + if ($authenticate && $user->canHoldConsoleSession()) { $user->updateLastLogin(); $token = $user->createToken($user->uuid); @@ -1019,6 +1024,10 @@ public function impersonate(AdminRequest $request) return response()->error('The selected user to impersonate was not found.'); } + if ($denied = Auth::denyConsoleSession($targetUser)) { + return $denied; + } + try { Auth::setSession($targetUser); session()->put('impersonator', $currentUser->uuid); diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php index e2647bc2..f81c7d58 100644 --- a/src/Http/Controllers/Internal/v1/TwoFaController.php +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -4,9 +4,11 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\TwoFaValidationRequest; +use Fleetbase\Support\Auth; use Fleetbase\Support\TwoFactorAuth; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Laravel\Sanctum\PersonalAccessToken; /** * Class TwoFaController. @@ -103,6 +105,15 @@ public function verifyCode(Request $request) try { $authToken = TwoFactorAuth::verifyCode($code, $token, $clientToken); + // Driver and contact accounts cannot sign in to the console. Customers + // are left to the customer portal, which shares this route path. + $accessToken = PersonalAccessToken::findToken($authToken); + if ($denied = Auth::denyConsoleSession($accessToken?->tokenable)) { + $accessToken->delete(); + + return $denied; + } + return response()->json([ 'authToken' => $authToken, ]); diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 90e88710..9efe64b0 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -164,6 +164,13 @@ public function findRecord(Request $request, $id) */ public function createRecord(Request $request) { + // A driver/customer/contact account in this organisation with the same + // email or phone is promoted to a team member instead of duplicated. + $managedUser = $this->findPromotableAccount($request->input('user.email'), $request->input('user.phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request); + } + $this->validateRequest($request); // Detect whether the email already belongs to an existing user. @@ -698,6 +705,13 @@ public function inviteUser(InviteUserRequest $request) return response()->error('The selected role is not available for this organisation.', 404); } + // A driver/customer/contact account in this organisation with the same + // email is promoted to a team member instead of invited. + $managedUser = $this->findPromotableAccount($email, data_get($data, 'phone')); + if ($managedUser) { + return $this->promoteManagedAccount($managedUser, $request); + } + // Check if user already exists in the system. $user = User::where('email', $email)->whereNull('deleted_at')->first(); @@ -739,6 +753,89 @@ public function inviteUser(InviteUserRequest $request) return response()->json(['user' => new $this->resource($user)]); } + /** + * Find a profile-managed account (driver, customer or contact) in the + * current organisation matching the given email or phone. + */ + private function findPromotableAccount(?string $email, ?string $phone): ?User + { + $email = $email ? strtolower(trim($email)) : null; + $phone = $phone ? trim($phone) : null; + if (!$email && !$phone) { + return null; + } + + return User::managed() + ->where(function ($query) use ($email, $phone) { + if ($email) { + $query->orWhere('email', $email); + } + if ($phone) { + $query->orWhere('phone', $phone); + } + }) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->first(); + } + + /** + * Promote a profile-managed account to a team member of the current + * organisation. Its driver/customer profiles stay linked, so the person + * keeps a single account for the console and their app. A join invite is + * sent so they can set a console password. + */ + private function promoteManagedAccount(User $user, Request $request): \Illuminate\Http\JsonResponse + { + $company = Auth::getCompany(); + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } + + $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); + $role = $roleIdentifier ? $this->resolveAssignableRole($roleIdentifier) : null; + if ($roleIdentifier && !$role) { + return response()->error('The selected role is not available for this organisation.', 404); + } + + $previousType = $user->getType(); + $user->meta = array_merge((array) ($user->meta ?? []), ['promoted_from' => $previousType]); + $user->setUserType('user'); + $user->assignSingleRole($role ? $role : 'Administrator'); + + if ($request->isArray('user.permissions')) { + $user->syncPermissions(Permission::whereIn('id', $request->array('user.permissions'))->get()); + } + + if ($request->isArray('user.policies')) { + $user->syncPolicies($this->getAssignablePolicies($request->array('user.policies'))); + } + + if ($user->email && !Invite::isAlreadySentToJoinCompany($user, $company)) { + $invitation = Invite::create([ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'subject_uuid' => $company->uuid, + 'subject_type' => Utils::getMutationType($company), + 'protocol' => 'email', + 'recipients' => [$user->email], + 'reason' => 'join_company', + 'meta' => array_filter(['role_uuid' => $role?->id, 'promoted_from' => $previousType]), + 'expires_at' => now()->addHours(48), + ]); + + $user->notify(new UserInvited($invitation)); + } + + UserCacheService::invalidateUser($user); + + return response()->json([ + 'user' => new $this->resource($user), + 'promoted_from' => $previousType, + ]); + } + /** * Issue a join-company invitation to a user who already exists in the * system but belongs to a different organisation. @@ -858,6 +955,14 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) // determine if user needs to set password (when status pending) $isPending = $needsPassword = $user->status === 'pending'; + // Invites come from IAM, so accepting one makes a profile-managed + // account (driver, customer, contact) a team member. Its password was + // generated for the app, so it sets a console password. + if ($user->isManagedAccount() || $invite->getMeta('promoted_from')) { + $user->setUserType('user'); + $needsPassword = true; + } + // Add user to company only if they are not already a member. // This guards against double-acceptance (e.g. clicking the invite // link twice) creating a duplicate company_users row. diff --git a/src/Models/User.php b/src/Models/User.php index a6fd8301..c3d0c3b4 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -69,6 +69,23 @@ class User extends Authenticatable use ClearsHttpCache; use HasSessionAttributes; + /** + * User types whose account is owned and managed by a profile record (a + * FleetOps driver, contact or customer) rather than managed in IAM. + * + * @var array + */ + public const MANAGED_TYPES = ['driver', 'customer', 'contact']; + + /** + * Managed types that have no console surface at all. Customers are excluded + * because the customer portal runs inside the console and restores its + * session through the core session endpoints. + * + * @var array + */ + public const SESSIONLESS_TYPES = ['driver', 'contact']; + /** * The database connection to use. * @@ -823,6 +840,57 @@ public function isNotType(string|array $type): bool return !$this->isType($type); } + /** + * Checks if the account is owned by a driver, contact or customer profile. + */ + public function isManagedAccount(): bool + { + return $this->isType(static::MANAGED_TYPES); + } + + /** + * Checks if the account is a staff account (managed in IAM). + */ + public function isStaffAccount(): bool + { + return !$this->isManagedAccount(); + } + + /** + * Checks if the account may sign in to the console. + */ + public function canAccessConsole(): bool + { + return $this->isStaffAccount(); + } + + /** + * Checks if the account may hold a console session. Customers hold one for + * the customer portal; drivers and contacts never do. + */ + public function canHoldConsoleSession(): bool + { + return $this->isNotType(static::SESSIONLESS_TYPES); + } + + /** + * Scope a query to staff accounts only. + */ + public function scopeStaff(Builder $query): Builder + { + return $query->where(function ($query) { + $query->whereNotIn($this->qualifyColumn('type'), static::MANAGED_TYPES)->orWhereNull($this->qualifyColumn('type')); + }); + } + + /** + * Scope a query to profile-managed accounts only. + */ + public function scopeManaged(Builder $query): Builder + { + return $query->whereIn($this->qualifyColumn('type'), static::MANAGED_TYPES); + } + /** * Adds a boolean dynamic property to check if user is an admin. * diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index c369fd04..3c825cb9 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -40,6 +40,11 @@ public function deleted(User $user) if (session('company')) { CompanyUser::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid])->delete(); } + + // Free the email and phone so they can be used by a new account + if (!$user->isForceDeleting()) { + $this->releaseIdentity($user); + } } /** @@ -47,6 +52,8 @@ public function deleted(User $user) */ public function restored(User $user): void { + $this->restoreIdentity($user); + // Invalidate user cache when user is restored UserCacheService::invalidateUser($user); @@ -54,6 +61,47 @@ public function restored(User $user): void $this->invalidateOrganizationsCache($user); } + /** + * Move a deleted user's email and phone into meta so they no longer block + * a new account, including lookups that include trashed users. + */ + private function releaseIdentity(User $user): void + { + $identity = array_filter(['email' => $user->email, 'phone' => $user->phone]); + if (empty($identity)) { + return; + } + + $meta = (array) ($user->meta ?? []); + $meta['deleted_identity'] = $identity; + + $user->forceFill(['email' => null, 'phone' => null, 'meta' => $meta])->saveQuietly(); + } + + /** + * Put a restored user's email and phone back when no other account has + * taken them in the meantime. + */ + private function restoreIdentity(User $user): void + { + $meta = (array) ($user->meta ?? []); + $identity = (array) data_get($meta, 'deleted_identity', []); + if (empty($identity)) { + return; + } + + foreach (['email', 'phone'] as $column) { + $value = $identity[$column] ?? null; + if ($value && !$user->{$column} && User::where($column, $value)->where('uuid', '!=', $user->uuid)->doesntExist()) { + $user->{$column} = $value; + } + } + + unset($meta['deleted_identity']); + $user->meta = $meta; + $user->saveQuietly(); + } + /** * Invalidate organizations cache for the user. * diff --git a/src/Support/Auth.php b/src/Support/Auth.php index e1dad804..a93df8bc 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -49,6 +49,37 @@ public static function register($owner, $company) return $owner; } + /** + * Returns an error response when the user may not sign in to the console, + * or null when access is allowed. Driver, contact and customer accounts are + * managed through their profile and sign in through their own apps. + */ + public static function denyConsoleLogin(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canAccessConsole()) { + return null; + } + + if ($user->isType('customer')) { + return response()->error('Customer accounts must sign in through the customer portal.', 403, ['code' => 'customer_login_not_allowed']); + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed']); + } + + /** + * Returns an error response when the user may not hold a console session, + * or null when allowed. Customers keep sessions for the customer portal. + */ + public static function denyConsoleSession(?User $user): ?\Illuminate\Http\JsonResponse + { + if (!$user instanceof User || $user->canHoldConsoleSession()) { + return null; + } + + return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed', 'restore' => false]); + } + /** * Set session variables for user. * diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 55febb4c..bfaee3b5 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1345,3 +1345,41 @@ function auth_controller_sms_request(array $input): Request ->and(app('redis')->get($key))->toBeNull() ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(1); }); + +test('login rejects driver and contact identities from the console', function (string $type) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $response = (new AuthController())->login(auth_controller_login_request([ + 'identity' => 'auth@example.test', + 'password' => 'correct-password', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'errors' => ['This account cannot sign in to the console.'], + 'code' => 'console_access_not_allowed', + ]) + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->count())->toBe(0); +})->with(['driver', 'contact']); + +test('bootstrap rejects driver and contact sessions but keeps customer portal sessions', function (string $type, int $status) { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, [ + 'type' => $type, + ]); + + $user = User::find('11111111-1111-4111-8111-111111111111'); + $response = (new AuthController())->bootstrap(auth_controller_bootstrap_request($user, 'bootstrap-token')); + + expect($response->getStatusCode())->toBe($status); + if ($status === 403) { + expect($response->getData(true)['code'])->toBe('console_access_not_allowed'); + } +})->with([ + ['driver', 403], + ['contact', 403], + ['customer', 200], +]); diff --git a/tests/Unit/Http/TwoFaControllerTest.php b/tests/Unit/Http/TwoFaControllerTest.php index 44173aa0..f9e77ba7 100644 --- a/tests/Unit/Http/TwoFaControllerTest.php +++ b/tests/Unit/Http/TwoFaControllerTest.php @@ -448,3 +448,24 @@ function two_fa_controller_verification_code(User $user, Carbon $expiresAt, stri expect($response->getData(true))->toBe(['shouldEnforce' => true]); }); + +test('two fa controller verify does not issue console tokens to driver accounts', function () { + two_fa_controller_database(); + app('db')->connection()->getSchemaBuilder()->table('users', fn ($table) => $table->string('type')->nullable()); + app('db')->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['type' => 'driver']); + $user = two_fa_controller_user(); + TwoFactorAuth::saveTwoFaSettingsForUser($user, ['enabled' => true, 'method' => 'email']); + $token = TwoFactorAuth::start($user->email, 10); + $verificationCode = two_fa_controller_verification_code($user, Carbon::now()->addMinutes(5)); + $clientToken = TwoFactorAuth::createClientSessionToken($verificationCode); + + $response = two_fa_controller()->verifyCode(Request::create('/int/v1/two-fa/verify', 'POST', [ + 'code' => '123456', + 'token' => $token, + 'clientToken' => $clientToken, + ])); + + expect($response->getStatusCode())->toBe(403) + ->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); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 77ad97ff..4d6e6880 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -1905,3 +1905,67 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($missingUser->getStatusCode())->toBe(400) ->and($missingUser->getData(true))->toBe(['errors' => ['Unable to locate the user for this invitation.']]); }); + +test('user controller promotes a managed driver account in the organization instead of inviting a duplicate', function (string $method) { + $capsule = user_controller_database(); + $connection = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $connection->table('users')->insert(['uuid' => 'driver-1', 'public_id' => 'user_driver_1', 'company_uuid' => 'company-1', 'email' => 'driver@example.test', 'phone' => '+15555550199', 'name' => 'Driver One', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('company_users')->insert(['uuid' => 'pivot-driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + + $payload = ['user' => ['email' => 'Driver@Example.test', 'name' => 'Driver One', 'role_uuid' => 'Administrator']]; + $response = $method === 'inviteUser' + ? user_controller()->inviteUser(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)) + : user_controller_without_request_validation()->createRecord(user_controller_request('POST', $payload, user_controller_user('owner-1'), 'createRecord')); + + $driver = User::find('driver-1'); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['promoted_from'])->toBe('driver') + ->and($response->getData(true)['user']['uuid'])->toBe('driver-1') + ->and($driver->type)->toBe('user') + ->and(User::where('email', 'driver@example.test')->count())->toBe(1) + ->and($connection->table('invites')->where('company_uuid', 'company-1')->where('reason', 'join_company')->count())->toBe(1); +})->with(['inviteUser', 'createRecord']); + +test('user controller accepting an invite promotes a managed driver account and asks for a console password', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $driver = User::create([ + 'uuid' => 'driver-invitee', + 'public_id' => 'user_driver_invitee', + 'email' => 'driver-invitee@example.test', + 'name' => 'Driver Invitee', + 'company_uuid' => 'company-2', + 'status' => 'active', + ]); + $driver->setType('driver'); + + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-driver', + 'public_id' => 'invite_public_driver', + 'code' => 'DRIVER123', + 'uri' => 'driver123', + 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', + 'subject_uuid' => 'company-1', + 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', + 'recipients' => json_encode(['driver-invitee@example.test']), + 'reason' => 'join_company', + 'meta' => json_encode(['role_uuid' => 'Administrator']), + 'expires_at' => now()->addHours(48), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', [ + 'code' => 'DRIVER123', + ], $driver, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + + expect($accepted->getStatusCode())->toBe(200) + ->and($accepted->getData(true)['needs_password'])->toBeTrue() + ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'driver-invitee')->value('type'))->toBe('user'); +}); diff --git a/tests/Unit/Observers/UserObserverTest.php b/tests/Unit/Observers/UserObserverTest.php index 13e413e0..dbe50a45 100644 --- a/tests/Unit/Observers/UserObserverTest.php +++ b/tests/Unit/Observers/UserObserverTest.php @@ -68,6 +68,9 @@ function user_observer_database(): Capsule $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); $schema->create('users', function ($table) { $table->string('uuid')->primary(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->json('meta')->nullable(); $table->timestamp('updated_at')->nullable(); $table->timestamp('deleted_at')->nullable(); }); @@ -153,3 +156,35 @@ function user_observer_subject(): array ->and($remaining)->not->toContain('session-company-user') ->and($deletedAt)->not->toBeNull(); }); + +it('frees the email and phone of a soft deleted user and keeps them in meta', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->update(['email' => 'driver@example.test', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->deleted($user); + + $row = $capsule->getConnection('mysql')->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBeNull() + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]); +}); + +it('restores a released identity only where no other account has taken it', function () { + [$user, , , $capsule, $observer] = user_observer_subject(); + $connection = $capsule->getConnection('mysql'); + $connection->table('users')->where('uuid', 'user-1')->update([ + 'meta' => json_encode(['deleted_identity' => ['email' => 'driver@example.test', 'phone' => '+15555550100']]), + ]); + $connection->table('users')->insert(['uuid' => 'user-2', 'phone' => '+15555550100']); + $user->refresh(); + + $observer->restored($user); + + $row = $connection->table('users')->where('uuid', 'user-1')->first(); + + expect($row->email)->toBe('driver@example.test') + ->and($row->phone)->toBeNull() + ->and(json_decode($row->meta, true))->toBe([]); +}); From 8c94dded6e940fc1de4ec959af2ca41b6b864efd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:04:02 +0800 Subject: [PATCH 2/3] test: cover managed account promotion and impersonation guards Covers the remaining paths: - promotion syncs permissions and policies; - promotion reports a missing organization and an unavailable role; - identity lookup with no email or phone; - impersonation refuses driver accounts. Also removes the unused User::scopeStaff(). --- src/Models/User.php | 10 ----- .../Http/AuthControllerLoginBootstrapTest.php | 14 +++++++ tests/Unit/Http/UserControllerTest.php | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/Models/User.php b/src/Models/User.php index c3d0c3b4..e75ade4d 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -873,16 +873,6 @@ public function canHoldConsoleSession(): bool return $this->isNotType(static::SESSIONLESS_TYPES); } - /** - * Scope a query to staff accounts only. - */ - public function scopeStaff(Builder $query): Builder - { - return $query->where(function ($query) { - $query->whereNotIn($this->qualifyColumn('type'), static::MANAGED_TYPES)->orWhereNull($this->qualifyColumn('type')); - }); - } - /** * Scope a query to profile-managed accounts only. */ diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index bfaee3b5..4996d6eb 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1383,3 +1383,17 @@ function auth_controller_sms_request(array $input): Request ['contact', 403], ['customer', 200], ]); + +test('admin impersonation refuses driver accounts', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_login_insert_user($capsule, ['uuid' => 'admin-user', 'email' => 'admin@example.test', 'type' => 'admin']); + auth_controller_login_insert_user($capsule, ['uuid' => 'driver-user', 'email' => 'driver@example.test', 'type' => 'driver']); + + $response = (new AuthController())->impersonate(auth_controller_authenticated_request('POST', [ + 'user' => 'driver-user', + ], User::find('admin-user'), '/int/v1/auth/impersonate', AdminRequest::class)); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') + ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->where('tokenable_id', 'driver-user')->count())->toBe(0); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 4d6e6880..73e727bf 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -1969,3 +1969,44 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($accepted->getData(true)['needs_password'])->toBeTrue() ->and($capsule->getConnection('mysql')->table('users')->where('uuid', 'driver-invitee')->value('type'))->toBe('user'); }); + +test('user controller promotion syncs permissions and policies and reports organization and role failures', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $now = '2026-07-18 10:00:00'; + $db->table('users')->insert(['uuid' => 'customer-1', 'public_id' => 'user_customer_1', 'company_uuid' => 'company-1', 'email' => 'customer@example.test', 'phone' => '+15555550188', 'name' => 'Customer One', 'type' => 'customer', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('company_users')->insert(['uuid' => 'pivot-customer-1', 'company_uuid' => 'company-1', 'user_uuid' => 'customer-1', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $db->table('permissions')->insert(['id' => 'permission-promote', 'name' => 'iam promote user', 'guard_name' => 'sanctum', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + $db->table('policies')->insert(['id' => 'policy-promote', 'company_uuid' => 'company-1', 'name' => 'Promote policy', 'guard_name' => 'sanctum', 'service' => 'iam', 'description' => 'Promote', 'created_at' => $now, 'updated_at' => $now]); + + $findPromotable = new ReflectionMethod(UserController::class, 'findPromotableAccount'); + $noIdentity = $findPromotable->invoke(user_controller(), null, ' '); + + $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['phone' => '+15555550188', 'role_uuid' => 'role-other-company'], + ], user_controller_user('owner-1'), 'createRecord')); + + $promoted = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => [ + 'phone' => '+15555550188', + 'permissions' => ['permission-promote'], + 'policies' => ['policy-promote'], + ], + ], user_controller_user('owner-1'), 'createRecord')); + + $db->table('users')->insert(['uuid' => 'driver-2', 'public_id' => 'user_driver_2', 'company_uuid' => 'company-1', 'email' => 'driver2@example.test', 'name' => 'Driver Two', 'type' => 'driver', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now]); + $promote = new ReflectionMethod(UserController::class, 'promoteManagedAccount'); + $promoteRequest = user_controller_request('POST', ['user' => []], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest); + + expect($noIdentity)->toBeNull() + ->and($invalidRole->getStatusCode())->toBe(404) + ->and($promoted->getStatusCode())->toBe(200) + ->and($promoted->getData(true)['promoted_from'])->toBe('customer') + ->and(User::find('customer-1')->type)->toBe('user') + ->and($db->table('model_has_permissions')->where('model_uuid', 'pivot-customer-1')->where('permission_id', 'permission-promote')->exists())->toBeTrue() + ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-customer-1')->where('policy_id', 'policy-promote')->exists())->toBeTrue() + ->and($noCompany->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]); +}); From ec25f4911bbbd2ea223d3bd6e97a7974454cbb02 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:20:03 +0800 Subject: [PATCH 3/3] fix: never grant the Administrator role by default Creating or inviting a user without a role gave them the Administrator role, which carries the administrator policy and full organization access. Reported for IAM > Customers > Add customer: a blank Role created a `user` account with full access. The same default sat behind every role-less path: - `Company::addUser`, `Company::assignUser` and `User::assignCompany` all defaulted the role to Administrator; - accepting an invite with no role gave Administrator; - `joinOrganization` ignored the invite's role altogether. Changes: - The model helpers take `?string $role = null` and assign no role when none is given. The owner paths pass Administrator explicitly: `Auth::register` and `FixUserCompanies`, the latter only for the company owner. - User create, invite and promotion require a role. Without one they return 422 "Select a role for this user." - Only admins or holders of the Administrator role may grant the Administrator role, on create, invite, promotion and role update. Anyone else gets 403. - Accepting an invite and joinOrganization use the invite's role. A legacy invite without one joins with no role. --- src/Console/Commands/FixUserCompanies.php | 3 +- .../Internal/v1/AuthController.php | 26 +++- .../Internal/v1/UserController.php | 119 ++++++++++------- src/Models/Company.php | 14 +- src/Models/Invite.php | 17 +++ src/Models/User.php | 5 +- src/Support/Auth.php | 16 ++- .../Console/AdminMaintenanceCommandsTest.php | 32 +++++ tests/Unit/Console/RecoveryCommandTest.php | 2 +- .../Http/AuthControllerLoginBootstrapTest.php | 36 +++++ tests/Unit/Http/UserControllerTest.php | 125 +++++++++++++++--- tests/Unit/Models/CompanyModelTest.php | 6 +- 12 files changed, 314 insertions(+), 87 deletions(-) diff --git a/src/Console/Commands/FixUserCompanies.php b/src/Console/Commands/FixUserCompanies.php index f1358421..c5f853e1 100644 --- a/src/Console/Commands/FixUserCompanies.php +++ b/src/Console/Commands/FixUserCompanies.php @@ -41,7 +41,8 @@ public function handle() $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = Company::where('uuid', $user->company_uuid)->first(); if ($company) { - $user->assignCompany($company); + // Only the company owner is restored as an Administrator + $user->assignCompany($company, $company->owner_uuid === $user->uuid ? 'Administrator' : null); $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 32e3e4fe..e0058520 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -19,6 +19,7 @@ use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; +use Fleetbase\Models\Role; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\UserForgotPassword; @@ -877,8 +878,8 @@ public function joinOrganization(JoinOrganizationRequest $request) $user = Auth::getUserFromSession($request); // Make sure user has been invited to join organizations - $isAlreadyInvited = Invite::isAlreadySentToJoinCompany($user, $company); - if (!$isAlreadyInvited) { + $invite = Invite::findSentToJoinCompany($user, $company); + if (!$invite) { return response()->error('User has not been invited to join this organization.'); } @@ -887,7 +888,8 @@ public function joinOrganization(JoinOrganizationRequest $request) return response()->error('User is already a member of this organization.'); } - $company->assignUser($user); + // Join with the role the invite carries; never a default one + $company->assignUser($user, $this->inviteRoleId($invite, $company)); Auth::setSession($user); return response()->json(['status' => 'ok']); @@ -899,6 +901,24 @@ public function joinOrganization(JoinOrganizationRequest $request) // @codeCoverageIgnoreEnd } + /** + * The id of the role an invite grants in the organization, when it names one + * that belongs to the organization or is global. + */ + private function inviteRoleId(Invite $invite, Company $company): ?string + { + $roleId = $invite->getMeta('role_uuid'); + if (!$roleId) { + return null; + } + + return Role::where(function ($query) use ($roleId) { + $query->where('id', $roleId)->orWhere('name', $roleId); + })->where(function ($query) use ($company) { + $query->where('company_uuid', $company->uuid)->orWhereNull('company_uuid'); + })->value('id'); + } + /** * Allows user to create a new organization. * diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 9efe64b0..c2f18f83 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -164,11 +164,17 @@ public function findRecord(Request $request, $id) */ public function createRecord(Request $request) { + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; + } + // A driver/customer/contact account in this organisation with the same // email or phone is promoted to a team member instead of duplicated. $managedUser = $this->findPromotableAccount($request->input('user.email'), $request->input('user.phone')); if ($managedUser) { - return $this->promoteManagedAccount($managedUser, $request); + return $this->promoteManagedAccount($managedUser, $request, $role); } $this->validateRequest($request); @@ -189,11 +195,7 @@ public function createRecord(Request $request) return response()->error('This user is already a member of your organisation.'); } - return $this->inviteExistingUser($existingUser, $request); - } - - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + return $this->inviteExistingUser($existingUser, $request, $role); } try { @@ -211,22 +213,15 @@ public function createRecord(Request $request) 'ip_address' => $request->ip(), 'timezone' => $timezone, ])); - }, function (&$request, &$user) { + }, function (&$request, &$user) use ($role) { // Make sure to assign to current company $company = Auth::getCompany(); // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); - - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); - - // Assign role if set - if ($role) { - $user->assignSingleRole($role); - } + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); // Sync Permissions if ($request->isArray('user.permissions')) { @@ -293,6 +288,10 @@ public function updateRecord(Request $request, string $id) if (!$roleToAssign) { return response()->error('The selected role is not available for this organisation.', 404); } + + if ($denied = $this->denyRoleGrant($roleToAssign, $request)) { + return $denied; + } } $record->update(Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at'])); @@ -444,6 +443,41 @@ private function identityValueMatches(string $field, mixed $incoming, mixed $cur /** * Resolve a role assignable by the active company. */ + /** + * Resolve the role a new or invited user joins with. A role is required and + * must be assignable by the current user. + */ + private function resolveRequestedRole(Request $request): Role|\Illuminate\Http\JsonResponse + { + $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); + if (is_array($roleIdentifier)) { + $roleIdentifier = data_get($roleIdentifier, 'id') ?? data_get($roleIdentifier, 'uuid'); + } + + if (!$roleIdentifier) { + return response()->error('Select a role for this user.', 422); + } + + $role = $this->resolveAssignableRole($roleIdentifier); + if (!$role) { + return response()->error('The selected role is not available for this organisation.', 404); + } + + return $this->denyRoleGrant($role, $request) ?? $role; + } + + /** + * Only administrators may grant the Administrator role. + */ + private function denyRoleGrant(Role $role, Request $request): ?\Illuminate\Http\JsonResponse + { + if (Auth::canGrantRole($role, Auth::getUserFromSession($request))) { + return null; + } + + return response()->error('Only administrators can grant the Administrator role.', 403); + } + private function resolveAssignableRole(string|Role|null $role, ?string $companyUuid = null): ?Role { $companyUuid ??= session('company'); @@ -701,15 +735,17 @@ public function inviteUser(InviteUserRequest $request) return response()->error('Unable to determine the current organisation.'); } - if ($request->filled('user.role_uuid') && !$this->resolveAssignableRole($request->input('user.role_uuid'))) { - return response()->error('The selected role is not available for this organisation.', 404); + // A role is always chosen explicitly: access is never granted by default. + $role = $this->resolveRequestedRole($request); + if ($role instanceof \Illuminate\Http\JsonResponse) { + return $role; } // A driver/customer/contact account in this organisation with the same // email is promoted to a team member instead of invited. $managedUser = $this->findPromotableAccount($email, data_get($data, 'phone')); if ($managedUser) { - return $this->promoteManagedAccount($managedUser, $request); + return $this->promoteManagedAccount($managedUser, $request, $role); } // Check if user already exists in the system. @@ -726,7 +762,7 @@ public function inviteUser(InviteUserRequest $request) } // Existing user from another org — issue a cross-org invite. - return $this->inviteExistingUser($user, $request); + return $this->inviteExistingUser($user, $request, $role); } // Brand-new user — create a pending record; assignCompany() below issues the join invite + notification. @@ -740,15 +776,8 @@ public function inviteUser(InviteUserRequest $request) // Set user type $user->setUserType('user'); - $role = $this->resolveAssignableRole($request->input('user.role_uuid')); - - // Assign to user - $user->assignCompany($company, $role ? $role->id : 'Administrator'); - - // Assign role if set - if ($role) { - $user->assignSingleRole($role); - } + // Assign to company with the chosen role + $user->assignCompany($company, $role->id); return response()->json(['user' => new $this->resource($user)]); } @@ -786,23 +815,17 @@ private function findPromotableAccount(?string $email, ?string $phone): ?User * keeps a single account for the console and their app. A join invite is * sent so they can set a console password. */ - private function promoteManagedAccount(User $user, Request $request): \Illuminate\Http\JsonResponse + private function promoteManagedAccount(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse { $company = Auth::getCompany(); if (!$company) { return response()->error('Unable to determine the current organisation.'); } - $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); - $role = $roleIdentifier ? $this->resolveAssignableRole($roleIdentifier) : null; - if ($roleIdentifier && !$role) { - return response()->error('The selected role is not available for this organisation.', 404); - } - $previousType = $user->getType(); $user->meta = array_merge((array) ($user->meta ?? []), ['promoted_from' => $previousType]); $user->setUserType('user'); - $user->assignSingleRole($role ? $role : 'Administrator'); + $user->assignSingleRole($role); if ($request->isArray('user.permissions')) { $user->syncPermissions(Permission::whereIn('id', $request->array('user.permissions'))->get()); @@ -821,7 +844,7 @@ private function promoteManagedAccount(User $user, Request $request): \Illuminat 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $role?->id, 'promoted_from' => $previousType]), + 'meta' => array_filter(['role_uuid' => $role->id, 'promoted_from' => $previousType]), 'expires_at' => now()->addHours(48), ]); @@ -847,8 +870,9 @@ private function promoteManagedAccount(User $user, Request $request): \Illuminat * * @param User $user the existing user to invite * @param Request $request the originating HTTP request + * @param Role $role the role the user joins with */ - private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse + private function inviteExistingUser(User $user, Request $request, Role $role): \Illuminate\Http\JsonResponse { $company = Auth::getCompany(); @@ -861,11 +885,6 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H return response()->error('This user has already been invited to join your organisation.'); } - $roleIdentifier = $request->input('user.role_uuid') ?? $request->input('user.role'); - if ($roleIdentifier && !$this->resolveAssignableRole($roleIdentifier)) { - return response()->error('The selected role is not available for this organisation.', 404); - } - $invitation = Invite::create([ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), @@ -874,7 +893,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $roleIdentifier]), + 'meta' => ['role_uuid' => $role->id], 'expires_at' => now()->addHours(48), ]); @@ -972,11 +991,11 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) if (!$alreadyMember) { // Use Company::addUser() so that role assignment is handled in - // one place. The role stored in the invite meta takes precedence; - // if none was set the default 'Administrator' role is used. - $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); - $roleIdentifier = $role ? $role->id : 'Administrator'; - $companyUser = $company->addUser($user, $roleIdentifier); + // one place. The user joins with the role stored in the invite; an + // invite without one (issued before roles were required) joins with + // no role, so access is never granted by default. + $role = $this->resolveAssignableRole($invite->getMeta('role_uuid'), $company->uuid); + $companyUser = $company->addUser($user, $role?->id); $user->setRelation('companyUser', $companyUser); } else { // User is already a member — ensure the companyUser relation is diff --git a/src/Models/Company.php b/src/Models/Company.php index 963acbcc..0e202cce 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -369,16 +369,14 @@ public static function currentSession(): ?Company * it defaults to the user's current role. The status can also be specified, defaulting to 'active'. * * @param User $user the user to be added to the company - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * @param string $status The status of the user within the company. Defaults to 'active'. * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { - // Get the currentuser role - $role = $role; - $companyUser = CompanyUser::firstOrCreate( [ 'company_uuid' => $this->uuid, @@ -392,7 +390,9 @@ public function addUser(User $user, string $role = 'Administrator', string $stat ); // Assign the role to the new user - $companyUser->assignSingleRole($role); + if ($role) { + $companyUser->assignSingleRole($role); + } return $companyUser; } @@ -461,7 +461,7 @@ public function changeUserRole(User $user, string $roleName): bool * * @return CompanyUser the CompanyUser instance representing the association between the user and the company */ - public function assignUser(User $user, string $role = 'Administrator'): CompanyUser + public function assignUser(User $user, ?string $role = null): CompanyUser { $companyUser = $this->addUser($user, $role); $user->assignCompany($this); diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 03c93883..15e6dabe 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -168,6 +168,23 @@ public static function isAlreadySentToJoinCompany(User $user, Company $company): return static::isAlreadySent($company, $user->email, 'join_company'); } + /** + * The invite sent to the user to join the company, if any. + */ + public static function findSentToJoinCompany(User $user, Company $company): ?self + { + if (!$user->email) { + return null; + } + + return static::where([ + 'company_uuid' => $company->uuid, + 'subject_uuid' => $company->uuid, + 'protocol' => 'email', + 'reason' => 'join_company', + ])->whereJsonContains('recipients', $user->email)->latest()->first(); + } + public static function isAlreadySent(Company $company, string $email, string $reason, string $protocol = 'email'): bool { return static::where([ diff --git a/src/Models/User.php b/src/Models/User.php index e75ade4d..411b1396 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -542,11 +542,12 @@ public function setCompanyUserRelation(Company $company): void * will be notified that a user has been created. * * @param Company $company the company to assign the user to - * @param string|null $role The name or ID of the role to assign to the user. Defaults to the user's current role if null. + * @param string|null $role The name or ID of the role to assign to the user. No role is assigned when null: + * access is only ever granted explicitly. * * @return self returns the current User instance */ - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { $this->company_uuid = $company->uuid; diff --git a/src/Support/Auth.php b/src/Support/Auth.php index a93df8bc..f5a21a4f 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -44,7 +44,7 @@ public static function register($owner, $company) ->setOwner($owner) ->saveInstance(); - $owner->assignCompany($company); + $owner->assignCompany($company, 'Administrator'); return $owner; } @@ -80,6 +80,20 @@ public static function denyConsoleSession(?User $user): ?\Illuminate\Http\JsonRe return response()->error('This account cannot sign in to the console.', 403, ['code' => 'console_access_not_allowed', 'restore' => false]); } + /** + * Whether the actor may grant the role to another user. The Administrator + * role gives full access to the organization, so only admins and users + * holding it may grant it. + */ + public static function canGrantRole(Role $role, ?User $actor): bool + { + if ($role->name !== 'Administrator') { + return true; + } + + return $actor instanceof User && ($actor->isAdmin() || $actor->hasRole('Administrator')); + } + /** * Set session variables for user. * diff --git a/tests/Unit/Console/AdminMaintenanceCommandsTest.php b/tests/Unit/Console/AdminMaintenanceCommandsTest.php index ed70729b..499a0e3f 100644 --- a/tests/Unit/Console/AdminMaintenanceCommandsTest.php +++ b/tests/Unit/Console/AdminMaintenanceCommandsTest.php @@ -264,3 +264,35 @@ public function clear(array $tags = []): void ])->exists())->toBeTrue() ->and($db->table('model_has_roles')->where('role_id', 'role-admin')->exists())->toBeTrue(); }); + +it('repairs a member who does not own the company without granting a role', function () { + $capsule = admin_maintenance_database(); + $db = $capsule->getConnection('mysql'); + + $db->table('users')->insert([ + 'uuid' => 'user-missing-member', + 'company_uuid' => 'company-1', + 'name' => 'Plain Member', + 'email' => 'member@example.test', + 'type' => 'admin', + 'status' => 'active', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + $db->table('companies')->insert([ + 'uuid' => 'company-1', + 'owner_id' => 'someone-else', + 'owner_uuid' => 'someone-else', + 'name' => 'Acme Logistics', + 'created_at' => '2026-07-18 00:00:00', + 'updated_at' => '2026-07-18 00:00:00', + ]); + + $command = new FixUserCompanies(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + + expect($tester->execute([]))->toBe(0) + ->and($db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'user-missing-member'])->exists())->toBeTrue() + ->and($db->table('model_has_roles')->count())->toBe(0); +}); diff --git a/tests/Unit/Console/RecoveryCommandTest.php b/tests/Unit/Console/RecoveryCommandTest.php index e46694f1..5f8e9996 100644 --- a/tests/Unit/Console/RecoveryCommandTest.php +++ b/tests/Unit/Console/RecoveryCommandTest.php @@ -133,7 +133,7 @@ public function changePassword($newPassword): self return $this; } - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { if (in_array('assignCompany', $this->throwOn, true)) { throw new RuntimeException('assign company failed'); diff --git a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php index 4996d6eb..1008904d 100644 --- a/tests/Unit/Http/AuthControllerLoginBootstrapTest.php +++ b/tests/Unit/Http/AuthControllerLoginBootstrapTest.php @@ -1397,3 +1397,39 @@ function auth_controller_sms_request(array $input): Request ->and($response->getData(true)['code'])->toBe('console_access_not_allowed') ->and($capsule->getConnection('mysql')->table('personal_access_tokens')->where('tokenable_id', 'driver-user')->count())->toBe(0); }); + +test('join organization grants the invite role and never a default one', function () { + $capsule = auth_controller_login_bootstrap_database(); + auth_controller_insert_administrator_role($capsule, 'company-join'); + auth_controller_insert_administrator_role($capsule, 'company-legacy'); + $capsule->getConnection('mysql')->table('roles')->insert([ + 'id' => '33333333-3333-4333-8333-333333333399', 'company_uuid' => 'company-join', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', + 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', 'deleted_at' => null, + ]); + auth_controller_login_insert_user($capsule, ['uuid' => 'joining-user', 'email' => 'joining@example.test', 'company_uuid' => 'company-current', 'type' => 'user']); + auth_controller_insert_company($capsule, ['uuid' => 'company-join', 'public_id' => 'company_join_public']); + auth_controller_insert_company($capsule, ['uuid' => 'company-legacy', 'public_id' => 'company_legacy_public', 'slug' => 'legacy-company']); + auth_controller_insert_join_invite($capsule, 'company-join', 'joining@example.test'); + $capsule->getConnection('mysql')->getSchemaBuilder()->table('invites', fn ($table) => $table->text('meta')->nullable()); + $capsule->getConnection('mysql')->table('invites')->where('company_uuid', 'company-join')->update(['meta' => json_encode(['role_uuid' => '33333333-3333-4333-8333-333333333399'])]); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'company_uuid' => 'company-legacy', 'subject_uuid' => 'company-legacy', 'subject_type' => Fleetbase\Models\Company::class, + 'created_by_uuid' => 'owner-user', 'protocol' => 'email', 'reason' => 'join_company', 'recipients' => json_encode(['joining@example.test']), + 'expires_at' => Carbon::now()->addDay()->toDateTimeString(), 'deleted_at' => null, 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00', + ]); + + $joined = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_join_public')); + $legacy = (new AuthController())->joinOrganization(auth_controller_join_organization_request(User::find('joining-user'), 'company_legacy_public')); + + $db = $capsule->getConnection('mysql'); + $joinPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-join'])->value('uuid'); + $legacyPivot = $db->table('company_users')->where(['user_uuid' => 'joining-user', 'company_uuid' => 'company-legacy'])->value('uuid'); + + expect($joined->getStatusCode())->toBe(200) + ->and($db->table('model_has_roles')->where('model_uuid', $joinPivot)->pluck('role_id')->all())->toBe(['33333333-3333-4333-8333-333333333399']) + ->and($legacy->getStatusCode())->toBe(200) + ->and($legacyPivot)->not->toBeNull() + ->and($db->table('model_has_roles')->where('model_uuid', $legacyPivot)->count())->toBe(0) + // An account without an email can't have been sent an invite + ->and(Fleetbase\Models\Invite::findSentToJoinCompany(new User(), Fleetbase\Models\Company::find('company-join')))->toBeNull(); +}); diff --git a/tests/Unit/Http/UserControllerTest.php b/tests/Unit/Http/UserControllerTest.php index 73e727bf..8a9d5749 100644 --- a/tests/Unit/Http/UserControllerTest.php +++ b/tests/Unit/Http/UserControllerTest.php @@ -605,6 +605,16 @@ public function clear(): void return $capsule; } +/** + * Give the owner the Administrator role, as a real organization owner has. + */ +function user_controller_owner_is_administrator(Capsule $capsule): void +{ + $capsule->getConnection('mysql')->table('model_has_roles')->insert([ + 'role_id' => 'Administrator', 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => 'pivot-owner-1', + ]); +} + function user_controller(): UserController { return new UserController(); @@ -927,12 +937,14 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record rejects duplicate active-company members and unavailable roles', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $duplicateMember = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'member@example.test', - 'name' => 'Member One', + 'email' => 'member@example.test', + 'name' => 'Member One', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -951,6 +963,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller create record invites existing users from another organization', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -969,14 +982,17 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller create record reports existing-user invite precondition failures', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); - session()->flush(); - $missingCompany = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + $missingCompanyRequest = user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], - ], user_controller_user('owner-1'), 'createRecord')); + ], user_controller_user('owner-1'), 'createRecord'); + session()->flush(); + $missingCompany = user_controller_without_request_validation()->createRecord($missingCompanyRequest); session(['company' => 'company-1', 'user' => 'owner-1']); $invalidRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ @@ -1137,7 +1153,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a }); test('user controller formats create and update exception responses by exception type', function () { - user_controller_database(); + $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $queryException = new Illuminate\Database\QueryException( 'mysql', @@ -1150,8 +1167,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createDatabaseFailure->model = new UserControllerThrowingModel($queryException); $createDatabaseResponse = $createDatabaseFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'database-failure@example.test', - 'name' => 'Database Failure', + 'email' => 'database-failure@example.test', + 'name' => 'Database Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1161,8 +1179,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ])); $createValidationResponse = $createValidationFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'validation-failure@example.test', - 'name' => 'Validation Failure', + 'email' => 'validation-failure@example.test', + 'name' => 'Validation Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1170,8 +1189,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a $createGenericFailure->model = new UserControllerThrowingModel(new RuntimeException('generic create failure')); $createGenericResponse = $createGenericFailure->createRecord(user_controller_request('POST', [ 'user' => [ - 'email' => 'generic-failure@example.test', - 'name' => 'Generic Failure', + 'email' => 'generic-failure@example.test', + 'name' => 'Generic Failure', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'createRecord')); @@ -1637,6 +1657,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites a brand new user and prevents duplicate organization invitations', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1649,8 +1670,9 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicate = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'fresh@example.test', - 'name' => 'Fresh User', + 'email' => 'fresh@example.test', + 'name' => 'Fresh User', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1675,6 +1697,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller invites existing users from another organization without creating duplicates', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); EloquentModel::setEventDispatcher(new Dispatcher(app())); $invite = user_controller()->inviteUser(user_controller_request('POST', [ @@ -1685,7 +1708,8 @@ function user_controller_assert_created_user_response(mixed $response): object|a ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); $duplicateInvite = user_controller()->inviteUser(user_controller_request('POST', [ 'user' => [ - 'email' => 'foreign@example.test', + 'email' => 'foreign@example.test', + 'role_uuid' => 'Administrator', ], ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); @@ -1908,6 +1932,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller promotes a managed driver account in the organization instead of inviting a duplicate', function (string $method) { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $connection = $capsule->getConnection('mysql'); EloquentModel::setEventDispatcher(new Dispatcher(app())); $now = '2026-07-18 10:00:00'; @@ -1972,6 +1997,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a test('user controller promotion syncs permissions and policies and reports organization and role failures', function () { $capsule = user_controller_database(); + user_controller_owner_is_administrator($capsule); $db = $capsule->getConnection('mysql'); EloquentModel::setEventDispatcher(new Dispatcher(app())); $now = '2026-07-18 10:00:00'; @@ -1990,6 +2016,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a $promoted = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ 'user' => [ 'phone' => '+15555550188', + 'role_uuid' => 'Administrator', 'permissions' => ['permission-promote'], 'policies' => ['policy-promote'], ], @@ -1999,7 +2026,7 @@ function user_controller_assert_created_user_response(mixed $response): object|a $promote = new ReflectionMethod(UserController::class, 'promoteManagedAccount'); $promoteRequest = user_controller_request('POST', ['user' => []], user_controller_user('owner-1'), 'createRecord'); session()->flush(); - $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest); + $noCompany = $promote->invoke(user_controller(), User::find('driver-2'), $promoteRequest, Role::find('Administrator')); expect($noIdentity)->toBeNull() ->and($invalidRole->getStatusCode())->toBe(404) @@ -2010,3 +2037,63 @@ function user_controller_assert_created_user_response(mixed $response): object|a ->and($db->table('model_has_policies')->where('model_uuid', 'pivot-customer-1')->where('policy_id', 'policy-promote')->exists())->toBeTrue() ->and($noCompany->getData(true))->toBe(['errors' => ['Unable to determine the current organisation.']]); }); + +test('user controller requires a role and only lets administrators grant the Administrator role', function () { + $capsule = user_controller_database(); + $db = $capsule->getConnection('mysql'); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + $db->table('roles')->insert(['id' => 'Dispatcher', 'company_uuid' => 'company-1', 'name' => 'Dispatcher', 'guard_name' => 'sanctum', 'created_at' => '2026-07-18 10:00:00', 'updated_at' => '2026-07-18 10:00:00']); + + $createWithoutRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'no-role@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'createRecord')); + $inviteWithoutRole = user_controller()->inviteUser(user_controller_request('POST', [ + 'user' => ['email' => 'no-role-invite@example.test', 'name' => 'No Role'], + ], user_controller_user('owner-1'), 'inviteUser', InviteUserRequest::class)); + + // owner-1 has no Administrator role in this organization, so it may not grant it + $createAdministrator = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'escalate@example.test', 'name' => 'Escalate', 'role_uuid' => 'Administrator'], + ], user_controller_user('owner-1'), 'createRecord')); + $updateToAdministrator = user_controller_without_request_validation()->updateRecord(user_controller_request('PATCH', [ + 'user' => ['role' => 'Administrator'], + ], user_controller_user('owner-1'), 'updateRecord'), 'member-1'); + $objectRole = user_controller_without_request_validation()->createRecord(user_controller_request('POST', [ + 'user' => ['email' => 'object-role@example.test', 'name' => 'Object Role', 'role' => ['id' => 'Dispatcher']], + ], user_controller_user('owner-1'), 'createRecord')); + + expect($createWithoutRole->getStatusCode())->toBe(422) + ->and($createWithoutRole->getData(true))->toBe(['errors' => ['Select a role for this user.']]) + ->and($inviteWithoutRole->getStatusCode())->toBe(422) + ->and(User::where('email', 'no-role-invite@example.test')->exists())->toBeFalse() + ->and($createAdministrator->getStatusCode())->toBe(403) + ->and($createAdministrator->getData(true))->toBe(['errors' => ['Only administrators can grant the Administrator role.']]) + ->and(User::where('email', 'escalate@example.test')->exists())->toBeFalse() + ->and($updateToAdministrator->getStatusCode())->toBe(403) + ->and($objectRole)->not->toBeInstanceOf(JsonResponse::class); + + $created = User::where('email', 'object-role@example.test')->first(); + $pivot = $db->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $created->uuid])->value('uuid'); + + expect($db->table('model_has_roles')->where('model_uuid', $pivot)->pluck('role_id')->all())->toBe(['Dispatcher']); +}); + +test('user controller accepting an invite without a role joins without one', function () { + $capsule = user_controller_database(); + EloquentModel::setEventDispatcher(new Dispatcher(app())); + + $invitee = User::create(['uuid' => 'legacy-invitee', 'public_id' => 'user_legacy_invitee', 'email' => 'legacy@example.test', 'name' => 'Legacy Invitee', 'company_uuid' => 'company-2', 'status' => 'active']); + $capsule->getConnection('mysql')->table('invites')->insert([ + 'uuid' => 'invite-legacy', 'public_id' => 'invite_public_legacy', 'code' => 'LEGACY1', 'uri' => 'legacy1', 'company_uuid' => 'company-1', + 'created_by_uuid' => 'owner-1', 'subject_uuid' => 'company-1', 'subject_type' => Fleetbase\Support\Utils::getMutationType(Fleetbase\Models\Company::where('uuid', 'company-1')->first()), + 'protocol' => 'email', 'recipients' => json_encode(['legacy@example.test']), 'reason' => 'join_company', 'meta' => json_encode([]), + 'expires_at' => now()->addHours(48), 'created_at' => now(), 'updated_at' => now(), + ]); + + $accepted = user_controller()->acceptCompanyInvite(user_controller_request('POST', ['code' => 'LEGACY1'], $invitee, 'acceptCompanyInvite', AcceptCompanyInvite::class)); + $pivot = $capsule->getConnection('mysql')->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => 'legacy-invitee'])->value('uuid'); + + expect($accepted->getStatusCode())->toBe(200) + ->and($pivot)->not->toBeNull() + ->and($capsule->getConnection('mysql')->table('model_has_roles')->where('model_uuid', $pivot)->count())->toBe(0); +}); diff --git a/tests/Unit/Models/CompanyModelTest.php b/tests/Unit/Models/CompanyModelTest.php index 07ab3382..5a946362 100644 --- a/tests/Unit/Models/CompanyModelTest.php +++ b/tests/Unit/Models/CompanyModelTest.php @@ -76,7 +76,7 @@ public function __construct(?CompanyUser $companyUser = null) $this->setRawAttributes(['uuid' => 'company-1'], true); } - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { $this->addedUsers[] = [$user->uuid, $role, $status]; @@ -348,7 +348,7 @@ function company_model_create_users_table(): void $user = new class extends User { public array $assignedCompanies = []; - public function assignCompany(Company $company, string $role = 'Administrator'): User + public function assignCompany(Company $company, ?string $role = null): User { $this->assignedCompanies[] = [$company->uuid, $role]; @@ -362,7 +362,7 @@ public function assignCompany(Company $company, string $role = 'Administrator'): expect($company->assignUser($user, 'Dispatcher'))->toBe($companyUser) ->and($company->addedUsers)->toBe([['user-1', 'Dispatcher', 'active']]) - ->and($user->assignedCompanies)->toBe([['company-1', 'Administrator']]); + ->and($user->assignedCompanies)->toBe([['company-1', null]]); }); it('resolves the current company from session using the configured connection', function () {