files(User $user) * @method Builder 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> */ private array $files = []; /** @var array> */ private array $folders = []; /** * @return Builder */ public function files(User $user): Builder { return clone ($this->files[$user->id] ??= $this->buildFiles($user)); } /** * @return Builder */ 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 */ public function folders(User $user): Builder { return clone ($this->folders[$user->id] ??= $this->buildFolders($user)); } /** * @return Builder */ 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|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|null */ public function assignableGroupIds(User $user): ?array { if (! $user->isClientScoped()) { return null; } return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all()); } /** * Every group this staff member may be told about, as a query. * * The listing half of assignableGroupIds(), and the same rule: a * group counts as theirs because one of their clients is in it. The * two were not the same code, and the listing simply had none — so * `/groups` and `/api/v1/groups` showed a scoped staff member every * group on the installation, name, description and member count, * including groups whose every member was somebody else's client * (GHSA-r3hg-3fxw-rcmr). * * Deliberately the *sharing* rule rather than the change rule below. * A scoped staff member may already share a file with a mixed group, * so its existence is not news to them; what they may not do is * rename, publish or delete it. * * @return Builder */ public function groups(User $user): Builder { $query = Group::query(); $clientIds = $this->assignableClientIds($user); if ($clientIds === null) { return $query; } return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds)); } 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 */ 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->groupIsNotWhollySomebodyElses($user, $group) && $this->groupReachesNoFurther($user, $group); } /** * Whether this group is somebody else's entirely — every member * outside the staff member's roster, and none of theirs in it. * * The half allowsGroupChange() was missing. Reach answers "what would * this group hand somebody", which is the right question for putting a * client *into* it; it says nothing about who is already there. So a * group with nothing shared with it yet passed the reach check * vacuously, and a scoped staff member could rename it, delete it, or * publish it — a group made entirely of clients they had never been * assigned (GHSA-r3hg-3fxw-rcmr). * * **Not "every member is mine", which is the obvious reading and is * wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7 * settled that a scoped staff member opens such a group's edit screen * and is shown only their own clients in it, rather than being refused * the screen. Requiring every member to be theirs turns that narrowing * back into a 404 and undoes the earlier fix. What is left over — a * mixed group whose shared content reaches past their library — is * refused by groupReachesNoFurther() beside this, which is the check * that has always covered it. * * **And deliberately not folded into groupReachesNoFurther() either.** * That predicate is shared with allowsGroupMembership(), where a group * nobody has joined must stay usable so its creator can put the first * member in — the case that method's own docblock calls out. * * An empty group is nobody else's, so whoever just made it can still * name it. */ private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool { $clientIds = $this->assignableClientIds($user); if ($clientIds === null) { return true; } if (! $group->members()->exists()) { return true; } return $group->members()->whereIn('users.id', $clientIds)->exists(); } /** * 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(); } }