From ef1aa7cf247f87e9a445e74ea05af9c455b77a04 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 10 Sep 2026 11:32:48 +0800 Subject: [PATCH] feat(biometrics): enforce strict branch scoping on biometric attendance and sync - Restrict biometric attendances query and grouped records strictly to employees of assigned branch - Scope bulk sync (syncAll) to only sync biometric records for employees within the user's branch - Guard individual sync and custom sync against processing employees from foreign branches - Tighten attendance records query to strictly filter by employee assigned branch - Add feature test verifying biometric attendance isolation and permission guards --- .../AttendanceRecordController.php | 7 +- .../BiometricAttendanceController.php | 59 +++++++++--- tests/Feature/BranchScopedAccessTest.php | 94 +++++++++++++++++++ 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/AttendanceRecordController.php b/app/Http/Controllers/AttendanceRecordController.php index fb2f24cdb..d9dd1902d 100644 --- a/app/Http/Controllers/AttendanceRecordController.php +++ b/app/Http/Controllers/AttendanceRecordController.php @@ -46,11 +46,8 @@ class AttendanceRecordController extends Controller ->where(function ($q) use ($scopedBranchId) { if ($scopedBranchId) { $q->whereIn('created_by', getCompanyAndUsersId()) - ->where(function ($subQ) use ($scopedBranchId) { - $subQ->where('attendance_records.branch_id', $scopedBranchId) - ->orWhereHas('employee.employee', function ($eq) use ($scopedBranchId) { - $eq->where('branch_id', $scopedBranchId); - }); + ->whereHas('employee.employee', function ($eq) use ($scopedBranchId) { + $eq->where('branch_id', $scopedBranchId); }); } elseif (Auth::user()->can('manage-any-attendance-records')) { $q->whereIn('created_by', getCompanyAndUsersId()); diff --git a/app/Http/Controllers/BiometricAttendanceController.php b/app/Http/Controllers/BiometricAttendanceController.php index 8be2d6f78..6a0e00a22 100644 --- a/app/Http/Controllers/BiometricAttendanceController.php +++ b/app/Http/Controllers/BiometricAttendanceController.php @@ -169,20 +169,22 @@ class BiometricAttendanceController extends Controller // Filter by permissions if ($scopedBranchId) { $branchEmpCodes = \App\Models\Employee::where('branch_id', $scopedBranchId)->whereNotNull('biometric_emp_id')->pluck('biometric_emp_id'); - $query->where(function($q) use ($scopedBranchId, $branchEmpCodes) { - $q->where('branch_id', $scopedBranchId) - ->orWhereIn('biometric_emp_id', $branchEmpCodes); - }); + if ($branchEmpCodes->isEmpty()) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('biometric_emp_id', $branchEmpCodes); + } } elseif ($canManageAny) { // Full company access } elseif ($canManageBranch) { $branchId = $user->branch_id ?? $user->employee?->branch_id; if ($branchId) { $branchEmpCodes = \App\Models\Employee::where('branch_id', $branchId)->whereNotNull('biometric_emp_id')->pluck('biometric_emp_id'); - $query->where(function($q) use ($branchId, $branchEmpCodes) { - $q->where('branch_id', $branchId) - ->orWhereIn('biometric_emp_id', $branchEmpCodes); - }); + if ($branchEmpCodes->isEmpty()) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('biometric_emp_id', $branchEmpCodes); + } } else { // Manager without a specific branch assigned has company-wide management } @@ -266,6 +268,13 @@ class BiometricAttendanceController extends Controller }) ->values(); + if ($scopedBranchId) { + $groupedAttendances = $groupedAttendances->filter(function ($item) use ($employees, $scopedBranchId) { + $emp = $employees->get($item['employee_code']); + return $emp && (int)$emp->branch_id === (int)$scopedBranchId; + })->values(); + } + // Attach employee names $groupedAttendances = $groupedAttendances->map(function($item) use ($employees) { $emp = $employees->get($item['employee_code']); @@ -437,7 +446,19 @@ class BiometricAttendanceController extends Controller $overwrite = $request->boolean('overwrite', false); + $scopedBranchId = authBranchId(); $query = \App\Models\BiometricAttendance::query(); + + if ($scopedBranchId) { + $branchEmpCodes = Employee::where('branch_id', $scopedBranchId) + ->whereNotNull('biometric_emp_id') + ->pluck('biometric_emp_id'); + if ($branchEmpCodes->isEmpty()) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('biometric_emp_id', $branchEmpCodes); + } + } if (!$overwrite) { $query->where('sync_status', 'pending'); @@ -456,11 +477,15 @@ class BiometricAttendanceController extends Controller // Eager-load employees with shifts $empIds = $pendingRecords->pluck('biometric_emp_id')->unique(); - $employees = Employee::with(['user', 'shift']) + $empQuery = Employee::with(['user', 'shift']) ->whereIn('created_by', getCompanyAndUsersId()) - ->whereIn('biometric_emp_id', $empIds) - ->get() - ->keyBy('biometric_emp_id'); + ->whereIn('biometric_emp_id', $empIds); + + if ($scopedBranchId) { + $empQuery->where('branch_id', $scopedBranchId); + } + + $employees = $empQuery->get()->keyBy('biometric_emp_id'); $groupedAttendances = $pendingRecords->groupBy(function ($item) use ($employees) { $employee = $employees->get($item->biometric_emp_id); @@ -591,6 +616,11 @@ class BiometricAttendanceController extends Controller $employee = Employee::with(['user', 'shift', 'branch'])->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $biometricEmpId)->first(); + $scopedBranchId = authBranchId(); + if ($scopedBranchId && $employee && (int)$employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. Employee belongs to another branch.')); + } + if ($employee) { // Check if record already exists $exists = AttendanceRecord::where('employee_id', $employee->user_id) @@ -690,6 +720,11 @@ class BiometricAttendanceController extends Controller return redirect()->back()->with('error', __('Employee not found.')); } + $scopedBranchId = authBranchId(); + if ($scopedBranchId && (int)$employee->branch_id !== (int)$scopedBranchId) { + return redirect()->back()->with('error', __('Permission Denied. Employee belongs to another branch.')); + } + $shift = Shift::where('id', $employee->shift_id)->where('status', 'active')->first() ?? Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); // Check if record already exists diff --git a/tests/Feature/BranchScopedAccessTest.php b/tests/Feature/BranchScopedAccessTest.php index 994b68880..4707e4e31 100644 --- a/tests/Feature/BranchScopedAccessTest.php +++ b/tests/Feature/BranchScopedAccessTest.php @@ -13,6 +13,7 @@ use App\Models\Payslip; use App\Models\OvertimeApplication; use App\Models\ThirteenthMonthRun; use App\Models\ThirteenthMonthEntry; +use App\Models\BiometricAttendance; use Illuminate\Foundation\Testing\RefreshDatabase; use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Permission; @@ -34,6 +35,7 @@ class BranchScopedAccessTest extends TestCase Permission::firstOrCreate(['name' => 'manage-payslips']); Permission::firstOrCreate(['name' => 'manage-any-payslips']); Permission::firstOrCreate(['name' => 'manage-payroll-runs']); + Permission::firstOrCreate(['name' => 'manage-biometric-attendance']); } public function test_auth_branch_id_helper_resolution() @@ -468,6 +470,98 @@ class BranchScopedAccessTest extends TestCase $response->assertSee('Employee Alpha User'); $response->assertDontSee('Employee Beta User'); } + + public function test_branch_scoped_user_only_sees_assigned_branch_biometric_attendance() + { + $company = User::create([ + 'name' => 'Company Admin', + 'email' => 'company_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'company', + ]); + + $branchA = Branch::create([ + 'name' => 'Branch Alpha', + 'created_by' => $company->id, + ]); + + $branchB = Branch::create([ + 'name' => 'Branch Beta', + 'created_by' => $company->id, + ]); + + // Employee in Branch A + $userA = User::create([ + 'name' => 'Employee Alpha User', + 'email' => 'alpha_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + $empA = Employee::create([ + 'user_id' => $userA->id, + 'employee_id' => 'EMP-A-' . rand(100, 999), + 'biometric_emp_id' => 'BIO-101', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + + // Employee in Branch B + $userB = User::create([ + 'name' => 'Employee Beta User', + 'email' => 'beta_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'employee', + 'created_by' => $company->id, + ]); + $empB = Employee::create([ + 'user_id' => $userB->id, + 'employee_id' => 'EMP-B-' . rand(100, 999), + 'biometric_emp_id' => 'BIO-102', + 'branch_id' => $branchB->id, + 'created_by' => $company->id, + ]); + + // Biometric punches + BiometricAttendance::create([ + 'biometric_emp_id' => 'BIO-101', + 'punch_time' => '2026-05-10 09:00:00', + 'punch_state' => 0, + 'branch_id' => $branchA->id, + 'sync_status' => 'pending', + ]); + + BiometricAttendance::create([ + 'biometric_emp_id' => 'BIO-102', + 'punch_time' => '2026-05-10 09:00:00', + 'punch_state' => 0, + 'branch_id' => $branchB->id, + 'sync_status' => 'pending', + ]); + + $hrUser = User::create([ + 'name' => 'HR Branch A User', + 'email' => 'hr_a_' . uniqid() . '@test.com', + 'password' => bcrypt('password'), + 'type' => 'hr', + 'branch_id' => $branchA->id, + 'created_by' => $company->id, + ]); + $hrUser->givePermissionTo('manage-biometric-attendance'); + + $response = $this->actingAs($hrUser)->get(route('hr.biometric-attendance.index')); + $response->assertStatus(200); + $response->assertSee('BIO-101'); + $response->assertDontSee('BIO-102'); + + // Test show route for foreign branch punch + $foreignShowResponse = $this->actingAs($hrUser)->get(route('hr.biometric-attendance.show', [ + 'employeeCode' => 'BIO-102', + 'date' => '2026-05-10', + ])); + $foreignShowResponse->assertStatus(403); + } } +