Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions server/src/Http/Controllers/Api/v1/DriverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ public function create(CreateDriverRequest $request)
// get user details for driver
$userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']);

// Get current company session
$company = $request->has('company') ? $this->companyFromRequest($request) : $this->currentCompany();

// Debugging: Ensure company is retrieved correctly
if (!$company) {
// Drivers are created in the credential's own organization. A request
// may still name it, but naming any other organization is refused the
// same way as one that does not exist.
$company = $this->currentCompany();
$requestedCompany = $request->input('company');
if (!$company || ($request->has('company') && !in_array($requestedCompany, [$company->uuid, $company->public_id], true))) {
return $this->apiError('Company not found.');
}

Expand Down Expand Up @@ -206,7 +207,7 @@ public function update($id, UpdateDriverRequest $request)

// update the driver's login account through its proxy fields
try {
ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails);
ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails, $driver->company_uuid);
} catch (ProfileIdentityConflictException $exception) {
return $this->jsonResponse(['error' => $exception->getMessage()], 422);
}
Expand Down Expand Up @@ -996,11 +997,6 @@ protected function driverInputFromRequest(Request $request, ?string $companyUuid
return $input;
}

protected function companyFromRequest(Request $request): ?Company
{
return Auth::getCompanyFromRequest($request);
}

protected function currentCompany(): ?Company
{
return Auth::getCompany();
Expand Down
19 changes: 12 additions & 7 deletions server/src/Http/Controllers/Internal/v1/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function createPortalLogin(Request $request)
return response()->error('Unable to create customer portal login.');
}

if ($error = $this->staffLinkedError($user)) {
if ($error = $this->staffLinkedError($user, $customer)) {
return $error;
}

Expand All @@ -58,7 +58,7 @@ public function sendCredentials(Request $request)
return response()->error('Unable to send customer portal credentials.');
}

if ($error = $this->staffLinkedError($user)) {
if ($error = $this->staffLinkedError($user, $customer)) {
return $error;
}

Expand All @@ -80,7 +80,7 @@ public function deactivatePortalLogin(Request $request)
return response()->error('Customer portal login not found.');
}

if ($error = $this->staffLinkedError($user)) {
if ($error = $this->staffLinkedError($user, $customer)) {
return $error;
}

Expand All @@ -100,7 +100,7 @@ public function reactivatePortalLogin(Request $request)
return response()->error('Customer portal login not found.');
}

if ($error = $this->staffLinkedError($user)) {
if ($error = $this->staffLinkedError($user, $customer)) {
return $error;
}

Expand Down Expand Up @@ -180,7 +180,7 @@ public function resetCredentials(Request $request)
return response()->error('Unable to reset customer credentials');
}

if ($error = $this->staffLinkedError($user)) {
if ($error = $this->staffLinkedError($user, $customer)) {
return $error;
}

Expand All @@ -200,14 +200,19 @@ public function resetCredentials(Request $request)

/**
* A customer linked to a team member's account signs in with that account,
* which is managed in IAM.
* which is managed in IAM. A login another organization also uses is not
* this organization's to change either.
*/
protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse
protected function staffLinkedError(User $user, Contact $customer): ?\Illuminate\Http\JsonResponse
{
if (ProfileAccountManager::isStaffAccount($user)) {
return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422);
}

if ($customer->company_uuid && ProfileAccountManager::isSharedWithAnotherCompany($user, $customer->company_uuid)) {
return response()->error(ProfileAccountManager::SHARED_LOGIN_MESSAGE, 422);
}

return null;
}

Expand Down
7 changes: 6 additions & 1 deletion server/src/Http/Controllers/Internal/v1/DriverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ function (&$request, &$driver, &$input) {
$input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'user_uuid', 'user'])->toArray();

// Update the driver's login account through its proxy fields
ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput);
ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput, $driver->company_uuid);

// Flush cache
$driver->flushAttributesCache();
Expand Down Expand Up @@ -333,6 +333,11 @@ protected function resolveDriverLogin(string $id): array
return [$driver, $user, response()->error('This driver signs in with a team member account. Manage this login in IAM.', 422)];
}

// Its password and status are not this organization's to change
if (ProfileAccountManager::isSharedWithAnotherCompany($user, $driver->company_uuid)) {
return [$driver, $user, response()->error(ProfileAccountManager::SHARED_LOGIN_MESSAGE, 422)];
}

return [$driver, $user, null];
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/Models/Contact.php
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ public function syncWithUser(): bool
return false;
}

return ProfileAccountManager::syncProxyFields($this->getUser(), $updates);
return ProfileAccountManager::syncProxyFields($this->getUser(), $updates, $this->company_uuid);
}

