Files
projectsend/tests/Feature/Identity/ReassignmentRosterBoundaryTest.php
ignacionelson 8372f42525 Ask the picker's own question of what comes back from it
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww.

Deleting an account that owns files makes the admin choose who inherits
them. The picker narrows that list for a client-scoped staff member to their
own roster, and says why two methods up: "a client-scoped staff member is
not shown the name of somebody they can reach nothing of, and a picker is no
more a reason to hand one over than a listing is."

The write asked something else entirely — exists, active, and not the
account being deleted. All three are true of every account on the
installation. So a scoped staffer could name an id the picker had
deliberately kept off the list, and a roster client's files and folders
landed with a client on somebody else's roster: readable, editable and
deletable there, because a client owns what they uploaded and
visibleToClient() includes uploaded_by.

The entry doors scope the source account and always did — guardTarget goes
through canAssignClient. It is the destination nobody scoped.

candidates() and validate() now run one predicate, reachableTargets(),
rather than two that happened to agree. Two that agree by inspection is what
this was: the narrowing existed, was correct, and was only ever applied to
the list.

The refusal deliberately reads as "no such account". An out-of-roster id and
an id belonging to nobody now produce the same message, because a refusal
that distinguishes them lets a scoped staffer walk the id space and learn
which accounts exist outside their roster. That is why Rule::exists is gone
rather than kept alongside: one code path, one answer. A test pins the two
messages as identical instead of naming either.

Both the web screen and the API twin come through this one validate(), so
both are fixed by it — and the test file proves each separately rather than
assuming the sharing holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:19:28 -03:00

157 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* GHSA-w29w-pj29-x7ww. Deleting an account that owns content makes the
* admin choose who inherits it, and the picker narrows that list to the
* viewer's own roster — its comment says so: "a client-scoped staff member
* is not shown the name of somebody they can reach nothing of."
*
* The write asked a different question. `exists, active, not the account
* being deleted` is true of every account on the installation, so a scoped
* staff member could name one the picker had deliberately kept off the
* list, and a roster client's files would land with a client on somebody
* else's roster — readable, editable and deletable there, because a client
* owns what they uploaded.
*
* The picker and the write now run one predicate, not two that agree by
* inspection.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
foreach ([Permission::DeleteClients, Permission::EditClients, Permission::CreateClients] as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
}
$this->rep = User::factory()->create(['role_id' => $role->id]);
// On this rep's roster, and owning something worth inheriting.
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
$this->rep->assignedClients()->sync([$this->mine->id]);
$this->file = File::factory()->create(['uploaded_by' => $this->mine->id]);
$this->folder = Folder::query()->create([
'name' => 'Theirs', 'slug' => 'theirs-'.Str::random(6), 'path' => '/', 'created_by' => $this->mine->id,
]);
// On nobody's roster as far as this rep is concerned.
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
});
test('the picker does not offer a client outside the roster', function () {
// The promise the write has to keep. Asserted first so that a change
// loosening the picker cannot quietly make the rest of this file vacuous.
$names = collect(app(AccountContentDeletion::class)->candidates($this->rep))
->pluck('name');
expect($names)->toContain('Mine')
->and($names)->not->toContain('Not Mine');
});
test('a scoped staff member cannot hand content to a client off their roster', function () {
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->stranger->id,
])->assertSessionHasErrors('reassign_to_id');
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id)
->and($this->folder->fresh()->created_by)->toBe($this->mine->id)
->and(User::withTrashed()->find($this->mine->id)->trashed())->toBeFalse();
});
test('the API twin refuses it too', function () {
$token = $this->rep->createToken('t', [Permission::DeleteClients->value])->plainTextToken;
$this->withToken($token)
->deleteJson("/api/v1/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->stranger->id,
])->assertStatus(422);
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id);
});
test('refusing reads the same as an account that is not there at all', function () {
// Otherwise the refusal is an oracle: a scoped staff member could walk
// the id space and learn which accounts exist outside their roster by
// the difference between the two answers.
$outsider = $this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->stranger->id,
]);
$nobody = $this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => 99999999,
]);
expect($outsider->getSession()->get('errors')->get('reassign_to_id'))
->toBe($nobody->getSession()->get('errors')->get('reassign_to_id'));
});
test('a client on the roster is still a valid target', function () {
$alsoMine = User::factory()->client()->create(['name' => 'Also Mine']);
$this->rep->assignedClients()->sync([$this->mine->id, $alsoMine->id]);
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $alsoMine->id,
])->assertRedirect();
expect($this->file->fresh()->uploaded_by)->toBe($alsoMine->id)
->and($this->folder->fresh()->created_by)->toBe($alsoMine->id);
});
test('a staff account is still a valid target, because staff are narrowed nowhere', function () {
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->admin->id,
])->assertRedirect();
expect($this->file->fresh()->uploaded_by)->toBe($this->admin->id);
});
test('an unscoped administrator can still reassign to anybody', function () {
// The tightening is about the roster, and an unscoped staff member has
// no roster — StaffLibraryScope::clients() is every client for them.
$this->actingAs($this->admin)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->stranger->id,
])->assertRedirect();
expect($this->file->fresh()->uploaded_by)->toBe($this->stranger->id);
});
test('the advisory controls still behave as they did', function () {
// Both were already enforced, and both must survive the new rule:
// an inactive account, and the account being deleted.
$inactive = User::factory()->client()->create(['active' => false]);
$this->rep->assignedClients()->sync([$this->mine->id, $inactive->id]);
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $inactive->id,
])->assertSessionHasErrors('reassign_to_id');
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
'content_action' => 'reassign',
'reassign_to_id' => $this->mine->id,
])->assertSessionHasErrors('reassign_to_id');
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id);
});