Narrow the dashboard's recent activity to what its viewer may actually read

#1685 fixed the dashboard rebuilding a log row by hand and dropping
`origin` from it. One layer down, the same method was skipping something
larger: it ran a bare ActivityLog::query(), so ActivityLogScope never
applied.

That scope exists for this exact case, and says so in its own docblock —
`view_actions_log` is not the whole answer for a client-scoped staff
member, because a log entry carries the subject's *name*. An unscoped log
reads out the name of every file in the installation, and who touched it,
to somebody who gets a 403 on the files themselves.

Measured before the fix, one client-scoped viewer with the permission:

  /activity   →  []
  /dashboard  →  Uploaded the file "Q3 delinquent accounts"

Same person, same permission, opposite answers. The activity page and the
download history both apply the scope; the dashboard was the one caller
that did not, which is the same shape of gap #1685 was about.

More reachable than it looks: the Client Manager system role ships with
`view_actions_log`, so this is the default configuration rather than
something an administrator has to build.

Two tests: a scoped viewer sees only the entry about a file in their
library, and an unscoped one still sees everything.

transferSeries() is left alone on purpose. It is unscoped too, but it
returns per-day counts with no names or subjects attached, which is a
different exposure and arguably not one at all.
This commit is contained in:
ignacionelson
2026-08-26 13:51:02 -03:00
parent 5fb98f4785
commit 67e9204654
3 changed files with 81 additions and 3 deletions
+8
View File
@@ -183,6 +183,14 @@ a version is cut.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1698](https://github.com/projectsend/projectsend/pull/1698))
- **The dashboard's recent activity now respects a limited role's boundary.** A staff role can be
limited to its own assigned clients, and the activity page has always honoured that — showing only
entries about files, folders and clients in that person's scope. The dashboard's Recent activity
widget did not: it listed the eight most recent entries from the whole installation, file names
and all, to someone who would be refused the files themselves. The Client Manager role ships with
the permission this widget needs, so any installation using it was affected. Both screens now
answer the same way. Nothing changes for an administrator or any unrestricted role.
## 2.1.0 — 18 August 2026
Updating, mostly. ProjectSend now tells you when there is a new version, ends an update somewhere
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Modules\Api\ApiUsage;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use App\Modules\Clients\ClientStorageUsage;
@@ -52,6 +53,7 @@ class DashboardController extends Controller
private readonly TimezoneRegistry $timezones,
private readonly SystemEnvironment $environment,
private readonly ActivityPresenter $presenter,
private readonly ActivityLogScope $scope,
) {}
public function __invoke(Request $request): Response
@@ -86,7 +88,7 @@ class DashboardController extends Controller
? $this->topClientsByStorage()
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity() : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
@@ -398,13 +400,21 @@ class DashboardController extends Controller
/**
* @return array<int, array<string, mixed>>
*/
private function recentActivity(): array
private function recentActivity(User $viewer): array
{
// Narrowed through ActivityLogScope, exactly as the activity page and
// the download history are. `view_actions_log` is not the whole
// answer for a client-scoped viewer: a log entry carries the
// subject's name, so an unscoped one reads out the name of every
// file in the installation and who touched it, to somebody who gets
// a 403 on the files themselves. The Client Manager role ships with
// the permission, so this is the default configuration.
//
// Presented through the shared ActivityPresenter, not rebuilt inline —
// the same sentence-ready shape the activity page and detail panels
// use. Rebuilding it here once dropped `origin`, which is the only
// thing that tells an actorless "Anonymous" entry from a "System" one.
return ActivityLog::query()
return $this->scope->apply(ActivityLog::query(), $viewer)
->orderByDesc('created_at')
->orderByDesc('id')
->limit(8)
+60
View File
@@ -336,3 +336,63 @@ test('the recent-activity widget carries origin so an actorless entry is not mis
->where('recent.0.actor_name', null),
);
});
test('the recent-activity widget shows a scoped viewer only what their own log would', function () {
// view_actions_log is not the whole answer for a client-scoped viewer:
// an entry carries the subject's name, so an unscoped widget reads out
// the name of every file in the installation to somebody who gets a
// 403 on the files themselves. The Client Manager role ships with the
// permission, so this is the default configuration, not an exotic one.
$role = Role::query()->create(['name' => 'Scoped log reader', 'client_scoped' => true]);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'view_actions_log'],
['role_id' => $role->id, 'permission' => 'upload'],
]);
$scoped = User::factory()->create(['role_id' => $role->id]);
$client = User::factory()->client()->create();
$scoped->assignedClients()->attach($client->id);
$theirs = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Q3 delinquent accounts']);
$mine = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Statement']);
shareFileWith($mine, $client);
foreach ([$theirs, $mine] as $file) {
ActivityLog::query()->create([
'actor_id' => $this->admin->id,
'actor_name' => $this->admin->name,
'actor_type' => $this->admin->type->value,
'action' => Action::FileUploaded,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'subject_name' => $file->name,
'created_at' => now(),
]);
}
$this->actingAs($scoped)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->has('recent', 1)
->where('recent.0.replacements.subject', 'Statement'),
);
});
test('an unscoped viewer still sees the whole installation in the widget', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Anything']);
ActivityLog::query()->create([
'actor_id' => $this->admin->id,
'actor_name' => $this->admin->name,
'actor_type' => $this->admin->type->value,
'action' => Action::FileUploaded,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'subject_name' => 'Anything',
'created_at' => now(),
]);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->has('recent', 1)->where('recent.0.replacements.subject', 'Anything'),
);
});