diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java index b362f9bb64..ffd7519bac 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java @@ -3,16 +3,6 @@ import lombok.Builder; import lombok.Data; -/** - * Result of a fire-and-forget RMM dispatch over core NATS — running a command, - * cancelling an in-flight execution, or running a saved script. Carries the - * server-minted {@code executionId} the dashboard uses to correlate the agent's - * asynchronous result. - * - *

Shared by all dispatch mutations ({@code runCommand}, {@code cancelExecution}, - * {@code runScript}): they are structurally identical, so a single type avoids - * three duplicate one-field DTOs. - */ @Data @Builder public class DispatchResponse { diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java index 73b5b89c77..954f677dee 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptFilterOption.java @@ -5,11 +5,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * One option for a scripts-list filter dropdown: the raw {@code value} to filter by, - * a human {@code label} to show, and the {@code count} of matching scripts. Mirrors - * {@code DeviceFilterOption}. - */ @Data @Builder @NoArgsConstructor diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java new file mode 100644 index 0000000000..8f72f6cebb --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java @@ -0,0 +1,31 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +public class CreateSoftwareBundleInput { + + @NotNull(message = "action must not be null") + private SoftwareAction action; + + @NotNull(message = "mode must not be null") + private SoftwareBundleMode mode; + + @NotEmpty(message = "machineIds must not be empty") + private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", + message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; + + @Valid + private List packages; + + private Instant startAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java new file mode 100644 index 0000000000..1d01d56f6e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceFilterInput.java @@ -0,0 +1,13 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import lombok.Data; + +import java.util.List; + +@Data +public class SoftwareActionDeviceFilterInput { + + private List statuses; + private List organizationIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java new file mode 100644 index 0000000000..f293337679 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionDeviceResponse.java @@ -0,0 +1,28 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; + +@Data +@Builder +public class SoftwareActionDeviceResponse { + + private String machineId; + private String hostname; + private String organizationId; + private String organizationName; + private SoftwareActionStatus status; + + private Integer exitCode; + private String stdout; + private Boolean stdoutTruncated; + private String stderr; + private Boolean stderrTruncated; + private String error; + + private Instant dispatchedAt; + private Instant finishedAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java new file mode 100644 index 0000000000..f178dea157 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilterInput.java @@ -0,0 +1,16 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import lombok.Data; + +import java.util.List; + +@Data +public class SoftwareActionFilterInput { + + private List statuses; + private List actions; + private List engines; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java new file mode 100644 index 0000000000..8917f3e6e1 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionFilters.java @@ -0,0 +1,21 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.api.dto.rmm.script.ScriptFilterOption; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareActionFilters { + + private List statuses; + private List actions; + private List engines; + private Integer filteredCount; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java new file mode 100644 index 0000000000..2c33849fa5 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java @@ -0,0 +1,41 @@ +package com.openframe.api.dto.rmm.software; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public record SoftwareActionId(String executionId, String bundleId, String scheduleId) { + + private static final String SEP = ":"; // absent in UUIDs / Mongo ObjectIds, and not regex-special + + public String encode() { + String raw = nullToEmpty(executionId) + SEP + nullToEmpty(bundleId) + SEP + nullToEmpty(scheduleId); + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + public static SoftwareActionId of(String executionId, String bundleId, String scheduleId) { + return new SoftwareActionId(executionId, bundleId, scheduleId); + } + + public static SoftwareActionId decode(String actionId) { + if (actionId == null || actionId.isBlank()) { + return new SoftwareActionId(null, null, null); + } + try { + String raw = new String(Base64.getUrlDecoder().decode(actionId), StandardCharsets.UTF_8); + String[] parts = raw.split(SEP, -1); + if (parts.length == 3) { + return new SoftwareActionId(emptyToNull(parts[0]), emptyToNull(parts[1]), emptyToNull(parts[2])); + } + } catch (IllegalArgumentException ignored) { + } + return new SoftwareActionId(actionId, null, null); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + private static String emptyToNull(String s) { + return s == null || s.isEmpty() ? null : s; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java new file mode 100644 index 0000000000..1442efa0ea --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionResponse.java @@ -0,0 +1,33 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; + +@Data +@Builder +public class SoftwareActionResponse { + + private String id; + private String executionId; + + private String software; + private SoftwareAction action; + private PackageManagerType engine; + private SoftwareActionStatus status; + + private int totalMachineCount; + private int respondedMachineCount; + + private Instant scheduledAt; + private Instant dispatchedAt; + private Instant finishedAt; + private String initiatedBy; + + private String bundleId; + private String scheduleId; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java new file mode 100644 index 0000000000..8a2bd2669a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java @@ -0,0 +1,30 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +public class SoftwareBundleResponse { + + private String id; + private SoftwareAction action; + private SoftwareBundleMode mode; + private SoftwareBundleStatus status; + private List machineIds; + private List packages; + private Instant startAt; + private String scheduleId; + private String createdBy; + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private List executionIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleScheduleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleScheduleInput.java new file mode 100644 index 0000000000..df6816ec2b --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleScheduleInput.java @@ -0,0 +1,23 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.Instant; + +@Data +public class SoftwareBundleScheduleInput { + + private String name; + private String description; + private ScheduleTimeReference timeReference; + private ScheduleOfflineBehavior offlineBehavior; + private Long reconnectWindowSeconds; + + @NotNull(message = "startAt is required") + private Instant startAt; + + private Long repeat; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java new file mode 100644 index 0000000000..b577a8fa07 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java @@ -0,0 +1,9 @@ +package com.openframe.api.dto.rmm.software; + +public enum SoftwareCveSeverity { + CRITICAL, + HIGH, + MEDIUM, + LOW, + NONE +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java new file mode 100644 index 0000000000..12973114a1 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java @@ -0,0 +1,17 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Data; + +import java.util.List; + +@Data +public class SoftwareFilterInput { + + private List sources; + + private List versionStatuses; + + private SoftwareCveSeverity minSeverity; + + private List deviceTagIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java new file mode 100644 index 0000000000..f0690c033e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java @@ -0,0 +1,13 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SoftwareFilterOption { + + private String value; + private String label; + private int count; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java new file mode 100644 index 0000000000..672f6e6743 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java @@ -0,0 +1,23 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class SoftwareFilters { + + private List sources; + private List versionStatuses; + private List severities; + + public static SoftwareFilters empty() { + return SoftwareFilters.builder() + .sources(List.of()) + .versionStatuses(List.of()) + .severities(List.of()) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java new file mode 100644 index 0000000000..948ee056c6 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java @@ -0,0 +1,14 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.device.Machine; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SoftwareOnDeviceResponse { + + private Machine device; + private String softwareVersion; + private SoftwareOnDeviceStatus status; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java new file mode 100644 index 0000000000..60cfdebe0a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java @@ -0,0 +1,9 @@ +package com.openframe.api.dto.rmm.software; + +public enum SoftwareOnDeviceStatus { + UP_TO_DATE, + OUTDATED, + SCHEDULED_UPDATE, + UNINSTALLING, + SCHEDULED_UNINSTALL +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java index 9238002edc..f6fcfdf4a7 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwarePackageInput.java @@ -15,6 +15,6 @@ public class SoftwarePackageInput { @NotBlank private String packageName; - /** Brew-only sub-type (CASK/FORMULA). Required for brew (enforced by its handler), ignored by managers that do not distinguish. */ private BrewPackageType brewPackageType; + private String version; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java new file mode 100644 index 0000000000..f6fdce1ba6 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java @@ -0,0 +1,24 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareResponse { + private String id; + private String name; + private String publisher; + private SoftwareSource source; + private String currentVersion; + private String latestVersion; + private SoftwareVersionStatus versionStatus; + private Integer olderVersionsCount; + private Integer devicesCount; + private SoftwareVulnerabilitySummaryResponse vulnerabilitySummary; + private Boolean cpeMatched; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java index 4b069c86a5..7b53544682 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java @@ -1,5 +1,6 @@ package com.openframe.api.dto.rmm.software; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger; @@ -23,6 +24,7 @@ public class SoftwareScheduleResponse { private SoftwareAction action; private List packages; private ScheduleDeviceSelectionMode selectionMode; + private ScheduleDeviceCriteria deviceCriteria; private ScheduleScriptTrigger trigger; private ScheduleTimeReference timeReference; private ScheduleOfflineBehavior offlineBehavior; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java new file mode 100644 index 0000000000..6cfa47fc29 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java @@ -0,0 +1,8 @@ +package com.openframe.api.dto.rmm.software; + +public enum SoftwareSource { + WINGET, + CHOCOLATEY, + BREW, + UNMANAGED +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java new file mode 100644 index 0000000000..b7ed5a3f98 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java @@ -0,0 +1,7 @@ +package com.openframe.api.dto.rmm.software; + +public enum SoftwareVersionStatus { + UP_TO_DATE, + OUTDATED, + UNKNOWN +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java new file mode 100644 index 0000000000..e3dd9b059a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java @@ -0,0 +1,20 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareVulnerabilityResponse { + private String cveId; + private SoftwareCveSeverity severity; + private Double cvssScore; + private String affectedVersion; + private Instant discoveredAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java new file mode 100644 index 0000000000..dc6352b210 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java @@ -0,0 +1,15 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareVulnerabilitySummaryResponse { + private SoftwareCveSeverity highestSeverity; + private int cveCount; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SubmitSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SubmitSoftwareBundleInput.java new file mode 100644 index 0000000000..3dd1b3d581 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SubmitSoftwareBundleInput.java @@ -0,0 +1,28 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareAction; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class SubmitSoftwareBundleInput { + + @NotNull + private String id; + + @NotNull + private SoftwareAction action; + + @NotEmpty(message = "packages must not be empty") + @Size(max = 50, message = "packages must not exceed 50") + @Valid + private List packages; + + @Valid + private SoftwareBundleScheduleInput schedule; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java new file mode 100644 index 0000000000..bb3033c995 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java @@ -0,0 +1,31 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +public class UpdateSoftwareBundleInput { + + @NotBlank(message = "id must not be blank") + private String id; + + @NotNull(message = "mode must not be null") + private SoftwareBundleMode mode; + + @NotEmpty(message = "machineIds must not be empty") + private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", + message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; + + @Valid + private List packages; + + private Instant startAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java new file mode 100644 index 0000000000..b0cd70fdd5 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java @@ -0,0 +1,26 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareSource; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AffectedSoftwareResponse { + + private String id; + + private String name; + + private SoftwareSource source; + + private String version; + + private Integer devicesCount; + + private String resolvedInVersion; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java new file mode 100644 index 0000000000..af35d3570f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java @@ -0,0 +1,12 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import lombok.Data; + +@Data +public class VulnerabilityFilterInput { + + private SoftwareCveSeverity minSeverity; + + private Boolean exploited; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilters.java new file mode 100644 index 0000000000..d1472c26da --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilters.java @@ -0,0 +1,14 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareFilterOption; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class VulnerabilityFilters { + + private List severities; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java new file mode 100644 index 0000000000..dab9a4f86d --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java @@ -0,0 +1,39 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VulnerabilityResponse { + + private String cveId; + + private SoftwareCveSeverity severity; + + private Double cvssScore; + + private Double epssProbability; + + private Boolean cisaKnownExploit; + + private Instant discoveredAt; + + private String detailsLink; + + private Integer devicesCount; + + private String description; + + private String resolvedInVersion; + + private List affectedSoftware; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java new file mode 100644 index 0000000000..c96a0c2e0d --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java @@ -0,0 +1,10 @@ +package com.openframe.api.dto.shared; + +import java.util.List; + +public record PageResult(List items, boolean hasNext, boolean hasPrevious, int filteredCount, int page) { + + public static PageResult empty(int page) { + return new PageResult<>(List.of(), false, false, 0, page); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java b/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java index ddc8cc3bef..b0d8d41697 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/relay/NodeType.java @@ -15,7 +15,8 @@ public enum NodeType { SCRIPT("Script"), SCRIPT_EXECUTION("ScriptExecution"), SCRIPT_SCHEDULE("ScriptSchedule"), - SCHEDULE_RUN("ScheduleRun"); + SCHEDULE_RUN("ScheduleRun"), + SOFTWARE_BUNDLE("SoftwareBundle"); private final String graphqlTypeName; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java index 4b9285c328..ca25e97360 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/FleetVulnerabilityStatusService.java @@ -4,6 +4,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.openframe.api.config.FleetVulnerabilityStatusProperties; import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.tool.IntegratedToolId; import com.openframe.data.document.tool.ToolApiKey; import com.openframe.data.document.tool.ToolCredentials; import com.openframe.data.document.tool.ToolUrl; @@ -33,7 +34,6 @@ @Slf4j public class FleetVulnerabilityStatusService { - private static final String FLEETMDM_SERVER_KEY = "fleetmdm-server"; private static final String CACHE_KEY = "lastCompletedVulnerabilityRunAt"; private static final String PORT_SEPARATOR = ":"; private static final int FIRST_PAGE = 0; @@ -69,7 +69,7 @@ public Instant getLastCompletedVulnerabilityRunAt() { private Optional loadLastCompletedRunAt() { try { - return integratedToolRepository.findByKey(FLEETMDM_SERVER_KEY) + return integratedToolRepository.findByKey(IntegratedToolId.FLEET_SERVER_ID.getValue()) .map(this::fetchWithinTimeout) .orElseGet(this::missingTool); } catch (Exception e) { @@ -79,7 +79,7 @@ private Optional loadLastCompletedRunAt() { } private Optional missingTool() { - log.debug("Fleet MDM tool not found: {}", FLEETMDM_SERVER_KEY); + log.debug("Fleet MDM tool not found: {}", IntegratedToolId.FLEET_SERVER_ID.getValue()); return Optional.empty(); } @@ -129,7 +129,7 @@ private Optional parseCountsUpdatedAt(VulnerabilitiesResponse response) private String resolveApiUrl(IntegratedTool tool) { List toolUrls = tool.getToolUrls(); if (isEmpty(toolUrls)) { - log.warn("Fleet MDM tool has no URLs configured: {}", FLEETMDM_SERVER_KEY); + log.warn("Fleet MDM tool has no URLs configured: {}", IntegratedToolId.FLEET_SERVER_ID.getValue()); return null; } return toolUrls.stream() @@ -155,17 +155,17 @@ private String buildBaseUrl(ToolUrl toolUrl) { private String resolveApiToken(IntegratedTool tool) { ToolCredentials credentials = tool.getCredentials(); if (credentials == null) { - log.warn("Fleet MDM tool has no credentials yet: {}", FLEETMDM_SERVER_KEY); + log.warn("Fleet MDM tool has no credentials yet: {}", IntegratedToolId.FLEET_SERVER_ID.getValue()); return null; } ToolApiKey apiKey = credentials.getApiKey(); if (apiKey == null) { - log.warn("Fleet API token is not provisioned yet: {}", FLEETMDM_SERVER_KEY); + log.warn("Fleet API token is not provisioned yet: {}", IntegratedToolId.FLEET_SERVER_ID.getValue()); return null; } String key = apiKey.getKey(); if (!hasText(key)) { - log.warn("Fleet API token is empty: {}", FLEETMDM_SERVER_KEY); + log.warn("Fleet API token is empty: {}", IntegratedToolId.FLEET_SERVER_ID.getValue()); return null; } return key; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java index 96a4142291..cfbb3bbc85 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceService.java @@ -97,11 +97,6 @@ public CountedGenericQueryResult queryDevices(DeviceFilterCriteria filt return paginate(machineFilter(filterOptions, null, null), search, paginationCriteria, sort); } - /** - * Same as {@link #queryDevices} but restricted to a fixed set of machineIds (e.g. the - * devices assigned to a script schedule). A {@code null}/empty set yields an empty page; - * the restriction is intersected with any tag filter. - */ public CountedGenericQueryResult queryAssignedDevices(Collection machineIds, DeviceFilterCriteria filterOptions, CursorPaginationCriteria paginationCriteria, @@ -170,20 +165,12 @@ private DeviceFilters deviceFilters(MachineQueryFilter filter, String search) { .build(); } - /** - * All machineIds of devices matching {@code filter}/{@code search} within the given platforms — - * backs "Add all devices" for a schedule (resolve the whole filtered set at once, unpaginated). - */ public List findDeviceIdsForPlatforms(Collection platformNames, DeviceFilterCriteria filterOptions, String search) { return machineRepository.findMachineIds(tenantIdProvider.getTenantId(), scheduleDeviceFilter(filterOptions, platformNames, null), search); } - /** - * Of the given machineIds, those matching {@code filter}/{@code search} — backs "Remove all - * devices" (the assigned set narrowed by the Selected-tab filter). Empty/null in → empty out. - */ public List findAssignedDeviceIds(Collection machineIds, DeviceFilterCriteria filterOptions, String search) { if (machineIds == null || machineIds.isEmpty()) { @@ -196,6 +183,11 @@ public List findAssignedDeviceIds(Collection machineIds, scheduleDeviceFilter(filterOptions, null, machineIds), search); } + public List findAllDeviceIds(DeviceFilterCriteria filterOptions, String search) { + return machineRepository.findMachineIds(tenantIdProvider.getTenantId(), + scheduleDeviceFilter(filterOptions, null, null), search); + } + private CountedGenericQueryResult paginate(MachineQueryFilter filter, String search, CursorPaginationCriteria paginationCriteria, SortInput sort) { @@ -233,10 +225,8 @@ private PageInfo buildPageInfo(List pageItems, boolean hasNextPage, boo .build(); } - /** Statuses a device may have to be offered to / kept on a script schedule: only live agents. */ private static final Set SCHEDULE_ALLOWED_STATUSES = EnumSet.of(DeviceStatus.ONLINE, DeviceStatus.OFFLINE); - /** Every status other than {@link #SCHEDULE_ALLOWED_STATUSES}, applied as a {@code $nin} on top of any user filter. */ private static final List SCHEDULE_EXCLUDED_STATUSES = Arrays.stream(DeviceStatus.values()) .filter(status -> !SCHEDULE_ALLOWED_STATUSES.contains(status)) .map(Enum::name) @@ -275,11 +265,7 @@ private static List resolveOsTypes(Collection filterOsTypes, Col return osTypeScope != null && !osTypeScope.isEmpty() ? new ArrayList<>(osTypeScope) : null; } - /** - * Combine the tag-filter machineId restriction with an explicit caller restriction into a - * single {@code $in} set. {@code null} on a side means "no restriction from that side"; - * when both are present the result is their intersection (possibly empty). - */ + // null on a side = no restriction from that side; both present = intersection (possibly empty). private static Collection intersectMachineIds(List tagMachineIds, Collection restrict) { if (tagMachineIds == null && restrict == null) { return null; @@ -294,11 +280,7 @@ private static Collection intersectMachineIds(List tagMachineIds return tagMachineIds.stream().filter(restrictSet::contains).collect(Collectors.toList()); } - /** - * Resolves tag-based filters (tagKeys, tagValues) to a set of machineIds. - * Returns null if no tag filters are applied, meaning no restriction needed. - * Returns an empty list if tag filters are applied but no machines match. - */ + // null = no tag filter applied (no restriction); empty list = filter applied but nothing matched. private List resolveTagFilterToMachineIds(DeviceFilterCriteria filter) { boolean hasTagKeys = filter.getTagKeys() != null && !filter.getTagKeys().isEmpty(); boolean hasTagValues = filter.getTagValues() != null && !filter.getTagValues().isEmpty(); diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java new file mode 100644 index 0000000000..ed45784c6a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricher.java @@ -0,0 +1,61 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.model.Host; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.IntStream; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class FleetDeviceCountEnricher { + + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + public void enrich(List rows, + Function> hostFetcher, + BiConsumer countSetter) { + if (rows == null || rows.isEmpty()) { + return; + } + Map> hostsByRow = fanOut(rows, hostFetcher); + Map correlated = batchCorrelate(hostsByRow); + assignCounts(rows, hostsByRow, correlated, countSetter); + } + + private static Map> fanOut(List rows, Function> hostFetcher) { + Map> byIndex = new ConcurrentHashMap<>(rows.size()); + IntStream.range(0, rows.size()).parallel().forEach(i -> + byIndex.put(i, hostFetcher.apply(rows.get(i)))); + return byIndex; + } + + private Map batchCorrelate(Map> hostsByRow) { + List allHosts = hostsByRow.values().stream() + .flatMap(List::stream) + .toList(); + return hostMachineResolver.resolve(tenantIdProvider.getTenantId(), allHosts); + } + + private static void assignCounts(List rows, + Map> hostsByRow, + Map correlated, + BiConsumer countSetter) { + for (int i = 0; i < rows.size(); i++) { + int count = (int) hostsByRow.getOrDefault(i, List.of()).stream() + .filter(h -> h.getId() != null && correlated.containsKey(h.getId())) + .count(); + countSetter.accept(rows.get(i), count); + } + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java new file mode 100644 index 0000000000..af9430736f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java @@ -0,0 +1,70 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.sdk.fleetmdm.model.Host; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class FleetHostMachineResolver { + + private final MachineRepository machineRepository; + + public Map resolve(String tenantId, List hosts) { + if (hosts == null || hosts.isEmpty()) { + return Map.of(); + } + Set osUuids = values(hosts, Host::getUuid); + Set serials = values(hosts, Host::getHardwareSerial); + Set hostnames = values(hosts, Host::getHostname); + + Map byOsUuid = index(machineRepository.findByTenantIdAndOsUuidIn(tenantId, osUuids), Machine::getOsUuid); + Map bySerial = index(machineRepository.findByTenantIdAndSerialNumberIn(tenantId, serials), Machine::getSerialNumber); + Map byHostname = index(machineRepository.findByTenantIdAndHostnameIn(tenantId, hostnames), Machine::getHostname); + + Map resolved = new LinkedHashMap<>(); + for (Host host : hosts) { + if (host.getId() == null || resolved.containsKey(host.getId())) { + continue; + } + Machine machine = lookup(byOsUuid, host.getUuid()); + if (machine == null) { + machine = lookup(bySerial, host.getHardwareSerial()); + } + if (machine == null) { + machine = lookup(byHostname, host.getHostname()); + } + if (machine != null) { + resolved.put(host.getId(), machine); + } + } + return resolved; + } + + private static Set values(List hosts, Function key) { + return hosts.stream().map(key).filter(v -> isNotBlank(v)).collect(Collectors.toSet()); + } + + private static Map index(List machines, Function key) { + return machines.stream() + .filter(m -> isNotBlank(key.apply(m))) + .collect(Collectors.toMap(key, Function.identity(), (a, b) -> a)); + } + + private static Machine lookup(Map byKey, String key) { + return isNotBlank(key) ? byKey.get(key) : null; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java new file mode 100644 index 0000000000..1adfa60c96 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java @@ -0,0 +1,35 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.api.dto.rmm.software.SoftwareSource; + +import java.util.Set; + +public enum FleetSoftwareCategory { + + CHOCOLATEY(SoftwareSource.CHOCOLATEY, "chocolatey_packages"), + HOMEBREW(SoftwareSource.BREW, "homebrew_packages"), + OTHER(SoftwareSource.UNMANAGED); + + private final SoftwareSource source; + private final Set fleetSources; + + FleetSoftwareCategory(SoftwareSource source, String... fleetSources) { + this.source = source; + this.fleetSources = Set.of(fleetSources); + } + + public SoftwareSource source() { + return source; + } + + public static FleetSoftwareCategory of(String fleetSource) { + if (fleetSource != null) { + for (FleetSoftwareCategory c : values()) { + if (c.fleetSources.contains(fleetSource)) { + return c; + } + } + } + return OTHER; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java new file mode 100644 index 0000000000..8f89db450b --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java @@ -0,0 +1,191 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.organization.Organization; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned; +import com.openframe.data.document.rmm.script.ExecutionStatus; +import com.openframe.data.document.rmm.script.ScriptExecution; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.organization.OrganizationRepository; +import com.openframe.data.repository.rmm.ScriptExecutionRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleOnlineDispatchRepository; +import com.openframe.data.service.TenantIdProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static org.springframework.util.StringUtils.hasText; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SoftwareActionDetailService { + + private final ScriptExecutionRepository scriptExecutionRepository; + private final SoftwareBundleOnlineDispatchRepository bundleOnlineDispatchRepository; + private final SoftwareScheduleOnlineDispatchRepository scheduleOnlineDispatchRepository; + private final SoftwareScheduleMachineAssignedRepository scheduleMachineAssignedRepository; + private final MachineRepository machineRepository; + private final OrganizationRepository organizationRepository; + private final TenantIdProvider tenantIdProvider; + + public List devices(String executionId, String bundleId, String scheduleId, + SoftwareActionDeviceFilterInput filter, String search) { + String tenantId = tenantIdProvider.getTenantId(); + Map byMachine = new LinkedHashMap<>(); + + // 1. Baseline: a schedule's full target set as SCHEDULED (not-yet-fired schedules have no leaves). + if (hasText(scheduleId)) { + for (SoftwareScheduleMachineAssigned a : scheduleMachineAssignedRepository + .findByTenantIdAndSoftwareScheduleId(tenantId, scheduleId)) { + byMachine.putIfAbsent(a.getMachineId(), pending(a.getMachineId(), SoftwareActionStatus.SCHEDULED, null)); + } + } + + // 2. Overlay reconnect sentinels (offline devices armed at fire time) — EXPIRED overrides the baseline. + addPending(byMachine, tenantId, bundleId, scheduleId); + + // 3. Overlay execution leaves — the real outcome wins over any pending state. + if (hasText(executionId)) { + for (ScriptExecution leaf : scriptExecutionRepository.findByTenantIdAndExecutionId(tenantId, executionId)) { + byMachine.put(leaf.getMachineId(), fromLeaf(leaf)); + } + } + + enrichWithCustomer(tenantId, byMachine); + + return byMachine.values().stream() + .filter(row -> matchesStatus(row, filter)) + .filter(row -> matchesCustomer(row, filter)) + .filter(row -> matchesSearch(row, search)) + .toList(); + } + + private void addPending(Map byMachine, String tenantId, + String bundleId, String scheduleId) { + if (hasText(bundleId)) { + bundleOnlineDispatchRepository.findByTenantIdAndBundleId(tenantId, bundleId).forEach(s -> { + if (s.getStatus() == DeviceOnlineDispatchStatus.NEW) { + byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.SCHEDULED, null)); + } + // DISPATCHED without a leaf = the package's OS did not apply to this device → not shown. + }); + } else if (hasText(scheduleId)) { + scheduleOnlineDispatchRepository.findByTenantIdAndScheduleId(tenantId, scheduleId).forEach(s -> { + if (s.getStatus() == DeviceOnlineDispatchStatus.NEW) { + byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.SCHEDULED, null)); + } else if (s.getStatus() == DeviceOnlineDispatchStatus.EXPIRED) { + byMachine.put(s.getMachineId(), pending(s.getMachineId(), SoftwareActionStatus.FAILED, + "Device did not reconnect within the retry window")); + } + }); + } + } + + // Batch-load hostname and customer in two queries, not per row. + private void enrichWithCustomer(String tenantId, Map byMachine) { + if (byMachine.isEmpty()) { + return; + } + Map machines = new HashMap<>(); + machineRepository.findByTenantIdAndMachineIdIn(tenantId, byMachine.keySet()) + .forEach(m -> machines.put(m.getMachineId(), m)); + + Set organizationIds = new HashSet<>(); + machines.values().forEach(m -> { + if (m.getOrganizationId() != null) { + organizationIds.add(m.getOrganizationId()); + } + }); + Map organizationNames = new HashMap<>(); + if (!organizationIds.isEmpty()) { + organizationRepository.findByOrganizationIdIn(organizationIds) + .forEach(o -> organizationNames.put(o.getOrganizationId(), o.getName())); + } + + byMachine.forEach((machineId, row) -> { + Machine m = machines.get(machineId); + if (m != null) { + row.setHostname(m.getHostname()); + row.setOrganizationId(m.getOrganizationId()); + row.setOrganizationName(organizationNames.get(m.getOrganizationId())); + } + }); + } + + private static boolean matchesStatus(SoftwareActionDeviceResponse row, SoftwareActionDeviceFilterInput filter) { + if (filter == null || filter.getStatuses() == null || filter.getStatuses().isEmpty()) { + return true; + } + return filter.getStatuses().contains(row.getStatus()); + } + + private static boolean matchesCustomer(SoftwareActionDeviceResponse row, SoftwareActionDeviceFilterInput filter) { + if (filter == null || filter.getOrganizationIds() == null || filter.getOrganizationIds().isEmpty()) { + return true; + } + return row.getOrganizationId() != null && filter.getOrganizationIds().contains(row.getOrganizationId()); + } + + private static boolean matchesSearch(SoftwareActionDeviceResponse row, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.trim().toLowerCase(Locale.ROOT); + return contains(row.getHostname(), needle) || contains(row.getMachineId(), needle); + } + + private static boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private static SoftwareActionDeviceResponse fromLeaf(ScriptExecution leaf) { + return SoftwareActionDeviceResponse.builder() + .machineId(leaf.getMachineId()) + .status(deviceStatus(leaf.getStatus())) + .exitCode(leaf.getExitCode()) + .stdout(leaf.getStdout()) + .stdoutTruncated(leaf.getStdoutTruncated()) + .stderr(leaf.getStderr()) + .stderrTruncated(leaf.getStderrTruncated()) + .error(leaf.getError()) + .dispatchedAt(leaf.getDispatchedAt()) + .finishedAt(leaf.getFinishedAt()) + .build(); + } + + private static SoftwareActionDeviceResponse pending(String machineId, SoftwareActionStatus status, String error) { + return SoftwareActionDeviceResponse.builder() + .machineId(machineId) + .status(status) + .error(error) + .build(); + } + + private static SoftwareActionStatus deviceStatus(ExecutionStatus status) { + if (status == null) { + return SoftwareActionStatus.IN_PROGRESS; + } + return switch (status) { + case SUCCESS -> SoftwareActionStatus.COMPLETED; + case FAILED -> SoftwareActionStatus.FAILED; + case QUEUED, RUNNING -> SoftwareActionStatus.IN_PROGRESS; + }; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java new file mode 100644 index 0000000000..dedfd7fcc6 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionService.java @@ -0,0 +1,236 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareActionFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareActionFilters; +import com.openframe.api.dto.rmm.software.SoftwareActionId; +import com.openframe.api.dto.rmm.software.SoftwareActionResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.mapper.ScriptFilterOptionMapper; +import com.openframe.api.dto.shared.SortDirection; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.data.document.rmm.filter.SoftwareActionQueryFilter; +import com.openframe.data.document.rmm.schedule.SoftwareSchedule; +import com.openframe.data.document.rmm.schedule.SoftwareSchedulePackage; +import com.openframe.data.document.rmm.script.ScriptStatus; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.document.rmm.software.SoftwareActionSummary; +import com.openframe.data.document.rmm.software.SoftwareExecutionId; +import com.openframe.data.repository.rmm.SoftwareActionAggregationRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleRepository; +import com.openframe.data.service.TenantIdProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SoftwareActionService { + + private static final int DEFAULT_PER_PAGE = 25; + + private static final Map SORT_FIELD = Map.of( + "status", "status", + "software", "packageName", + "processedDevices", "totalMachineCount", + "devices", "totalMachineCount", + "dispatchedAt", "dispatchedAt"); + + private static final String FIELD_STATUS = "status"; + private static final String FIELD_ACTION = "action"; + private static final String FIELD_ENGINE = "packageManager"; + + private final SoftwareActionAggregationRepository aggregationRepository; + private final SoftwareScheduleRepository scheduleRepository; + private final SoftwareScheduleMachineAssignedRepository assignedRepository; + private final ScriptFilterOptionMapper optionMapper; + private final TenantIdProvider tenantIdProvider; + + public PageResult list(SoftwareActionFilterInput filter, String search, + SortInput sort, int page, Integer perPage) { + String tenantId = tenantIdProvider.getTenantId(); + int size = perPage != null && perPage > 0 ? perPage : DEFAULT_PER_PAGE; + SoftwareActionQueryFilter queryFilter = toQueryFilter(filter); + String sortField = resolveSortField(sort); + Sort.Direction direction = resolveDirection(sort); + + List scheduled = includeScheduled(filter) + ? scheduledRows(tenantId, filter, search) + : List.of(); + long executedTotal = aggregationRepository.count(tenantId, queryFilter, search); + long total = scheduled.size() + executedTotal; + + int offset = Math.max(0, page) * size; + List items = new ArrayList<>(size); + if (offset < scheduled.size()) { + int to = Math.min(scheduled.size(), offset + size); + items.addAll(scheduled.subList(offset, to)); + int remaining = size - items.size(); + if (remaining > 0) { + items.addAll(mapExecuted(aggregationRepository.findPage(tenantId, queryFilter, search, sortField, direction, 0, remaining))); + } + } else { + int executedSkip = offset - scheduled.size(); + items.addAll(mapExecuted(aggregationRepository.findPage(tenantId, queryFilter, search, sortField, direction, executedSkip, size))); + } + + boolean hasNext = (long) offset + items.size() < total; + boolean hasPrev = page > 0; + return new PageResult<>(items, hasNext, hasPrev, (int) total, page); + } + + public SoftwareActionFilters filters(SoftwareActionFilterInput filter, String search) { + String tenantId = tenantIdProvider.getTenantId(); + SoftwareActionQueryFilter queryFilter = toQueryFilter(filter); + + Map statuses = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_STATUS)); + Map actions = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_ACTION)); + Map engines = new LinkedHashMap<>(aggregationRepository.facet(tenantId, queryFilter, search, FIELD_ENGINE)); + + List scheduled = includeScheduled(filter) + ? scheduledRows(tenantId, filter, search) + : List.of(); + for (SoftwareActionResponse row : scheduled) { + bump(statuses, row.getStatus() != null ? row.getStatus().name() : null); + bump(actions, row.getAction() != null ? row.getAction().name() : null); + bump(engines, row.getEngine() != null ? row.getEngine().name() : null); + } + + long filteredCount = scheduled.size() + aggregationRepository.count(tenantId, queryFilter, search); + + return SoftwareActionFilters.builder() + .statuses(optionMapper.selfLabeled(statuses)) + .actions(optionMapper.selfLabeled(actions)) + .engines(optionMapper.selfLabeled(engines)) + .filteredCount((int) filteredCount) + .build(); + } + + private static void bump(Map counts, String key) { + if (key != null) { + counts.merge(key, 1, Integer::sum); + } + } + + public Optional findById(String actionId) { + String executionId = SoftwareActionId.decode(actionId).executionId(); + if (executionId == null) { + return Optional.empty(); + } + return aggregationRepository.findByExecutionId(tenantIdProvider.getTenantId(), executionId) + .map(SoftwareActionService::toResponse); + } + + private List scheduledRows(String tenantId, SoftwareActionFilterInput filter, String search) { + List upcoming = scheduleRepository + .findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(tenantId, ScriptStatus.ACTIVE, Instant.now()); + List rows = new ArrayList<>(); + for (SoftwareSchedule schedule : upcoming) { + if (schedule.getPackages() == null) { + continue; + } + int deviceCount = (int) assignedRepository.countByTenantIdAndSoftwareScheduleId(tenantId, schedule.getId()); + for (SoftwareSchedulePackage pkg : schedule.getPackages()) { + if (!scheduledMatchesFilter(filter, search, schedule, pkg)) { + continue; + } + String executionId = SoftwareExecutionId.forSchedule(schedule.getId(), pkg.getPackageManager(), pkg.getPackageName()); + rows.add(SoftwareActionResponse.builder() + .id(SoftwareActionId.of(executionId, null, schedule.getId()).encode()) + .executionId(executionId) + .software(pkg.getPackageName()) + .action(schedule.getAction()) + .engine(pkg.getPackageManager()) + .status(SoftwareActionStatus.SCHEDULED) + .totalMachineCount(deviceCount) + .respondedMachineCount(0) + .scheduledAt(schedule.getNextRunAt()) + .initiatedBy(schedule.getCreatedBy()) + .scheduleId(schedule.getId()) + .build()); + } + } + return rows; + } + + private static boolean scheduledMatchesFilter(SoftwareActionFilterInput filter, String search, + SoftwareSchedule schedule, SoftwareSchedulePackage pkg) { + if (filter != null) { + if (filter.getActions() != null && !filter.getActions().isEmpty() + && !filter.getActions().contains(schedule.getAction())) { + return false; + } + if (filter.getEngines() != null && !filter.getEngines().isEmpty() + && !filter.getEngines().contains(pkg.getPackageManager())) { + return false; + } + } + if (search != null && !search.isBlank()) { + String needle = search.trim().toLowerCase(Locale.ROOT); + return pkg.getPackageName() != null && pkg.getPackageName().toLowerCase(Locale.ROOT).contains(needle); + } + return true; + } + + private static boolean includeScheduled(SoftwareActionFilterInput filter) { + return filter == null || filter.getStatuses() == null || filter.getStatuses().isEmpty() + || filter.getStatuses().contains(SoftwareActionStatus.SCHEDULED); + } + + private String resolveSortField(SortInput sort) { + if (sort == null || sort.getField() == null) { + return aggregationRepository.getDefaultSortField(); + } + String mapped = SORT_FIELD.get(sort.getField()); + return mapped != null && aggregationRepository.isSortableField(mapped) + ? mapped : aggregationRepository.getDefaultSortField(); + } + + private static Sort.Direction resolveDirection(SortInput sort) { + return sort != null && sort.getDirection() == SortDirection.ASC ? Sort.Direction.ASC : Sort.Direction.DESC; + } + + private static SoftwareActionQueryFilter toQueryFilter(SoftwareActionFilterInput input) { + if (input == null) { + return null; + } + return SoftwareActionQueryFilter.builder() + .statuses(input.getStatuses()) + .actions(input.getActions()) + .engines(input.getEngines()) + .build(); + } + + private static List mapExecuted(List summaries) { + return summaries.stream().map(SoftwareActionService::toResponse).toList(); + } + + private static SoftwareActionResponse toResponse(SoftwareActionSummary s) { + return SoftwareActionResponse.builder() + .id(SoftwareActionId.of(s.getExecutionId(), s.getBundleId(), s.getScheduleId()).encode()) + .executionId(s.getExecutionId()) + .software(s.getPackageName()) + .action(s.getAction()) + .engine(s.getPackageManager()) + .status(s.getStatus()) + .totalMachineCount(s.getTotalMachineCount()) + .respondedMachineCount(s.getRespondedMachineCount()) + .dispatchedAt(s.getDispatchedAt()) + .initiatedBy(s.getInitiatedBy()) + .bundleId(s.getBundleId()) + .scheduleId(s.getScheduleId()) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java new file mode 100644 index 0000000000..4a922ebaee --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java @@ -0,0 +1,362 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwareBundleScheduleInput; +import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareSchedulePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; +import com.openframe.api.dto.rmm.software.SubmitSoftwareBundleInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.core.exception.NotFoundException; +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; +import com.openframe.data.document.rmm.software.SoftwareActionResult; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.document.rmm.software.SoftwareExecutionId; +import com.openframe.data.repository.rmm.SoftwareActionResultRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.data.service.rmm.MachinePlatformResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SoftwareBundleService { + + private static final int MAX_PACKAGES = 50; + + private final SoftwareBundleRepository bundleRepository; + private final SoftwareBundleOnlineDispatchRepository onlineDispatchRepository; + private final SoftwareScheduleService softwareScheduleService; + private final SoftwareActionResultRepository softwareActionResultRepository; + private final MachinePlatformResolver machinePlatformResolver; + private final TenantIdProvider tenantIdProvider; + + @Value("${openframe.rmm.software.bundle.pending-ttl}") + private Duration pendingTtl; + + @Value("${openframe.rmm.software.bundle.schedule.reconnect-window-seconds}") + private long scheduleReconnectWindowSeconds; + + public boolean delete(String id, String actor) { + Optional found = bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id); + if (found.isEmpty()) { + return true; + } + SoftwareBundle entity = found.get(); + if (entity.getStatus() == SoftwareBundleStatus.COMPLETED) { + return false; + } + bundleRepository.delete(entity); + log.info("Deleted PENDING software bundle id={} actor={}", id, actor); + return true; + } + + public Optional findById(String id) { + return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id).map(SoftwareBundleService::toResponse); + } + + public SoftwareBundleResponse createBundleStub(String createdBy) { + String tenantId = tenantIdProvider.getTenantId(); + Instant now = Instant.now(); + SoftwareBundle entity = SoftwareBundle.builder() + .tenantId(tenantId) + .status(SoftwareBundleStatus.PENDING) + .machineIds(List.of()) + .packages(List.of()) + .createdBy(createdBy) + .createdAt(now) + .updatedAt(now) + .expireAt(now.plus(pendingTtl)) + .build(); + SoftwareBundle saved = bundleRepository.save(entity); + log.info("Created draft software bundle id={} tenantId={}", saved.getId(), tenantId); + return toResponse(saved); + } + + public SoftwareBundleResponse addDevices(String bundleId, List machineIds, String actor) { + SoftwareBundle entity = loadPendingOrThrow(bundleId); + LinkedHashSet set = new LinkedHashSet<>(currentMachineIds(entity)); + set.addAll(machineIds); + entity.setMachineIds(List.copyOf(set)); + return saveTouched(entity, actor); + } + + public SoftwareBundleResponse removeDevices(String bundleId, List machineIds, String actor) { + SoftwareBundle entity = loadPendingOrThrow(bundleId); + LinkedHashSet set = new LinkedHashSet<>(currentMachineIds(entity)); + machineIds.forEach(set::remove); + entity.setMachineIds(List.copyOf(set)); + return saveTouched(entity, actor); + } + + public SoftwareBundleResponse submit(SubmitSoftwareBundleInput input, String actor) { + SoftwareBundle entity = loadOrThrow(input.getId()); + if (entity.getStatus() == SoftwareBundleStatus.COMPLETED) { + log.info("submitSoftwareBundle id={} already COMPLETED — returning unchanged", input.getId()); + return toResponse(entity); + } + List machineIds = currentMachineIds(entity); + if (machineIds.isEmpty()) { + throw new BadRequestException("Cannot submit software bundle " + input.getId() + ": no devices assigned"); + } + List packages = toDomainPackages(input.getPackages()); + validatePackages(packages); + Map deviceOsTypes = machinePlatformResolver.osTypesByMachineId(machineIds); + rejectDevicesWithoutCompatiblePackage(machineIds, packages, deviceOsTypes); + + entity.setAction(input.getAction()); + entity.setPackages(packages); + + Instant now = Instant.now(); + if (input.getSchedule() != null) { + entity.setMode(SoftwareBundleMode.SCHEDULED); + entity.setStartAt(input.getSchedule().getStartAt()); + submitScheduled(entity, input.getSchedule(), actor); + } else { + entity.setMode(SoftwareBundleMode.NOW); + armOnlineDispatch(entity, now); + entity.setExecutionIds(executionIds(entity)); + } + + entity.setStatus(SoftwareBundleStatus.COMPLETED); + entity.setCompletedAt(now); + entity.setUpdatedAt(now); + entity.setExpireAt(null); // completed bundles are history — never reaped + bundleRepository.save(entity); + + writeActionResults(entity, packages, deviceOsTypes, now, actor); + + log.info("Submitted software bundle id={} mode={} action={} devices={} packages={} actor={}", + entity.getId(), entity.getMode(), entity.getAction(), machineIds.size(), packages.size(), actor); + return toResponse(entity); + } + + private void writeActionResults(SoftwareBundle bundle, List packages, + Map deviceOsTypes, Instant now, String actor) { + boolean scheduled = bundle.getMode() == SoftwareBundleMode.SCHEDULED; + List results = new java.util.ArrayList<>(packages.size()); + for (SoftwareBundlePackage pkg : packages) { + OsType packageOs = osTypeOf(pkg.getPackageManager()); + List targets = currentMachineIds(bundle).stream() + .filter(machineId -> deviceOsTypes.get(machineId) == packageOs) + .toList(); + String executionId = scheduled + ? SoftwareExecutionId.forSchedule(bundle.getScheduleId(), pkg.getPackageManager(), pkg.getPackageName()) + : SoftwareExecutionId.forBundle(bundle.getId(), pkg.getPackageManager(), pkg.getPackageName()); + results.add(SoftwareActionResult.builder() + .id(executionId) + .tenantId(bundle.getTenantId()) + .executionId(executionId) + .action(bundle.getAction()) + .packageManager(pkg.getPackageManager()) + .packageName(pkg.getPackageName()) + .version(pkg.getVersion()) + .status(scheduled ? SoftwareActionStatus.SCHEDULED : SoftwareActionStatus.IN_PROGRESS) + .mode(bundle.getMode()) + .machineIds(targets) + .totalMachineCount(targets.size()) + .bundleId(scheduled ? null : bundle.getId()) + .scheduleId(scheduled ? bundle.getScheduleId() : null) + .scheduledAt(scheduled ? bundle.getStartAt() : null) + .dispatchedAt(scheduled ? null : now) + .initiatedBy(actor) + .createdAt(now) + .build()); + } + softwareActionResultRepository.saveAll(results); + log.info("Wrote {} software action result(s) for bundle id={}", results.size(), bundle.getId()); + } + + private static void validatePackages(List packages) { + if (packages.isEmpty()) { + throw new BadRequestException("Cannot submit software bundle: at least one package is required"); + } + if (packages.size() > MAX_PACKAGES) { + throw new BadRequestException("Cannot submit software bundle: at most " + MAX_PACKAGES + + " packages (got " + packages.size() + ")"); + } + List brewMissingType = packages.stream() + .filter(p -> p.getPackageManager() == PackageManagerType.BREW && p.getBrewPackageType() == null) + .map(SoftwareBundlePackage::getPackageName) + .toList(); + if (!brewMissingType.isEmpty()) { + throw new BadRequestException("brewPackageType (CASK or FORMULA) is required for brew packages: " + + brewMissingType); + } + } + + private void rejectDevicesWithoutCompatiblePackage(List machineIds, List packages, + Map deviceOsTypes) { + Set packageOsTypes = packages.stream() + .map(p -> osTypeOf(p.getPackageManager())) + .collect(Collectors.toSet()); + List incompatible = machineIds.stream() + .filter(machineId -> { + OsType os = deviceOsTypes.get(machineId); + return os != null && !packageOsTypes.contains(os); + }) + .toList(); + if (!incompatible.isEmpty()) { + throw new BadRequestException("These devices have no package compatible with their OS: " + incompatible + + ". Add a matching package or remove the devices."); + } + } + + private static List executionIds(SoftwareBundle bundle) { + return bundle.getPackages().stream() + .map(p -> SoftwareExecutionId.forBundle(bundle.getId(), p.getPackageManager(), p.getPackageName())) + .toList(); + } + + private static OsType osTypeOf(PackageManagerType packageManager) { + return packageManager == PackageManagerType.BREW ? OsType.MAC_OS : OsType.WINDOWS; + } + + private void submitScheduled(SoftwareBundle bundle, SoftwareBundleScheduleInput sched, String actor) { + CreateSoftwareScheduleInput input = new CreateSoftwareScheduleInput(); + input.setName(sched.getName() != null ? sched.getName() : scheduleName(bundle)); + input.setDescription(sched.getDescription()); + input.setAction(bundle.getAction()); + input.setPackages(toSchedulePackages(bundle.getPackages())); + input.setTimeReference(sched.getTimeReference() != null ? sched.getTimeReference() : ScheduleTimeReference.SERVER); + input.setOfflineBehavior(sched.getOfflineBehavior() != null ? sched.getOfflineBehavior() : ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + input.setReconnectWindowSeconds(sched.getReconnectWindowSeconds() != null + ? sched.getReconnectWindowSeconds() : scheduleReconnectWindowSeconds); + input.setStartAt(sched.getStartAt()); + input.setRepeat(sched.getRepeat()); + input.setMachineIds(bundle.getMachineIds()); + + SoftwareScheduleResponse schedule = softwareScheduleService.create(input, actor); + bundle.setScheduleId(schedule.getId()); + log.info("Submitted SCHEDULED software bundle id={} → schedule id={} startAt={}", + bundle.getId(), schedule.getId(), bundle.getStartAt()); + } + + private static List currentMachineIds(SoftwareBundle entity) { + return entity.getMachineIds() == null ? List.of() : entity.getMachineIds(); + } + + private SoftwareBundleResponse saveTouched(SoftwareBundle entity, String actor) { + Instant now = Instant.now(); + entity.setUpdatedAt(now); + entity.setExpireAt(now.plus(pendingTtl)); + SoftwareBundle saved = bundleRepository.save(entity); + log.debug("Touched software bundle id={} devices={} actor={}", + saved.getId(), currentMachineIds(saved).size(), actor); + return toResponse(saved); + } + + private static String scheduleName(SoftwareBundle bundle) { + String first = bundle.getPackages().get(0).getPackageName(); + int extra = bundle.getPackages().size() - 1; + String label = extra > 0 ? first + " +" + extra + " more" : first; + return bundle.getAction() + " " + label + " [" + bundle.getId() + "]"; + } + + private static List toSchedulePackages(List packages) { + return packages.stream() + .map(p -> { + SoftwareSchedulePackageInput i = new SoftwareSchedulePackageInput(); + i.setPackageManager(p.getPackageManager()); + i.setPackageName(p.getPackageName()); + i.setBrewPackageType(p.getBrewPackageType()); + return i; + }) + .toList(); + } + + private void armOnlineDispatch(SoftwareBundle bundle, Instant now) { + for (String machineId : new LinkedHashSet<>(bundle.getMachineIds())) { + boolean alreadyArmed = onlineDispatchRepository + .findByTenantIdAndMachineIdAndBundleId(bundle.getTenantId(), machineId, bundle.getId()) + .isPresent(); + if (alreadyArmed) { + continue; + } + try { + onlineDispatchRepository.save(SoftwareBundleOnlineDispatch.builder() + .tenantId(bundle.getTenantId()) + .machineId(machineId) + .bundleId(bundle.getId()) + .firstSeenAt(now) + .status(DeviceOnlineDispatchStatus.NEW) + .build()); + } catch (DuplicateKeyException raced) { + log.debug("Bundle online sentinel already armed bundleId={} machineId={}", bundle.getId(), machineId); + } + } + } + + private SoftwareBundle loadOrThrow(String id) { + return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id) + .orElseThrow(() -> new NotFoundException("Software bundle not found: " + id)); + } + + private SoftwareBundle loadPendingOrThrow(String id) { + SoftwareBundle entity = loadOrThrow(id); + if (entity.getStatus() != SoftwareBundleStatus.PENDING) { + throw new BadRequestException("Software bundle " + id + " is " + entity.getStatus() + + " and can no longer be modified or run"); + } + return entity; + } + + private static List toDomainPackages(List packages) { + if (packages == null) { + return List.of(); + } + return packages.stream() + .map(p -> SoftwareBundlePackage.builder() + .packageManager(p.getPackageManager()) + .packageName(p.getPackageName()) + .brewPackageType(p.getBrewPackageType()) + .version(p.getVersion()) + .build()) + .toList(); + } + + private static SoftwareBundleResponse toResponse(SoftwareBundle b) { + return SoftwareBundleResponse.builder() + .id(b.getId()) + .action(b.getAction()) + .mode(b.getMode()) + .status(b.getStatus()) + .machineIds(b.getMachineIds() == null ? List.of() : b.getMachineIds()) + .packages(b.getPackages() == null ? List.of() : b.getPackages()) + .startAt(b.getStartAt()) + .scheduleId(b.getScheduleId()) + .createdBy(b.getCreatedBy()) + .createdAt(b.getCreatedAt()) + .updatedAt(b.getUpdatedAt()) + .completedAt(b.getCompletedAt()) + .executionIds(b.getExecutionIds()) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java index c848db1a13..3fcb8eeb70 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java @@ -6,8 +6,10 @@ import com.openframe.api.dto.rmm.software.SoftwarePackageInput; import com.openframe.api.service.rmm.script.ScriptService; import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; import com.openframe.data.document.rmm.software.SoftwareAction; import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.service.rmm.MachinePlatformResolver; import com.openframe.data.service.rmm.software.PackageManagerHandler; import com.openframe.data.service.rmm.software.PackageManagerRegistry; import lombok.RequiredArgsConstructor; @@ -29,6 +31,7 @@ public class SoftwareInstallUpdateManagementService { private final PackageManagerRegistry packageManagerRegistry; private final ScriptService scriptService; private final SoftwareDispatchService softwareDispatchService; + private final MachinePlatformResolver machinePlatformResolver; public List install(SoftwareManagementInput input, String initiatedBy, ExecutionSource source) { return dispatch(SoftwareAction.INSTALL, input, initiatedBy, source); @@ -41,6 +44,7 @@ public List update(SoftwareManagementInput input, String private List dispatch(SoftwareAction action, SoftwareManagementInput input, String initiatedBy, ExecutionSource source) { List machineIds = input.getMachineIds(); + Map osTypes = machinePlatformResolver.osTypesByMachineId(machineIds); Map scriptCache = new EnumMap<>(SoftwareScriptCode.class); List results = new ArrayList<>(input.getPackages().size()); @@ -48,9 +52,17 @@ private List dispatch(SoftwareAction action, SoftwareMan PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager()); SoftwareScriptCode code = handler.scriptCode(action); ScriptResponse script = scriptCache.computeIfAbsent(code, scriptService::getSoftwareScript); - List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType()); - String executionId = softwareDispatchService.dispatch(script, machineIds, args, initiatedBy, source, + List targets = machinePlatformResolver.compatible(machineIds, osTypes, script.getSupportedPlatforms()); + if (targets.isEmpty()) { + log.warn("Software {} skipped package {}/{}: no OS-compatible device among {} target(s) (supports {})", + action, pkg.getPackageManager(), pkg.getPackageName(), machineIds.size(), + script.getSupportedPlatforms()); + continue; + } + + List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType()); + String executionId = softwareDispatchService.dispatch(script, targets, args, initiatedBy, source, pkg.getPackageManager(), pkg.getPackageName(), action); results.add(SoftwareDispatchResult.builder() @@ -60,8 +72,8 @@ private List dispatch(SoftwareAction action, SoftwareMan .build()); } - log.info("Software {} dispatched: packages={} machines={} initiatedBy={} source={}", - action, input.getPackages().size(), machineIds.size(), initiatedBy, source); + log.info("Software {} dispatched: packages={}/{} machines={} initiatedBy={} source={}", + action, results.size(), input.getPackages().size(), machineIds.size(), initiatedBy, source); return results; } } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java new file mode 100644 index 0000000000..516a2700f7 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -0,0 +1,411 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareFilterOption; +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceStatus; +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.dto.shared.SortDirection; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher; +import com.openframe.api.util.FleetSoftwareMapper; +import com.openframe.api.util.FleetVulnerabilityMapper; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.tool.IntegratedToolId; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.FleetTenantHeader; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.HostSearchRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.springframework.util.StringUtils.hasText; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SoftwareInventoryService { + + private static final int DEVICES_PER_VERSION_LIMIT = 500; + private static final int HOSTS_PER_TITLE_LIMIT = 500; + private static final Map FLEET_SORT = Map.of( + "name", "name", + "devicesCount", "hosts_count"); + private static final Set CLIENT_SORT = Set.of("cveCount", "highestSeverity", "severity"); + private static final String SORTABLE_FIELDS = "name, devicesCount, cveCount, highestSeverity"; + private static final int TITLES_FETCH_PAGE = 500; + private static final int TITLES_FETCH_CAP = 5000; + + private final IntegratedToolRepository integratedToolRepository; + private final FleetDeviceCountEnricher deviceCountEnricher; + private final com.openframe.api.service.rmm.fleet.FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + @Value("${TENANT_ID:}") + private String tenantIdEnv; + + @Value("${openframe.fleet.multi-tenancy.enabled}") + private boolean fleetMultiTenancyEnabled; + + private FleetMdmClient fleet; + + @PostConstruct + void wireFleetClient() { + FleetTenantHeader.validate(fleetMultiTenancyEnabled, tenantIdEnv); + String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); + IntegratedTool tool = integratedToolRepository.findByKey(key) + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); + this.fleet = new FleetMdmClient(tool.apiUrl(), tool.apiToken(), tenantIdEnv); + } + + public Optional findById(String softwareId) { + return parseNumericId(softwareId) + .map(fleet::getSoftwareTitle) + .map(FleetSoftwareMapper::toResponse) + .map(row -> { + enrichRealDevicesCount(List.of(row)); + return row; + }); + } + + public PageResult listSoftware(String search, int page, Integer perPage, + SortInput sort, Boolean vulnerable) { + String field = sort == null ? null : sort.getField(); + boolean desc = sort != null && sort.getDirection() == SortDirection.DESC; + + if (field == null || FLEET_SORT.containsKey(field)) { + String orderKey = field == null ? null : FLEET_SORT.get(field); + // Fleet rejects order_direction without order_key — send it only when there is a key. + String orderDirection = orderKey == null ? null : (desc ? "desc" : "asc"); + PageResult pageResult = fleetPage(search, page, perPage, orderKey, orderDirection, vulnerable); + // Enrich ONLY the visible page (bounded by perPage) to keep Fleet fan-out predictable. + enrichRealDevicesCount(pageResult.items()); + return pageResult; + } + if (!CLIENT_SORT.contains(field)) { + throw new BadRequestException("Unknown sort field '" + field + "'. Sortable fields: " + SORTABLE_FIELDS); + } + // fetchAllTitles is bounded by TITLES_FETCH_CAP; we sort in memory then enrich only the returned slice. + List all = fetchAllTitles(search, vulnerable).stream() + .sorted(clientComparator(field, desc)) + .toList(); + PageResult pageResult = paginateList(all, page, perPage); + enrichRealDevicesCount(pageResult.items()); + return pageResult; + } + + // Replace Fleet's raw hosts_count with the count of correlated OpenFrame Machines. + private void enrichRealDevicesCount(List titles) { + deviceCountEnricher.enrich(titles, + row -> hostsForTitle(row.getId()), + SoftwareResponse::setDevicesCount); + } + + private List hostsForTitle(String titleIdStr) { + return parseNumericId(titleIdStr) + .map(titleId -> { + HostSearchRequest request = new HostSearchRequest(); + request.setSoftwareTitleId(titleId); + request.setPerPage(HOSTS_PER_TITLE_LIMIT); + return fleet.searchHosts(request); + }) + .orElseGet(List::of); + } + + private PageResult fleetPage(String search, int page, Integer perPage, String orderKey, + String orderDirection, Boolean vulnerable) { + SoftwareTitleRequest request = SoftwareTitleRequest.builder() + .page(page).perPage(perPage).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .vulnerable(vulnerable) + .build(); + SoftwareTitlesResponse response = fleet.listSoftwareTitles(request); + List items = mapTitles(response); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + boolean hasPrev = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults()); + int total = response.getCount() == null ? items.size() : response.getCount(); + return new PageResult<>(items, hasNext, hasPrev, total, page); + } + + private List fetchAllTitles(String search, Boolean vulnerable) { + List all = new ArrayList<>(); + int page = 0; + while (all.size() < TITLES_FETCH_CAP) { + SoftwareTitleRequest request = SoftwareTitleRequest.builder() + .page(page).perPage(TITLES_FETCH_PAGE).query(search) + .vulnerable(vulnerable) + .build(); + SoftwareTitlesResponse response = fleet.listSoftwareTitles(request); + all.addAll(mapTitles(response)); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + if (!hasNext || response.getSoftwareTitles() == null || response.getSoftwareTitles().isEmpty()) { + break; + } + page++; + } + return all; + } + + private static List mapTitles(SoftwareTitlesResponse response) { + return response.getSoftwareTitles() == null ? List.of() + : response.getSoftwareTitles().stream() + .map(FleetSoftwareMapper::toResponse) + .filter(Objects::nonNull) + .toList(); + } + + private static Comparator clientComparator(String field, boolean desc) { + Comparator base = switch (field) { + case "cveCount" -> Comparator.comparingInt(SoftwareInventoryService::cveCount); + case "highestSeverity", "severity" -> Comparator.comparingInt(SoftwareInventoryService::severityRank); + default -> throw new BadRequestException( + "Unknown sort field '" + field + "'. Sortable fields: " + SORTABLE_FIELDS); + }; + Comparator directed = desc ? base.reversed() : base; + // Stable tiebreaker: name ascending, case-insensitive, regardless of the primary direction. + return directed.thenComparing(row -> row.getName() == null ? "" : row.getName(), + String.CASE_INSENSITIVE_ORDER); + } + + private static int cveCount(SoftwareResponse row) { + return row.getVulnerabilitySummary() == null ? 0 : row.getVulnerabilitySummary().getCveCount(); + } + + private static int severityRank(SoftwareResponse row) { + SoftwareCveSeverity s = row.getVulnerabilitySummary() == null + ? null : row.getVulnerabilitySummary().getHighestSeverity(); + return s == null ? 0 : switch (s) { + case CRITICAL -> 4; + case HIGH -> 3; + case MEDIUM -> 2; + case LOW -> 1; + case NONE -> 0; + }; + } + + public PageResult listVulnerabilitiesForSoftware( + String softwareId, String search, int page, Integer perPage, + String sortField, boolean sortAsc) { + Optional parsed = parseNumericId(softwareId); + if (parsed.isEmpty()) { + return PageResult.empty(page); + } + SoftwareTitle title = fleet.getSoftwareTitle(parsed.get()); + if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) { + return PageResult.empty(page); + } + List pairs = collectVersionCves(title.getVersions()); + if (pairs.isEmpty()) { + return PageResult.empty(page); + } + Map enrichment = enrichCves(uniqueCves(pairs)); + List all = pairs.stream() + .map(p -> FleetVulnerabilityMapper.toResponse(p.cve(), enrichment.get(p.cve()), p.version())) + .filter(Objects::nonNull) + .filter(v -> matchesSearch(v, search)) + .sorted(comparator(sortField, sortAsc)) + .toList(); + return paginate(all, page, perPage); + } + + public PageResult listDevicesForSoftware(String softwareId, String search, + int page, Integer perPage) { + Optional titleId = parseNumericId(softwareId); + if (titleId.isEmpty()) { + return PageResult.empty(page); + } + SoftwareTitle title = fleet.getSoftwareTitle(titleId.get()); + if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) { + return PageResult.empty(page); + } + String latestVersion = FleetSoftwareMapper.toResponse(title).getLatestVersion(); + + List hostVersions = new ArrayList<>(); + for (SoftwareTitleVersion version : title.getVersions()) { + if (version.getId() == null) { + continue; + } + HostSearchRequest request = new HostSearchRequest(); + request.setSoftwareVersionId(version.getId()); + request.setPerPage(DEVICES_PER_VERSION_LIMIT); + List hosts = fleet.searchHosts(request); + hosts.forEach(host -> hostVersions.add(new HostVersion(host, version.getVersion()))); + } + + Map machinesByHostId = hostMachineResolver.resolve( + tenantIdProvider.getTenantId(), hostVersions.stream().map(HostVersion::host).toList()); + + List all = hostVersions.stream() + .map(hv -> toDeviceResponse(hv, machinesByHostId, latestVersion)) + .filter(Objects::nonNull) + .filter(row -> matchesDeviceSearch(row, search)) + .toList(); + return paginateList(all, page, perPage); + } + + private static SoftwareOnDeviceResponse toDeviceResponse(HostVersion hv, Map machinesByHostId, + String latestVersion) { + Machine device = hv.host().getId() == null ? null : machinesByHostId.get(hv.host().getId()); + if (device == null) { + return null; + } + SoftwareOnDeviceStatus status = latestVersion != null && latestVersion.equals(hv.version()) + ? SoftwareOnDeviceStatus.UP_TO_DATE : SoftwareOnDeviceStatus.OUTDATED; + return SoftwareOnDeviceResponse.builder() + .device(device) + .softwareVersion(hv.version()) + .status(status) + .build(); + } + + private static boolean matchesDeviceSearch(SoftwareOnDeviceResponse row, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + Machine d = row.getDevice(); + return (d.getHostname() != null && d.getHostname().toLowerCase(Locale.ROOT).contains(needle)) + || (row.getSoftwareVersion() != null && row.getSoftwareVersion().toLowerCase(Locale.ROOT).contains(needle)); + } + + private static PageResult paginateList(List all, int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = size == 0 ? 0 : Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } + + private record HostVersion(Host host, String version) { + } + + public SoftwareFilters getSoftwareFilters(String search) { + // Facet counts don't depend on the enriched devicesCount, so bypass enrichRealDevicesCount + // here — it would fire N Fleet /hosts lookups just to produce numbers we don't use. + List titles = fetchAllTitles(search, null); + return SoftwareFilters.builder() + .sources(facet(titles, SoftwareResponse::getSource)) + .versionStatuses(facet(titles, SoftwareResponse::getVersionStatus)) + .severities(facet(titles, row -> row.getVulnerabilitySummary() == null + ? null : row.getVulnerabilitySummary().getHighestSeverity())) + .build(); + } + + private static > List facet(List titles, + Function dimension) { + Map counts = titles.stream() + .map(dimension) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + return counts.entrySet().stream() + .sorted(Comparator.comparingInt(e -> e.getKey().ordinal())) + .map(e -> SoftwareFilterOption.builder() + .value(e.getKey().name()) + .label(humanize(e.getKey().name())) + .count(e.getValue().intValue()) + .build()) + .toList(); + } + + private static String humanize(String enumName) { + String lower = enumName.toLowerCase(Locale.ROOT).replace('_', ' '); + return lower.isEmpty() ? lower : Character.toUpperCase(lower.charAt(0)) + lower.substring(1); + } + + private static Optional parseNumericId(String softwareId) { + if (!hasText(softwareId)) { + return Optional.empty(); + } + try { + return Optional.of(Long.parseLong(softwareId)); + } catch (NumberFormatException e) { + log.debug("softwareId={} is not numeric — Fleet ids are numeric, returning empty", softwareId); + return Optional.empty(); + } + } + + private static List collectVersionCves(List versions) { + return versions.stream() + .filter(v -> v.getVulnerabilities() != null) + .flatMap(v -> v.getVulnerabilities().stream() + .filter(cve -> hasText(cve)) + .map(cve -> new VersionCve(v.getVersion(), cve))) + .toList(); + } + + private static Set uniqueCves(List pairs) { + return pairs.stream().map(VersionCve::cve).collect(Collectors.toSet()); + } + + private Map enrichCves(Set cves) { + return cves.parallelStream().collect(Collectors.toConcurrentMap( + cve -> cve, + cve -> Optional.ofNullable(fleet.getVulnerability(cve)).orElse(null))); + } + + private static boolean matchesSearch(SoftwareVulnerabilityResponse row, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + return row.getCveId() != null && row.getCveId().toLowerCase(Locale.ROOT).contains(needle); + } + + private static Comparator comparator(String field, boolean ascending) { + Comparator base = switch (field == null ? "" : field) { + case "severity", "cvssScore" -> Comparator.comparing( + SoftwareVulnerabilityResponse::getCvssScore, + Comparator.nullsLast(Comparator.naturalOrder())); + case "discoveredAt", "discovered" -> Comparator.comparing( + SoftwareVulnerabilityResponse::getDiscoveredAt, + Comparator.nullsLast(Comparator.naturalOrder())); + default -> Comparator.comparing( + SoftwareVulnerabilityResponse::getCveId, + Comparator.nullsLast(Comparator.naturalOrder())); + }; + return ascending ? base : base.reversed(); + } + + private static PageResult paginate(List all, + int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } + + private record VersionCve(String version, String cve) { + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java index 951245d9b3..17d456b9ef 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java @@ -8,6 +8,8 @@ import com.openframe.core.exception.BadRequestException; import com.openframe.core.exception.ConflictException; import com.openframe.core.exception.NotFoundException; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger; import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.schedule.SoftwareSchedule; @@ -118,7 +120,6 @@ public List list() { .stream().map(this::toResponse).toList(); } - /** Soft-delete (status DELETED). Idempotent; returns the id. */ public String delete(String id) { String tenantId = tenantIdProvider.getTenantId(); SoftwareSchedule entity = scheduleRepository.findByTenantIdAndId(tenantId, id) @@ -131,12 +132,10 @@ public String delete(String id) { return id; } - /** Archive a schedule (idempotent). */ public SoftwareScheduleResponse archive(String id) { return transitionTo(id, ScriptStatus.ARCHIVED); } - /** Restore an archived schedule back to ACTIVE (idempotent). */ public SoftwareScheduleResponse unarchive(String id) { return transitionTo(id, ScriptStatus.ACTIVE); } @@ -152,22 +151,22 @@ private SoftwareScheduleResponse transitionTo(String id, ScriptStatus target) { return toResponse(entity); } - /** Replace the full assigned device set (PUT — backs "Edit Devices"). */ public SoftwareScheduleResponse setDevices(String scheduleId, List machineIds, String actor) { String tenantId = tenantIdProvider.getTenantId(); SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); + ensureSpecificMode(entity); replaceDevices(tenantId, scheduleId, machineIds, actor); return toResponse(entity); } - /** Incrementally assign devices (idempotent — already-assigned ids are skipped). */ public void addDevices(String scheduleId, List machineIds, String actor) { String tenantId = tenantIdProvider.getTenantId(); - loadVisibleOrThrow(tenantId, scheduleId); + SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); if (machineIds == null || machineIds.isEmpty()) { return; } - Set existing = new HashSet<>(getMachineIds(scheduleId)); + ensureSpecificMode(entity); + Set existing = new HashSet<>(assignedMachineIds(tenantId, scheduleId)); List rows = machineIds.stream().distinct() .filter(id -> !existing.contains(id)) .map(machineId -> SoftwareScheduleMachineAssigned.builder() @@ -187,13 +186,36 @@ public void removeDevices(String scheduleId, List machineIds, String act assignedRepository.deleteByTenantIdAndSoftwareScheduleIdAndMachineIdIn(tenantId, scheduleId, machineIds); } + public SoftwareScheduleResponse setDeviceCriteria(String scheduleId, ScheduleDeviceCriteria criteria, String actor) { + String tenantId = tenantIdProvider.getTenantId(); + SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); + entity.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA); + entity.setDeviceCriteria(criteria); + SoftwareSchedule saved = scheduleRepository.save(entity); + log.info("Applied device criteria to software schedule id={} tenantId={} by user={}: {}", + scheduleId, tenantId, actor, criteria); + return toResponse(saved); + } + public List getMachineIds(String scheduleId) { - return targetResolver.resolveMachineIds(tenantIdProvider.getTenantId(), scheduleId); + return targetResolver.resolveMachineIds(loadVisibleOrThrow(tenantIdProvider.getTenantId(), scheduleId)); } public int deviceCount(String scheduleId) { - return (int) assignedRepository - .countByTenantIdAndSoftwareScheduleId(tenantIdProvider.getTenantId(), scheduleId); + return getMachineIds(scheduleId).size(); + } + + private List assignedMachineIds(String tenantId, String scheduleId) { + return assignedRepository.findByTenantIdAndSoftwareScheduleId(tenantId, scheduleId).stream() + .map(SoftwareScheduleMachineAssigned::getMachineId).toList(); + } + + private void ensureSpecificMode(SoftwareSchedule schedule) { + if (schedule.getSelectionMode() != ScheduleDeviceSelectionMode.SPECIFIC) { + schedule.setSelectionMode(ScheduleDeviceSelectionMode.SPECIFIC); + schedule.setDeviceCriteria(null); + scheduleRepository.save(schedule); + } } private void replaceDevices(String tenantId, String scheduleId, List machineIds, String actor) { @@ -218,7 +240,7 @@ private static ScheduleTimeReference defaultTimeReference(ScheduleTimeReference return timeReference != null ? timeReference : ScheduleTimeReference.SERVER; } - /** SERVER runs off {@code nextRunAt}=startAt; DEVICE_LOCAL is timezone-driven, so its nextRunAt is null. */ + // DEVICE_LOCAL is timezone-driven → no seeded nextRunAt; SERVER runs off startAt. private static Instant seedNextRunAt(ScheduleTimeReference timeReference, Instant startAt) { return timeReference == ScheduleTimeReference.DEVICE_LOCAL ? null : startAt; } @@ -244,7 +266,8 @@ private SoftwareScheduleResponse toResponse(SoftwareSchedule s) { return SoftwareScheduleResponse.builder() .id(s.getId()).name(s.getName()).description(s.getDescription()) .action(s.getAction()).packages(s.getPackages()) - .selectionMode(s.getSelectionMode()).trigger(s.getTrigger()).timeReference(s.getTimeReference()) + .selectionMode(s.getSelectionMode()).deviceCriteria(s.getDeviceCriteria()) + .trigger(s.getTrigger()).timeReference(s.getTimeReference()) .offlineBehavior(s.getOfflineBehavior()).reconnectWindowSeconds(s.getReconnectWindowSeconds()) .startAt(s.getStartAt()).repeat(s.getRepeat()) .nextRunAt(s.getNextRunAt()).lastRunAt(s.getLastRunAt()) diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java new file mode 100644 index 0000000000..58aef1ca16 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java @@ -0,0 +1,95 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory; +import com.openframe.sdk.fleetmdm.model.AffectedSoftware; +import com.openframe.sdk.fleetmdm.model.Vulnerability; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; + +public final class FleetGlobalVulnerabilityMapper { + + private FleetGlobalVulnerabilityMapper() { + } + + public static VulnerabilityResponse toListRow(Vulnerability v) { + if (v == null || v.getCve() == null) { + return null; + } + return baseBuilder(v).build(); + } + + public static VulnerabilityResponse toDetail(Vulnerability v) { + if (v == null || v.getCve() == null) { + return null; + } + return baseBuilder(v) + .description(v.getCveDescription()) + .resolvedInVersion(v.getResolvedInVersion()) + .affectedSoftware(mapAffected(v.getSoftware())) + .build(); + } + + private static VulnerabilityResponse.VulnerabilityResponseBuilder baseBuilder(Vulnerability v) { + return VulnerabilityResponse.builder() + .cveId(v.getCve()) + .severity(bucketSeverity(v.getCvssScore())) + .cvssScore(v.getCvssScore()) + .epssProbability(v.getEpssProbability()) + .cisaKnownExploit(v.getCisaKnownExploit()) + .discoveredAt(parseInstant(v.getCreatedAt())) + .detailsLink(v.getDetailsLink()) + .devicesCount(v.getHostsCount()); + } + + private static List mapAffected(List software) { + if (software == null || software.isEmpty()) { + return List.of(); + } + return software.stream() + .map(FleetGlobalVulnerabilityMapper::toAffected) + .filter(Objects::nonNull) + .toList(); + } + + private static AffectedSoftwareResponse toAffected(AffectedSoftware s) { + if (s == null) { + return null; + } + return AffectedSoftwareResponse.builder() + .id(Objects.toString(s.getId(), null)) + .name(s.getName()) + .source(FleetSoftwareCategory.of(s.getSource()).source()) + .version(s.getVersion()) + .devicesCount(s.getHostsCount()) + .resolvedInVersion(s.getResolvedInVersion()) + .build(); + } + + static SoftwareCveSeverity bucketSeverity(Double cvss) { + if (cvss == null) { + return null; + } + if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL; + if (cvss >= 7.0) return SoftwareCveSeverity.HIGH; + if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM; + if (cvss > 0.0) return SoftwareCveSeverity.LOW; + return SoftwareCveSeverity.NONE; + } + + private static Instant parseInstant(String iso) { + if (iso == null || iso.isBlank()) { + return null; + } + try { + return Instant.parse(iso); + } catch (DateTimeParseException e) { + return null; + } + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java new file mode 100644 index 0000000000..7276c95b05 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java @@ -0,0 +1,270 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareFilterOption; +import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilters; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.tool.IntegratedToolId; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.FleetTenantHeader; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.HostSearchRequest; +import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.springframework.util.StringUtils.hasText; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class VulnerabilityInventoryService { + + private static final int DEVICES_LIMIT = 500; + private static final int HOSTS_PER_ROW_LIMIT = 500; + private static final int FETCH_PAGE_SIZE = 500; + private static final int FETCH_CAP = 5000; + + private final IntegratedToolRepository integratedToolRepository; + private final FleetDeviceCountEnricher deviceCountEnricher; + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + @Value("${TENANT_ID:}") + private String tenantIdEnv; + + @Value("${openframe.fleet.multi-tenancy.enabled}") + private boolean fleetMultiTenancyEnabled; + + private FleetMdmClient fleet; + + @PostConstruct + void wireFleetClient() { + FleetTenantHeader.validate(fleetMultiTenancyEnabled, tenantIdEnv); + String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); + IntegratedTool tool = integratedToolRepository.findByKey(key) + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); + this.fleet = new FleetMdmClient(tool.apiUrl(), tool.apiToken(), tenantIdEnv); + } + + public PageResult listDevicesForCve(String cveId, String search, int page, Integer perPage) { + if (!hasText(cveId)) { + return PageResult.empty(page); + } + HostSearchRequest request = new HostSearchRequest(); + request.setCve(cveId); + request.setPerPage(DEVICES_LIMIT); + List hosts = fleet.searchHosts(request); + Map machinesByHostId = hostMachineResolver.resolve(tenantIdProvider.getTenantId(), hosts); + + List all = hosts.stream() + .map(host -> host.getId() == null ? null : machinesByHostId.get(host.getId())) + .filter(Objects::nonNull) + .distinct() + .filter(machine -> matchesDeviceSearch(machine, search)) + .toList(); + return paginate(all, page, perPage); + } + + private static boolean matchesDeviceSearch(Machine machine, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + return machine.getHostname() != null && machine.getHostname().toLowerCase(Locale.ROOT).contains(needle); + } + + private static PageResult paginate(List all, int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = size == 0 ? 0 : Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } + + public Optional findByCveId(String cveId) { + if (!hasText(cveId)) { + return Optional.empty(); + } + Vulnerability detail = fleet.getVulnerability(cveId); + return Optional.ofNullable(FleetGlobalVulnerabilityMapper.toDetail(detail)) + .map(this::enrichDetail); + } + + private VulnerabilityResponse enrichDetail(VulnerabilityResponse row) { + enrichVulnerabilityDeviceCounts(List.of(row)); + enrichAffectedSoftwareDeviceCounts(row.getAffectedSoftware()); + return row; + } + + public PageResult listVulnerabilities( + String search, int page, Integer perPage, + String orderKey, String orderDirection, + Boolean exploit, SoftwareCveSeverity minSeverity) { + boolean bySeverity = minSeverity != null && minSeverity != SoftwareCveSeverity.NONE; + boolean byExploited = Boolean.TRUE.equals(exploit); + + if (!bySeverity && !byExploited) { + VulnerabilityRequest request = VulnerabilityRequest.builder() + .page(page).perPage(perPage).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .build(); + VulnerabilitiesResponse response = fleet.listVulnerabilities(request); + List items = mapRows(response); + enrichVulnerabilityDeviceCounts(items); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + boolean hasPrev = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults()); + int total = response.getCount() == null ? items.size() : response.getCount().intValue(); + return new PageResult<>(items, hasNext, hasPrev, total, page); + } + + List filtered = fetchAll(search, orderKey, orderDirection).stream() + .filter(row -> passesMinSeverity(row, minSeverity)) + .filter(row -> passesExploited(row, byExploited)) + .toList(); + PageResult pageResult = paginate(filtered, page, perPage); + enrichVulnerabilityDeviceCounts(pageResult.items()); + return pageResult; + } + + private void enrichVulnerabilityDeviceCounts(List rows) { + deviceCountEnricher.enrich(rows, + row -> hostsForCve(row.getCveId()), + VulnerabilityResponse::setDevicesCount); + } + + private void enrichAffectedSoftwareDeviceCounts(List rows) { + deviceCountEnricher.enrich(rows, + row -> hostsForTitle(row.getId()), + AffectedSoftwareResponse::setDevicesCount); + } + + private List hostsForCve(String cveId) { + if (!hasText(cveId)) { + return List.of(); + } + HostSearchRequest request = new HostSearchRequest(); + request.setCve(cveId); + request.setPerPage(HOSTS_PER_ROW_LIMIT); + return fleet.searchHosts(request); + } + + private List hostsForTitle(String titleIdStr) { + if (!hasText(titleIdStr)) { + return List.of(); + } + try { + HostSearchRequest request = new HostSearchRequest(); + request.setSoftwareTitleId(Long.parseLong(titleIdStr)); + request.setPerPage(HOSTS_PER_ROW_LIMIT); + return fleet.searchHosts(request); + } catch (NumberFormatException e) { + return List.of(); + } + } + + public VulnerabilityFilters getVulnerabilityFilters(String search) { + List all = fetchAll(search, null, null); + return VulnerabilityFilters.builder() + .severities(facet(all, VulnerabilityResponse::getSeverity)) + .build(); + } + + private List fetchAll(String search, String orderKey, String orderDirection) { + List all = new ArrayList<>(); + int page = 0; + while (all.size() < FETCH_CAP) { + VulnerabilityRequest request = VulnerabilityRequest.builder() + .page(page).perPage(FETCH_PAGE_SIZE).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .build(); + VulnerabilitiesResponse response = fleet.listVulnerabilities(request); + all.addAll(mapRows(response)); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + if (!hasNext || response.getVulnerabilities() == null || response.getVulnerabilities().isEmpty()) { + break; + } + page++; + } + return all; + } + + private static List mapRows(VulnerabilitiesResponse response) { + return response.getVulnerabilities() == null ? List.of() + : response.getVulnerabilities().stream() + .map(FleetGlobalVulnerabilityMapper::toListRow) + .filter(Objects::nonNull) + .toList(); + } + + private static boolean passesExploited(VulnerabilityResponse row, boolean wantExploited) { + return !wantExploited || Boolean.TRUE.equals(row.getCisaKnownExploit()); + } + + private static > List facet( + List rows, Function dimension) { + Map counts = rows.stream() + .map(dimension) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + return counts.entrySet().stream() + .sorted(Comparator.comparingInt(e -> e.getKey().ordinal())) + .map(e -> SoftwareFilterOption.builder() + .value(e.getKey().name()) + .label(humanize(e.getKey().name())) + .count(e.getValue().intValue()) + .build()) + .toList(); + } + + private static String humanize(String enumName) { + String lower = enumName.toLowerCase(Locale.ROOT).replace('_', ' '); + return lower.isEmpty() ? lower : Character.toUpperCase(lower.charAt(0)) + lower.substring(1); + } + + private static boolean passesMinSeverity(VulnerabilityResponse row, SoftwareCveSeverity min) { + if (min == null || min == SoftwareCveSeverity.NONE) { + return true; + } + SoftwareCveSeverity actual = row.getSeverity(); + return actual != null && severityRank(actual) >= severityRank(min); + } + + private static int severityRank(SoftwareCveSeverity s) { + return switch (s) { + case CRITICAL -> 4; + case HIGH -> 3; + case MEDIUM -> 2; + case LOW -> 1; + case NONE -> 0; + }; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java new file mode 100644 index 0000000000..aa543deee6 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetSoftwareMapper.java @@ -0,0 +1,64 @@ +package com.openframe.api.util; + +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilitySummaryResponse; +import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import lombok.experimental.UtilityClass; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +@UtilityClass +public class FleetSoftwareMapper { + + public static SoftwareResponse toResponse(SoftwareTitle title) { + if (title == null) { + return null; + } + List versions = title.getVersions(); + SoftwareTitleVersion current = pickCurrentVersion(versions); + return SoftwareResponse.builder() + .id(Objects.toString(title.getId(), null)) + .name(title.getName()) + .source(FleetSoftwareCategory.of(title.getSource()).source()) + .currentVersion(current == null ? null : current.getVersion()) + .olderVersionsCount(olderVersionsCount(versions)) + .devicesCount(title.getHostsCount()) + .vulnerabilitySummary(rollUpVulnerabilities(versions)) + .build(); + } + + private static SoftwareTitleVersion pickCurrentVersion(List versions) { + if (versions == null || versions.isEmpty()) { + return null; + } + return versions.stream() + .max(Comparator.comparingInt(v -> v.getHostsCount() == null ? 0 : v.getHostsCount())) + .orElse(versions.get(0)); + } + + private static Integer olderVersionsCount(List versions) { + if (versions == null || versions.isEmpty()) { + return 0; + } + return versions.size() - 1; + } + + private static SoftwareVulnerabilitySummaryResponse rollUpVulnerabilities(List versions) { + if (versions == null || versions.isEmpty()) { + return null; + } + int total = versions.stream() + .mapToInt(v -> v.getVulnerabilities() == null ? 0 : v.getVulnerabilities().size()) + .sum(); + if (total == 0) { + return null; + } + return SoftwareVulnerabilitySummaryResponse.builder() + .cveCount(total) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/util/FleetVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetVulnerabilityMapper.java new file mode 100644 index 0000000000..f827b55860 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/util/FleetVulnerabilityMapper.java @@ -0,0 +1,50 @@ +package com.openframe.api.util; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import lombok.experimental.UtilityClass; + +import java.time.Instant; +import java.time.format.DateTimeParseException; + +@UtilityClass +public class FleetVulnerabilityMapper { + + public static SoftwareVulnerabilityResponse toResponse(String cveId, Vulnerability enrichment, String affectedVersion) { + if (cveId == null) { + return null; + } + Double cvss = enrichment == null ? null : enrichment.getCvssScore(); + String discovered = enrichment == null ? null : enrichment.getCreatedAt(); + return SoftwareVulnerabilityResponse.builder() + .cveId(cveId) + .severity(bucketSeverity(cvss)) + .cvssScore(cvss) + .affectedVersion(affectedVersion) + .discoveredAt(parseInstant(discovered)) + .build(); + } + + private static SoftwareCveSeverity bucketSeverity(Double cvss) { + if (cvss == null) { + return null; + } + if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL; + if (cvss >= 7.0) return SoftwareCveSeverity.HIGH; + if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM; + if (cvss > 0.0) return SoftwareCveSeverity.LOW; + return SoftwareCveSeverity.NONE; + } + + private static Instant parseInstant(String iso) { + if (iso == null || iso.isBlank()) { + return null; + } + try { + return Instant.parse(iso); + } catch (DateTimeParseException e) { + return null; + } + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java b/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java new file mode 100644 index 0000000000..0bd78bebb2 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/dto/rmm/software/SoftwareActionIdTest.java @@ -0,0 +1,41 @@ +package com.openframe.api.dto.rmm.software; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SoftwareActionIdTest { + + @Test + void roundTripsAllThreeKeys() { + SoftwareActionId decoded = SoftwareActionId.decode( + SoftwareActionId.of("exec-1", "bundle-1", "sched-1").encode()); + assertThat(decoded.executionId()).isEqualTo("exec-1"); + assertThat(decoded.bundleId()).isEqualTo("bundle-1"); + assertThat(decoded.scheduleId()).isEqualTo("sched-1"); + } + + @Test + void roundTripsWithMissingKeys() { + SoftwareActionId decoded = SoftwareActionId.decode( + SoftwareActionId.of("exec-1", null, "sched-1").encode()); + assertThat(decoded.executionId()).isEqualTo("exec-1"); + assertThat(decoded.bundleId()).isNull(); + assertThat(decoded.scheduleId()).isEqualTo("sched-1"); + } + + @Test + void nonTokenValueIsTreatedAsBareExecutionId() { + // A legacy raw executionId (not base64 of 3 parts) still resolves as the executionId. + SoftwareActionId decoded = SoftwareActionId.decode("plain-execution-id"); + assertThat(decoded.executionId()).isEqualTo("plain-execution-id"); + assertThat(decoded.bundleId()).isNull(); + assertThat(decoded.scheduleId()).isNull(); + } + + @Test + void blankIsEmpty() { + assertThat(SoftwareActionId.decode(null).executionId()).isNull(); + assertThat(SoftwareActionId.decode("").executionId()).isNull(); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricherTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricherTest.java new file mode 100644 index 0000000000..441b52233e --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/fleet/FleetDeviceCountEnricherTest.java @@ -0,0 +1,155 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.model.Host; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FleetDeviceCountEnricherTest { + + @Mock private FleetHostMachineResolver hostMachineResolver; + @Mock private TenantIdProvider tenantIdProvider; + + private FleetDeviceCountEnricher enricher; + + @BeforeEach + void setUp() { + enricher = new FleetDeviceCountEnricher(hostMachineResolver, tenantIdProvider); + } + + @Test + void enrich_setsCountToCorrelatedHostsPerRow_orphansDropped() { + Row rowA = new Row("A"); + Row rowB = new Row("B"); + Host h1 = host(1L); + Host h2 = host(2L); // orphan — no matching Machine + Host h3 = host(3L); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())) + .thenReturn(Map.of(1L, new Machine(), 3L, new Machine())); + + enricher.enrich( + List.of(rowA, rowB), + row -> row.name.equals("A") ? List.of(h1, h2) : List.of(h3), + Row::setCount); + + assertThat(rowA.count).isEqualTo(1); // h2 dropped as orphan + assertThat(rowB.count).isEqualTo(1); + } + + @Test + void enrich_batchesCorrelateIntoOneResolverCall_notPerRow() { + // Invariant: N rows → 1 resolver.resolve(...) call over the UNION of every row's hosts. + List rows = List.of(new Row("A"), new Row("B"), new Row("C")); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of()); + + enricher.enrich(rows, row -> List.of(host(1L)), Row::setCount); + + verify(hostMachineResolver, times(1)).resolve(eq("t1"), anyList()); + } + + @Test + void enrich_callsHostFetcherOncePerRow_neverMore() { + // Invariant: exactly one Fleet /hosts call per row (parallel is OK, N > per-row is NOT). + AtomicInteger fetchCalls = new AtomicInteger(); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of()); + List rows = new ArrayList<>(List.of(new Row("A"), new Row("B"), new Row("C"), new Row("D"))); + + enricher.enrich(rows, + row -> { fetchCalls.incrementAndGet(); return List.of(host(1L)); }, + Row::setCount); + + assertThat(fetchCalls).hasValue(rows.size()); + } + + @Test + void enrich_correlateReceivesTheUnionOfAllRows_hosts() { + // Invariant: the resolver is asked about the FULL union — a host appearing on more than one + // row is still resolved once, and every row's hosts are eligible for correlation. + Row rowA = new Row("A"); + Row rowB = new Row("B"); + Host shared = host(1L); + Host rowBOnly = host(2L); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + when(hostMachineResolver.resolve(eq("t1"), captor.capture())).thenReturn(Map.of()); + + enricher.enrich( + List.of(rowA, rowB), + row -> row.name.equals("A") ? List.of(shared) : List.of(shared, rowBOnly), + Row::setCount); + + // The captured list should contain every host the fetchers produced (duplicates allowed — + // the resolver's Mongo query naturally dedups; we don't slim it here to keep the enricher generic). + assertThat(captor.getValue()).contains(shared, rowBOnly); + } + + @Test + void enrich_emptyRows_noResolverCall_noFleetCall() { + // Empty page must not call the resolver — no work to do, no wasted round-trip. + enricher.enrich(List.of(), row -> List.of(host(1L)), (r, c) -> {}); + + verifyNoInteractions(hostMachineResolver, tenantIdProvider); + } + + @Test + void enrich_nullRows_isANoOp() { + // Defensive: null callers (from an upstream that couldn't build a page) don't NPE. + enricher.enrich(null, row -> List.of(), (r, c) -> {}); + + verifyNoInteractions(hostMachineResolver, tenantIdProvider); + } + + @Test + void enrich_rowWithNoHosts_setsZeroCount() { + // A row whose fetcher returns [] must get count = 0 (not left untouched). + Row row = new Row("A"); + row.count = -1; // sentinel: fails if we forget to write + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of()); + + enricher.enrich(List.of(row), r -> List.of(), Row::setCount); + + assertThat(row.count).isZero(); + } + + private static Host host(long id) { + Host h = new Host(); + h.setId(id); + return h; + } + + private static class Row { + final String name; + int count = -1; + + Row(String name) { + this.name = name; + } + + void setCount(int c) { + this.count = c; + } + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java new file mode 100644 index 0000000000..47dedb38f1 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java @@ -0,0 +1,172 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.organization.Organization; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned; +import com.openframe.data.document.rmm.schedule.SoftwareScheduleOnlineDispatch; +import com.openframe.data.document.rmm.script.ExecutionStatus; +import com.openframe.data.document.rmm.script.ScriptExecution; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.organization.OrganizationRepository; +import com.openframe.data.repository.rmm.ScriptExecutionRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleOnlineDispatchRepository; +import com.openframe.data.service.TenantIdProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareActionDetailServiceTest { + + private static final String TENANT = "t1"; + private static final String EXEC = "exec-1"; + + @Mock private ScriptExecutionRepository scriptExecutionRepository; + @Mock private SoftwareBundleOnlineDispatchRepository bundleOnlineDispatchRepository; + @Mock private SoftwareScheduleOnlineDispatchRepository scheduleOnlineDispatchRepository; + @Mock private SoftwareScheduleMachineAssignedRepository scheduleMachineAssignedRepository; + @Mock private MachineRepository machineRepository; + @Mock private OrganizationRepository organizationRepository; + @Mock private TenantIdProvider tenantIdProvider; + + private SoftwareActionDetailService service; + + @BeforeEach + void setUp() { + service = new SoftwareActionDetailService(scriptExecutionRepository, bundleOnlineDispatchRepository, + scheduleOnlineDispatchRepository, scheduleMachineAssignedRepository, machineRepository, + organizationRepository, tenantIdProvider); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + // Enrichment always runs; default to no machine metadata unless a test provides it. + lenient().when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())).thenReturn(List.of()); + } + + @Test + @DisplayName("NOW: leaves map their status, offline NEW sentinels become SCHEDULED, OS-skip is hidden") + void bundleDevices() { + when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of( + leaf("m-success", ExecutionStatus.SUCCESS), + leaf("m-running", ExecutionStatus.RUNNING))); + when(bundleOnlineDispatchRepository.findByTenantIdAndBundleId(TENANT, "b1")).thenReturn(List.of( + bundleSentinel("m-offline", DeviceOnlineDispatchStatus.NEW), + bundleSentinel("m-skipped", DeviceOnlineDispatchStatus.DISPATCHED))); + + List rows = service.devices(EXEC, "b1", null, null, null); + + assertThat(rows).extracting(SoftwareActionDeviceResponse::getMachineId) + .containsExactlyInAnyOrder("m-success", "m-running", "m-offline"); + assertThat(status(rows, "m-success")).isEqualTo(SoftwareActionStatus.COMPLETED); + assertThat(status(rows, "m-running")).isEqualTo(SoftwareActionStatus.IN_PROGRESS); + assertThat(status(rows, "m-offline")).isEqualTo(SoftwareActionStatus.SCHEDULED); + } + + @Test + @DisplayName("SCHEDULE: assigned machines seed SCHEDULED; EXPIRED sentinel → FAILED; leaf wins") + void scheduleDevices() { + when(scheduleMachineAssignedRepository.findByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(List.of( + assigned("m-a"), assigned("m-b"), assigned("m-done"))); + when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of( + leaf("m-done", ExecutionStatus.FAILED))); + when(scheduleOnlineDispatchRepository.findByTenantIdAndScheduleId(TENANT, "s1")).thenReturn(List.of( + scheduleSentinel("m-a", DeviceOnlineDispatchStatus.NEW), + scheduleSentinel("m-b", DeviceOnlineDispatchStatus.EXPIRED))); + + List rows = service.devices(EXEC, null, "s1", null, null); + + assertThat(rows).extracting(SoftwareActionDeviceResponse::getMachineId) + .containsExactlyInAnyOrder("m-a", "m-b", "m-done"); + assertThat(status(rows, "m-a")).isEqualTo(SoftwareActionStatus.SCHEDULED); + assertThat(status(rows, "m-b")).isEqualTo(SoftwareActionStatus.FAILED); + assertThat(status(rows, "m-done")).isEqualTo(SoftwareActionStatus.FAILED); + } + + @Test + @DisplayName("enrich + filters: hostname/customer resolved; status / customer / search narrow the rows") + void enrichAndFilter() { + when(scriptExecutionRepository.findByTenantIdAndExecutionId(TENANT, EXEC)).thenReturn(List.of( + leaf("alpha", ExecutionStatus.SUCCESS), + leaf("beta", ExecutionStatus.SUCCESS), + leaf("gamma", ExecutionStatus.FAILED))); + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())).thenReturn(List.of( + machine("alpha", "alpha-host", "org-1"), + machine("beta", "beta-host", "org-2"), + machine("gamma", "gamma-host", "org-1"))); + when(organizationRepository.findByOrganizationIdIn(any())).thenReturn(List.of( + org("org-1", "Acme"), org("org-2", "Globex"))); + + List all = service.devices(EXEC, null, null, null, null); + assertThat(all).hasSize(3); + SoftwareActionDeviceResponse alpha = all.stream().filter(r -> r.getMachineId().equals("alpha")).findFirst().orElseThrow(); + assertThat(alpha.getHostname()).isEqualTo("alpha-host"); + assertThat(alpha.getOrganizationId()).isEqualTo("org-1"); + assertThat(alpha.getOrganizationName()).isEqualTo("Acme"); + + List completed = service.devices(EXEC, null, null, filter(List.of(SoftwareActionStatus.COMPLETED), null), null); + assertThat(completed).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "beta"); + + List org1 = service.devices(EXEC, null, null, filter(null, List.of("org-1")), null); + assertThat(org1).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "gamma"); + + List search = service.devices(EXEC, null, null, null, "BETA-HOST"); + assertThat(search).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactly("beta"); + } + + private static SoftwareActionDeviceFilterInput filter(List statuses, List organizationIds) { + SoftwareActionDeviceFilterInput f = new SoftwareActionDeviceFilterInput(); + f.setStatuses(statuses); + f.setOrganizationIds(organizationIds); + return f; + } + + private static SoftwareActionStatus status(List rows, String machineId) { + return rows.stream().filter(r -> r.getMachineId().equals(machineId)).findFirst().orElseThrow().getStatus(); + } + + private static ScriptExecution leaf(String machineId, ExecutionStatus status) { + return ScriptExecution.builder().machineId(machineId).status(status).build(); + } + + private static SoftwareBundleOnlineDispatch bundleSentinel(String machineId, DeviceOnlineDispatchStatus status) { + return SoftwareBundleOnlineDispatch.builder().machineId(machineId).status(status).build(); + } + + private static SoftwareScheduleOnlineDispatch scheduleSentinel(String machineId, DeviceOnlineDispatchStatus status) { + return SoftwareScheduleOnlineDispatch.builder().machineId(machineId).status(status).build(); + } + + private static SoftwareScheduleMachineAssigned assigned(String machineId) { + SoftwareScheduleMachineAssigned a = new SoftwareScheduleMachineAssigned(); + a.setMachineId(machineId); + return a; + } + + private static Machine machine(String machineId, String hostname, String organizationId) { + Machine m = new Machine(); + m.setMachineId(machineId); + m.setHostname(hostname); + m.setOrganizationId(organizationId); + return m; + } + + private static Organization org(String organizationId, String name) { + return Organization.builder().organizationId(organizationId).name(name).build(); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java new file mode 100644 index 0000000000..df94675b38 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionServiceTest.java @@ -0,0 +1,149 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.script.ScriptFilterOption; +import com.openframe.api.dto.rmm.software.SoftwareActionFilters; +import com.openframe.api.dto.rmm.software.SoftwareActionResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.mapper.ScriptFilterOptionMapper; +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.schedule.SoftwareSchedule; +import com.openframe.data.document.rmm.schedule.SoftwareSchedulePackage; +import com.openframe.data.document.rmm.script.ScriptStatus; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.document.rmm.software.SoftwareActionSummary; +import com.openframe.data.repository.rmm.SoftwareActionAggregationRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository; +import com.openframe.data.repository.rmm.SoftwareScheduleRepository; +import com.openframe.data.service.TenantIdProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Sort; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareActionServiceTest { + + private static final String TENANT = "t1"; + + @Mock private SoftwareActionAggregationRepository aggregationRepository; + @Mock private SoftwareScheduleRepository scheduleRepository; + @Mock private SoftwareScheduleMachineAssignedRepository assignedRepository; + @Mock private TenantIdProvider tenantIdProvider; + + private final ScriptFilterOptionMapper optionMapper = new ScriptFilterOptionMapper(null, null); + + private SoftwareActionService service; + + @BeforeEach + void setUp() { + service = new SoftwareActionService(aggregationRepository, scheduleRepository, assignedRepository, optionMapper, tenantIdProvider); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + @Test + @DisplayName("list: executed rows come from the leaf aggregation with derived status + X/Y") + void list_executedRows() { + when(aggregationRepository.getDefaultSortField()).thenReturn("dispatchedAt"); + when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any())) + .thenReturn(List.of()); + when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(1L); + when(aggregationRepository.findPage(eq(TENANT), any(), any(), eq("dispatchedAt"), eq(Sort.Direction.DESC), eq(0), eq(25))) + .thenReturn(List.of(summary("exec-1", PackageManagerType.BREW, "slack", + SoftwareAction.UPDATE, SoftwareActionStatus.FAILED, 7, 4))); + + PageResult result = service.list(null, null, null, 0, 25); + + assertThat(result.filteredCount()).isEqualTo(1); + SoftwareActionResponse row = result.items().get(0); + assertThat(row.getSoftware()).isEqualTo("slack"); + assertThat(row.getAction()).isEqualTo(SoftwareAction.UPDATE); + assertThat(row.getEngine()).isEqualTo(PackageManagerType.BREW); + assertThat(row.getStatus()).isEqualTo(SoftwareActionStatus.FAILED); + assertThat(row.getTotalMachineCount()).isEqualTo(7); + assertThat(row.getRespondedMachineCount()).isEqualTo(4); + } + + @Test + @DisplayName("list: upcoming schedules appear as SCHEDULED rows (0/Y) on top, merged with executed") + void list_mergesScheduledOnTop() { + SoftwareSchedule schedule = SoftwareSchedule.builder() + .id("s1").tenantId(TENANT).createdBy("user-1").action(SoftwareAction.INSTALL) + .nextRunAt(Instant.now().plusSeconds(3600)) + .packages(List.of(SoftwareSchedulePackage.builder() + .packageManager(PackageManagerType.BREW).packageName("crowdstrike").build())) + .build(); + when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any())) + .thenReturn(List.of(schedule)); + when(assignedRepository.countByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(7L); + when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(0L); + when(aggregationRepository.findPage(eq(TENANT), any(), any(), any(), any(), eq(0), eq(24))) + .thenReturn(List.of()); + + PageResult result = service.list(null, null, null, 0, 25); + + assertThat(result.filteredCount()).isEqualTo(1); + SoftwareActionResponse row = result.items().get(0); + assertThat(row.getStatus()).isEqualTo(SoftwareActionStatus.SCHEDULED); + assertThat(row.getSoftware()).isEqualTo("crowdstrike"); + assertThat(row.getTotalMachineCount()).isEqualTo(7); + assertThat(row.getRespondedMachineCount()).isZero(); + assertThat(row.getScheduledAt()).isNotNull(); + } + + @Test + @DisplayName("filters: executed facets merge with scheduled tallies; SCHEDULED comes only from schedules") + void filters_mergeExecutedAndScheduled() { + when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("status"))) + .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("COMPLETED", 3, "FAILED", 1))); + when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("action"))) + .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("UPDATE", 4))); + when(aggregationRepository.facet(eq(TENANT), any(), any(), eq("packageManager"))) + .thenReturn(new java.util.LinkedHashMap<>(java.util.Map.of("BREW", 4))); + when(aggregationRepository.count(eq(TENANT), any(), any())).thenReturn(4L); + + SoftwareSchedule schedule = SoftwareSchedule.builder() + .id("s1").tenantId(TENANT).createdBy("user-1").action(SoftwareAction.INSTALL) + .nextRunAt(Instant.now().plusSeconds(3600)) + .packages(List.of(SoftwareSchedulePackage.builder() + .packageManager(PackageManagerType.WINGET).packageName("chrome").build())) + .build(); + when(scheduleRepository.findByTenantIdAndStatusAndNextRunAtGreaterThanOrderByNextRunAtAsc(eq(TENANT), eq(ScriptStatus.ACTIVE), any())) + .thenReturn(List.of(schedule)); + when(assignedRepository.countByTenantIdAndSoftwareScheduleId(TENANT, "s1")).thenReturn(7L); + + SoftwareActionFilters filters = service.filters(null, null); + + assertThat(count(filters.getStatuses(), "COMPLETED")).isEqualTo(3); + assertThat(count(filters.getStatuses(), "FAILED")).isEqualTo(1); + assertThat(count(filters.getStatuses(), "SCHEDULED")).isEqualTo(1); // only from the schedule + assertThat(count(filters.getActions(), "UPDATE")).isEqualTo(4); + assertThat(count(filters.getActions(), "INSTALL")).isEqualTo(1); // from the schedule + assertThat(count(filters.getEngines(), "BREW")).isEqualTo(4); + assertThat(count(filters.getEngines(), "WINGET")).isEqualTo(1); // from the schedule + assertThat(filters.getFilteredCount()).isEqualTo(5); // 4 executed + 1 scheduled + } + + private static int count(List options, String value) { + return options.stream().filter(o -> o.getValue().equals(value)).map(ScriptFilterOption::getCount) + .findFirst().orElse(0); + } + + private static SoftwareActionSummary summary(String executionId, PackageManagerType pm, String name, + SoftwareAction action, SoftwareActionStatus status, int total, int responded) { + return SoftwareActionSummary.builder() + .executionId(executionId).packageManager(pm).packageName(name).action(action).status(status) + .totalMachineCount(total).respondedMachineCount(responded).build(); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java new file mode 100644 index 0000000000..352c0da741 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java @@ -0,0 +1,215 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.SubmitSoftwareBundleInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.data.document.packagesearch.BrewPackageType; +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareActionResult; +import com.openframe.data.document.rmm.software.SoftwareActionStatus; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareActionResultRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.data.service.rmm.MachinePlatformResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareBundleServiceTest { + + private static final String TENANT = "t1"; + private static final String USER = "user-1"; + private static final String BUNDLE_ID = "b1"; + private static final long RECONNECT_WINDOW = 604800L; + + @Mock private SoftwareBundleRepository bundleRepository; + @Mock private SoftwareBundleOnlineDispatchRepository onlineDispatchRepository; + @Mock private SoftwareScheduleService softwareScheduleService; + @Mock private SoftwareActionResultRepository softwareActionResultRepository; + @Mock private MachinePlatformResolver machinePlatformResolver; + @Mock private TenantIdProvider tenantIdProvider; + + private SoftwareBundleService service; + + @BeforeEach + void setUp() { + service = new SoftwareBundleService(bundleRepository, onlineDispatchRepository, softwareScheduleService, + softwareActionResultRepository, machinePlatformResolver, tenantIdProvider); + ReflectionTestUtils.setField(service, "pendingTtl", Duration.ofHours(1)); + ReflectionTestUtils.setField(service, "scheduleReconnectWindowSeconds", RECONNECT_WINDOW); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + @Test + @DisplayName("delete: a PENDING bundle is removed") + void delete_pending_ok() { + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + + assertThat(service.delete(BUNDLE_ID, USER)).isTrue(); + verify(bundleRepository).delete(pending); + } + + @Test + @DisplayName("delete: a COMPLETED bundle returns false and is left untouched (history is immutable)") + void delete_completed_returnsFalseUntouched() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + assertThat(service.delete(BUNDLE_ID, USER)).isFalse(); + verify(bundleRepository, never()).delete(any()); + } + + @Test + @DisplayName("delete: an unknown/already-reaped id returns true (idempotent, never throws)") + void delete_unknown_returnsTrue() { + when(bundleRepository.findByTenantIdAndId(TENANT, "gone")).thenReturn(Optional.empty()); + + assertThat(service.delete("gone", USER)).isTrue(); + verify(bundleRepository, never()).delete(any()); + } + + @Test + @DisplayName("submit NOW: validates, arms sentinels, fills one executionId per package, marks COMPLETED") + void submit_now_ok() { + SoftwareBundle draft = pending(SoftwareBundleMode.NOW, null, List.of("m1", "m2"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("x").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(draft)); + when(machinePlatformResolver.osTypesByMachineId(any())) + .thenReturn(java.util.Map.of("m1", OsType.MAC_OS, "m2", OsType.MAC_OS)); + when(onlineDispatchRepository.findByTenantIdAndMachineIdAndBundleId(eq(TENANT), anyString(), eq(BUNDLE_ID))) + .thenReturn(Optional.empty()); + + SoftwareBundleResponse res = service.submit(submitInput(brewPkg("slack"), brewPkg("chrome")), USER); + + verify(onlineDispatchRepository, times(2)).save(any()); + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getValue().getExecutionIds()).hasSize(2); + assertThat(saved.getValue().getExpireAt()).isNull(); + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + + @SuppressWarnings("unchecked") + ArgumentCaptor> results = ArgumentCaptor.forClass(List.class); + verify(softwareActionResultRepository).saveAll(results.capture()); + assertThat(results.getValue()).hasSize(2).allSatisfy(r -> { + assertThat(r.getBundleId()).isEqualTo(BUNDLE_ID); + assertThat(r.getScheduleId()).isNull(); + assertThat(r.getStatus()).isEqualTo(SoftwareActionStatus.IN_PROGRESS); + assertThat(r.getMode()).isEqualTo(SoftwareBundleMode.NOW); + assertThat(r.getMachineIds()).containsExactlyInAnyOrder("m1", "m2"); // both macOS -> both compatible with brew + assertThat(r.getId()).isEqualTo(r.getExecutionId()); + assertThat(r.getVersion()).isEqualTo("1.2.3"); // catalog version recorded on the action + }); + } + + @Test + @DisplayName("submit: re-submitting a COMPLETED bundle returns it unchanged, no second dispatch") + void submit_completed_idempotent() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("x").build()); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + SoftwareBundleResponse res = service.submit(submitInput(brewPkg("slack")), USER); + + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + verify(bundleRepository, never()).save(any()); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); + } + + @Test + @DisplayName("submit: a device whose OS no package can run fails, naming that device") + void submit_incompatibleDevice_rejected() { + SoftwareBundle draft = pending(SoftwareBundleMode.NOW, null, List.of("m-win"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("x").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(draft)); + when(machinePlatformResolver.osTypesByMachineId(any())).thenReturn(java.util.Map.of("m-win", OsType.WINDOWS)); + + assertThatThrownBy(() -> service.submit(submitInput(brewPkg("slack")), USER)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("m-win"); + verify(bundleRepository, never()).save(any()); + verifyNoInteractions(onlineDispatchRepository); + } + + @Test + @DisplayName("submit: a brew package without brewPackageType is rejected") + void submit_brewNoType_rejected() { + SoftwareBundle draft = pending(SoftwareBundleMode.NOW, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("x").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(draft)); + + SoftwarePackageInput noType = new SoftwarePackageInput(); + noType.setPackageManager(PackageManagerType.BREW); + noType.setPackageName("slack"); // brewPackageType missing + + assertThatThrownBy(() -> service.submit(submitInput(noType), USER)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("brewPackageType"); + verify(bundleRepository, never()).save(any()); + } + + private static SubmitSoftwareBundleInput submitInput(SoftwarePackageInput... packages) { + SubmitSoftwareBundleInput input = new SubmitSoftwareBundleInput(); + input.setId(BUNDLE_ID); + input.setAction(SoftwareAction.INSTALL); + input.setPackages(List.of(packages)); + return input; + } + + private static SoftwarePackageInput brewPkg(String name) { + SoftwarePackageInput p = new SoftwarePackageInput(); + p.setPackageManager(PackageManagerType.BREW); + p.setPackageName(name); + p.setBrewPackageType(BrewPackageType.CASK); + p.setVersion("1.2.3"); + return p; + } + + private static SoftwareBundle pending(SoftwareBundleMode mode, Instant startAt, List machineIds, + SoftwareBundlePackage... packages) { + return SoftwareBundle.builder() + .id(BUNDLE_ID) + .tenantId(TENANT) + .action(SoftwareAction.INSTALL) + .mode(mode) + .status(SoftwareBundleStatus.PENDING) + .machineIds(machineIds) + .packages(List.of(packages)) + .startAt(startAt) + .build(); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java new file mode 100644 index 0000000000..e27935c7b9 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java @@ -0,0 +1,242 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareFilterOption; +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareSource; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.dto.shared.SortDirection; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.HostSearchRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareInventoryServiceTest { + + @Mock private FleetMdmClient fleet; + @Mock private FleetDeviceCountEnricher deviceCountEnricher; + @Mock private FleetHostMachineResolver hostMachineResolver; + @Mock private TenantIdProvider tenantIdProvider; + @Mock private com.openframe.data.repository.tool.IntegratedToolRepository integratedToolRepository; + + private SoftwareInventoryService service; + + @BeforeEach + void setUp() { + service = new SoftwareInventoryService(integratedToolRepository, deviceCountEnricher, hostMachineResolver, tenantIdProvider); + // Bypass @PostConstruct wireFleetClient — inject the mocked FleetMdmClient directly. + ReflectionTestUtils.setField(service, "fleet", fleet); + } + + @Test + @DisplayName("listDevicesForSoftware: a non-numeric id short-circuits to empty without touching Fleet") + void listDevices_nonNumericId_emptyNoFleet() { + assertThat(service.listDevicesForSoftware("not-a-number", null, 0, 50).items()).isEmpty(); + verifyNoInteractions(fleet, hostMachineResolver); + } + + @Test + @DisplayName("listDevicesForSoftware: fans out per version, tags each host with its version, correlates to a Machine, and drops hosts not enrolled in OpenFrame") + void listDevices_correlatesAndDropsUnenrolled() { + SoftwareTitle title = new SoftwareTitle(); + title.setName("Google Chrome"); + SoftwareTitleVersion version = new SoftwareTitleVersion(); + version.setId(10L); + version.setVersion("1.2.3"); + title.setVersions(List.of(version)); + + Host enrolled = host(1L, "u1", "host-1"); + Host foreign = host(2L, "u2", "host-2"); // no matching Machine -> dropped + + when(fleet.getSoftwareTitle(42L)).thenReturn(title); + when(fleet.searchHosts(any(HostSearchRequest.class))).thenReturn(List.of(enrolled, foreign)); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + Machine machine = new Machine(); + machine.setMachineId("m-1"); + machine.setHostname("host-1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(1L, machine)); + + PageResult result = service.listDevicesForSoftware("42", null, 0, 50); + + assertThat(result.items()).hasSize(1); + SoftwareOnDeviceResponse row = result.items().get(0); + assertThat(row.getDevice().getMachineId()).isEqualTo("m-1"); + assertThat(row.getSoftwareVersion()).isEqualTo("1.2.3"); + assertThat(row.getStatus()).isNotNull(); + } + + @Test + @DisplayName("getSoftwareFilters: no titles -> valid empty facet lists (never null)") + void getSoftwareFilters_noTitles_emptyFacets() { + SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of()); + when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(response); + + SoftwareFilters filters = service.getSoftwareFilters(null); + + assertThat(filters.getSources()).isEmpty(); + assertThat(filters.getVersionStatuses()).isEmpty(); + assertThat(filters.getSeverities()).isEmpty(); + } + + @Test + @DisplayName("listSoftware: sort by cveCount DESC is a client-side sort — highest CVE count first, ties by name") + void listSoftware_sortByCveCount_desc_clientSideTiesByName() { + SoftwareTitlesResponse response = org.mockito.Mockito.mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of( + titleWithCves("Bravo", 5), + titleWithCves("Alpha", 5), + titleWithCves("Chrome", 40))); + when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(response); // meta null -> single page in the scan + + PageResult result = service.listSoftware("", 0, 20, sort("cveCount", SortDirection.DESC), null); + + assertThat(result.items()).extracting(SoftwareResponse::getName) + .containsExactly("Chrome", "Alpha", "Bravo"); // 40 first; the two 5s by name ascending + } + + @Test + @DisplayName("getSoftwareFilters: with titles → source facet counts each package-manager bucket; unknown fleet source falls back to UNMANAGED") + void getSoftwareFilters_countsSourceBucketsAcrossTitles() { + SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of( + titleWithSource("Chocolatey app 1", "chocolatey_packages"), + titleWithSource("Chocolatey app 2", "chocolatey_packages"), + titleWithSource("Homebrew tool", "homebrew_packages"), + titleWithSource("Random pkg", "programs"))); // unknown -> UNMANAGED + when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(response); + + SoftwareFilters filters = service.getSoftwareFilters(null); + + // Each dimension is capped by SoftwareFilterOption; order follows enum ordinal so we assert as-map. + java.util.Map sourceCounts = filters.getSources().stream() + .collect(java.util.stream.Collectors.toMap(SoftwareFilterOption::getValue, SoftwareFilterOption::getCount)); + assertThat(sourceCounts).containsEntry(SoftwareSource.CHOCOLATEY.name(), 2); + assertThat(sourceCounts).containsEntry(SoftwareSource.BREW.name(), 1); + assertThat(sourceCounts).containsEntry(SoftwareSource.UNMANAGED.name(), 1); + } + + @Test + @DisplayName("listSoftware: delegates devicesCount enrichment to FleetDeviceCountEnricher — whatever count the enricher sets is what the user sees, and Fleet's raw hosts_count is discarded in the process") + void listSoftware_delegatesEnrichmentToEnricher() { + // Fleet says 31 devices; enricher correlates down to 2 real Machines. + SoftwareTitle raw = new SoftwareTitle(); + raw.setId(42L); + raw.setName("Chrome"); + raw.setHostsCount(31); + raw.setVersions(List.of()); + SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of(raw)); + when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(response); + simulateEnricherSetsCount(2); + + PageResult result = service.listSoftware("", 0, 20, null, null); + + assertThat(result.items()).hasSize(1); + // The critical invariant: user sees the enricher-provided count, not Fleet's raw 31. + assertThat(result.items().get(0).getDevicesCount()).isEqualTo(2); + } + + @Test + @DisplayName("getSoftwareFilters: bypasses devicesCount enrichment — facet counts don't need it, and firing N Fleet /hosts calls per facet request is wasteful") + void getSoftwareFilters_doesNotFireEnrichment() { + SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of( + titleWithSource("Chocolatey app", "chocolatey_packages"))); + when(fleet.listSoftwareTitles(any(SoftwareTitleRequest.class))).thenReturn(response); + + service.getSoftwareFilters(null); + + // Invariant: facet reads must never fire the enricher — that would explode into N Fleet + // /hosts lookups per facet request. + org.mockito.Mockito.verify(deviceCountEnricher, org.mockito.Mockito.never()) + .enrich(anyList(), any(), any()); + } + + // Simulate the enricher's side-effect: write count back via the passed countSetter. + // Its correlation math is tested in FleetDeviceCountEnricherTest, not here. + @SuppressWarnings("unchecked") + private void simulateEnricherSetsCount(int count) { + org.mockito.Mockito.doAnswer(inv -> { + List rows = inv.getArgument(0); + java.util.function.BiConsumer setter = inv.getArgument(2); + rows.forEach(row -> setter.accept(row, count)); + return null; + }).when(deviceCountEnricher).enrich(anyList(), any(), any()); + } + + @Test + @DisplayName("listSoftware: an unknown sort field is a client error (BadRequest), not a Fleet call") + void listSoftware_unknownSortField_throwsBadRequest() { + BadRequestException ex = org.junit.jupiter.api.Assertions.assertThrows(BadRequestException.class, + () -> service.listSoftware("", 0, 20, sort("bogus", SortDirection.ASC), null)); + + assertThat(ex.getMessage()).contains("bogus").contains("cveCount"); + org.mockito.Mockito.verifyNoInteractions(fleet); + } + + private static SortInput sort(String field, SortDirection direction) { + SortInput s = new SortInput(); + s.setField(field); + s.setDirection(direction); + return s; + } + + private static SoftwareTitle titleWithSource(String name, String fleetSource) { + SoftwareTitle t = new SoftwareTitle(); + t.setName(name); + t.setSource(fleetSource); + SoftwareTitleVersion v = new SoftwareTitleVersion(); + v.setVersion("1.0"); + t.setVersions(List.of(v)); + return t; + } + + private static SoftwareTitle titleWithCves(String name, int cveCount) { + SoftwareTitle t = new SoftwareTitle(); + t.setName(name); + SoftwareTitleVersion v = new SoftwareTitleVersion(); + v.setVersion("1.0"); + v.setVulnerabilities(java.util.stream.IntStream.range(0, cveCount) + .mapToObj(i -> "CVE-" + name + "-" + i).toList()); + t.setVersions(List.of(v)); + return t; + } + + private static Host host(long id, String uuid, String hostname) { + Host h = new Host(); + h.setId(id); + h.setUuid(uuid); + h.setHostname(hostname); + return h; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java new file mode 100644 index 0000000000..57809bcffb --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java @@ -0,0 +1,178 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareSource; +import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.sdk.fleetmdm.model.AffectedSoftware; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FleetGlobalVulnerabilityMapperTest { + + @Test + void toListRow_nullInput_returnsNull() { + assertNull(FleetGlobalVulnerabilityMapper.toListRow(null)); + } + + @Test + void toListRow_missingCve_returnsNullSoTheListSkipsRowsWithoutId() { + Vulnerability v = new Vulnerability(); + v.setCvssScore(7.0); + + assertNull(FleetGlobalVulnerabilityMapper.toListRow(v)); + } + + @Test + void toDetail_nullInput_returnsNull() { + assertNull(FleetGlobalVulnerabilityMapper.toDetail(null)); + } + + + @Test + void toListRow_populatesBaseFields_leavesDetailOnlyFieldsNull() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + v.setCvssScore(9.8); + v.setEpssProbability(0.42); + v.setCisaKnownExploit(Boolean.TRUE); + v.setCreatedAt("2024-08-13T00:00:00Z"); + v.setDetailsLink("https://nvd.nist.gov/vuln/detail/CVE-2024-38063"); + v.setHostsCount(17); + v.setCveDescription("Windows TCP/IP RCE"); + v.setResolvedInVersion("2023.001.20143"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(42L); + sw.setName("WinRAR"); + v.setSoftware(List.of(sw)); + + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + assertNotNull(row); + assertEquals("CVE-2024-38063", row.getCveId()); + assertEquals(SoftwareCveSeverity.CRITICAL, row.getSeverity()); + assertEquals(9.8, row.getCvssScore()); + assertEquals(0.42, row.getEpssProbability()); + assertEquals(Boolean.TRUE, row.getCisaKnownExploit()); + assertEquals(Instant.parse("2024-08-13T00:00:00Z"), row.getDiscoveredAt()); + assertEquals("https://nvd.nist.gov/vuln/detail/CVE-2024-38063", row.getDetailsLink()); + assertEquals(17, row.getDevicesCount()); + assertNull(row.getDescription()); + assertNull(row.getResolvedInVersion()); + assertNull(row.getAffectedSoftware()); + } + + @Test + void toListRow_nullCvssScore_severityIsNull() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-00000"); + + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + assertNotNull(row); + assertNull(row.getSeverity()); + assertNull(row.getCvssScore()); + } + + + @Test + void toListRow_cvssBucketing_matchesNvdRanges() { + // 9.0+ → CRITICAL, 7.0..8.9 → HIGH, 4.0..6.9 → MEDIUM, >0..3.9 → LOW, 0 → NONE + assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.9)); + assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.0)); + assertEquals(SoftwareCveSeverity.HIGH, severityFor(8.9)); + assertEquals(SoftwareCveSeverity.HIGH, severityFor(7.0)); + assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(6.9)); + assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(4.0)); + assertEquals(SoftwareCveSeverity.LOW, severityFor(3.9)); + assertEquals(SoftwareCveSeverity.LOW, severityFor(0.1)); + assertEquals(SoftwareCveSeverity.NONE, severityFor(0.0)); + } + + private static SoftwareCveSeverity severityFor(double cvss) { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-x"); + v.setCvssScore(cvss); + return FleetGlobalVulnerabilityMapper.toListRow(v).getSeverity(); + } + + @Test + void toListRow_unparseableDiscoveredAt_leavesInstantNullInsteadOfCrashing() { + // Fleet occasionally returns partial or malformed dates + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-00000"); + v.setCreatedAt("not-a-date"); + + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + // parseInstant swallows the DateTimeParseException, leaves discoveredAt null + assertNull(row.getDiscoveredAt()); + } + + + @Test + void toDetail_populatesDetailFieldsAndAffectedSoftware() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + v.setCvssScore(9.2); + v.setCveDescription("Windows TCP/IP RCE"); + v.setResolvedInVersion("N/A"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(42L); + sw.setName("WinRAR"); + sw.setSource("homebrew_packages"); + sw.setVersion("6.22"); + sw.setHostsCount(2); + sw.setResolvedInVersion("6.23"); + v.setSoftware(List.of(sw)); + + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + assertNotNull(detail); + assertEquals("Windows TCP/IP RCE", detail.getDescription()); + assertEquals("N/A", detail.getResolvedInVersion()); + assertNotNull(detail.getAffectedSoftware()); + assertEquals(1, detail.getAffectedSoftware().size()); + AffectedSoftwareResponse aff = detail.getAffectedSoftware().get(0); + assertEquals("42", aff.getId()); + assertEquals("WinRAR", aff.getName()); + assertEquals(SoftwareSource.BREW, aff.getSource()); + assertEquals("6.22", aff.getVersion()); + assertEquals(2, aff.getDevicesCount()); + assertEquals("6.23", aff.getResolvedInVersion()); + } + + @Test + void toDetail_nullSoftwareList_producesEmptyListNotNull_soFeCanTellAskedFromNotAsked() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + assertNotNull(detail.getAffectedSoftware()); + assertTrue(detail.getAffectedSoftware().isEmpty()); + } + + @Test + void toDetail_unknownSourceString_fallsBackToUnmanaged() { + // Fleet emits sources like "programs", "apps" etc that our category doesn't map + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(1L); + sw.setSource("programs"); + v.setSoftware(List.of(sw)); + + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + assertEquals(SoftwareSource.UNMANAGED, detail.getAffectedSoftware().get(0).getSource()); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java new file mode 100644 index 0000000000..c34f77643c --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java @@ -0,0 +1,340 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetDeviceCountEnricher; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VulnerabilityInventoryServiceTest { + + @Mock private FleetMdmClient fleet; + @Mock private FleetDeviceCountEnricher deviceCountEnricher; + @Mock private FleetHostMachineResolver hostMachineResolver; + @Mock private TenantIdProvider tenantIdProvider; + @Mock private IntegratedToolRepository integratedToolRepository; + + private VulnerabilityInventoryService service; + + @BeforeEach + void setUp() { + service = new VulnerabilityInventoryService( + integratedToolRepository, deviceCountEnricher, hostMachineResolver, tenantIdProvider); + // Bypass @PostConstruct wireFleetClient — inject the mocked FleetMdmClient directly. + ReflectionTestUtils.setField(service, "fleet", fleet); + } + + @Test + void listDevicesForCve_blankCve_returnsEmptyWithoutHittingFleet() { + assertTrue(service.listDevicesForCve("", null, 0, 50).items().isEmpty()); + assertTrue(service.listDevicesForCve(null, null, 0, 50).items().isEmpty()); + } + + @Test + void listDevicesForCve_correlatesHostsToMachines_dropsUnenrolled() { + com.openframe.sdk.fleetmdm.model.Host h1 = new com.openframe.sdk.fleetmdm.model.Host(); + h1.setId(1L); + h1.setHostname("host-1"); + com.openframe.sdk.fleetmdm.model.Host h2 = new com.openframe.sdk.fleetmdm.model.Host(); + h2.setId(2L); + h2.setHostname("host-2"); + when(fleet.searchHosts(any(com.openframe.sdk.fleetmdm.model.HostSearchRequest.class))) + .thenReturn(List.of(h1, h2)); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + com.openframe.data.document.device.Machine m1 = new com.openframe.data.document.device.Machine(); + m1.setMachineId("m-1"); + m1.setHostname("host-1"); + when(hostMachineResolver.resolve(eq("t1"), any())).thenReturn(java.util.Map.of(1L, m1)); + + PageResult result = + service.listDevicesForCve("CVE-2024-38063", null, 0, 50); + + assertEquals(1, result.items().size()); // h2 dropped (no matching Machine) + assertEquals("m-1", result.items().get(0).getMachineId()); + } + + @Test + void findByCveId_blankId_returnsEmptyWithoutHittingFleet() { + assertTrue(service.findByCveId("").isEmpty()); + assertTrue(service.findByCveId(null).isEmpty()); + } + + @Test + void findByCveId_fleetReturnsNull_returnsEmpty() { + // 404 from Fleet detail: the SDK returns null and we propagate as empty + when(fleet.getVulnerability("CVE-2024-00000")).thenReturn(null); + + Optional found = service.findByCveId("CVE-2024-00000"); + + assertTrue(found.isEmpty()); + } + + @Test + void findByCveId_fleetReturnsDetail_mapsToDetailFlow() { + Vulnerability detail = new Vulnerability(); + detail.setCve("CVE-2024-38063"); + detail.setCveDescription("Windows TCP/IP RCE"); + detail.setCvssScore(9.2); + when(fleet.getVulnerability("CVE-2024-38063")).thenReturn(detail); + + Optional found = service.findByCveId("CVE-2024-38063"); + + assertTrue(found.isPresent()); + assertEquals("CVE-2024-38063", found.get().getCveId()); + assertEquals("Windows TCP/IP RCE", found.get().getDescription()); + assertEquals(SoftwareCveSeverity.CRITICAL, found.get().getSeverity()); + // detail flow always populates affectedSoftware (possibly empty), never null + assertNotNull(found.get().getAffectedSoftware()); + } + + @Test + void listVulnerabilities_noClientFilter_buildsRequestAndWiresPagingFromMeta() { + // with no severity/exploited filter we use Fleet's server-side paging (fast path) + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-1", 9.2), cve("CVE-2", 5.0))); + response.setCount(37L); + VulnerabilitiesResponse.Meta meta = new VulnerabilitiesResponse.Meta(); + meta.setHasNextResults(true); + meta.setHasPreviousResults(false); + response.setMeta(meta); + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(VulnerabilityRequest.class); + when(fleet.listVulnerabilities(reqCaptor.capture())).thenReturn(response); + + PageResult result = service.listVulnerabilities( + "chrome", 0, 20, "cvss_score", "desc", null, null); + + VulnerabilityRequest req = reqCaptor.getValue(); + assertEquals("chrome", req.getQuery()); + assertEquals(0, req.getPage()); + assertEquals(20, req.getPerPage()); + assertEquals("cvss_score", req.getOrderKey()); + assertEquals("desc", req.getOrderDirection()); + assertNull(req.getExploit()); // exploited is a client-side filter — never sent to Fleet + + assertEquals(2, result.items().size()); + assertTrue(result.hasNext()); + assertFalse(result.hasPrevious()); + assertEquals(37, result.filteredCount()); + assertEquals(0, result.page()); + } + + @Test + void listVulnerabilities_severityFilterWithNoSeverityData_emptyPageNeverPagesForever() { + // the reported bug: Fleet reports 3276 total + hasNext, but no row has a severity + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cveNoCvss("CVE-A"), cveNoCvss("CVE-B"))); + response.setCount(3276L); + VulnerabilitiesResponse.Meta meta = new VulnerabilitiesResponse.Meta(); + meta.setHasNextResults(false); // single page in this fetch; real Fleet caps at FETCH_CAP + response.setMeta(meta); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.CRITICAL); + + assertTrue(result.items().isEmpty()); + assertFalse(result.hasNext()); + assertEquals(0, result.filteredCount()); + } + + @Test + void listVulnerabilities_minSeverityHigh_filtersRowsBelowFloor() { + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of( + cve("CVE-CRIT", 9.5), + cve("CVE-HIGH", 7.2), + cve("CVE-MED", 4.5), + cve("CVE-LOW", 1.2))); + response.setCount(4L); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.HIGH); + + assertEquals(2, result.items().size()); + assertEquals("CVE-CRIT", result.items().get(0).getCveId()); + assertEquals("CVE-HIGH", result.items().get(1).getCveId()); + } + + @Test + void listVulnerabilities_minSeverityNone_isTreatedAsNoFilter() { + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-LOW", 1.2))); + response.setCount(1L); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.NONE); + + // NONE floor keeps the row (defensive against strict-mode UI) + assertEquals(1, result.items().size()); + } + + @Test + void listVulnerabilities_nullVulnerabilitiesArray_emitsEmptyPageNotNpe() { + // Fleet returns {"vulnerabilities": null, "count": 0} on some pages + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(null); + response.setCount(0L); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + PageResult result = service.listVulnerabilities( + null, 5, 20, null, null, null, null); + + assertTrue(result.items().isEmpty()); + assertFalse(result.hasNext()); + assertEquals(5, result.page()); // page-index preserved on empty responses + assertEquals(0, result.filteredCount()); + } + + @Test + void getVulnerabilityFilters_countsSeverityBucketsAcrossAllPages() { + // CVSS is bucketed by NVD ranges (CRITICAL ≥ 9, HIGH ≥ 7, MEDIUM ≥ 4). + // Missing CVSS (Fleet Free) resolves to a null severity and MUST NOT contribute to any bucket. + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of( + cve("CVE-CRIT-1", 9.5), + cve("CVE-CRIT-2", 9.1), + cve("CVE-HIGH", 7.2), + cve("CVE-MED", 4.5), + cveNoCvss("CVE-UNKNOWN"))); // dropped from every bucket + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilters filters = + service.getVulnerabilityFilters(null); + + java.util.Map counts = filters.getSeverities().stream() + .collect(java.util.stream.Collectors.toMap( + com.openframe.api.dto.rmm.software.SoftwareFilterOption::getValue, + com.openframe.api.dto.rmm.software.SoftwareFilterOption::getCount)); + assertEquals(2, counts.getOrDefault(SoftwareCveSeverity.CRITICAL.name(), 0)); + assertEquals(1, counts.getOrDefault(SoftwareCveSeverity.HIGH.name(), 0)); + assertEquals(1, counts.getOrDefault(SoftwareCveSeverity.MEDIUM.name(), 0)); + assertFalse(counts.containsKey(SoftwareCveSeverity.NONE.name())); + } + + @Test + void getVulnerabilityFilters_freeTier_allSeveritiesNull_emptyFacet() { + // Fleet Free returns no CVSS anywhere → every row buckets to a null severity → empty facet. + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cveNoCvss("CVE-1"), cveNoCvss("CVE-2"))); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilters filters = + service.getVulnerabilityFilters(null); + + assertTrue(filters.getSeverities().isEmpty()); + } + + @Test + void listVulnerabilities_delegatesEnrichment_devicesCountComesFromEnricherNotFleet() { + // Fleet reports 100 hosts_count on each row; simulate enricher correlating to 3. + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + Vulnerability v = cve("CVE-2024-1", 9.2); + v.setHostsCount(100); + response.setVulnerabilities(List.of(v)); + response.setCount(1L); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + simulateEnricherSetsCount(3); + + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, null); + + // The critical invariant: what user sees is the enricher-provided count (3), not Fleet's raw 100. + assertEquals(1, result.items().size()); + assertEquals(3, result.items().get(0).getDevicesCount()); + } + + @Test + void findByCveId_delegatesEnrichment_forBothVulnAndAffectedSoftware() { + // Fleet returns raw counts (999 on the CVE, 999 on each affected-software row). + // Simulate enricher correlating everything to 5. + Vulnerability detail = new Vulnerability(); + detail.setCve("CVE-2024-38063"); + detail.setCvssScore(9.2); + detail.setHostsCount(999); + com.openframe.sdk.fleetmdm.model.AffectedSoftware sw = new com.openframe.sdk.fleetmdm.model.AffectedSoftware(); + sw.setId(42L); + sw.setName("WinRAR"); + sw.setHostsCount(999); + detail.setSoftware(List.of(sw)); + when(fleet.getVulnerability("CVE-2024-38063")).thenReturn(detail); + simulateEnricherSetsCount(5); + + Optional found = service.findByCveId("CVE-2024-38063"); + + assertTrue(found.isPresent()); + // Both the CVE row and each affected-software row must reflect the enricher's count, not Fleet's raw. + assertEquals(5, found.get().getDevicesCount()); + assertNotNull(found.get().getAffectedSoftware()); + assertEquals(1, found.get().getAffectedSoftware().size()); + assertEquals(5, found.get().getAffectedSoftware().get(0).getDevicesCount()); + } + + @Test + void getVulnerabilityFilters_doesNotFireEnrichment() { + // Facet reads must never explode into N Fleet /hosts lookups. + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-1", 9.2))); + when(fleet.listVulnerabilities(any(VulnerabilityRequest.class))).thenReturn(response); + + service.getVulnerabilityFilters(null); + + org.mockito.Mockito.verify(deviceCountEnricher, org.mockito.Mockito.never()) + .enrich(org.mockito.ArgumentMatchers.anyList(), any(), any()); + } + + // Simulate the enricher's side-effect: write the given count back via the passed countSetter. + // The real enrichment math lives in FleetDeviceCountEnricherTest. + @SuppressWarnings({"unchecked", "rawtypes"}) + private void simulateEnricherSetsCount(int count) { + org.mockito.Mockito.doAnswer(inv -> { + List rows = inv.getArgument(0); + java.util.function.BiConsumer setter = inv.getArgument(2); + for (Object row : rows) { + setter.accept(row, count); + } + return null; + }).when(deviceCountEnricher).enrich(org.mockito.ArgumentMatchers.anyList(), any(), any()); + } + + private static Vulnerability cve(String id, Double cvss) { + Vulnerability v = new Vulnerability(); + v.setCve(id); + v.setCvssScore(cvss); + return v; + } + + // A CVE with no CVSS score — as Fleet returns without a Premium license (severity resolves to null). + private static Vulnerability cveNoCvss(String id) { + Vulnerability v = new Vulnerability(); + v.setCve(id); + return v; + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NodeDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NodeDataFetcher.java index 43c53e93ec..5a0285b1d9 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NodeDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/NodeDataFetcher.java @@ -13,6 +13,7 @@ import com.openframe.api.service.rmm.script.ScriptExecutionService; import com.openframe.api.service.rmm.schedule.ScheduleScriptService; import com.openframe.api.service.rmm.script.ScriptService; +import com.openframe.api.service.rmm.software.SoftwareBundleService; import com.openframe.data.repository.tenant.TenantRepository; import com.openframe.data.service.OrganizationService; import graphql.relay.Relay; @@ -43,6 +44,9 @@ public class NodeDataFetcher { @Autowired(required = false) private TenantRepository tenantRepository; + @Autowired(required = false) + private SoftwareBundleService softwareBundleService; + @DgsQuery public Object node(@InputArgument String id) { log.debug("Resolving node with global ID: {}", id); @@ -79,9 +83,8 @@ private Object resolveNode(Relay.ResolvedGlobalId globalId) { case SCRIPT_EXECUTION -> scriptExecutionService.findById(globalId.getId()).orElse(null); case SCRIPT_SCHEDULE -> scheduleScriptService.findById(globalId.getId()).orElse(null); case SCHEDULE_RUN -> scheduleRunService.findById(globalId.getId()).orElse(null); - case TENANT -> tenantRepository != null - ? tenantRepository.findById(globalId.getId()).orElse(null) - : null; + case TENANT -> tenantRepository != null ? tenantRepository.findById(globalId.getId()).orElse(null) : null; + case SOFTWARE_BUNDLE -> softwareBundleService != null ? softwareBundleService.findById(globalId.getId()).orElse(null) : null; default -> throw new IllegalArgumentException("Unsupported node type: " + globalId.getType()); }; } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java new file mode 100644 index 0000000000..bb43b6fd47 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java @@ -0,0 +1,52 @@ +package com.openframe.api.datafetcher.rmm; + +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.shared.PageInfo; +import com.openframe.api.dto.shared.PageResult; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +final class PageCursors { + + private PageCursors() { + } + + static int decodePage(String cursor) { + if (cursor == null || cursor.isBlank()) { + return 0; + } + try { + return Math.max(0, Integer.parseInt( + new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8))); + } catch (IllegalArgumentException e) { + return 0; + } + } + + static String encodePage(int page) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(Integer.toString(page).getBytes(StandardCharsets.UTF_8)); + } + + static CountedGenericConnection> toConnection(PageResult page) { + String currentCursor = encodePage(page.page()); + String nextCursor = page.hasNext() ? encodePage(page.page() + 1) : null; + List> edges = page.items().stream() + .map(node -> GenericEdge.builder().node(node).cursor(currentCursor).build()) + .toList(); + PageInfo pageInfo = PageInfo.builder() + .hasNextPage(page.hasNext()) + .hasPreviousPage(page.hasPrevious()) + .startCursor(edges.isEmpty() ? null : currentCursor) + .endCursor(nextCursor != null ? nextCursor : (edges.isEmpty() ? null : currentCursor)) + .build(); + return CountedGenericConnection.>builder() + .edges(edges) + .pageInfo(pageInfo) + .filteredCount(page.filteredCount()) + .build(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/ScriptDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/ScriptDataFetcher.java index c9353b43ec..e7c9a2e3c4 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/ScriptDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/ScriptDataFetcher.java @@ -43,14 +43,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; -/** - * GraphQL resolver for RMM script CRUD. - * - *

Pure passthrough to {@link ScriptService} — tenant scoping is resolved - * inside the service via {@code TenantIdProvider}. Authorisation (which roles - * may invoke which mutation) is intentionally not enforced here yet — it will - * be added in a dedicated security pass once the RMM role model is agreed. - */ @DgsComponent @RequiredArgsConstructor @Slf4j @@ -146,7 +138,6 @@ public DispatchResponse batchRunScript(@InputArgument @Valid BatchRunScriptInput return scriptDispatchService.batchRunScript(input, getCurrentUserId(), ExecutionSource.MANUAL); } - /** Returns the Relay global id (Base64 "Script:<rawId>") for the {@code id} field. */ @DgsData(parentType = "Script", field = "id") public String scriptNodeId(DgsDataFetchingEnvironment dfe) { ScriptResponse script = dfe.getSource(); @@ -161,7 +152,6 @@ private static List decodeIds(List globalIds) { return globalIds == null ? null : globalIds.stream().map(ScriptDataFetcher::decodeId).toList(); } - /** Re-encode a facet's raw option values to Relay global ids of the given node type (in place). */ private static void encodeNodeOptions(List options, String nodeType) { if (options == null) { return; @@ -169,7 +159,6 @@ private static void encodeNodeOptions(List options, String n options.forEach(o -> o.setValue(RELAY.toGlobalId(nodeType, o.getValue()))); } - /** Resolves the {@code Script.tags} field, batched per request via the data loader. */ @DgsData(parentType = "Script", field = "tags") public CompletableFuture> tags(DgsDataFetchingEnvironment dfe) { ScriptResponse script = dfe.getSource(); @@ -177,7 +166,6 @@ public CompletableFuture> tags(DgsDataFetchingEnvironment dfe) { return loader.load(script.getId()); } - /** Resolves the {@code Script.author} field from {@code createdBy}, batched via the user loader. */ @DgsData(parentType = "Script", field = "author") public CompletableFuture author(DgsDataFetchingEnvironment dfe) { ScriptResponse script = dfe.getSource(); diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java new file mode 100644 index 0000000000..c855201a36 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareActionDataFetcher.java @@ -0,0 +1,63 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareActionDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareActionFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareActionFilters; +import com.openframe.api.dto.rmm.software.SoftwareActionId; +import com.openframe.api.dto.rmm.software.SoftwareActionResponse; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.service.rmm.software.SoftwareActionDetailService; +import com.openframe.api.service.rmm.software.SoftwareActionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.List; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareActionDataFetcher { + + private final SoftwareActionService softwareActionService; + private final SoftwareActionDetailService softwareActionDetailService; + + @DgsQuery + public SoftwareActionResponse softwareAction(@InputArgument String id) { + return softwareActionService.findById(id).orElse(null); + } + + @DgsQuery + public CountedGenericConnection> softwareActions( + @InputArgument SoftwareActionFilterInput filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + return PageCursors.toConnection(softwareActionService.list(filter, search, sort, page, perPage)); + } + + @DgsQuery + public SoftwareActionFilters softwareActionFilters( + @InputArgument SoftwareActionFilterInput filter, + @InputArgument String search) { + return softwareActionService.filters(filter, search); + } + + @DgsQuery + public List softwareActionExecutions( + @InputArgument String actionId, + @InputArgument SoftwareActionDeviceFilterInput filter, + @InputArgument String search) { + SoftwareActionId id = SoftwareActionId.decode(actionId); + return softwareActionDetailService.devices(id.executionId(), id.bundleId(), id.scheduleId(), filter, search); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java new file mode 100644 index 0000000000..8449f1d081 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java @@ -0,0 +1,194 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.AvailableDeviceEdge; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.CountedGenericQueryResult; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.device.DeviceFilterCriteria; +import com.openframe.api.dto.device.DeviceFilterInput; +import com.openframe.api.dto.device.DeviceFilters; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SubmitSoftwareBundleInput; +import com.openframe.api.dto.shared.ConnectionArgs; +import com.openframe.api.dto.shared.CursorPaginationCriteria; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.mapper.GraphQLDeviceMapper; +import com.openframe.api.service.device.DeviceService; +import com.openframe.api.service.rmm.software.SoftwareBundleService; +import com.openframe.data.document.device.Machine; +import com.openframe.security.authentication.AuthPrincipal; +import graphql.relay.Relay; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.validation.annotation.Validated; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +@Validated +public class SoftwareBundleDataFetcher { + + private static final Relay RELAY = new Relay(); + + private final SoftwareBundleService softwareBundleService; + private final DeviceService deviceService; + private final GraphQLDeviceMapper deviceMapper; + + @DgsQuery + public SoftwareBundleResponse softwareBundle(@InputArgument String id) { + return softwareBundleService.findById(decodeId(id)).orElse(null); + } + + @DgsMutation + public SoftwareBundleResponse createSoftwareBundle() { + return softwareBundleService.createBundleStub(getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse addDevicesToSoftwareBundle(@InputArgument String bundleId, + @InputArgument List machineIds) { + return softwareBundleService.addDevices(decodeId(bundleId), decodeMachineIds(machineIds), getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse removeDevicesFromSoftwareBundle(@InputArgument String bundleId, + @InputArgument List machineIds) { + return softwareBundleService.removeDevices(decodeId(bundleId), decodeMachineIds(machineIds), getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse addAllDevicesToSoftwareBundle(@InputArgument String bundleId, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument String search) { + List machineIds = deviceService.findAllDeviceIds(deviceMapper.toDeviceFilterCriteria(filter), search); + return softwareBundleService.addDevices(decodeId(bundleId), machineIds, getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse removeAllDevicesFromSoftwareBundle(@InputArgument String bundleId, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument String search) { + List machineIds = deviceService.findAllDeviceIds(deviceMapper.toDeviceFilterCriteria(filter), search); + return softwareBundleService.removeDevices(decodeId(bundleId), machineIds, getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse submitSoftwareBundle(@InputArgument @Valid SubmitSoftwareBundleInput input) { + input.setId(decodeId(input.getId())); + return softwareBundleService.submit(input, getCurrentUserId()); + } + + @DgsMutation + public boolean deleteSoftwareBundle(@InputArgument String id) { + return softwareBundleService.delete(decodeId(id), getCurrentUserId()); + } + + @DgsData(parentType = "SoftwareBundle", field = "id") + public String softwareBundleNodeId(DgsDataFetchingEnvironment dfe) { + SoftwareBundleResponse bundle = dfe.getSource(); + return RELAY.toGlobalId("SoftwareBundle", bundle.getId()); + } + + @DgsData(parentType = "SoftwareBundle", field = "deviceCount") + public int deviceCount(DgsDataFetchingEnvironment dfe) { + SoftwareBundleResponse bundle = dfe.getSource(); + return bundle.getMachineIds() == null ? 0 : bundle.getMachineIds().size(); + } + + @DgsData(parentType = "SoftwareBundle", field = "assignedDevices") + public CountedGenericConnection> assignedDevices( + DgsDataFetchingEnvironment dfe, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument Integer first, + @InputArgument String after, + @InputArgument Integer last, + @InputArgument String before, + @InputArgument String search, + @InputArgument @Valid SortInput sort) { + SoftwareBundleResponse bundle = dfe.getSource(); + List machineIds = bundle.getMachineIds() == null ? List.of() : bundle.getMachineIds(); + DeviceFilterCriteria filterOptions = deviceMapper.toDeviceFilterCriteria(filter); + CursorPaginationCriteria pagination = deviceMapper.toCursorPaginationCriteria( + ConnectionArgs.builder().first(first).after(after).last(last).before(before).build()); + CountedGenericQueryResult result = + deviceService.queryAssignedDevices(machineIds, filterOptions, pagination, search, sort); + return deviceMapper.toDeviceConnection(result); + } + + @DgsData(parentType = "SoftwareBundle", field = "availableDevices") + public CountedGenericConnection availableDevices( + DgsDataFetchingEnvironment dfe, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument Integer first, + @InputArgument String after, + @InputArgument Integer last, + @InputArgument String before, + @InputArgument String search, + @InputArgument @Valid SortInput sort) { + SoftwareBundleResponse bundle = dfe.getSource(); + DeviceFilterCriteria filterOptions = deviceMapper.toDeviceFilterCriteria(filter); + CursorPaginationCriteria pagination = deviceMapper.toCursorPaginationCriteria( + ConnectionArgs.builder().first(first).after(after).last(last).before(before).build()); + Set assignedMachineIds = new HashSet<>(bundle.getMachineIds() == null ? List.of() : bundle.getMachineIds()); + // No OS restriction at device-selection time — packages (and thus platforms) are chosen at submit. + CountedGenericQueryResult result = deviceService.queryAvailableDevicesForSchedule( + null, assignedMachineIds, filterOptions, pagination, search); + return deviceMapper.toAvailableDeviceConnection(result, assignedMachineIds); + } + + @DgsData(parentType = "SoftwareBundle", field = "assignedDeviceFilters") + public DeviceFilters assignedDeviceFilters( + DgsDataFetchingEnvironment dfe, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument String search) { + SoftwareBundleResponse bundle = dfe.getSource(); + List machineIds = bundle.getMachineIds() == null ? List.of() : bundle.getMachineIds(); + DeviceFilterCriteria filterOptions = deviceMapper.toDeviceFilterCriteria(filter); + return deviceService.getAssignedDeviceFilters(machineIds, filterOptions, search); + } + + @DgsData(parentType = "SoftwareBundle", field = "availableDeviceFilters") + public DeviceFilters availableDeviceFilters( + DgsDataFetchingEnvironment dfe, + @InputArgument @Valid DeviceFilterInput filter, + @InputArgument String search) { + DeviceFilterCriteria filterOptions = deviceMapper.toDeviceFilterCriteria(filter); + return deviceService.getAvailableDeviceFilters(null, filterOptions, search); + } + + private static List decodeMachineIds(List machineIds) { + if (machineIds == null) { + return List.of(); + } + return machineIds.stream().map(SoftwareBundleDataFetcher::decodeId).toList(); + } + + private static String decodeId(String id) { + try { + return RELAY.fromGlobalId(id).getId(); + } catch (Exception e) { + return id; + } + } + + private String getCurrentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return AuthPrincipal.fromJwt((Jwt) auth.getPrincipal()).getId(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java new file mode 100644 index 0000000000..04bf9d30d1 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -0,0 +1,80 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.api.dto.shared.SortDirection; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.service.rmm.software.SoftwareInventoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareDataFetcher { + + private final SoftwareInventoryService softwareInventoryService; + + @DgsQuery + public SoftwareResponse software(@InputArgument String id) { + return softwareInventoryService.findById(id).orElse(null); + } + + @DgsQuery + public CountedGenericConnection> softwares( + @InputArgument SoftwareFilterInput filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + Boolean vulnerable = filter != null && filter.getMinSeverity() != null + && filter.getMinSeverity() != SoftwareCveSeverity.NONE + ? Boolean.TRUE : null; + return PageCursors.toConnection(softwareInventoryService.listSoftware( + search, page, perPage, sort, vulnerable)); + } + + @DgsQuery + public CountedGenericConnection> softwareDevices( + @InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument Object sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + return PageCursors.toConnection( + softwareInventoryService.listDevicesForSoftware(softwareId, search, page, perPage)); + } + + @DgsQuery + public CountedGenericConnection> softwareVulnerabilities( + @InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + String sortField = sort == null ? null : sort.getField(); + boolean asc = sort != null && sort.getDirection() == SortDirection.ASC; + return PageCursors.toConnection(softwareInventoryService.listVulnerabilitiesForSoftware( + softwareId, search, page, perPage, sortField, asc)); + } + + @DgsQuery + public SoftwareFilters softwareFilters(@InputArgument Object filter, @InputArgument String search) { + return softwareInventoryService.getSoftwareFilters(search); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java index 120bc2aa44..acf2888541 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java @@ -18,6 +18,7 @@ import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; import com.openframe.api.dto.rmm.software.UpdateSoftwareScheduleInput; import com.openframe.api.dto.shared.ConnectionArgs; +import com.openframe.api.dto.rmm.schedule.ScheduleDeviceCriteriaInput; import com.openframe.api.dto.shared.CursorPaginationCriteria; import com.openframe.api.dto.shared.SortInput; import com.openframe.api.dto.user.UserResponse; @@ -25,6 +26,7 @@ import com.openframe.api.service.device.DeviceService; import com.openframe.api.service.rmm.software.SoftwareScheduleService; import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; import com.openframe.security.authentication.AuthPrincipal; import graphql.relay.Relay; import jakarta.validation.Valid; @@ -118,6 +120,18 @@ public SoftwareScheduleResponse removeDevicesFromSoftwareSchedule(@InputArgument return scheduleService.get(rawScheduleId); } + @DgsMutation + public SoftwareScheduleResponse setSoftwareScheduleDeviceCriteria(@InputArgument @NotBlank String scheduleId, + @InputArgument @Valid ScheduleDeviceCriteriaInput criteria, + @AuthenticationPrincipal AuthPrincipal principal) { + ScheduleDeviceCriteria domainCriteria = ScheduleDeviceCriteria.builder() + .organizationIds(criteria.getOrganizationIds()) + .deviceTypes(criteria.getDeviceTypes()) + .osTypes(criteria.getOsTypes()) + .build(); + return scheduleService.setDeviceCriteria(decodeId(scheduleId), domainCriteria, principal.getId()); + } + @DgsData(parentType = "SoftwareSchedule", field = "id") public String softwareScheduleNodeId(DgsDataFetchingEnvironment dfe) { SoftwareScheduleResponse schedule = dfe.getSource(); diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java new file mode 100644 index 0000000000..3121d3b9b7 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java @@ -0,0 +1,78 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilterInput; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilters; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.service.rmm.vulnerability.VulnerabilityInventoryService; +import com.openframe.data.document.device.Machine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.Locale; +import java.util.Map; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class VulnerabilityDataFetcher { + + private static final Map FLEET_ORDER_KEY = Map.of( + "cveId", "cve", + "cvssScore", "cvss_score", + "severity", "cvss_score", + "devicesCount", "hosts_count", + "epssProbability", "epss_probability", + "discoveredAt", "created_at", + "createdAt", "created_at"); + + private final VulnerabilityInventoryService vulnerabilityInventoryService; + + @DgsQuery + public VulnerabilityResponse vulnerability(@InputArgument String cveId) { + return vulnerabilityInventoryService.findByCveId(cveId).orElse(null); + } + + @DgsQuery + public CountedGenericConnection> vulnerabilities( + @InputArgument VulnerabilityFilterInput filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField()); + String orderDirection = sort == null || sort.getDirection() == null + ? null : sort.getDirection().name().toLowerCase(Locale.ROOT); + Boolean exploit = filter != null && Boolean.TRUE.equals(filter.getExploited()) + ? Boolean.TRUE : null; + return PageCursors.toConnection(vulnerabilityInventoryService.listVulnerabilities( + search, page, perPage, orderKey, orderDirection, exploit, + filter == null ? null : filter.getMinSeverity())); + } + + @DgsQuery + public VulnerabilityFilters vulnerabilityFilters( + @InputArgument VulnerabilityFilterInput filter, @InputArgument String search) { + return vulnerabilityInventoryService.getVulnerabilityFilters(search); + } + + @DgsQuery + public CountedGenericConnection> vulnerabilityDevices( + @InputArgument String cveId, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + return PageCursors.toConnection( + vulnerabilityInventoryService.listDevicesForCve(cveId, search, page, perPage)); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/exception/GraphQLExceptionHandler.java b/openframe-api-service-core/src/main/java/com/openframe/api/exception/GraphQLExceptionHandler.java index 677edbd605..c6266c12ac 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/exception/GraphQLExceptionHandler.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/exception/GraphQLExceptionHandler.java @@ -79,4 +79,4 @@ private GraphQLError buildError(String message, ErrorCode errorCode) { )) .build(); } -} \ No newline at end of file +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/relay/NodeTypeResolver.java b/openframe-api-service-core/src/main/java/com/openframe/api/relay/NodeTypeResolver.java index aa23645b0a..b1597bcb95 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/relay/NodeTypeResolver.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/relay/NodeTypeResolver.java @@ -6,6 +6,7 @@ import com.openframe.api.dto.rmm.schedule.ScriptScheduleResponse; import com.openframe.api.dto.rmm.schedulerun.ScheduleRunResponse; import com.openframe.api.dto.rmm.script.ScriptResponse; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; import com.openframe.data.document.assignment.ItemAssignment; import com.openframe.data.document.device.Machine; import com.openframe.data.document.knowledgebase.KnowledgeBaseItem; @@ -56,6 +57,9 @@ public String resolveNode(Object node) { if (node instanceof ScheduleRunResponse) { return "ScheduleRun"; } + if (node instanceof SoftwareBundleResponse) { + return "SoftwareBundle"; + } throw new IllegalArgumentException("Unknown Node type: " + node.getClass().getName()); } } diff --git a/openframe-api-service-core/src/main/resources/schema/software-action.graphqls b/openframe-api-service-core/src/main/resources/schema/software-action.graphqls new file mode 100644 index 0000000000..49c67b41c1 --- /dev/null +++ b/openframe-api-service-core/src/main/resources/schema/software-action.graphqls @@ -0,0 +1,124 @@ +# Software Actions — the "Software Actions" page: one row per install/update of a package across its devices. +# A row is aggregated from the execution leaves by executionId; the X/Y ratio is respondedMachineCount / totalMachineCount. +# Gated behind openframe.rmm.software.enabled=true. Reuses SoftwareAction, PackageManagerType, Instant, SortInput. + +extend type Query { + """One software action by id. Null when unknown.""" + softwareAction(id: ID!): SoftwareActionRun + + """Software actions for the tenant (install/update runs + scheduled ones), newest first.""" + softwareActions( + filter: SoftwareActionFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareActionRunConnection! + + """ + Device drill-down for one action: every target device with its result. Pass the SoftwareActionRun's + opaque id as actionId (it carries the execution / bundle / schedule keys). Executed devices come from + their execution leaves (with output for "Show Result"); devices still offline come from the action's + reconnect sentinels, and a not-yet-fired schedule lists its assigned devices — all as SCHEDULED. + Optional filter (Status / Customer) and search (hostname or machine id). + """ + softwareActionExecutions( + actionId: String!, + filter: SoftwareActionDeviceFilterInput, + search: String + ): [SoftwareActionDevice!]! + + """Available filter options (with live counts) for the softwareActions list — the ACTION / ENGINE / + STATUS dropdowns. Same filter/search scope as softwareActions; each facet is counted under the active + filters. filteredCount = total rows (executed + scheduled) matching them.""" + softwareActionFilters( + filter: SoftwareActionFilterInput, + search: String + ): SoftwareActionFilters! +} + +"""A single install/update of one package across its target devices.""" +type SoftwareActionRun { + id: ID! + executionId: String! + "Package name (the SOFTWARE column)." + software: String! + "INSTALL or UPDATE." + action: SoftwareAction! + "Package manager engine — BREW / WINGET / CHOCO." + engine: PackageManagerType! + status: SoftwareActionStatus! + "Total target devices (Y in X / Y)." + totalMachineCount: Int! + "Devices that have finished (X in X / Y)." + respondedMachineCount: Int! + "When a SCHEDULED action will fire. Null once dispatched / for NOW." + scheduledAt: Instant + "When the action was dispatched. Null while SCHEDULED." + dispatchedAt: Instant + finishedAt: Instant + initiatedBy: String + "Source bundle of a NOW action — pass to softwareActionExecutions. Null for SCHEDULE." + bundleId: String + "Source schedule of a SCHEDULE action — pass to softwareActionExecutions. Null for NOW." + scheduleId: String +} + +enum SoftwareActionStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + FAILED +} + +"""One target device of a software action, with its result. Customer = the device's organization.""" +type SoftwareActionDevice { + machineId: ID! + hostname: String + organizationId: String + organizationName: String + status: SoftwareActionStatus! + exitCode: Int + stdout: String + stdoutTruncated: Boolean + stderr: String + stderrTruncated: Boolean + error: String + dispatchedAt: Instant + finishedAt: Instant +} + +"""Filter for softwareActionExecutions. All fields optional; null/empty = no constraint. +organizationIds is the Customer filter.""" +input SoftwareActionDeviceFilterInput { + statuses: [SoftwareActionStatus!] + organizationIds: [ID!] +} + +type SoftwareActionRunConnection { + edges: [SoftwareActionRunEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareActionRunEdge { + node: SoftwareActionRun! + cursor: String! +} + +input SoftwareActionFilterInput { + statuses: [SoftwareActionStatus!] + actions: [SoftwareAction!] + engines: [PackageManagerType!] +} + +"""Faceted filter options (with live counts) for the "Software Actions" page. Reuses ScriptFilterOption +(value / label / count); values are enum names. filteredCount = total rows matching all active filters.""" +type SoftwareActionFilters { + statuses: [ScriptFilterOption!]! + actions: [ScriptFilterOption!]! + engines: [ScriptFilterOption!]! + filteredCount: Int! +} diff --git a/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls new file mode 100644 index 0000000000..9b536f9826 --- /dev/null +++ b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls @@ -0,0 +1,134 @@ +# Software bundle — the staging layer for Install / Update Software. +# +# The frontend creates an empty draft bundle (createSoftwareBundle) the moment the user opens the +# Install/Update screen, assigns devices to it (add/remove, individually or by filter+search), then submits +# it (submitSoftwareBundle) with the action + packages, choosing run-now vs a schedule. A PENDING bundle left +# unsubmitted is disposable and reaped by a Mongo TTL index. +# +# Gated behind openframe.rmm.software.enabled=true (+ spring.cloud.stream.enabled for the run dispatch). +# Reuses SoftwareAction, SoftwarePackageInput from software.graphqls; Node, SortInput from shared.graphqls; +# DeviceFilterInput, DeviceConnection, DeviceFilters from device.graphqls; AvailableDeviceConnection, +# ScheduleTimeReference, ScheduleOfflineBehavior from script-schedule.graphqls. + +extend type Query { + """One bundle by id. Null when it does not exist (or was already reaped).""" + softwareBundle(id: ID!): SoftwareBundle +} + +extend type Mutation { + """ + Start an empty draft: status PENDING, no devices, no packages, no action. + Scoped to the tenant and the calling user. The frontend calls it right before the + first device is assigned. + """ + createSoftwareBundle: SoftwareBundle! + + """ + Assign the given devices. Idempotent: ids that are already assigned are skipped. + machineIds are Machine global ids. Fails if the bundle is COMPLETED. + """ + addDevicesToSoftwareBundle(bundleId: ID!, machineIds: [ID!]!): SoftwareBundle! + + """ + Unassign the given devices. Ids that are not assigned are no-ops. + Fails if the bundle is COMPLETED. + """ + removeDevicesFromSoftwareBundle(bundleId: ID!, machineIds: [ID!]!): SoftwareBundle! + + """ + Assign every device matching filter + search, resolved on the server ("Add N Devices"). + Takes the same filter/search as availableDevices. Already-assigned devices are skipped. + """ + addAllDevicesToSoftwareBundle(bundleId: ID!, filter: DeviceFilterInput, search: String): SoftwareBundle! + + """ + Unassign every assigned device matching filter + search ("Remove N Devices"). + With no filter and no search it clears the whole assignment. + """ + removeAllDevicesFromSoftwareBundle(bundleId: ID!, filter: DeviceFilterInput, search: String): SoftwareBundle! + + """ + Validate the bundle, then run it now (schedule: null) or create a software schedule from it, + and mark it COMPLETED. + """ + submitSoftwareBundle(input: SubmitSoftwareBundleInput!): SoftwareBundle! + + """ + Discard a PENDING bundle. Idempotent: an unknown or already reaped id returns true. A COMPLETED + bundle returns false and is left untouched. It never throws. + """ + deleteSoftwareBundle(id: ID!): Boolean! +} + +input SubmitSoftwareBundleInput { + id: ID! + action: SoftwareAction! + """1..50, as in SoftwareManagementInput.""" + packages: [SoftwarePackageInput!]! + """Null: run now (Device-Online). Set: create a software schedule.""" + schedule: SoftwareBundleScheduleInput +} + +"""Same fields and rules as CreateSoftwareScheduleInput, minus action / packages / machineIds.""" +input SoftwareBundleScheduleInput { + """Null: the server names it, e.g. "Install jq, wget".""" + name: String + description: String + timeReference: ScheduleTimeReference + offlineBehavior: ScheduleOfflineBehavior + reconnectWindowSeconds: Long + startAt: Instant! + repeat: Long +} + +"""A staged install/update operation: assigned devices + packages, plus lifecycle state.""" +type SoftwareBundle implements Node { + id: ID! + "PENDING while drafting, COMPLETED once submitted. Server-controlled; never settable." + status: SoftwareBundleStatus! + "Null while PENDING; set by submit." + action: SoftwareAction + "Null while PENDING; set by submit." + mode: SoftwareBundleMode + "Empty while PENDING; set by submit." + packages: [SoftwareBundlePackage!]! + + "Number of assigned devices." + deviceCount: Int! + "Assigned devices. Same filter/search/sort/pagination as the top-level devices query." + assignedDevices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): DeviceConnection! + "The picker's \"Available Devices\". Each edge carries assigned." + availableDevices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): AvailableDeviceConnection! + assignedDeviceFilters(filter: DeviceFilterInput, search: String): DeviceFilters! + availableDeviceFilters(filter: DeviceFilterInput, search: String): DeviceFilters! + + "For SCHEDULED bundles: when the run fires (absolute instant, SERVER time). Null for NOW." + startAt: Instant + "For SCHEDULED bundles: the schedule created on submit. Null for NOW / while PENDING." + scheduleId: String + "Execution ids from the run (one per dispatched package). Null/empty while PENDING." + executionIds: [String!] + createdBy: String + createdAt: Instant + updatedAt: Instant + "When the bundle was submitted. Null while PENDING." + completedAt: Instant +} + +enum SoftwareBundleMode { + NOW + SCHEDULED +} + +"""One catalog package staged in a bundle.""" +type SoftwareBundlePackage { + packageManager: PackageManagerType! + packageName: String! + brewPackageType: BrewPackageType + version: String +} + +enum SoftwareBundleStatus { + PENDING + COMPLETED +} diff --git a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls index 7f63ade35f..0225e4a748 100644 --- a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls @@ -41,6 +41,12 @@ extend type Mutation { """Incrementally unassign the given devices (the trash / "Remove selected" actions). Missing ids are no-ops.""" removeDevicesFromSoftwareSchedule(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule! + + """Switch a software schedule to CRITERIA device selection and store its rule (customer / type / OS) — + backs "Select Devices by Criteria" → Save Devices. The target device set is then resolved live at + dispatch and display time, so devices registered later that match the rule are included automatically. + Reuses the same ScheduleDeviceCriteriaInput as script schedules. Returns the updated schedule.""" + setSoftwareScheduleDeviceCriteria(scheduleId: ID!, criteria: ScheduleDeviceCriteriaInput!): SoftwareSchedule! } type SoftwareSchedule implements Node { @@ -51,8 +57,12 @@ type SoftwareSchedule implements Node { action: SoftwareAction! """The packages this schedule installs/updates.""" packages: [SoftwareSchedulePackage!]! - """How this schedule targets devices. Always SPECIFIC for now (criteria out of scope).""" + """How this schedule targets devices: SPECIFIC (an explicit machine set) or CRITERIA (a live rule; + devices registered later that match are included automatically). Never null — legacy schedules read as SPECIFIC.""" selectionMode: ScheduleDeviceSelectionMode! + """The device-selection rule when selectionMode is CRITERIA (customer / type / OS). Null for SPECIFIC schedules. + Reuses the ScheduleDeviceCriteria type from script schedules.""" + deviceCriteria: ScheduleDeviceCriteria """How a DATE_TIME schedule reads its startAt: SERVER (an absolute instant) or DEVICE_LOCAL (a wall-clock re-based into each device's own timezone). Never null; defaults to SERVER.""" timeReference: ScheduleTimeReference! """What to do when a target device is offline at the scheduled time. Never null; defaults to SKIP.""" diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls index f4da4808e2..5181c324f9 100644 --- a/openframe-api-service-core/src/main/resources/schema/software.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -1,3 +1,315 @@ +# Software inventory (read side, thin proxy over Fleet MDM REST). +# +# The wire contract is stable so the frontend can start binding against it; +# resolvers are gated behind a Conditional-property and only wire up when the +# Software feature is enabled via openframe.rmm.software.enabled=true. + +extend type Query { + """Single software row by id (Relay global id). Null when unknown.""" + software(id: ID!): Software + + """Paginated list of software titles installed across the tenant's fleet. + One row = one software title (aggregated across devices). + Sortable fields: name, devicesCount, cveCount, highestSeverity (default: Fleet order by name). + An unknown sort field is rejected as a client error (BAD_REQUEST), not a server error.""" + softwares( + filter: SoftwareFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareConnection! + + """Devices that have a given software title installed, with each device's own + installed version and per-device software status (OUTDATED / SCHEDULED_UPDATE / …). + Powers the Software → Devices tab.""" + softwareDevices( + softwareId: ID!, + filter: SoftwareOnDeviceFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareOnDeviceConnection! + + """CVEs affecting a given software title. Powers the Software → Vulnerabilities tab.""" + softwareVulnerabilities( + softwareId: ID!, + filter: SoftwareVulnerabilityFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareVulnerabilityConnection! + + """Faceted filter-option counts for the software list — Type / Source / Version-status / + Vulnerability-severity dropdowns. Mirrors scriptFilters / deviceFilters.""" + softwareFilters(filter: SoftwareFilterInput, search: String): SoftwareFilters! + + """Single global CVE record by id (e.g. "CVE-2024-38063"). Null when Fleet has no record. + Populates the vulnerability detail page — includes affected-software list.""" + vulnerability(cveId: String!): Vulnerability + + """Global list of CVEs across every software title in the tenant's fleet. + Powers the Vulnerabilities page. One row = one CVE (not one CVE × software).""" + vulnerabilities( + filter: VulnerabilityFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): VulnerabilityConnection! + + """Faceted filter options (with live counts) for the vulnerabilities list — the Severity dropdown. + Same filter/search scope as vulnerabilities; mirrors softwareFilters so the UI can disable a band + with no matching rows.""" + vulnerabilityFilters(filter: VulnerabilityFilterInput, search: String): VulnerabilityFilters! + + """Devices affected by a given CVE — the hosts that have an affected software version, correlated + to their OpenFrame Machine. Powers the vulnerability-detail "Devices" table. Each node is a Machine + (same shape as the top-level `devices` query).""" + vulnerabilityDevices( + cveId: String!, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): DeviceConnection! +} + +# ────────── Software (aggregate row) ────────── + +type Software { + id: ID! + name: String! + publisher: String + + """Package-manager source — WINGET / CHOCOLATEY / BREW / UNMANAGED.""" + source: SoftwareSource + + """Most-recent version installed anywhere in the fleet — shown as CURRENT VERSION.""" + currentVersion: String + + """Most up-to-date known version — shown as Latest Version on the detail page.""" + latestVersion: String + + """UP_TO_DATE / OUTDATED / UNKNOWN — drives the "OUTDATED" chip in the list.""" + versionStatus: SoftwareVersionStatus + + """Additional older versions in use across the fleet, beyond currentVersion. + Shows as "+N older versions" line in the list.""" + olderVersionsCount: Int + + """Number of devices with this software installed.""" + devicesCount: Int + + """Vulnerability roll-up — highest severity + CVE count for the badge.""" + vulnerabilitySummary: SoftwareVulnerabilitySummary + + """False when the vulnerability scanner could not find a matching CPE entry + — shown as "no CPE match" instead of a severity badge.""" + cpeMatched: Boolean +} + +type SoftwareVulnerabilitySummary { + highestSeverity: SoftwareCveSeverity + cveCount: Int! +} + +# ────────── Software on device (per-device row) ────────── + +type SoftwareOnDevice { + device: Machine! + softwareVersion: String + status: SoftwareOnDeviceStatus +} + +# ────────── Vulnerability (CVE row) ────────── + +type SoftwareVulnerability { + cveId: String! + severity: SoftwareCveSeverity + cvssScore: Float + affectedVersion: String + discoveredAt: String +} + +# ────────── Enums ────────── + +enum SoftwareSource { + WINGET + CHOCOLATEY + BREW + UNMANAGED +} + +enum SoftwareVersionStatus { + UP_TO_DATE + OUTDATED + UNKNOWN +} + +enum SoftwareCveSeverity { + CRITICAL + HIGH + MEDIUM + LOW + NONE +} + +"""Per-device software state: static (UP_TO_DATE / OUTDATED) or in a lifecycle +transition (SCHEDULED_UPDATE / UNINSTALLING / SCHEDULED_UNINSTALL).""" +enum SoftwareOnDeviceStatus { + UP_TO_DATE + OUTDATED + SCHEDULED_UPDATE + UNINSTALLING + SCHEDULED_UNINSTALL +} + +# ────────── Connections (Relay-style) ────────── + +type SoftwareConnection { + edges: [SoftwareEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareEdge { + node: Software! + cursor: String! +} + +type SoftwareOnDeviceConnection { + edges: [SoftwareOnDeviceEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareOnDeviceEdge { + node: SoftwareOnDevice! + cursor: String! +} + +type SoftwareVulnerabilityConnection { + edges: [SoftwareVulnerabilityEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareVulnerabilityEdge { + node: SoftwareVulnerability! + cursor: String! +} + +# ────────── Facet dropdowns ────────── + +type SoftwareFilters { + sources: [SoftwareFilterOption!]! + versionStatuses: [SoftwareFilterOption!]! + severities: [SoftwareFilterOption!]! +} + +type SoftwareFilterOption { + value: String! + label: String! + count: Int! +} + +# ────────── Inputs ────────── + +input SoftwareFilterInput { + sources: [SoftwareSource!] + versionStatuses: [SoftwareVersionStatus!] + minSeverity: SoftwareCveSeverity + deviceTagIds: [ID!] +} + +input SoftwareOnDeviceFilterInput { + statuses: [SoftwareOnDeviceStatus!] + deviceTagIds: [ID!] +} + +input SoftwareVulnerabilityFilterInput { + severities: [SoftwareCveSeverity!] +} + +# ────────── Global vulnerability (CVE row) ────────── + +"""Global CVE record. Detail-only fields (description, resolvedInVersion, affectedSoftware) +are null on list rows and populated on the detail page.""" +type Vulnerability { + cveId: String! + + """Severity bucketed from CVSS via the NVD ranges (CRITICAL ≥ 9, HIGH ≥ 7, MEDIUM ≥ 4, LOW > 0). + Null when Fleet has no CVSS score for the CVE.""" + severity: SoftwareCveSeverity + + cvssScore: Float + + """Exploit Prediction Scoring System probability (0..1) — Fleet Premium only.""" + epssProbability: Float + + """True when the CVE is on CISA's Known Exploited Vulnerabilities list — Fleet Premium only.""" + cisaKnownExploit: Boolean + + """When Fleet first matched this CVE against installed software (Fleet's created_at).""" + discoveredAt: String + + """External details link Fleet returns (usually NVD).""" + detailsLink: String + + """Number of tenant devices affected by any (software, version) pair hit by this CVE.""" + devicesCount: Int + + # ── Detail-only ── + description: String + resolvedInVersion: String + affectedSoftware: [AffectedSoftware!] +} + +"""One row of the "Affected Software" table on the vulnerability detail page.""" +type AffectedSoftware { + id: ID! + name: String! + source: SoftwareSource + version: String + devicesCount: Int + resolvedInVersion: String +} + +type VulnerabilityConnection { + edges: [VulnerabilityEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type VulnerabilityFilters { + severities: [SoftwareFilterOption!]! +} + +type VulnerabilityEdge { + node: Vulnerability! + cursor: String! +} + +input VulnerabilityFilterInput { + """Cut-off severity — rows with severity below this are excluded.""" + minSeverity: SoftwareCveSeverity + """True → only CVEs on CISA's Known Exploited Vulnerabilities list.""" + exploited: Boolean +} + # GraphQL schema for RMM software management (install / update packages). # # Built entirely on top of the existing RMM script-execution machinery: each package becomes its @@ -37,6 +349,7 @@ input SoftwarePackageInput { packageName: String! "Brew-only sub-type (CASK/FORMULA). Required for brew (enforced server-side), ignored by managers that do not distinguish." brewPackageType: BrewPackageType + version: String } """The dispatch outcome for one package: its shared executionId across the targeted devices.""" diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java index 51e954a92e..c2064ded1a 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java @@ -11,12 +11,13 @@ import com.openframe.data.document.device.filter.MachineQueryFilter; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.rmm.ScriptScheduleMachineAssignedRepository; +import com.openframe.data.service.rmm.ScheduleCriteriaDeviceResolver; import com.openframe.data.service.rmm.ScheduleDeviceTargetResolver; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -40,7 +41,12 @@ class ScheduleDeviceTargetResolverTest { @Mock private MachineRepository machineRepository; @Mock private ScriptScheduleMachineAssignedRepository assignedRepository; - @InjectMocks private ScheduleDeviceTargetResolver resolver; + private ScheduleDeviceTargetResolver resolver; + + @BeforeEach + void setUp() { + resolver = new ScheduleDeviceTargetResolver(machineRepository, assignedRepository, new ScheduleCriteriaDeviceResolver(machineRepository)); + } @Test @DisplayName("resolveTargetMachineIds: SPECIFIC reads the join rows (deduped) and keeps active machines in input order") diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java index d9d55a50c8..51b629ddbd 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java @@ -5,14 +5,20 @@ import com.openframe.api.dto.rmm.software.SoftwareManagementInput; import com.openframe.api.dto.rmm.software.SoftwarePackageInput; import com.openframe.api.service.rmm.script.ScriptService; +import com.openframe.data.document.device.Machine; import com.openframe.data.document.packagesearch.BrewPackageType; import com.openframe.data.document.packagesearch.PackageManagerType; import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; import com.openframe.data.document.rmm.script.PrivilegeLevel; import com.openframe.data.document.rmm.software.SoftwareAction; import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.data.service.rmm.MachinePlatformResolver; import com.openframe.data.service.rmm.software.BrewPackageManagerHandler; import com.openframe.data.service.rmm.software.PackageManagerRegistry; +import com.openframe.data.service.rmm.software.WingetPackageManagerHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -36,22 +43,35 @@ class SoftwareInstallUpdateManagementServiceTest { private static final String USER = "user-1"; private static final List MACHINES = List.of("m1", "m2"); + private static final String TENANT = "t1"; + @Mock private PackageManagerRegistry registry; @Mock private ScriptService scriptService; @Mock private SoftwareDispatchService softwareDispatchService; + @Mock private MachineRepository machineRepository; + @Mock private TenantIdProvider tenantIdProvider; private SoftwareInstallUpdateManagementService service; @org.junit.jupiter.api.BeforeEach void setUp() { - service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService); + MachinePlatformResolver platformResolver = new MachinePlatformResolver(machineRepository, tenantIdProvider); + service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService, + platformResolver); // A real brew handler so we exercise real script-code + arg building. when(registry.handlerFor(PackageManagerType.BREW)).thenReturn(new BrewPackageManagerHandler()); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + private void allMacTargets() { + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m1", OsType.MAC_OS), machine("m2", OsType.MAC_OS))); } @Test @DisplayName("install: each package dispatched with the right script + args; system script resolved once") void install_dispatchesPerPackage() { + allMacTargets(); ScriptResponse installScript = systemScript("brew-install-id"); when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(installScript); when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), @@ -67,7 +87,6 @@ void install_dispatchesPerPackage() { // System script resolved once for both brew packages (cached). verify(scriptService, times(1)).getSoftwareScript(SoftwareScriptCode.BREW_INSTALL); - // Two dispatches, with cask/formula-correct args. @SuppressWarnings("unchecked") ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(List.class); verify(softwareDispatchService, times(2)) @@ -87,6 +106,7 @@ void install_dispatchesPerPackage() { @Test @DisplayName("update: routes to the BREW_UPDATE system script and propagates the caller's ExecutionSource (e.g. AI_ASSISTANT for MingoAI)") void update_usesUpdateScript_andPropagatesSource() { + allMacTargets(); ScriptResponse updateScript = systemScript("brew-update-id"); when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_UPDATE)).thenReturn(updateScript); when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.AI_ASSISTANT), @@ -102,6 +122,64 @@ void update_usesUpdateScript_andPropagatesSource() { eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.UPDATE)); } + @Test + @DisplayName("install: a mixed brew+winget bundle routes each package to its own OS — brew→macOS, winget→Windows") + void install_mixedOs_routesPerPackage() { + when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler()); + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m-mac", OsType.MAC_OS), machine("m-win", OsType.WINDOWS))); + ScriptResponse brew = script("brew-install-id", OsType.MAC_OS); + ScriptResponse winget = script("winget-install-id", OsType.WINDOWS); + when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew); + when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget); + when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), + any(), any(), any())) + .thenReturn("exec-brew", "exec-winget"); + + SoftwareManagementInput mixed = new SoftwareManagementInput(); + mixed.setMachineIds(List.of("m-mac", "m-win")); + mixed.setPackages(List.of( + pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK), + pkg(PackageManagerType.WINGET, "vscode", null))); + + service.install(mixed, USER, ExecutionSource.MANUAL); + + verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL)); + verify(softwareDispatchService).dispatch(eq(winget), eq(List.of("m-win")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.WINGET), eq("vscode"), eq(SoftwareAction.INSTALL)); + } + + @Test + @DisplayName("install: a package with no OS-compatible device is skipped, while a compatible one still dispatches") + void install_incompatiblePackage_skipped() { + when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler()); + // Only a macOS device is assigned — the winget package has nowhere to go. + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m-mac", OsType.MAC_OS))); + ScriptResponse brew = script("brew-install-id", OsType.MAC_OS); + ScriptResponse winget = script("winget-install-id", OsType.WINDOWS); + when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew); + when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget); + when(softwareDispatchService.dispatch(eq(brew), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), + any(), any(), any())) + .thenReturn("exec-brew"); + + SoftwareManagementInput in = new SoftwareManagementInput(); + in.setMachineIds(List.of("m-mac")); + in.setPackages(List.of( + pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK), + pkg(PackageManagerType.WINGET, "vscode", null))); + + List results = service.install(in, USER, ExecutionSource.MANUAL); + + assertThat(results).extracting(SoftwareDispatchResult::getPackageName).containsExactly("slack"); + verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL)); + verify(softwareDispatchService, never()).dispatch(eq(winget), anyList(), anyList(), any(), any(), + any(), any(), any()); + } + private static SoftwareManagementInput input(SoftwarePackageInput... packages) { SoftwareManagementInput input = new SoftwareManagementInput(); input.setMachineIds(MACHINES); @@ -118,10 +196,22 @@ private static SoftwarePackageInput pkg(PackageManagerType manager, String id, B } private static ScriptResponse systemScript(String id) { + return script(id, OsType.MAC_OS); + } + + private static ScriptResponse script(String id, OsType supported) { return ScriptResponse.builder() .id(id) .privilegeLevel(PrivilegeLevel.ADMIN) .defaultTimeoutSeconds(600) + .supportedPlatforms(List.of(supported)) .build(); } + + private static Machine machine(String machineId, OsType osType) { + Machine m = new Machine(); + m.setMachineId(machineId); + m.setOsType(osType); + return m; + } } diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java index 5f690ddfdf..0b808efcf9 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java @@ -10,6 +10,8 @@ import com.openframe.core.exception.NotFoundException; import com.openframe.data.document.packagesearch.BrewPackageType; import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.schedule.SoftwareSchedule; import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned; @@ -218,6 +220,33 @@ void getDeletedThrows() { assertThatThrownBy(() -> service.get(SCHEDULE_ID)).isInstanceOf(NotFoundException.class); } + @Test + @DisplayName("setDeviceCriteria: switches the schedule to CRITERIA and stores the rule") + void setDeviceCriteria_switchesToCriteria() { + SoftwareSchedule existing = existingActive(); + when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing)); + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")).build(); + + service.setDeviceCriteria(SCHEDULE_ID, criteria, ACTOR); + + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareSchedule.class); + verify(scheduleRepository).save(saved.capture()); + assertThat(saved.getValue().getSelectionMode()).isEqualTo(ScheduleDeviceSelectionMode.CRITERIA); + assertThat(saved.getValue().getDeviceCriteria()).isEqualTo(criteria); + } + + @Test + @DisplayName("getMachineIds: delegates to the target resolver (CRITERIA resolved live)") + void getMachineIds_delegatesToResolver() { + SoftwareSchedule existing = existingActive(); + existing.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA); + when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing)); + when(targetResolver.resolveMachineIds(existing)).thenReturn(List.of("m-7", "m-8")); + + assertThat(service.getMachineIds(SCHEDULE_ID)).containsExactly("m-7", "m-8"); + } + private static SoftwareSchedule existingActive() { return SoftwareSchedule.builder() .id(SCHEDULE_ID).tenantId(TENANT_ID).name("Nightly Slack") diff --git a/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java new file mode 100644 index 0000000000..238555ba25 --- /dev/null +++ b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java @@ -0,0 +1,93 @@ +package com.openframe.data.service.rmm; + +import com.openframe.data.document.device.DeviceType; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.device.filter.MachineQueryFilter; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.repository.device.MachineRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static com.openframe.data.document.rmm.script.OsType.MAC_OS; +import static com.openframe.data.document.rmm.script.OsType.WINDOWS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScheduleCriteriaDeviceResolverTest { + + private static final String TENANT = "tenant-1"; + + @Mock private MachineRepository machineRepository; + @InjectMocks private ScheduleCriteriaDeviceResolver resolver; + + @Test + @DisplayName("resolveMachineIds: criteria OS ∩ supportedPlatforms is passed as the scope; org/type land on the filter") + void resolve_intersectsOsScopeAndBuildsFilter() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")) + .deviceTypes(List.of(DeviceType.DESKTOP)) + .osTypes(List.of(WINDOWS, MAC_OS)) + .build(); + when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(List.of(WINDOWS)))) + .thenReturn(List.of("m-1", "m-2")); + + List result = resolver.resolveMachineIds(TENANT, criteria, List.of(WINDOWS)); + + assertThat(result).containsExactly("m-1", "m-2"); + ArgumentCaptor filter = ArgumentCaptor.forClass(MachineQueryFilter.class); + verify(machineRepository).findMachineIdsByCriteria(eq(TENANT), filter.capture(), eq(List.of(WINDOWS))); + assertThat(filter.getValue().getOrganizationIds()).containsExactly("org-1"); + assertThat(filter.getValue().getDeviceTypes()).containsExactly(DeviceType.DESKTOP.name()); + } + + @Test + @DisplayName("resolveMachineIds: a contradictory OS scope (criteria OS disjoint from supported) matches nothing — no query") + void resolve_contradictoryScope_shortCircuits() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().osTypes(List.of(WINDOWS)).build(); + + assertThat(resolver.resolveMachineIds(TENANT, criteria, List.of(MAC_OS))).isEmpty(); + verify(machineRepository, never()).findMachineIdsByCriteria(any(), any(), any()); + } + + @Test + @DisplayName("resolveMachineIds: no OS anywhere → unconstrained scope (null) passed through to the repository") + void resolve_unconstrained_passesNullScope() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().organizationIds(List.of("org-1")).build(); + when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(null))).thenReturn(List.of("m-9")); + + assertThat(resolver.resolveMachineIds(TENANT, criteria, null)).containsExactly("m-9"); + } + + @Test + @DisplayName("matches: device satisfies every constrained dimension; a wrong OS or org fails it") + void matches_perDimension() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")).osTypes(List.of(WINDOWS)).build(); + + assertThat(resolver.matches(machine("org-1", WINDOWS), criteria, null)).isTrue(); + assertThat(resolver.matches(machine("org-1", MAC_OS), criteria, null)).isFalse(); // wrong OS + assertThat(resolver.matches(machine("org-2", WINDOWS), criteria, null)).isFalse(); // wrong org + assertThat(resolver.matches(null, criteria, null)).isFalse(); + } + + private static Machine machine(String orgId, OsType osType) { + Machine m = new Machine(); + m.setMachineId("m"); + m.setOrganizationId(orgId); + m.setOsType(osType); + return m; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java new file mode 100644 index 0000000000..7106eab07e --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java @@ -0,0 +1,31 @@ +package com.openframe.client.scheduler; + +import com.openframe.client.service.rmm.SoftwareBundleOnlineDispatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatchScheduler { + + private final SoftwareBundleOnlineDispatchService dispatchService; + + @Scheduled(fixedDelayString = "${openframe.rmm.software.bundle.online-dispatch.interval:60000}") + @SchedulerLock(name = "softwareBundleOnlineDispatch", + lockAtMostFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-most-for:2m}", + lockAtLeastFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-least-for:10s}" + ) + public void run() { + try { + dispatchService.processDevicesBecameOnline(); + } catch (Exception e) { + log.error("Software bundle online-dispatch sweep failed", e); + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java new file mode 100644 index 0000000000..e07057b401 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java @@ -0,0 +1,31 @@ +package com.openframe.client.scheduler; + +import com.openframe.client.service.rmm.SoftwareScheduleOnlineDispatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareScheduleOnlineDispatchScheduler { + + private final SoftwareScheduleOnlineDispatchService dispatchService; + + @Scheduled(fixedDelayString = "${openframe.rmm.software.schedule.online-dispatch.interval:60000}") + @SchedulerLock(name = "softwareScheduleOnlineDispatch", + lockAtMostFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-most-for:2m}", + lockAtLeastFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-least-for:10s}" + ) + public void run() { + try { + dispatchService.processReconnectedDevices(); + } catch (Exception e) { + log.error("Software schedule reconnect sweep failed", e); + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java new file mode 100644 index 0000000000..3a319043d7 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java @@ -0,0 +1,108 @@ +package com.openframe.client.service.rmm; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatchService { + + private static final String FIELD_FIRST_SEEN_AT = "firstSeenAt"; + + private final SoftwareBundleOnlineDispatchRepository dispatchRepository; + private final MachineRepository machineRepository; + private final SoftwareBundleRepository bundleRepository; + private final SoftwareBundleOnlineDispatcher bundleDispatcher; + + @Value("${openframe.rmm.software.bundle.online-dispatch.batch-size:500}") + private int batchSize; + + public void processDevicesBecameOnline() { + List batch = dispatchRepository.findByStatus( + DeviceOnlineDispatchStatus.NEW, PageRequest.of(0, batchSize, Sort.by(Sort.Direction.ASC, FIELD_FIRST_SEEN_AT))); + if (batch.isEmpty()) { + return; + } + log.info("Software bundle online-dispatch tick: processing up to {} pending row(s) (oldest first)", batch.size()); + + Instant now = Instant.now(); + Map> rowsByTenant = + batch.stream().collect(groupingBy(SoftwareBundleOnlineDispatch::getTenantId)); + + List changed = new ArrayList<>(); + for (Map.Entry> e : rowsByTenant.entrySet()) { + changed.addAll(processTenant(e.getKey(), e.getValue(), now)); + } + + if (!changed.isEmpty()) { + dispatchRepository.saveAll(changed); + } + } + + private List processTenant(String tenantId, + List tenantRows, Instant now) { + Set machineIds = tenantRows.stream() + .map(SoftwareBundleOnlineDispatch::getMachineId).collect(toSet()); + Map machinesById = machineRepository + .findByTenantIdAndMachineIdIn(tenantId, machineIds).stream() + .collect(toMap(Machine::getMachineId, m -> m)); + + Set bundleIds = tenantRows.stream() + .map(SoftwareBundleOnlineDispatch::getBundleId).collect(toSet()); + Map bundlesById = bundleRepository + .findByTenantIdAndIdIn(tenantId, bundleIds).stream() + .collect(toMap(SoftwareBundle::getId, b -> b)); + + List changed = new ArrayList<>(tenantRows.size()); + for (SoftwareBundleOnlineDispatch row : tenantRows) { + try { + Machine machine = machinesById.get(row.getMachineId()); + if (machine == null || machine.getStatus() != DeviceStatus.ONLINE) { + continue; + } + + SoftwareBundle bundle = bundlesById.get(row.getBundleId()); + if (bundle != null) { + bundleDispatcher.dispatch(bundle, machine); + log.info("Software bundle online-dispatched: machineId={} bundleId={} tenantId={}", + row.getMachineId(), row.getBundleId(), tenantId); + } else { + log.warn("Software bundle online-dispatch: bundle missing bundleId={} machineId={} tenantId={} — draining", + row.getBundleId(), row.getMachineId(), tenantId); + } + row.setStatus(DeviceOnlineDispatchStatus.DISPATCHED); + row.setDispatchedAt(now); + changed.add(row); + } catch (Exception ex) { + log.error("Software bundle online-dispatch failed: tenantId={} machineId={} bundleId={} (will retry next tick)", + row.getTenantId(), row.getMachineId(), row.getBundleId(), ex); + } + } + return changed; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java new file mode 100644 index 0000000000..416c34d9cb --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java @@ -0,0 +1,106 @@ +package com.openframe.client.service.rmm; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.script.DeliveryChannel; +import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.document.rmm.script.RunningExecutionRows; +import com.openframe.data.document.rmm.script.Script; +import com.openframe.data.document.rmm.script.ScriptType; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareExecutionId; +import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.nats.rmm.model.ScriptMessage; +import com.openframe.data.nats.rmm.publisher.SoftwareNatsPublisher; +import com.openframe.data.repository.rmm.ScriptExecutionRepository; +import com.openframe.data.repository.rmm.ScriptRepository; +import com.openframe.data.service.rmm.software.PackageManagerHandler; +import com.openframe.data.service.rmm.software.PackageManagerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatcher { + + private final ScriptRepository scriptRepository; + private final PackageManagerRegistry packageManagerRegistry; + private final ScriptExecutionRepository scriptExecutionRepository; + private final SoftwareNatsPublisher softwareNatsPublisher; + private final ScriptDeliveryRetryStore retryStore; + + public void dispatch(SoftwareBundle bundle, Machine machine) { + if (bundle.getPackages() == null || bundle.getPackages().isEmpty()) { + return; + } + for (SoftwareBundlePackage pkg : bundle.getPackages()) { + dispatchPackage(bundle, pkg, machine); + } + } + + private void dispatchPackage(SoftwareBundle bundle, SoftwareBundlePackage pkg, Machine machine) { + String machineId = machine.getMachineId(); + PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager()); + SoftwareScriptCode code = handler.scriptCode(bundle.getAction()); + + Optional