Fixed #19089 - show uploads in activity report for companies, models, etc

This commit is contained in:
snipe
2026-05-28 10:14:30 +01:00
parent 78acc3685d
commit 6f1c49e14d
8 changed files with 219 additions and 14 deletions
+10
View File
@@ -405,6 +405,16 @@ final class Company extends SnipeModel
return $query->whereNull($table.$column);
}
// action_logs: a NULL company_id means the logged object (AssetModel, Company, etc.)
// has no company_id column of its own. Those are global objects, visible to all users,
// so their log entries should not be hidden by the company filter.
if ($query->getModel()->getTable() === 'action_logs') {
return $query->where(function ($q) use ($table, $column, $companyIds) {
$q->whereIn($table.$column, $companyIds)
->orWhereNull($table.$column);
});
}
return $query->whereIn($table.$column, $companyIds);
}
}
+5
View File
@@ -3,12 +3,17 @@
namespace App\Models\Traits;
use App\Models\Actionlog;
use App\Models\CompanyableScope;
trait HasUploads
{
public function uploads()
{
// Bypass FMCS company scoping: access is already gated by the policy on the
// parent object. Objects like AssetModel and Company have no company_id, so
// their upload logs always have company_id = null, which the scope would hide.
return $this->hasMany(Actionlog::class, 'item_id')
->withoutGlobalScope(CompanyableScope::class)
->where('item_type', self::class)
->where('action_type', '=', 'uploaded')
->whereNotNull('filename')
+5 -2
View File
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CompanyableScope;
use App\Models\ICompanyableChild;
use App\Models\License;
use App\Models\LicenseSeat;
@@ -41,13 +42,15 @@ trait Loggable
public function history()
{
// Bypass FMCS company scoping: access is already gated by the policy on the
// parent object. Objects like AssetModel and Company have no company_id, so
// their history logs always have company_id = null, which the scope would hide.
return $this->morphMany(Actionlog::class, 'item')
->withoutGlobalScope(CompanyableScope::class)
->orWhere(function ($query) {
$query->where('target_type', '=', static::class)
->where('target_id', '=', $this->getKey());
});
}
public function getHistory(Request $request)
+20
View File
@@ -450,6 +450,26 @@ class UserFactory extends Factory
return $this->appendPermission(['assets.audit' => '1']);
}
public function manageModelFiles()
{
return $this->appendPermission(['models.files' => '1']);
}
public function manageLocationFiles()
{
return $this->appendPermission(['locations.files' => '1']);
}
public function manageCompanyFiles()
{
return $this->appendPermission(['companies.files' => '1']);
}
public function manageSupplierFiles()
{
return $this->appendPermission(['suppliers.files' => '1']);
}
private function appendPermission(array $permission)
{
return $this->state(function ($currentState) use ($permission) {
@@ -1744,41 +1744,51 @@
if ((value) && (value.type)) {
if (value.type == 'asset') {
if (value.type === 'asset') {
item_destination = 'hardware';
item_icon = 'fas fa-barcode';
} else if (value.type == 'accessory') {
}
else if (value.type === 'accessory') {
item_destination = 'accessories';
item_icon = 'far fa-keyboard';
} else if (value.type == 'component') {
}
else if (value.type === 'component') {
item_destination = 'components';
item_icon = 'far fa-hdd';
} else if (value.type == 'consumable') {
}
else if (value.type === 'consumable') {
item_destination = 'consumables';
item_icon = 'fas fa-tint';
} else if (value.type == 'license') {
}
else if (value.type === 'license') {
item_destination = 'licenses';
item_icon = 'far fa-save';
} else if (value.type == 'user') {
}
else if (value.type === 'user') {
item_destination = 'users';
item_icon = 'fas fa-user';
} else if (value.type == 'location') {
}
else if (value.type === 'location') {
item_destination = 'locations'
item_icon = 'fas fa-map-marker-alt';
} else if (value.type == 'maintenance') {
}
else if (value.type === 'maintenance') {
item_destination = 'maintenances'
item_icon = 'fa-solid fa-screwdriver-wrench';
} else if (value.type == 'model') {
}
else if (value.type === 'model') {
item_destination = 'models'
item_icon = 'fa-solid fa-boxes-stacked';
} else if (value.type == 'supplier') {
}
else if (value.type === 'supplier') {
item_destination = 'suppliers';
item_icon = 'fa-solid fa-store';
} else if (value.type == 'department') {
}
else if (value.type === 'department') {
item_destination = 'departments';
item_icon = 'fa-solid fa-building-user';
}
else if (value.type == 'company') {
else if (value.type === 'company') {
item_destination = 'companies';
item_icon = 'fa-regular fa-building';
}
@@ -3,6 +3,7 @@
namespace Tests\Feature\AssetModels\Api;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;
@@ -216,4 +217,34 @@ class AssetModelFilesTest extends TestCase
]
);
}
public function test_non_superuser_can_list_model_files_with_fmcs_enabled()
{
// AssetModel has no company_id column, so its upload logs get company_id = null.
// With FMCS active the CompanyableScope previously applied WHERE company_id IN (...)
// which excluded NULLs, making the files invisible to non-superusers.
$this->settings->enableMultipleFullCompanySupport();
$model = AssetModel::factory()->create();
$company = Company::factory()->create();
$superUser = User::factory()->superuser()->create();
$normalUser = User::factory()
->manageModelFiles()
->create(['company_id' => $company->id]);
// Superuser uploads a file (company_id on the log will be null)
$this->actingAsForApi($superUser)
->post(
route('api.files.store', ['object_type' => 'models', 'id' => $model->id]),
['file' => [UploadedFile::fake()->create('test.jpg', 100)]]
)
->assertOk();
// Non-superuser in a specific company should still see it
$this->actingAsForApi($normalUser)
->getJson(route('api.files.index', ['object_type' => 'models', 'id' => $model->id]))
->assertOk()
->assertJsonPath('total', 1);
}
}
@@ -2,6 +2,7 @@
namespace Tests\Feature\Locations\Api;
use App\Models\Company;
use App\Models\Location;
use App\Models\User;
use Illuminate\Http\UploadedFile;
@@ -214,4 +215,57 @@ class LocationFileTest extends TestCase
]
);
}
public function test_non_superuser_can_list_location_files_with_fmcs_enabled()
{
// A location in the user's own company: upload logs get company_id = location.company_id.
// Verify that FMCS scoping does not hide those logs from the owning user.
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$location = Location::factory()->create(['company_id' => $company->id]);
$superUser = User::factory()->superuser()->create();
$normalUser = User::factory()
->manageLocationFiles()
->create(['company_id' => $company->id]);
$this->actingAsForApi($superUser)
->post(
route('api.files.store', ['object_type' => 'locations', 'id' => $location->id]),
['file' => [UploadedFile::fake()->create('test.jpg', 100)]]
)
->assertOk();
$this->actingAsForApi($normalUser)
->getJson(route('api.files.index', ['object_type' => 'locations', 'id' => $location->id]))
->assertOk()
->assertJsonPath('total', 1);
}
public function test_user_in_different_company_cannot_access_location_files_with_fmcs_enabled()
{
// The policy must block a user from listing files for a location that belongs to a different company.
$this->settings->enableMultipleFullCompanySupport();
$companyA = Company::factory()->create();
$companyB = Company::factory()->create();
$location = Location::factory()->create(['company_id' => $companyA->id]);
$superUser = User::factory()->superuser()->create();
$userInCompanyB = User::factory()
->manageLocationFiles()
->create(['company_id' => $companyB->id]);
$this->actingAsForApi($superUser)
->post(
route('api.files.store', ['object_type' => 'locations', 'id' => $location->id]),
['file' => [UploadedFile::fake()->create('test.jpg', 100)]]
)
->assertOk();
$this->actingAsForApi($userInCompanyB)
->getJson(route('api.files.index', ['object_type' => 'locations', 'id' => $location->id]))
->assertForbidden();
}
}
@@ -4,8 +4,10 @@ namespace Tests\Feature\Reporting;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
@@ -58,6 +60,76 @@ class ActivityReportTest extends TestCase
->assertJson(fn (AssertableJson $json) => $json->has('rows', 2)->etc());
}
public function test_null_company_upload_logs_visible_in_activity_report_with_fmcs_enabled()
{
// AssetModel and Company objects have no company_id column, so their upload logs always
// get company_id = null. With FMCS active the scope previously applied
// WHERE company_id IN (...) which excluded NULLs, hiding these logs from the activity report.
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$superUser = User::factory()->superuser()->create();
$viewingUser = User::factory()
->canViewReports()
->create(['company_id' => $company->id]);
$model = AssetModel::factory()->create();
// Superuser uploads a file to the AssetModel (log gets company_id = null)
$this->actingAsForApi($superUser)
->post(
route('api.files.store', ['object_type' => 'models', 'id' => $model->id]),
['file' => [UploadedFile::fake()->create('test.jpg', 100)]]
)
->assertOk();
// Non-superuser with activity.view (reports.view) should see the uploaded log
$this->actingAsForApi($viewingUser)
->getJson(route('api.activity.index', [
'action_type' => 'uploaded',
'item_type' => 'AssetModel',
'item_id' => $model->id,
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
}
public function test_upload_logs_for_another_companys_asset_not_visible_in_activity_report_with_fmcs()
{
// Our null-company fix adds OR company_id IS NULL to action_log queries.
// Verify this does NOT leak logs that have a real company_id belonging to a different company.
$this->settings->enableMultipleFullCompanySupport();
$companyA = Company::factory()->create();
$companyB = Company::factory()->create();
$superUser = User::factory()->superuser()->create();
$assetInCompanyA = Asset::factory()->create(['company_id' => $companyA->id]);
$viewerInCompanyB = User::factory()
->canViewReports()
->create(['company_id' => $companyB->id]);
// Superuser uploads a file to company A's asset (log gets company_id = companyA->id)
$this->actingAsForApi($superUser)
->post(
route('api.files.store', ['object_type' => 'hardware', 'id' => $assetInCompanyA->id]),
['file' => [UploadedFile::fake()->create('test.jpg', 100)]]
)
->assertOk();
// User in company B should not see the upload log for company A's asset
$this->actingAsForApi($viewerInCompanyB)
->getJson(route('api.activity.index', [
'action_type' => 'uploaded',
'item_type' => 'asset',
'item_id' => $assetInCompanyA->id,
]))
->assertOk()
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
}
public function test_records_are_scoped_to_company_when_multiple_company_support_enabled()
{
// $this->markTestIncomplete('This test returns strange results. Need to figure out why.');