diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9295ef..5278f85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php index 88b5a877..5f9c69df 100644 --- a/app/Modules/Audit/Http/Controllers/DashboardController.php +++ b/app/Modules/Audit/Http/Controllers/DashboardController.php @@ -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> */ - 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) diff --git a/tests/Feature/Audit/DashboardTest.php b/tests/Feature/Audit/DashboardTest.php index 1d75c56f..873967a3 100644 --- a/tests/Feature/Audit/DashboardTest.php +++ b/tests/Feature/Audit/DashboardTest.php @@ -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'), + ); +});