Files
ignacionelson 7da4635f13 Say which clients a scoped staff member may be told about
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.

The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.

12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: a
colleague's name is not a client identity, and hiding it would hide who
uploaded most of the library from the people who work in it. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.

Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.

The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.

Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
2026-09-03 00:56:41 -03:00

176 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
/**
* Who a file or folder is shared with, and who it could still be shared
* with — the lists behind every sharing panel.
*
* Splitting a subject's assignments into clients and groups was written out
* at four call sites and had already started to drift: one compared
* assignable_type against User::class while the others used the morph class.
* Those agree only for as long as no morph map is registered, which is
* exactly the kind of difference that goes unnoticed until it doesn't.
*
* Scoping is not re-derived here. Which clients and groups a staff member
* may share with belongs to StaffLibraryScope, and this asks it.
*/
class ShareTargets
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
) {}
/**
* The clients and groups a subject is already shared with, as id/name
* pairs. Neutral keys, so callers can nest it ('shares' on the details
* panel) or flatten it (the edit pages' assigned_* props).
*
* **This is the unfiltered truth, and it is not what a screen should
* show.** Everyone a file is really in front of is the right answer for
* deciding something — VisibleCommentScope resolves notification
* recipients from it, and a recipient left out of that list is one who
* never hears about a message addressed to them. It is the wrong answer
* for telling somebody, because a client-scoped viewer may hold a file
* that is also shared with a client they have no business knowing
* exists. Anything rendering these names wants assignedFor() below.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assigned(File|Folder $subject): array
{
[$clientIds, $groupIds] = $this->assignedIds($subject);
return [
'clients' => $this->clients($clientIds),
'groups' => $this->groups($groupIds),
];
}
/**
* assigned(), narrowed to the recipients this viewer may be told
* about. The display half of the pair — see the warning above.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assignedFor(File|Folder $subject, ?User $viewer): array
{
return $this->identity->filterShares($viewer, $this->assigned($subject));
}
/**
* The assigned lists plus everything still available to share with,
* narrowed to what this viewer is allowed to reach.
*
* @return array{assigned_clients: list<array{id: int, name: string}>, assigned_groups: list<array{id: int, name: string}>, available_clients: list<array{id: int, name: string}>, available_groups: list<array{id: int, name: string}>}
*/
public function forSubject(File|Folder $subject, User $viewer): array
{
[$clientIds, $groupIds] = $this->assignedIds($subject);
// Resolved once each: these hit the database, and the call sites this
// replaces evaluated them twice apiece — once to decide whether to
// filter, once to supply the ids.
$assignableClientIds = $this->scope->assignableClientIds($viewer);
$assignableGroupIds = $this->scope->assignableGroupIds($viewer);
$availableClients = User::query()
->where('type', UserType::Client)
->whereNotIn('id', $clientIds)
->when($assignableClientIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableClientIds))
->orderBy('name')
->get();
$availableGroups = Group::query()
->whereNotIn('id', $groupIds)
->when($assignableGroupIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableGroupIds))
->orderBy('name')
->get();
// assignedFor, not assigned: an edit page listing a recipient this
// viewer may not identify would both name them and offer a control
// for a share the viewer cannot otherwise reach. available_* below
// was already narrowed this way; assigned_* was not, which is the
// asymmetry that made the whole panel a roster listing.
$assigned = $this->assignedFor($subject, $viewer);
return [
'assigned_clients' => $assigned['clients'],
'assigned_groups' => $assigned['groups'],
'available_clients' => $this->pairs($availableClients),
'available_groups' => $this->pairs($availableGroups),
];
}
/**
* @return array{0: Collection<int, mixed>, 1: Collection<int, mixed>}
*/
private function assignedIds(File|Folder $subject): array
{
// A file revision owns no assignment rows — it inherits the
// recipients of its version chain's root (File::sharingOwnerId).
// Reading its own rows would show every revision as shared with
// nobody, which is the opposite of what is true.
$assigned = $subject instanceof File
? FileAssignment::query()->where('file_id', $subject->sharingOwnerId())->get()
: $subject->assignments()->get();
// getMorphClass() rather than ::class, so this keeps agreeing with
// what was written to assignable_type even if a morph map is added.
return [
$assigned->where('assignable_type', (new User)->getMorphClass())->pluck('assignable_id'),
$assigned->where('assignable_type', (new Group)->getMorphClass())->pluck('assignable_id'),
];
}
/**
* @param Collection<int, mixed> $ids
* @return list<array{id: int, name: string}>
*/
private function clients(Collection $ids): array
{
return $this->pairs(User::query()->whereIn('id', $ids)->orderBy('name')->get());
}
/**
* @param Collection<int, mixed> $ids
* @return list<array{id: int, name: string}>
*/
private function groups(Collection $ids): array
{
return $this->pairs(Group::query()->whereIn('id', $ids)->orderBy('name')->get());
}
/**
* Built by iteration rather than map()->values(): Collection's value
* template is invariant, so passing a Collection<Group> where
* Collection<User|Group> is declared does not type-check, and the
* result of map() is not provably a list either.
*
* @param iterable<User|Group> $models
* @return list<array{id: int, name: string}>
*/
private function pairs(iterable $models): array
{
$pairs = [];
foreach ($models as $model) {
$pairs[] = ['id' => (int) $model->getKey(), 'name' => $model->name];
}
return $pairs;
}
}