feat(auth): enforce strict branch scoping for overtime requests, attendance regularizations, and 13th month pay

- Restrict overtime applications query, submission, approval, and attendance details to assigned branch
- Restrict attendance regularization listing, statistics, and dropdowns to assigned branch
- Restrict 13th month pay batch generation, historical runs, entry updates, approvals, and CSV exports to assigned branch
- Add feature tests in BranchScopedAccessTest for overtime and 13th month isolation
This commit is contained in:
2026-09-10 11:22:15 +08:00
parent 7f467600e9
commit 33f776c6b2
4 changed files with 251 additions and 20 deletions

View File

@@ -15,8 +15,14 @@ class AttendanceRegularizationController extends Controller
public function index(Request $request)
{
if (Auth::user()->can('manage-attendance-regularizations')) {
$query = AttendanceRegularization::with(['employee', 'attendanceRecord', 'approver', 'creator'])->where(function ($q) {
if (Auth::user()->can('manage-any-attendance-regularizations')) {
$scopedBranchId = authBranchId();
$query = AttendanceRegularization::with(['employee', 'attendanceRecord', 'approver', 'creator'])->where(function ($q) use ($scopedBranchId) {
if ($scopedBranchId) {
$q->whereIn('created_by', getCompanyAndUsersId())
->whereHas('employee.employee', function ($eq) use ($scopedBranchId) {
$eq->where('branch_id', $scopedBranchId);
});
} elseif (Auth::user()->can('manage-any-attendance-regularizations')) {
$q->whereIn('created_by', getCompanyAndUsersId());
} elseif (Auth::user()->can('manage-own-attendance-regularizations')) {
$q->where('created_by', Auth::id())->orWhere('employee_id', Auth::id())->orWhere('approved_by', Auth::id());
@@ -80,21 +86,29 @@ class AttendanceRegularizationController extends Controller
return $record;
});
$employees = User::where('type', 'employee')
->whereIn('created_by', getCompanyAndUsersId())
->get(['id', 'name']);
// Get attendance records for form dropdown
$attendanceRecords = AttendanceRecord::whereIn('created_by', getCompanyAndUsersId())
$attQuery = AttendanceRecord::whereIn('created_by', getCompanyAndUsersId())
->with('employee')
->orderBy('date', 'desc')
->take(50)
->get();
->orderBy('date', 'desc');
if ($scopedBranchId) {
$attQuery->where(function($aq) use ($scopedBranchId) {
$aq->where('branch_id', $scopedBranchId)
->orWhereHas('employee', function($eq) use ($scopedBranchId) {
$eq->where('branch_id', $scopedBranchId);
});
});
}
$attendanceRecords = $attQuery->take(50)->get();
$companyUserIds = getCompanyAndUsersId();
$statsQuery = AttendanceRegularization::where(function ($q) {
if (Auth::user()->can('manage-any-attendance-regularizations')) {
$statsQuery = AttendanceRegularization::where(function ($q) use ($scopedBranchId) {
if ($scopedBranchId) {
$q->whereIn('created_by', getCompanyAndUsersId())
->whereHas('employee.employee', function ($eq) use ($scopedBranchId) {
$eq->where('branch_id', $scopedBranchId);
});
} elseif (Auth::user()->can('manage-any-attendance-regularizations')) {
$q->whereIn('created_by', getCompanyAndUsersId());
} elseif (Auth::user()->can('manage-own-attendance-regularizations')) {
$q->where('created_by', Auth::id())->orWhere('employee_id', Auth::id())->orWhere('approved_by', Auth::id());
@@ -125,6 +139,10 @@ class AttendanceRegularizationController extends Controller
{
// Get employees for filter dropdown (compatible with getFilteredEmployees logic)
$employeeQuery = Employee::whereIn('created_by', getCompanyAndUsersId());
$scopedBranchId = authBranchId();
if ($scopedBranchId) {
$employeeQuery->where('branch_id', $scopedBranchId);
}
if (Auth::user()->can('manage-own-attendance-regularizations') && !Auth::user()->can('manage-any-attendance-regularizations')) {
$employeeQuery->where(function ($q) {

View File

@@ -24,6 +24,14 @@ class OvertimeApplicationController extends Controller
if (!in_array(Auth::user()->type, ['admin', 'hr', 'company'])) {
abort(403);
}
$scopedBranchId = authBranchId();
if ($scopedBranchId) {
$targetEmp = \App\Models\Employee::where('user_id', $userId)->first();
if ($targetEmp && (int)$targetEmp->branch_id !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to view attendance details for an employee outside your assigned branch.');
}
}
}
$record = AttendanceRecord::with('employee.user', 'employee.shift')
@@ -85,19 +93,30 @@ class OvertimeApplicationController extends Controller
public function index()
{
$user = Auth::user();
$scopedBranchId = authBranchId();
$query = OvertimeApplication::with('user.employee');
// If not admin/hr, only show own requests
if (!in_array($user->type, ['admin', 'hr', 'company'])) {
$query->where('user_id', $user->id);
} elseif ($scopedBranchId) {
$query->whereHas('user.employee', function($q) use ($scopedBranchId) {
$q->where('branch_id', $scopedBranchId);
});
}
$applications = $query->orderBy('date', 'desc')->get();
$users = [];
if (in_array($user->type, ['admin', 'hr', 'company'])) {
$users = \App\Models\User::where('type', 'employee')->select('id', 'name')->get();
$usersQuery = \App\Models\User::where('type', 'employee');
if ($scopedBranchId) {
$usersQuery->whereHas('employee', function($q) use ($scopedBranchId) {
$q->where('branch_id', $scopedBranchId);
});
}
$users = $usersQuery->select('id', 'name')->get();
}
return Inertia::render('hr/overtime/index', [
@@ -117,10 +136,17 @@ class OvertimeApplicationController extends Controller
]);
$userId = Auth::id();
$scopedBranchId = authBranchId();
// If an admin/hr specifies a user_id, use it.
if ($request->has('user_id') && $request->user_id != Auth::id()) {
if (in_array(Auth::user()->type, ['admin', 'hr', 'company'])) {
if ($scopedBranchId) {
$targetEmp = \App\Models\Employee::where('user_id', $request->user_id)->first();
if ($targetEmp && (int)$targetEmp->branch_id !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to apply overtime for an employee outside your assigned branch.');
}
}
$userId = $request->user_id;
} else {
abort(403, 'Unauthorized to apply overtime for another employee.');
@@ -160,6 +186,14 @@ class OvertimeApplicationController extends Controller
abort(403);
}
$scopedBranchId = authBranchId();
if ($scopedBranchId) {
$applicantEmp = $overtimeApplication->user?->employee;
if ($applicantEmp && (int)$applicantEmp->branch_id !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to update overtime status for an employee outside your assigned branch.');
}
}
$request->validate([
'status' => 'required|in:approved,rejected',
'approved_hours' => 'nullable|required_if:status,approved|numeric|min:0',

View File

@@ -31,11 +31,16 @@ class ThirteenthMonthController extends Controller
$endYear = $request->input('end_year') ? (int) $request->input('end_year') : $currentYear;
$endMonth = $request->input('end_month') ? (int) $request->input('end_month') : 12;
$selectedBranchId = $request->input('branch_id') ? (int) $request->input('branch_id') : null;
$scopedBranchId = authBranchId();
$selectedBranchId = $scopedBranchId ?: ($request->input('branch_id') ? (int) $request->input('branch_id') : null);
// Available years for back-year filtering (last 5 years up to next year)
$availableYears = range($currentYear - 5, $currentYear + 1);
$branches = Branch::all(['id', 'name']);
$branchesQuery = Branch::query();
if ($scopedBranchId) {
$branchesQuery->where('id', $scopedBranchId);
}
$branches = $branchesQuery->get(['id', 'name']);
// Check if an existing run matches these range parameters
$existingRun = ThirteenthMonthRun::where('start_year', $startYear)
@@ -56,10 +61,15 @@ class ThirteenthMonthController extends Controller
}
// Fetch historical runs list
$pastRuns = ThirteenthMonthRun::with(['branch', 'creator'])
$pastRunsQuery = ThirteenthMonthRun::with(['branch', 'creator'])
->orderBy('year', 'desc')
->orderBy('created_at', 'desc')
->get();
->orderBy('created_at', 'desc');
if ($scopedBranchId) {
$pastRunsQuery->where('branch_id', $scopedBranchId);
}
$pastRuns = $pastRunsQuery->get();
return Inertia::render('hr/thirteenth-month/index', [
'activeRun' => $existingRun,
@@ -93,7 +103,9 @@ class ThirteenthMonthController extends Controller
$startMonth = (int) $request->start_month;
$endYear = (int) $request->end_year;
$endMonth = (int) $request->end_month;
$branchId = $request->branch_id ? (int) $request->branch_id : null;
$scopedBranchId = authBranchId();
$branchId = $scopedBranchId ?: ($request->branch_id ? (int) $request->branch_id : null);
$run = $this->service->generateRun($startYear, $startMonth, $endYear, $endMonth, $branchId, null, Auth::id());
@@ -111,7 +123,14 @@ class ThirteenthMonthController extends Controller
'notes' => 'nullable|string|max:255',
]);
$entry = ThirteenthMonthEntry::findOrFail($id);
$entry = ThirteenthMonthEntry::with(['employee', 'run'])->findOrFail($id);
$scopedBranchId = authBranchId();
if ($scopedBranchId) {
$empBranchId = $entry->employee?->branch_id ?? $entry->run?->branch_id;
if ($empBranchId && (int)$empBranchId !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to update 13th Month entry for an employee outside your assigned branch.');
}
}
if ($request->has('adjustment_amount')) {
$entry->adjustment_amount = (float) $request->adjustment_amount;
@@ -152,6 +171,11 @@ class ThirteenthMonthController extends Controller
public function approveRun(Request $request, $id)
{
$run = ThirteenthMonthRun::findOrFail($id);
$scopedBranchId = authBranchId();
if ($scopedBranchId && $run->branch_id && (int)$run->branch_id !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to approve 13th Month run for another branch.');
}
$run->status = 'approved';
$run->save();
@@ -164,6 +188,10 @@ class ThirteenthMonthController extends Controller
public function exportCsv($id)
{
$run = ThirteenthMonthRun::with(['entries.employee.user', 'entries.employee.department', 'branch'])->findOrFail($id);
$scopedBranchId = authBranchId();
if ($scopedBranchId && $run->branch_id && (int)$run->branch_id !== (int)$scopedBranchId) {
abort(403, 'Unauthorized to export 13th Month run for another branch.');
}
$filename = "13th_Month_Pay_{$run->start_year}_{$run->start_month}_to_{$run->end_year}_{$run->end_month}_Export.csv";
$headers = [

View File

@@ -10,6 +10,9 @@ use App\Models\Employee;
use App\Models\PayrollRun;
use App\Models\PayrollEntry;
use App\Models\Payslip;
use App\Models\OvertimeApplication;
use App\Models\ThirteenthMonthRun;
use App\Models\ThirteenthMonthEntry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
@@ -30,6 +33,7 @@ class BranchScopedAccessTest extends TestCase
Permission::firstOrCreate(['name' => 'manage-any-employees']);
Permission::firstOrCreate(['name' => 'manage-payslips']);
Permission::firstOrCreate(['name' => 'manage-any-payslips']);
Permission::firstOrCreate(['name' => 'manage-payroll-runs']);
}
public function test_auth_branch_id_helper_resolution()
@@ -318,5 +322,152 @@ class BranchScopedAccessTest extends TestCase
$response->assertSee('PAY-ALPHA-001');
$response->assertDontSee('PAY-BETA-002');
}
public function test_branch_scoped_user_only_sees_assigned_branch_overtime_requests()
{
$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,
]);
Employee::create([
'user_id' => $userA->id,
'employee_id' => 'EMP-A-' . rand(100, 999),
'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,
]);
Employee::create([
'user_id' => $userB->id,
'employee_id' => 'EMP-B-' . rand(100, 999),
'branch_id' => $branchB->id,
'created_by' => $company->id,
]);
// Overtime requests
OvertimeApplication::create([
'user_id' => $userA->id,
'date' => '2026-05-10',
'requested_hours' => 2.5,
'reason' => 'Alpha Overtime Work',
'status' => 'pending',
]);
OvertimeApplication::create([
'user_id' => $userB->id,
'date' => '2026-05-10',
'requested_hours' => 3.0,
'reason' => 'Beta Overtime Work',
'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,
]);
$response = $this->actingAs($hrUser)->get(route('hr.overtime.index'));
$response->assertStatus(200);
$response->assertSee('Alpha Overtime Work');
$response->assertDontSee('Beta Overtime Work');
}
public function test_branch_scoped_user_only_sees_assigned_branch_thirteenth_month()
{
$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,
]);
Employee::create([
'user_id' => $userA->id,
'employee_id' => 'EMP-A-' . rand(100, 999),
'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,
]);
Employee::create([
'user_id' => $userB->id,
'employee_id' => 'EMP-B-' . rand(100, 999),
'branch_id' => $branchB->id,
'created_by' => $company->id,
]);
$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-payroll-runs');
$response = $this->actingAs($hrUser)->get(route('hr.thirteenth-month.index'));
$response->assertStatus(200);
$response->assertSee('Employee Alpha User');
$response->assertDontSee('Employee Beta User');
}
}