Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
5 changed files with 887 additions and 886 deletions

View File

@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own'); $this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all'); $this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -9,9 +9,9 @@
"ext-zip": "*", "ext-zip": "*",
"brick/money": "^0.10.0", "brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0", "datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.20", "dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.5.0", "flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3", "inertiajs/inertia-laravel": "^2.0.3",

1681
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,25 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [], default: [],
}); });
const props = defineProps<{ const props = withDefaults(
timeEntries: TimeEntry[]; defineProps<{
projects: Project[]; timeEntries: TimeEntry[];
tasks: Task[]; projects: Project[];
tags: Tag[]; tasks: Task[];
clients: Client[]; tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>; clients: Client[];
updateTimeEntry: (entry: TimeEntry) => void; createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void; updateTimeEntry: (entry: TimeEntry) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void; updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void; deleteTimeEntries: (entries: TimeEntry[]) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
currency: string; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
organizationBillableRate: number | null; currency: string;
enableEstimatedTime: boolean; organizationBillableRate: number | null;
canCreateProject: boolean; enableEstimatedTime: boolean;
groupSimilarTimeEntries: boolean; canCreateProject: boolean;
}>(); groupSimilarTimeEntries?: boolean;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {}; const groupedEntriesByDay: Record<string, TimeEntry[]> = {};

View File

@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_update_endpoint_fails_if_time_entry_belongs_to_different_organization_than_url_even_with_update_all_permission(): void
{
// Arrange
// Attacker: has `time-entries:update:all` in their own organization (orgA).
$data = $this->createUserWithPermission([
'time-entries:update:all',
'projects:view:all',
]);
$attackerProject = Project::factory()->forOrganization($data->organization)->create();
// Victim: entirely separate organization (orgB). Attacker has NO membership in orgB.
$victimOrgData = $this->createUserWithPermission([], true);
$victimTimeEntry = TimeEntry::factory()
->forOrganization($victimOrgData->organization)
->forMember($victimOrgData->ownerMember)
->create([
'description' => 'victim-original',
'project_id' => null,
'task_id' => null,
]);
Passport::actingAs($data->user);
// Act: PUT to /organizations/{orgA}/time-entries/{victim_uuid} — URL org is attacker's
// own org, but the route-bound time entry belongs to orgB.
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $victimTimeEntry->getKey()]), [
'description' => 'attacker-overwrite',
'project_id' => $attackerProject->getKey(),
]);
// Assert: must be rejected. Before the fix this returned 200 and rewrote the
// victim row with attacker's project_id while keeping organization_id = orgB.
$response->assertForbidden();
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $victimTimeEntry->getKey(),
'organization_id' => $victimOrgData->organization->getKey(),
'description' => 'victim-original',
'project_id' => null,
]);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
{ {
// Arrange // Arrange