mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
1644d634d5
groupReachesNoFurther() decides whether a client-scoped staff member may edit a group, by asking whether anything shared with it sits outside their library.f1b35cc9settled that answer for deleted files: start from the live row, because a deleted file is not reach, because nobody can reach it. An expired file is the same case and was not covered. File::scopeVisibleToClient ends in notExpired(), so the moment a file expires it leaves every member's /my-files and the download answers 403 -- but it also leaves files(), where its absence reads as "outside my library". The group then became unmanageable for good: the rep could not add anyone, and could not undo their own membership change either. The reach query now skips expired files as it already skips deleted ones. File::scopeVisibleToClient is unchanged -- what expiry does to a scoped viewer's library was settled deliberately inc8078f65, and this is about what counts as reach, not about what anyone may open. The folder half needs nothing, because folders do not expire. The limit this leaves open, stated rather than implied: a membership added while a file was expired outlives the expiry, so if somebody later clears expires_at the client reaches a file that was outside the actor's library when the decision was made.f1b35cc9leaves exactly the same opening for a file restored from the trash, and closing either would mean the guard weighing rows nobody can currently reach. Verified before merging: this changes the file half of the same method #1719 changed the folder half of, so the merged tree was read rather than trusted -- both halves now skip expired files consistently. 29 passed on the merged tree, 1 failed / 28 passed with app/ reset. The "an expired file does not excuse a live one that is still out of reach" test is green either way. Reported and fixed by @denkfabrik-li.
356 lines
13 KiB
PHP
356 lines
13 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\Files\Models\FolderAssignment;
|
||
use App\Modules\Groups\Models\Group;
|
||
use App\Modules\Identity\UserType;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
|
||
/**
|
||
* The single point that decides which library content a staff member
|
||
* sees. An unscoped staff member sees the whole shared library; a
|
||
* client-scoped one (see User::isClientScoped) sees only the files &
|
||
* folders they created, plus everything belonging to the clients
|
||
* assigned to them.
|
||
*
|
||
* Every staff listing goes through here, and the policies consult
|
||
* allowsFile()/allowsFolder() so direct access (download, details,
|
||
* edit…) respects the same boundary.
|
||
*
|
||
* @method Builder<File> files(User $user)
|
||
* @method Builder<Folder> folders(User $user)
|
||
*/
|
||
class StaffLibraryScope
|
||
{
|
||
/**
|
||
* Built queries, by user id. Building one is not free: it walks the
|
||
* assigned clients and File::scopeVisibleToClient runs four immediate
|
||
* lookups for each of them, none of which depend on the query being
|
||
* built. Callers ask over and over — the policies ask once per row on
|
||
* a listing, and Gate resolves a fresh policy for every check — so the
|
||
* same handful of lookups were being repeated per row.
|
||
*
|
||
* A clone goes back rather than the query itself, since every caller
|
||
* adds to it. Registered with the container as `scoped`, so the memo
|
||
* lasts a request and is dropped between queue jobs.
|
||
*
|
||
* @var array<int, Builder<File>>
|
||
*/
|
||
private array $files = [];
|
||
|
||
/** @var array<int, Builder<Folder>> */
|
||
private array $folders = [];
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
public function files(User $user): Builder
|
||
{
|
||
return clone ($this->files[$user->id] ??= $this->buildFiles($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
private function buildFiles(User $user): Builder
|
||
{
|
||
$query = File::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
// Own uploads ∪ files visible to each assigned client. The
|
||
// per-client visibility is File::scopeVisibleToClient — the single
|
||
// source of truth for client file access — so no rule is duplicated.
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('uploaded_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
public function folders(User $user): Builder
|
||
{
|
||
return clone ($this->folders[$user->id] ??= $this->buildFolders($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
private function buildFolders(User $user): Builder
|
||
{
|
||
$query = Folder::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('created_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Whether a scoped staff member may reach this specific file. Unscoped
|
||
* staff always may; the policies AND this into their permission checks
|
||
* so direct access respects the same boundary as the listings.
|
||
*/
|
||
public function allowsFile(User $user, File $file): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->files($user)->whereKey($file->getKey())->exists();
|
||
}
|
||
|
||
public function allowsFolder(User $user, Folder $folder): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->folders($user)->whereKey($folder->getKey())->exists();
|
||
}
|
||
|
||
/**
|
||
* Client ids a user may share with, or null when unrestricted (the
|
||
* whole roster). A scoped user may only share with their assigned
|
||
* clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableClientIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
return array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
/**
|
||
* Group ids a user may share with, or null when unrestricted. A scoped
|
||
* user may share with any group that contains at least one of their
|
||
* assigned clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableGroupIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
$clientIds = $this->assignableClientIds($user) ?? [];
|
||
|
||
if ($clientIds === []) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(Group::query()
|
||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
public function canAssignClient(User $user, User $client): bool
|
||
{
|
||
$ids = $this->assignableClientIds($user);
|
||
|
||
return $ids === null || in_array($client->id, $ids, true);
|
||
}
|
||
|
||
/**
|
||
* Every client this staff member may act on, as a query.
|
||
*
|
||
* The listing half of canAssignClient(), so a screen narrows by the
|
||
* same rule its buttons are guarded with rather than restating it —
|
||
* which is how ClientsController came to list every client on the
|
||
* installation, name and email, to a viewer who could reach nothing
|
||
* of theirs. An unscoped user gets the whole roster, unchanged.
|
||
*
|
||
* @return Builder<User>
|
||
*/
|
||
public function clients(User $user): Builder
|
||
{
|
||
$query = User::query()->where('type', UserType::Client);
|
||
$ids = $this->assignableClientIds($user);
|
||
|
||
return $ids === null ? $query : $query->whereIn('id', $ids);
|
||
}
|
||
|
||
public function canAssignGroup(User $user, Group $group): bool
|
||
{
|
||
$ids = $this->assignableGroupIds($user);
|
||
|
||
return $ids === null || in_array($group->id, $ids, true);
|
||
}
|
||
|
||
/**
|
||
* Whether a staff member may put a client into a group, or take one
|
||
* out again.
|
||
*
|
||
* Not canAssignGroup(): that answers "may I share with this group",
|
||
* and it answers it *from* the membership — a group counts as the
|
||
* user's because one of their clients is in it. Deciding membership
|
||
* with a predicate derived from membership means whoever may edit
|
||
* the list also decides what the list entitles them to, which is not
|
||
* a boundary at all. It is also the wrong answer here in the other
|
||
* direction: a group nobody has joined yet belongs to nobody, so a
|
||
* scoped staff member could never put the first member into a group
|
||
* they had just created.
|
||
*
|
||
* The question membership actually asks is about reach. Joining a
|
||
* group hands the new member everything shared with it, and — when
|
||
* that member is one of the actor's own clients — hands the actor
|
||
* the same content back through File::scopeVisibleToClient, which is
|
||
* what StaffLibraryScope::files() is built on. So both sides have to
|
||
* hold: the client must be one this staff member holds, and the
|
||
* group must not already reach beyond their library. A group with
|
||
* nothing shared with it passes trivially, which is what keeps a
|
||
* newly created one usable.
|
||
*
|
||
* Unscoped staff are unaffected — both halves are true for them by
|
||
* construction.
|
||
*/
|
||
public function allowsGroupMembership(User $user, Group $group, User $client): bool
|
||
{
|
||
return $this->canAssignClient($user, $client) && $this->groupReachesNoFurther($user, $group);
|
||
}
|
||
|
||
/**
|
||
* Whether this staff member may change a group itself — rename it,
|
||
* make it public, delete it.
|
||
*
|
||
* The reach half of allowsGroupMembership, on its own because there
|
||
* is no client in the question. Deleting a group is the destructive
|
||
* end of it: an assignment to a group is how its members reach a
|
||
* file, so removing the group takes that access away from every one
|
||
* of them. A staff member who may not add somebody to a group out of
|
||
* their reach should not be able to delete it out from under the
|
||
* people already in it.
|
||
*/
|
||
public function allowsGroupChange(User $user, Group $group): bool
|
||
{
|
||
return $this->groupReachesNoFurther($user, $group);
|
||
}
|
||
|
||
/**
|
||
* Whether everything shared with this group is already inside the
|
||
* user's library — files assigned to it, and the folders whose
|
||
* subtrees it can browse.
|
||
*
|
||
* Asked as "is anything shared with this group outside my library",
|
||
* rather than by counting assignment rows against library rows. An
|
||
* assignment outlives the thing it points at: nothing clears these
|
||
* rows when a file or folder is deleted, and a deleted one can never
|
||
* appear in files()/folders(), which exclude trashed rows. Counting
|
||
* therefore never balanced again, and the group became permanently
|
||
* unmanageable for a scoped staff member — including for their own
|
||
* clients, and including removing somebody. Starting from the live
|
||
* row rather than from the assignment ignores the dead ones by
|
||
* construction, which is also the right answer: a deleted file is
|
||
* not reach, because nobody can reach it.
|
||
*
|
||
* An expired file is the same answer for the same reason. Membership
|
||
* in this group grants nobody access to it — File::scopeVisibleToClient
|
||
* ends in notExpired(), so it is gone from every member's /my-files and
|
||
* the download is refused — while its absence from files() otherwise
|
||
* reads as "outside my library" and locks the group exactly as a
|
||
* deleted file used to. Expiry is reversible where deletion is not, so
|
||
* the file counts as reach again the moment it does: this asks what is
|
||
* reachable now, at the moment somebody is added or removed.
|
||
*/
|
||
private function groupReachesNoFurther(User $user, Group $group): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
$morph = $group->getMorphClass();
|
||
|
||
$assignedFiles = FileAssignment::query()->select('file_id')
|
||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||
|
||
$outside = File::query()
|
||
->whereIn('id', $assignedFiles)
|
||
->notExpired()
|
||
->whereNotIn('id', $this->files($user)->select('id'))
|
||
->exists();
|
||
|
||
if ($outside) {
|
||
return false;
|
||
}
|
||
|
||
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||
|
||
// The whole subtree, not the folder the assignment names. A folder
|
||
// shared with a group hands its members everything inside it —
|
||
// File::scopeVisibleToClient matches on folder placement, and a
|
||
// folder is visible to a client when it or an ancestor is shared
|
||
// with them — so "is anything shared with this group outside my
|
||
// library" has to ask about the contents, which is what the
|
||
// docblock above already claims ("the folders whose subtrees it
|
||
// can browse").
|
||
//
|
||
// Measured: a scoped staff member's own folder, with a subfolder
|
||
// somebody else created inside it and somebody else's file in
|
||
// that. The folder is theirs, its contents are not, and adding
|
||
// their own client to a group holding the parent handed that
|
||
// client the file — which then enters the staff member's own
|
||
// library too, because files() is "everything my clients can
|
||
// see". That is the widening this guard exists to refuse, and the
|
||
// test above it says so in as many words.
|
||
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
|
||
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
|
||
if ($reachable === []) {
|
||
return true;
|
||
}
|
||
|
||
if (Folder::query()
|
||
->whereIn('id', $reachable)
|
||
->whereNotIn('id', $this->folders($user)->select('id'))
|
||
->exists()
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
// And the files sitting in them. A folder can be inside the
|
||
// library while a file in it is not: files() is own uploads plus
|
||
// what an assigned client may see, and neither covers somebody
|
||
// else's upload into a folder this staff member happens to own.
|
||
//
|
||
// notExpired() for the same reason the assignment half above skips
|
||
// deleted files: membership in this group grants nobody access to
|
||
// an expired file, because scopeVisibleToClient ends by excluding
|
||
// them, and something nobody can reach is not reach.
|
||
return ! File::query()
|
||
->whereIn('folder_id', $reachable)
|
||
->notExpired()
|
||
->whereNotIn('id', $this->files($user)->select('id'))
|
||
->exists();
|
||
}
|
||
}
|