/**
Expand Down
61 changes: 50 additions & 11 deletions server/src/Support/ProfileAccountManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
* staff account (type `user` or `admin`) of the same organization. A staff
* account is managed in IAM, so a profile never changes its email or phone,
* resets its password, deactivates it or deletes it.
*
* Emails and phone numbers identify one account across the whole instance,
* but an organization only ever reuses an account that already belongs to it.
* An account of another organization is never linked, and the refusal does
* not say whose it is.
*/
class ProfileAccountManager
{
Expand All @@ -32,6 +37,12 @@ class ProfileAccountManager
*/
public const MANAGED_TYPES = ['driver', 'contact', 'customer'];

/**
* Why an organization can't change the password or status of a login that
* another organization's profile also holds.
*/
public const SHARED_LOGIN_MESSAGE = 'This login is also used with another organization, so it can only be managed by its holder.';

/**
* The organization role given to each managed account type.
*/
Expand Down Expand Up @@ -126,8 +137,8 @@ public static function lookup(string $companyUuid, string $type, ?string $email,

/**
* Resolve the account for a new profile. A staff member of the organization
* or a managed account of the same type with the email/phone is linked;
* otherwise a managed account is created.
* or a managed account of the same type in the organization with the
* email/phone is linked; otherwise a managed account is created.
*
* @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta
*
Expand Down Expand Up @@ -191,18 +202,21 @@ public static function createManagedAccount(string $companyUuid, string $type, ?

/**
* Push the profile's proxy fields to its account. A staff account only
* takes the name: its email and phone are its console identity.
* takes the name: its email and phone are its console identity. So does a
* managed account that another organization's profile also holds, since
* its sign-in details are not this organization's to change.
*
* @throws ProfileIdentityConflictException
*/
public static function syncProxyFields(?User $user, array $attributes): bool
public static function syncProxyFields(?User $user, array $attributes, ?string $companyUuid = null): bool
{
if (!$user) {
return false;
}

$fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password'];
$input = array_intersect_key($attributes, array_flip($fields));
$nameOnly = static::isStaffAccount($user) || ($companyUuid && static::isSharedWithAnotherCompany($user, $companyUuid));
$fields = $nameOnly ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password'];
$input = array_intersect_key($attributes, array_flip($fields));

if (array_key_exists('email', $input)) {
$input['email'] = static::normalizeEmail($input['email']);
Expand Down Expand Up @@ -312,11 +326,13 @@ public static function deliverCredentials(Driver|Contact $profile, User $user, s
*/
public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void
{
if (static::isStaffAccount($account)) {
if (!static::isCompanyMember($account, $companyUuid)) {
throw new ProfileIdentityConflictException(static::takenMessage($field), $field);
}
} elseif ($account->type !== $type) {
// An account of another organization is never linked, and nothing
// about it (its name, type or which organization holds it) is told
if (!static::isCompanyMember($account, $companyUuid) && !(static::isManagedAccount($account) && static::isUnowned($account))) {
throw new ProfileIdentityConflictException(static::takenMessage($field), $field);
}

if (static::isManagedAccount($account) && $account->type !== $type) {
throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field);
}

Expand Down Expand Up @@ -345,6 +361,29 @@ public static function profileCompanies(User $user): \Illuminate\Support\Collect
return $drivers->merge($contacts)->filter()->unique()->values();
}

/**
* Whether another organization holds the account through a membership or a
* profile. The account's own `company_uuid` is left out: it is only where
* the account was first created, and stays after that organization lets go.
*/
public static function isSharedWithAnotherCompany(User $user, string $companyUuid): bool
{
$memberships = CompanyUser::where('user_uuid', $user->uuid)->pluck('company_uuid');

return $memberships->merge(static::profileCompanies($user))->filter()->contains(fn ($uuid) => $uuid !== $companyUuid);
}

/**
* Whether the account belongs to no organization at all: no organization of
* its own, no membership and no live profile anywhere.
*/
public static function isUnowned(User $user): bool
{
return !$user->company_uuid
&& !CompanyUser::where('user_uuid', $user->uuid)->exists()
&& static::profileCompanies($user)->isEmpty();
}

public static function isCompanyMember(User $user, ?string $companyUuid): bool
{
if (!$companyUuid) {
Expand Down
31 changes: 23 additions & 8 deletions server/tests/ApiDriverControllerContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,6 @@ class FleetOpsApiDriverControllerProbe extends DriverController
public array $resolvedFiles = [];
public string $sessionCompanyUuid = 'company-uuid';

protected function companyFromRequest(Request $request): ?Company
{
$this->companyCalls[] = ['request', $request->input('company')];

return $this->missingCompany ? null : $this->company();
}

protected function currentCompany(): ?Company
{
$this->companyCalls[] = ['current'];
Expand Down Expand Up @@ -401,7 +394,7 @@ public function or(array $keys, mixed $default = null): mixed
]));

expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
->and($controller->companyCalls)->toBe([['request', 'company_public']])
->and($controller->companyCalls)->toBe([['current']])
// The login account is resolved by ProfileAccountManager in the driver's company
->and($controller->resolvedAccounts)->toBe(['company-uuid'])
->and($controller->createdUsers[0])->toMatchArray([
Expand Down Expand Up @@ -444,6 +437,28 @@ public function or(array $keys, mixed $default = null): mixed
->and($controller->createdUsers)->toBe([]);
});

test('api driver controller only creates drivers in the credential organization', function (string $company) {
// Naming the credential's own organization, by uuid or public id, is fine
$controller = new FleetOpsApiDriverControllerProbe();
$controller->create(new CreateDriverRequest(['company' => $company, 'name' => 'Driver One']));

expect($controller->resolvedAccounts)->toBe(['company-uuid'])
->and($controller->createdDrivers[0]['company_uuid'])->toBe('company-uuid');
})->with(['company-uuid', 'company_public']);

test('api driver controller refuses to create drivers in another organization', function (mixed $company) {
// Any other organization is refused exactly like one that does not exist,
// so the endpoint can't be used to probe or write into another tenant
$controller = new FleetOpsApiDriverControllerProbe();

expect($controller->create(new CreateDriverRequest(['company' => $company, 'name' => 'Driver One'])))->toBe([
'apiError' => 'Company not found.',
'status' => 400,
])
->and($controller->resolvedAccounts)->toBe([])
->and($controller->createdDrivers)->toBe([]);
})->with(['other-company-uuid', 'company_other', '', null]);

test('api driver controller updates drivers user details assignments location and photo', function () {
fleetopsApiDriverContractsUsersTable();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,8 @@ public function parameters()

expect($probe->callHelper('sessionCompany'))->toBe('22222222-2222-4222-8222-222222222222')
->and($probe->callHelper('currentCompany')?->public_id)->toBe('company_one')
->and(true)->toBeTrue();

// With no identifying inputs the core auth resolver finds nothing and
// violates its own non-nullable declaration — the helper body still
// executes, which is the covered contract here.
expect(fn () => $probe->callHelper('companyFromRequest', Request::create('/x', 'GET')))->toThrow(TypeError::class);
// A create request can no longer pick its organization from the input
->and(method_exists($probe, 'companyFromRequest'))->toBeFalse();
});

test('user and driver persistence helpers write records', function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ public function __call($method, $arguments)
'companies' => ['uuid', 'public_id', 'name', 'country'],
// session_status reads the user's membership of the session company
'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
// a login held by another organization's driver profile is shared
'drivers' => ['uuid', 'company_uuid', 'user_uuid'],
'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
];
foreach ($tables as $table => $columns) {
Expand Down Expand Up @@ -180,6 +182,25 @@ public function __call($method, $arguments)
->and($GLOBALS['fleetopsCustomerMailFake']->sent)->not->toBeEmpty();
});

test('credential actions refuse a login another organization also holds', function () {
$connection = fleetopsCustomerCredentialsBoot();
$connection->table('company_users')->insert(['uuid' => 'cu-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => 'user-1', 'status' => 'active']);
$connection->table('users')->where('uuid', 'user-1')->update(['password' => 'hashed:original']);
$controller = new CustomerController();
$request = Request::create('/x', 'POST', ['customer' => 'contact_custcred1', 'password' => 'takeover', 'password_confirmation' => 'takeover']);

foreach (['resetCredentials', 'sendCredentials', 'createPortalLogin', 'deactivatePortalLogin', 'reactivatePortalLogin'] as $action) {
$response = $controller->{$action}($request);

expect($response->getStatusCode())->toBe(422)
->and($response->getData(true))->toBe(['error' => 'This login is also used with another organization, so it can only be managed by its holder.']);
}

expect($connection->table('users')->value('password'))->toBe('hashed:original')
->and($connection->table('users')->value('status'))->toBe('active')
->and($GLOBALS['fleetopsCustomerMailFake']->sent)->toBeEmpty();
});

test('customer resolution and payload helpers expose user details', function () {
fleetopsCustomerCredentialsBoot();
$probe = new FleetOpsInternalCustomerProbe();
Expand Down
Loading
Loading