mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
Rank top clients by roster, not by library, and factor the client guard
Two things found by checking #1696 and #1699 -- open branches carrying the same fixes I wrote this morning -- against what I actually shipped. **topClientsByStorage was scoped with the wrong question.**4b8220anarrowed it with StaffLibraryScope::files(), which is right for the two widgets that name files and wrong for the one that names clients: a stranger client's upload can sit legitimately inside a scoped viewer's library, shared with a group one of their own clients belongs to. So the file was theirs to read and the uploader's name was not theirs to see. Measured: "Stranger Client Ltd", on nobody's roster, ranked on a scoped dashboard. assignableClientIds is what the widget is actually asking, and it is what #1699 used. Their version was right and mine was not. **The client guard is one method now, not eight copies.** #1696 wrote it as a private guardTarget() rather than repeating viewer-resolve plus abort at each site, which is better, and this is a change whose whole argument is that a rule stated in many places drifts. Behaviour is identical; the eight sites now read as one rule. The published document reorders a 404 below a 422 on one path. Scramble reads abort_unless out of a method body but not out of a helper it calls, so the 404 now comes from route model binding instead of from the inline abort -- same response, different position. #1701's body names this trap; worth knowing it costs ordering and not content. Credit where it is due: both come from denkfabrik-li's #1696 and #1699, which were open while I was writing the same fixes. Those two are closed against this and againste7b5b6a,4b8220aand67e9204.
This commit is contained in:
@@ -27,6 +27,7 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Storage\StorageDurability;
|
||||
use App\Modules\Platform\System\SystemEnvironment;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -290,12 +291,19 @@ class DashboardController extends Controller
|
||||
*/
|
||||
private function topClientsByStorage(User $viewer): array
|
||||
{
|
||||
// Scoped like the other two: this one names clients rather than
|
||||
// files, which is the same thing MembershipRequest::approvableBy
|
||||
// and ActivityLogScope exist to keep inside a viewer's roster.
|
||||
$rows = $this->library->files($viewer)
|
||||
// Narrowed by roster, not by library. This widget names *clients*,
|
||||
// and files() is the wrong lens for that: a stranger client's
|
||||
// upload can be inside a scoped viewer's library — shared with a
|
||||
// group one of their own clients is in — which put the stranger's
|
||||
// name on the widget. Measured: a client called "Stranger Client
|
||||
// Ltd", on nobody's roster, ranked on a scoped dashboard.
|
||||
// assignableClientIds is the question actually being asked.
|
||||
$clientIds = $this->library->assignableClientIds($viewer);
|
||||
|
||||
$rows = File::query()
|
||||
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
|
||||
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
|
||||
->when($clientIds !== null, fn (Builder $query) => $query->whereIn('uploaded_by', $clientIds))
|
||||
->groupBy('uploaded_by')
|
||||
->orderByDesc('total_bytes')
|
||||
->limit(5)
|
||||
|
||||
@@ -86,17 +86,24 @@ class ClientsController extends Controller
|
||||
return ClientResource::collection($this->polling->paginate($request, $query, 'users'));
|
||||
}
|
||||
|
||||
public function show(Request $request, User $client): ClientResource
|
||||
/**
|
||||
* Mirrors the web controller's guard, as every API twin here does:
|
||||
* the token's `edit_clients` says its owner manages clients, not
|
||||
* that they manage *this* one.
|
||||
*/
|
||||
private function guardTarget(Request $request, User $client): void
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: the token's `edit_clients`
|
||||
// says its owner manages clients, not that they manage *this*
|
||||
// one. Mirrors the web controller, as every API twin here does.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
}
|
||||
|
||||
public function show(Request $request, User $client): ClientResource
|
||||
{
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
return $this->resourceFor($client);
|
||||
@@ -149,15 +156,7 @@ class ClientsController extends Controller
|
||||
|
||||
public function update(Request $request, User $client): ClientResource
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: the token's `edit_clients`
|
||||
// says its owner manages clients, not that they manage *this*
|
||||
// one. Mirrors the web controller, as every API twin here does.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -230,15 +229,7 @@ class ClientsController extends Controller
|
||||
*/
|
||||
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): JsonResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: the token's `edit_clients`
|
||||
// says its owner manages clients, not that they manage *this*
|
||||
// one. Mirrors the web controller, as every API twin here does.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$twoFactor->reset($client);
|
||||
@@ -264,15 +255,7 @@ class ClientsController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, User $client): JsonResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: the token's `edit_clients`
|
||||
// says its owner manages clients, not that they manage *this*
|
||||
// one. Mirrors the web controller, as every API twin here does.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $client);
|
||||
|
||||
@@ -141,17 +141,28 @@ class ClientsController extends Controller
|
||||
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, User $client): Response
|
||||
/**
|
||||
* The one question every route binding a client has to ask.
|
||||
*
|
||||
* A permission is not a boundary: `edit_clients` says this staff
|
||||
* member manages clients, not that they manage *this* one — the same
|
||||
* rule ClientFilesController::index applies one route over. 404
|
||||
* rather than 403, so a client outside the roster is not
|
||||
* distinguishable from one that is not there.
|
||||
*/
|
||||
private function guardTarget(Request $request, User $client): void
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: `edit_clients` says this staff
|
||||
// member manages clients, not that they manage *this* one. The
|
||||
// same rule ClientFilesController::index applies one route over.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
}
|
||||
|
||||
public function edit(Request $request, User $client): Response
|
||||
{
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
return Inertia::render('clients/edit', [
|
||||
@@ -177,15 +188,7 @@ class ClientsController extends Controller
|
||||
|
||||
public function update(Request $request, User $client): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: `edit_clients` says this staff
|
||||
// member manages clients, not that they manage *this* one. The
|
||||
// same rule ClientFilesController::index applies one route over.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
@@ -247,15 +250,7 @@ class ClientsController extends Controller
|
||||
*/
|
||||
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: `edit_clients` says this staff
|
||||
// member manages clients, not that they manage *this* one. The
|
||||
// same rule ClientFilesController::index applies one route over.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$twoFactor->reset($client);
|
||||
@@ -265,15 +260,7 @@ class ClientsController extends Controller
|
||||
|
||||
public function destroy(Request $request, User $client): RedirectResponse
|
||||
{
|
||||
abort_unless($client->isClient(), 404);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A permission is not a boundary: `edit_clients` says this staff
|
||||
// member manages clients, not that they manage *this* one. The
|
||||
// same rule ClientFilesController::index applies one route over.
|
||||
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
||||
$this->guardTarget($request, $client);
|
||||
|
||||
|
||||
$validated = $this->accountDeletion->validate($request, $client);
|
||||
|
||||
@@ -975,12 +975,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/ModelNotFoundException"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#/components/responses/ValidationException"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/ModelNotFoundException"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/AuthenticationException"
|
||||
}
|
||||
|
||||
@@ -471,3 +471,41 @@ test('an unscoped viewer still sees the whole installation in those widgets', fu
|
||||
->where('expired_files.scoped', false),
|
||||
);
|
||||
});
|
||||
|
||||
// The top-clients widget names clients, so the roster is the question,
|
||||
// not the library. A stranger's upload can sit inside a scoped viewer's
|
||||
// library — shared with a group one of their own clients is in — which
|
||||
// put the stranger's name on the widget while the file itself was
|
||||
// legitimately visible.
|
||||
test('the top-clients widget names only clients on the viewer roster', function () {
|
||||
$role = Role::query()->create(['name' => 'Scoped stats roster', 'client_scoped' => true]);
|
||||
RolePermission::query()->insert([
|
||||
['role_id' => $role->id, 'permission' => 'view_statistics'],
|
||||
['role_id' => $role->id, 'permission' => 'upload'],
|
||||
]);
|
||||
|
||||
$viewer = User::factory()->create(['role_id' => $role->id]);
|
||||
$mine = User::factory()->client()->create(['name' => 'My Own Client']);
|
||||
$viewer->assignedClients()->attach($mine->id);
|
||||
|
||||
$stranger = User::factory()->client()->create(['name' => 'Stranger Client Ltd']);
|
||||
|
||||
// Both clients are in one group, and the stranger's upload is shared
|
||||
// with it — so my client may legitimately read the file, and the
|
||||
// file is legitimately inside my library.
|
||||
$group = Group::query()->create(['name' => 'Shared', 'slug' => 'shared-stats', 'public' => false]);
|
||||
$group->members()->syncWithoutDetaching([$mine->id, $stranger->id]);
|
||||
|
||||
$file = File::factory()->create(['uploaded_by' => $stranger->id, 'name' => 'Theirs', 'size' => 5_000_000]);
|
||||
shareFileWithGroup($file, $group);
|
||||
|
||||
// Something of my own client's, so the widget is not empty for the
|
||||
// wrong reason.
|
||||
File::factory()->create(['uploaded_by' => $mine->id, 'name' => 'Ours', 'size' => 1_000]);
|
||||
|
||||
$this->actingAs($viewer)->get('/dashboard')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page
|
||||
->has('top_clients_by_storage', 1)
|
||||
->where('top_clients_by_storage.0.name', 'My Own Client'),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user