mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-23 12:03:20 +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
|
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||||
[#1698](https://github.com/projectsend/projectsend/pull/1698))
|
[#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
|
## 2.1.0 — 18 August 2026
|
||||||
|
|
||||||
Updating, mostly. ProjectSend now tells you when there is a new version, ends an update somewhere
|
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\Api\ApiUsage;
|
||||||
use App\Modules\Audit\Action;
|
use App\Modules\Audit\Action;
|
||||||
use App\Modules\Audit\ActivityLog;
|
use App\Modules\Audit\ActivityLog;
|
||||||
|
use App\Modules\Audit\ActivityLogScope;
|
||||||
use App\Modules\Audit\ActivityPresenter;
|
use App\Modules\Audit\ActivityPresenter;
|
||||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||||
use App\Modules\Clients\ClientStorageUsage;
|
use App\Modules\Clients\ClientStorageUsage;
|
||||||
@@ -52,6 +53,7 @@ class DashboardController extends Controller
|
|||||||
private readonly TimezoneRegistry $timezones,
|
private readonly TimezoneRegistry $timezones,
|
||||||
private readonly SystemEnvironment $environment,
|
private readonly SystemEnvironment $environment,
|
||||||
private readonly ActivityPresenter $presenter,
|
private readonly ActivityPresenter $presenter,
|
||||||
|
private readonly ActivityLogScope $scope,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
@@ -86,7 +88,7 @@ class DashboardController extends Controller
|
|||||||
? $this->topClientsByStorage()
|
? $this->topClientsByStorage()
|
||||||
: null,
|
: null,
|
||||||
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : 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,
|
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
|
||||||
// Both editions — informational content, not an update action,
|
// Both editions — informational content, not an update action,
|
||||||
// so no Capability check alongside the permission (unlike
|
// so no Capability check alongside the permission (unlike
|
||||||
@@ -398,13 +400,21 @@ class DashboardController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @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 —
|
// Presented through the shared ActivityPresenter, not rebuilt inline —
|
||||||
// the same sentence-ready shape the activity page and detail panels
|
// the same sentence-ready shape the activity page and detail panels
|
||||||
// use. Rebuilding it here once dropped `origin`, which is the only
|
// use. Rebuilding it here once dropped `origin`, which is the only
|
||||||
// thing that tells an actorless "Anonymous" entry from a "System" one.
|
// 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('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->limit(8)
|
->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),
|
->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