Files
projectsend/app/Modules/Files/Access/StaffLibraryScope.php
T
ignacionelson f1b35cc9f6 Stop a deleted file locking a scoped staff member out of a group for good
#1701 closed a real hole: group membership decides what a client reaches,
and through File::scopeVisibleToClient it decides what the staff member
holding that client reaches, so `edit_groups` alone was never a boundary.
The predicate it added asks whether everything shared with a group is
already inside the actor's library.

It asked by counting: pluck the group's assignment rows, count how many
of those ids the library query returns, and require the two to match. An
assignment row outlives the thing it points at — nothing clears them when
a file or folder is deleted — while files() and folders() exclude trashed
rows by construction. So one deleted file left a count that could never
balance again, and the group closed permanently: the scoped staff member
could no longer add their own client to it, or remove anybody from it,
with a 403 and nothing to explain it. Every group accumulates dead
assignments over time, so groups would have gone quiet one at a time.

Asked the other way round — is there anything live, shared with this
group, that is outside my library — the dead rows drop out by
construction, because the query starts from File/Folder rather than from
the assignment. That is also the truer question: a deleted file is not
reach, since nobody can reach it.

Three tests. A group stays usable after a file shared with it is deleted,
including removing a member; the same for a deleted folder assignment;
and the half that must not soften — a live file still out of reach is
still refused, deleted siblings or not.
2026-08-26 15:21:30 -03:00

264 lines
9.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 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);
}
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 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.
*/
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)
->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);
return ! Folder::query()
->whereIn('id', $assignedFolders)
->whereNotIn('id', $this->folders($user)->select('id'))
->exists();
}
}