mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user