mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 09:35:07 +00:00
f1b35cc9f6
#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.
264 lines
9.0 KiB
PHP
264 lines
9.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\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();
|
||
}
|
||
}
|