Files
denkfabrik-li c8de16101f Gate the comment moderation surfaces on reading, not just on the library
FilePolicy::view() has two halves for staff: one of the three file keys
(upload / edit_files / edit_others_files), AND StaffLibraryScope. Every
comment surface that spans files narrowed by the library half alone.

A role holding moderate_comments and no file key therefore got a 403 on
every file in the installation while reading every comment written about
them on /comments: the text, staff-only notes, the client name a
Clients-visibility comment carries, and a visitor's IP address. The API
queue answered the same way, and approving through it hands the body back
in the response, so it was a reading door as well as a writing one.

The class says this is not supposed to happen -- across()'s own docblock
("a moderation screen is not a way around the visibility model"), the
route comment on /comments ("the list itself is still narrowed by
VisibleCommentScope, so holding the permission does not widen what a
viewer may read"), and routes/api.php ("reading and writing a comment is
gated by 'may see this file', the same three keys the file endpoints
use"). FileCommentPolicy::view() enforces it for a single comment, by
running the file's own gate first. Only the cross-file queries did not.

So they now take their files from ViewableFileScope, which is
FilePolicy::view() expressed as a query, instead of from StaffLibraryScope,
which is only its second half: across(), pendingTotal() and the API's
pending list. The permission half moves into a named method on that class,
since three modules now ask the same question.

FileCommentPolicy::moderate() gets it too, in both forms. Its row form is
otherwise unchanged -- the library check still runs by file id, so a
comment on a soft-deleted file behaves exactly as before.

No system role changes behaviour: Account Manager and System Administrator
are the two that ship with moderate_comments, and both hold upload. What
changes is a hand-built role that holds moderation and nothing else.

Seven tests. Without the fix, five go red; the other two are the premise
(that the viewer really is refused the file itself) and the guard that a
moderator who may read files still moderates the whole installation.

docs/api/openapi.json regenerated for the one changed description.
2026-08-28 06:40:42 +02:00

106 lines
3.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Comments;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use Illuminate\Support\Facades\Gate;
/**
* Per-comment authorization. `view` defers wholly to VisibleCommentScope
* rather than restating its rules — a policy and a query scope that both
* describe the same privacy boundary will eventually disagree, and the
* one that disagrees quietly is the query.
*/
class FileCommentPolicy
{
public function __construct(
private readonly VisibleCommentScope $scope,
private readonly CommentingRules $rules,
private readonly StaffLibraryScope $library,
private readonly ViewableFileScope $viewable,
) {}
public function view(User $user, FileComment $comment): bool
{
if (! Gate::forUser($user)->allows('view', $comment->file)) {
return false;
}
return $this->scope->for($user, $comment->file)->whereKey($comment->getKey())->exists();
}
/**
* Editing is the author's alone, inside a short window. Moderators are
* deliberately excluded: deleting somebody's comment is moderation,
* rewriting their words is not, and no permission in this app should
* imply the latter.
*/
public function update(User $user, FileComment $comment): bool
{
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
}
public function delete(User $user, FileComment $comment): bool
{
if ($this->moderate($user, $comment)) {
return true;
}
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
}
/**
* Called both ways: with a comment, to decide about that one, and
* against the class, to ask whether this user moderates at all (the
* queue's own gate, and the affordances that offer it).
*
* The library boundary belongs here rather than in each caller. Named
* against the class it cannot be applied — there is no file to weigh —
* so that form answers the coarser question and every caller holding a
* comment should pass it.
*/
public function moderate(User $user, ?FileComment $comment = null): bool
{
if (! $user->isStaff() || ! $user->can('moderate_comments')) {
return false;
}
// Moderating is deciding about comments you can already see, so the
// permission half of file reading is part of the answer in both
// forms. Without one of the three file keys this user gets a 403 on
// every file these comments are about, and approving one hands back
// its body — so this is a reading door, not only a writing one.
if (! $this->viewable->permitsAnyFile($user)) {
return false;
}
if ($comment === null || ! $user->isClientScoped()) {
return true;
}
// By file id rather than through the relation: a file soft-deleted
// out from under its comments resolves to null there, and the
// answer for a scoped moderator is the same either way — it is not
// in their library. Unscoped staff never reach this line.
return $this->library->files($user)->whereKey($comment->file_id)->exists();
}
private function withinEditWindow(FileComment $comment): bool
{
$minutes = $this->rules->editWindowMinutes();
if ($minutes <= 0) {
return false;
}
return $comment->created_at !== null
&& $comment->created_at->diffInMinutes(now()) < $minutes;
}
}