mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 20:52:14 +01:00
Compare commits
3 Commits
9007ad686a
...
555675f3fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555675f3fc | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f |
@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
|
||||
/** @var Member|null $member */
|
||||
$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())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own');
|
||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||
} 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) {
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.10.0",
|
||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||
"dedoc/scramble": "^0.12.2",
|
||||
"dedoc/scramble": "^0.13.21",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.4.0",
|
||||
"flowframe/laravel-trend": "^0.5.0",
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^2.0.3",
|
||||
|
||||
1695
composer.lock
generated
1695
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,25 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
|
||||
default: [],
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
updateTimeEntry: (entry: TimeEntry) => void;
|
||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
groupSimilarTimeEntries: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
updateTimeEntry: (entry: TimeEntry) => void;
|
||||
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
|
||||
deleteTimeEntries: (entries: TimeEntry[]) => void;
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
groupSimilarTimeEntries?: boolean;
|
||||
}>(),
|
||||
{
|
||||
groupSimilarTimeEntries: true,
|
||||
}
|
||||
);
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
|
||||
@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$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
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user