mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
eb2917f5ff
This overturns something #1701 decided, so it should say so. That PR closed the membership hole and left GroupsController::update and destroy installation-wide on purpose, on the grounds that managing the group object is a different question from managing who is in it. What decides it is a measurement that was not in front of that decision. An assignment to a group is how its members reach a file, so deleting a group revokes that access for every member. Measured before this guard, with a client-scoped role holding the group permissions: stranger client can read the shared file true PATCH /groups/{stranger group} 302, renamed DELETE /groups/{stranger group} 302, group gone stranger client can read the shared file false So a staff member who may not add somebody to a group out of their reach could delete it out from under the people already in it. That is not a gentler version of the membership rule, it is a harder one, and the two sitting on opposite sides of the same boundary was the odd part. StaffLibraryScope::allowsGroupChange is the reach half of allowsGroupMembership on its own, since no client appears in this question -- one predicate, two callers, rather than a second statement of it. Both surfaces take it, at 404, matching the membership guards. A group that shares nothing beyond the actor's library still passes, so a group they created or one holding their own clients stays theirs, and unscoped staff are unaffected by construction. The API document moves a 404 above a 422 on two paths. Both already documented the 404 -- route model binding produced one -- and Scramble orders responses by where they appear in the method, so the guard landing before the validate() call is the whole of the change.
423 lines
17 KiB
PHP
423 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Files\Folders\FolderService;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\FolderAssignment;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Groups\Models\MembershipRequest;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* Group membership decides what a client can reach, and through
|
|
* File::scopeVisibleToClient it decides what the staff member holding
|
|
* that client can reach too. The routes that edit it were gated on
|
|
* edit_groups and nothing else.
|
|
*/
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
|
|
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
|
foreach ([Permission::EditGroups, Permission::CreateGroups, Permission::Upload, Permission::EditFiles] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
|
}
|
|
|
|
$this->rep = User::factory()->create(['role_id' => $role->id]);
|
|
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id]);
|
|
|
|
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
|
|
|
|
$this->strangerGroup = Group::query()->create(['name' => 'Theirs', 'slug' => 'theirs', 'public' => false]);
|
|
$this->strangerGroup->members()->syncWithoutDetaching([$this->stranger->id]);
|
|
|
|
$this->secret = uploadNamedFile($this->admin, 'stranger-secret');
|
|
shareFileWithGroup($this->secret, $this->strangerGroup);
|
|
});
|
|
|
|
function libraryHolds(User $rep, int $fileId): bool
|
|
{
|
|
return in_array($fileId, app(StaffLibraryScope::class)->files($rep)->pluck('id')->all(), true);
|
|
}
|
|
|
|
test('a scoped staff member cannot widen their own library through a group', function () {
|
|
expect(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
$this->actingAs($this->rep)->get("/files/{$this->secret->id}/download")->assertForbidden();
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1)
|
|
->and(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
|
|
$this->actingAs($this->rep)->get("/files/{$this->secret->id}/download")->assertForbidden();
|
|
});
|
|
|
|
test('the API twin refuses it too', function () {
|
|
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->postJson("/api/v1/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1);
|
|
});
|
|
|
|
test('a scoped staff member cannot hand somebody else client the files of their own', function () {
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
|
$ours->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$ours->id}/members", ['user_id' => $this->stranger->id])
|
|
->assertForbidden();
|
|
|
|
expect($ours->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a scoped staff member cannot pull somebody else client out of a group', function () {
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertForbidden();
|
|
|
|
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
|
$this->withToken($token)
|
|
->deleteJson("/api/v1/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertForbidden();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1);
|
|
});
|
|
|
|
test('approving a membership request is held to the same boundary', function () {
|
|
$role = $this->rep->role;
|
|
RolePermission::query()->create([
|
|
'role_id' => $role->id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$request = MembershipRequest::query()->create([
|
|
'group_id' => $this->strangerGroup->id,
|
|
'user_id' => $this->mine->id,
|
|
'status' => MembershipRequest::STATUS_PENDING,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)->post("/membership-requests/{$request->id}/approve")->assertNotFound();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(1)
|
|
->and(libraryHolds($this->rep, $this->secret->id))->toBeFalse();
|
|
});
|
|
|
|
test('denying somebody else client request is held to the same boundary', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$request = MembershipRequest::query()->create([
|
|
'group_id' => $this->strangerGroup->id,
|
|
'user_id' => $this->stranger->id,
|
|
'status' => MembershipRequest::STATUS_PENDING,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)->delete("/membership-requests/{$request->id}")->assertNotFound();
|
|
|
|
expect($request->fresh()->status)->toBe(MembershipRequest::STATUS_PENDING);
|
|
});
|
|
|
|
test('the queue stops naming clients this viewer has no business hearing about', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => true]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->mine->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->stranger->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
|
|
$body = $this->actingAs($this->rep)->get('/membership-requests')->getContent();
|
|
|
|
// The row carries client_name and client_email, so an unnarrowed
|
|
// queue hands over both for a client outside the roster.
|
|
expect(str_contains($body, 'Not Mine'))->toBeFalse()
|
|
->and(str_contains($body, $this->stranger->email))->toBeFalse()
|
|
->and(str_contains($body, 'Mine'))->toBeTrue();
|
|
});
|
|
|
|
test('the sidebar badge counts what the queue lists', function () {
|
|
RolePermission::query()->create([
|
|
'role_id' => $this->rep->role_id,
|
|
'permission' => Permission::ApproveGroupsMembershipsRequests->value,
|
|
]);
|
|
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => true]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->mine->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
MembershipRequest::query()->create(['group_id' => $ours->id, 'user_id' => $this->stranger->id, 'status' => MembershipRequest::STATUS_PENDING]);
|
|
|
|
$this->actingAs($this->rep)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending.membership_requests', 1),
|
|
);
|
|
|
|
// Unscoped staff are told about both, and see both.
|
|
$wide = Role::query()->create(['name' => 'Wide '.Str::random(6), 'client_scoped' => false]);
|
|
RolePermission::query()->create(['role_id' => $wide->id, 'permission' => Permission::ApproveGroupsMembershipsRequests->value]);
|
|
$manager = User::factory()->create(['role_id' => $wide->id]);
|
|
|
|
$this->actingAs($manager)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending.membership_requests', 2),
|
|
);
|
|
});
|
|
|
|
test('a group nobody has shared anything with can still be populated', function () {
|
|
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$fresh->id}/members", ['user_id' => $this->mine->id])
|
|
->assertRedirect();
|
|
|
|
expect($fresh->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a group already holding the actor own client stays editable', function () {
|
|
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
|
$ours->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$ourFile = uploadNamedFile($this->admin, 'our-brochure');
|
|
shareFileWithGroup($ourFile, $ours);
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Also Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id, $second->id]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$ours->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$ours->id}/members/{$second->id}")
|
|
->assertRedirect();
|
|
|
|
expect($ours->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('unscoped staff manage membership exactly as before', function () {
|
|
$role = Role::query()->create(['name' => 'Wide '.Str::random(6), 'client_scoped' => false]);
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => Permission::EditGroups->value]);
|
|
$manager = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$this->actingAs($manager)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertRedirect();
|
|
|
|
expect($this->strangerGroup->members()->count())->toBe(2);
|
|
|
|
$this->actingAs($manager)
|
|
->delete("/groups/{$this->strangerGroup->id}/members/{$this->stranger->id}")
|
|
->assertRedirect();
|
|
|
|
expect($this->strangerGroup->members()->pluck('users.id')->all())->toBe([$this->mine->id]);
|
|
});
|
|
|
|
test('a folder shared with a group counts as reach too', function () {
|
|
$folder = app(FolderService::class)->create('Their Folder', null);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $this->strangerGroup->getMorphClass(),
|
|
'assignable_id' => $this->strangerGroup->id,
|
|
]);
|
|
|
|
$bare = Group::query()->create(['name' => 'Folder Only', 'slug' => 'folder-only', 'public' => false]);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $bare->getMorphClass(),
|
|
'assignable_id' => $bare->id,
|
|
]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$bare->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
|
|
expect($bare->members()->count())->toBe(0);
|
|
});
|
|
|
|
// An assignment row outlives the file it points at — nothing clears them
|
|
// on delete — and a trashed file can never appear in files(). Asking
|
|
// "is anything outside my library" from the live row rather than counting
|
|
// assignment rows is what keeps a group usable after somebody deletes a
|
|
// file that was once shared with it.
|
|
test('a group is not locked shut by a file that has since been deleted', function () {
|
|
$group = Group::query()->create(['name' => 'Newsletter', 'slug' => 'newsletter', 'public' => false]);
|
|
$group->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
// Shared with the group, and reachable by this rep because their own
|
|
// client is a member — so the group is theirs to manage.
|
|
$file = uploadNamedFile($this->admin, 'seasonal-offer');
|
|
shareFileWithGroup($file, $group);
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Also Mine']);
|
|
$this->rep->assignedClients()->attach($second->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
// The uploader deletes it. The assignment row stays behind.
|
|
$file->delete();
|
|
|
|
$third = User::factory()->client()->create(['name' => 'Mine Too']);
|
|
$this->rep->assignedClients()->attach($third->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $third->id])
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(3);
|
|
|
|
// And taking somebody out again still works, which the count form
|
|
// also blocked.
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$group->id}/members/{$third->id}")
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(2);
|
|
});
|
|
|
|
test('a deleted folder assignment does not lock a group either', function () {
|
|
$group = Group::query()->create(['name' => 'Bulletin', 'slug' => 'bulletin', 'public' => false]);
|
|
$group->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$folder = app(FolderService::class)->create('Seasonal', null);
|
|
FolderAssignment::query()->create([
|
|
'folder_id' => $folder->id,
|
|
'assignable_type' => $group->getMorphClass(),
|
|
'assignable_id' => $group->id,
|
|
]);
|
|
|
|
$folder->delete();
|
|
|
|
$second = User::factory()->client()->create(['name' => 'Second']);
|
|
$this->rep->assignedClients()->attach($second->id);
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$group->id}/members", ['user_id' => $second->id])
|
|
->assertRedirect();
|
|
|
|
expect($group->members()->count())->toBe(2);
|
|
});
|
|
|
|
// The half that must not soften: a live file outside the library is still
|
|
// reach, deleted siblings or not.
|
|
test('a deleted file does not excuse a live one that is still out of reach', function () {
|
|
$dead = uploadNamedFile($this->admin, 'was-shared');
|
|
shareFileWithGroup($dead, $this->strangerGroup);
|
|
$dead->delete();
|
|
|
|
$this->actingAs($this->rep)
|
|
->post("/groups/{$this->strangerGroup->id}/members", ['user_id' => $this->mine->id])
|
|
->assertForbidden();
|
|
});
|
|
|
|
|
|
/**
|
|
* The rep role ships without delete_groups, and these cases are about the
|
|
* boundary rather than the permission: grant it so the route lets the
|
|
* request through and the guard is what answers.
|
|
*/
|
|
function grantGroupDeletion(User $rep): void
|
|
{
|
|
RolePermission::query()->firstOrCreate([
|
|
'role_id' => $rep->role_id,
|
|
'permission' => Permission::DeleteGroups->value,
|
|
]);
|
|
}
|
|
|
|
// #1701 drew the line for membership and left the group object
|
|
// installation-wide. Deleting one is the sharper end of the same
|
|
// question: an assignment to a group is how its members reach a file, so
|
|
// removing the group takes that access away from every member — measured
|
|
// before this guard, a scoped role deleted a stranger's group and the
|
|
// stranger's client stopped seeing the file it carried.
|
|
test('a scoped staff member cannot rename or delete a group out of their reach', function () {
|
|
grantGroupDeletion($this->rep);
|
|
|
|
$this->actingAs($this->rep)
|
|
->patch("/groups/{$this->strangerGroup->id}", [
|
|
'name' => 'Renamed By Somebody Else',
|
|
'slug' => 'theirs',
|
|
'description' => null,
|
|
'public' => false,
|
|
])
|
|
->assertNotFound();
|
|
|
|
$this->actingAs($this->rep)
|
|
->delete("/groups/{$this->strangerGroup->id}")
|
|
->assertNotFound();
|
|
|
|
expect($this->strangerGroup->fresh()->name)->toBe('Theirs')
|
|
->and(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('deleting a stranger group would have cost its members their access', function () {
|
|
grantGroupDeletion($this->rep);
|
|
|
|
$stranger = $this->strangerGroup->members()->first();
|
|
|
|
expect(File::query()->visibleToClient($stranger)->whereKey($this->secret->id)->exists())->toBeTrue();
|
|
|
|
$this->actingAs($this->rep)->delete("/groups/{$this->strangerGroup->id}")->assertNotFound();
|
|
|
|
// Still theirs to read, because the group is still there.
|
|
expect(File::query()->visibleToClient($stranger)->whereKey($this->secret->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('the API refuses the same two', function () {
|
|
grantGroupDeletion($this->rep);
|
|
|
|
Sanctum::actingAs($this->rep, ['edit_groups', 'delete_groups']);
|
|
|
|
$this->patchJson("/api/v1/groups/{$this->strangerGroup->id}", ['name' => 'Nope'])->assertNotFound();
|
|
$this->deleteJson("/api/v1/groups/{$this->strangerGroup->id}")->assertNotFound();
|
|
|
|
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
test('a group inside their reach stays theirs to rename and delete', function () {
|
|
grantGroupDeletion($this->rep);
|
|
|
|
$mine = Group::query()->create(['name' => 'Mine', 'slug' => 'mine', 'public' => false]);
|
|
$mine->members()->syncWithoutDetaching([$this->mine->id]);
|
|
|
|
$this->actingAs($this->rep)
|
|
->patch("/groups/{$mine->id}", ['name' => 'Mine, Renamed', 'slug' => 'mine', 'description' => null, 'public' => false])
|
|
->assertRedirect();
|
|
|
|
expect($mine->fresh()->name)->toBe('Mine, Renamed');
|
|
|
|
$this->actingAs($this->rep)->delete("/groups/{$mine->id}")->assertRedirect();
|
|
|
|
expect(Group::query()->whereKey($mine->id)->exists())->toBeFalse();
|
|
});
|
|
|
|
test('an unscoped administrator manages every group exactly as before', function () {
|
|
$this->actingAs($this->admin)
|
|
->patch("/groups/{$this->strangerGroup->id}", [
|
|
'name' => 'Renamed By An Admin',
|
|
'slug' => 'theirs',
|
|
'description' => null,
|
|
'public' => false,
|
|
])
|
|
->assertRedirect();
|
|
|
|
$this->actingAs($this->admin)->delete("/groups/{$this->strangerGroup->id}")->assertRedirect();
|
|
|
|
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeFalse();
|
|
});
|