mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 13:55:21 +00:00
fix(members): scope member view & update to the acting company
Member view/update bound the target user by global id and authorized only that the requester owns their active company, not that the target belonged to it. Bind the route model under the members param and require shared company membership in UserPolicy so an owner of one company can no longer read or modify users of another. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,11 +61,11 @@ class MembersController extends Controller
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(User $user)
|
||||
public function show(User $member)
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
$this->authorize('view', $member);
|
||||
|
||||
return new UserResource($user);
|
||||
return new UserResource($member);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,13 +73,13 @@ class MembersController extends Controller
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(MemberRequest $request, User $user)
|
||||
public function update(MemberRequest $request, User $member)
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
$this->authorize('update', $member);
|
||||
|
||||
$this->memberService->update($user, $request);
|
||||
$this->memberService->update($member, $request);
|
||||
|
||||
return new UserResource($user);
|
||||
return new UserResource($member);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,7 +52,7 @@ class MemberRequest extends FormRequest
|
||||
$rules['email'] = [
|
||||
'required',
|
||||
new IdnEmail,
|
||||
Rule::unique('users')->ignore($this->user),
|
||||
Rule::unique('users')->ignore($this->member),
|
||||
];
|
||||
$rules['password'] = [
|
||||
'nullable',
|
||||
|
||||
@@ -30,11 +30,7 @@ class UserPolicy
|
||||
*/
|
||||
public function view(User $user, User $model): bool
|
||||
{
|
||||
if ($user->isOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->isOwner() && $this->sharesActiveCompany($model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +54,7 @@ class UserPolicy
|
||||
*/
|
||||
public function update(User $user, User $model): bool
|
||||
{
|
||||
if ($user->isOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->isOwner() && $this->sharesActiveCompany($model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,11 +64,7 @@ class UserPolicy
|
||||
*/
|
||||
public function delete(User $user, User $model): bool
|
||||
{
|
||||
if ($user->isOwner()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $user->isOwner() && $this->sharesActiveCompany($model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,4 +122,18 @@ class UserPolicy
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A company owner may only act on members of the company set in the
|
||||
* `company` request header. Without this, view/update/delete resolve the
|
||||
* target user by global id, which would let an owner of one company read
|
||||
* or overwrite users belonging to another company (cross-tenant IDOR).
|
||||
*/
|
||||
private function sharesActiveCompany(User $model): bool
|
||||
{
|
||||
$companyId = request()->header('company');
|
||||
|
||||
return $companyId
|
||||
&& $model->companies()->wherePivot('company_id', $companyId)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
use function Pest\Laravel\putJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
@@ -37,12 +38,35 @@ test('store member using a form request', function () {
|
||||
);
|
||||
});
|
||||
|
||||
test('get member', function () {
|
||||
test('get member belonging to the current company', function () {
|
||||
$companyId = User::where('role', 'super admin')->first()->companies()->first()->id;
|
||||
$user = User::factory()->create();
|
||||
$user->companies()->attach($companyId);
|
||||
|
||||
getJson("/api/v1/members/{$user->id}")->assertOk();
|
||||
});
|
||||
|
||||
test('cannot view a member belonging to another company', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->companies()->attach(Company::factory()->create()->id);
|
||||
|
||||
getJson("/api/v1/members/{$user->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
test('cannot update a member belonging to another company', function () {
|
||||
$companyId = User::where('role', 'super admin')->first()->companies()->first()->id;
|
||||
$user = User::factory()->create();
|
||||
$user->companies()->attach(Company::factory()->create()->id);
|
||||
|
||||
putJson("/api/v1/members/{$user->id}", [
|
||||
'name' => 'Hacked',
|
||||
'email' => 'pwned@attacker.test',
|
||||
'companies' => [['id' => $companyId, 'role' => 'super admin']],
|
||||
])->assertForbidden();
|
||||
|
||||
$this->assertDatabaseMissing('users', ['email' => 'pwned@attacker.test']);
|
||||
});
|
||||
|
||||
test('update member using a form request', function () {
|
||||
$this->assertActionUsesFormRequest(
|
||||
MembersController::class,
|
||||
|
||||
Reference in New Issue
Block a user