Files
projectsend/app/Modules/Files/Access/StaffLibraryScope.php
T
ignacionelson 0a8b609e8b Build a scoped staff member's library query once per request, not once per row
#1698 moved the library boundary into FileCommentPolicy, where it
belongs, and said plainly what that cost: the moderation screen went
from 65 queries to 465 for a client-scoped moderator with five assigned
clients. Measured here, those numbers are exactly right.

The cost is not in asking. It is that StaffLibraryScope::files() rebuilds
its query every time, and building one runs four immediate lookups per
assigned client — the client's group ids, the same ids again inside
Folder::sharedFolderIds(), that method's own assignment lookup, and the
shared-folder get() in Folder::scopeVisibleToClient(). None of them
depend on the query being built. Gate resolves a fresh policy for every
check, so a listing paid for all of it once per row.

The built query is now memoised per user and handed back as a clone,
since every caller adds to it, and the scope is registered as `scoped`
rather than transient so the memo survives a request. Scoped rather than
a singleton on purpose: a long-lived queue worker keeps singletons
between jobs, and a library query built from one job's data has no
business answering the next one's question.

That is 465 queries down to 60 on the same page — below the 65 it cost
before #1698, because the memo also helps the callers that were already
asking repeatedly. FileVersions::sharedAudience(), which runs the same
helper twice per candidate while resolving notification recipients, gets
it for free.

So the answer to the question #1698 left open is neither of the two it
offered. can_delete stays a real question asked of the policy; nothing
restates the boundary; and the page is faster than it was before the
fix. Three tests: one user's query never answers another's, one caller's
constraints never follow the next, and the moderation screen does not
ask once per row.
2026-08-26 13:30:50 -03:00

184 lines
5.5 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\Folder;
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);
}
}