From 9ddfab62c29d25d10e64386390eade806c2e4c00 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 19 Jun 2026 10:23:16 +0000 Subject: [PATCH] Optimierung 3.1: Company::scopeAccessibleBy() zentralisiert Firmenzugriff Die 4x identische Zugriffs-Closure (owner_user_id == me OR whereHas('users', me)) in CompanyController (index/show), PressReleaseController:: findOwnedCompany und CustomerCompanyContext::accessibleCompanyQuery durch einen Company::scopeAccessibleBy() ersetzt; das Scope-Verhalten (withoutGlobalScopes) bleibt beim Aufrufer. Bewusst unangetastet: User::accessibleCompanyIds() (relationenbasiert MIT globalen Scopes - anderes Scope-Verhalten) und PressReleaseImageController (prueft PR-Autorschaft user_id, nicht Firmenzugriff). Tests: neuer Scope-Test; volle Suite gruen (801 passed, 1 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/V1/CompanyController.php | 10 ++-------- .../Api/V1/PressReleaseController.php | 5 +---- app/Models/Company.php | 14 ++++++++++++++ app/Services/Customer/CustomerCompanyContext.php | 5 +---- tests/Feature/CompanySearchByTermTest.php | 16 ++++++++++++++++ 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/Api/V1/CompanyController.php b/app/Http/Controllers/Api/V1/CompanyController.php index da7f4f1d..a36e215e 100644 --- a/app/Http/Controllers/Api/V1/CompanyController.php +++ b/app/Http/Controllers/Api/V1/CompanyController.php @@ -15,10 +15,7 @@ class CompanyController extends Controller abort_unless($request->user()->tokenCan('companies:read'), 403); $companies = Company::withoutGlobalScopes() - ->where(function ($query) use ($request): void { - $query->where('owner_user_id', $request->user()->id) - ->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id)); - }) + ->accessibleBy($request->user()) ->orderBy('name') ->paginate(min((int) $request->query('per_page', 25), 100)); @@ -36,10 +33,7 @@ class CompanyController extends Controller $company = Company::withoutGlobalScopes() ->whereKey($company) - ->where(function ($query) use ($request): void { - $query->where('owner_user_id', $request->user()->id) - ->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id)); - }) + ->accessibleBy($request->user()) ->first(); abort_unless($company !== null, 403); diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index ce561f7d..b8dd0df0 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -183,10 +183,7 @@ class PressReleaseController extends Controller { return Company::withoutGlobalScopes() ->whereKey($companyId) - ->where(function ($query) use ($request): void { - $query->where('owner_user_id', $request->user()->id) - ->orWhereHas('users', fn ($users) => $users->whereKey($request->user()->id)); - }) + ->accessibleBy($request->user()) ->first(); } diff --git a/app/Models/Company.php b/app/Models/Company.php index 28bd2fde..9ea4e011 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -103,6 +103,20 @@ class Company extends Model }); } + /** + * Firmen, auf die ein User zugreifen darf: als Owner (owner_user_id) oder + * als Mitglied über den company_user-Pivot. Single Source of Truth für die + * zuvor in mehreren API-Controllern und Services duplizierte Closure. Das + * Scope-Verhalten (mit/ohne globale Scopes) bestimmt der Aufrufer. + */ + public function scopeAccessibleBy(Builder $query, User $user): Builder + { + return $query->where(function (Builder $query) use ($user): void { + $query->where('owner_user_id', $user->id) + ->orWhereHas('users', fn (Builder $users) => $users->whereKey($user->id)); + }); + } + public function owner(): BelongsTo { return $this->belongsTo(User::class, 'owner_user_id'); diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index a474aec6..248c9cd8 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -144,10 +144,7 @@ class CustomerCompanyContext { return Company::query() ->withoutGlobalScopes() - ->where(function ($query) use ($user): void { - $query->where('owner_user_id', $user->id) - ->orWhereHas('users', fn ($userQuery) => $userQuery->whereKey($user->id)); - }); + ->accessibleBy($user); } public function findFor(User $user, int $companyId): ?Company diff --git a/tests/Feature/CompanySearchByTermTest.php b/tests/Feature/CompanySearchByTermTest.php index 36e58a7c..1122d53f 100644 --- a/tests/Feature/CompanySearchByTermTest.php +++ b/tests/Feature/CompanySearchByTermTest.php @@ -1,6 +1,7 @@ create(['name' => 'Acme Industries', 'slug' => 'acme-industries']); @@ -23,3 +24,18 @@ test('searchByTerm strips a trailing portal abbreviation from the term', functio expect(Company::query()->searchByTerm('Musterfirma (PE)')->pluck('id'))->toContain($company->id); }); + +test('accessibleBy includes owned and member companies but excludes others', function () { + $user = User::factory()->create(); + + $owned = Company::factory()->create(['owner_user_id' => $user->id]); + $member = Company::factory()->create(); + $member->users()->attach($user->id, ['role' => 'member']); + $foreign = Company::factory()->create(); + + $ids = Company::query()->withoutGlobalScopes()->accessibleBy($user)->pluck('id'); + + expect($ids)->toContain($owned->id) + ->toContain($member->id) + ->not->toContain($foreign->id); +});