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..1ddbaf032a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareActionId.java @@ -0,0 +1,43 @@ +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); + } + + /** Decodes a token; falls back to treating a non-token value as a bare executionId (backward-safe). */ + 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) { + // not a token — treat as a raw executionId + } + 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/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/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..d295a6ec96 --- /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 publishedAt; +} 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/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/VulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java new file mode 100644 index 0000000000..1f63e694e3 --- /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 publishedAt; + + 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/service/rmm/fleet/FleetClientProvider.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java new file mode 100644 index 0000000000..d63e757a62 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java @@ -0,0 +1,76 @@ +package com.openframe.api.service.rmm.fleet; + +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; +import com.openframe.data.document.tool.ToolUrlType; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.exception.FleetMdmException; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; + +import static org.springframework.util.CollectionUtils.isEmpty; +import static org.springframework.util.StringUtils.hasText; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class FleetClientProvider { + + private static final String PORT_SEPARATOR = ":"; + + private final IntegratedToolRepository integratedToolRepository; + private final TenantIdProvider tenantIdProvider; + + public T call(FleetSdkCall call, String action) { + try { + return call.execute(client()); + } catch (IOException e) { + throw new FleetMdmException("Failed to " + action, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FleetMdmException("Interrupted while " + action, e); + } + } + + @FunctionalInterface + public interface FleetSdkCall { + T execute(FleetMdmClient client) throws IOException, InterruptedException; + } + + private FleetMdmClient client() { + String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); + IntegratedTool tool = integratedToolRepository.findByKey(key) + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); + return new FleetMdmClient(resolveApiUrl(tool), resolveApiToken(tool), tenantIdProvider.getTenantId()); + } + + private static String resolveApiUrl(IntegratedTool tool) { + List urls = tool.getToolUrls(); + if (isEmpty(urls)) { + throw new IllegalStateException("Fleet MDM tool has no configured URLs"); + } + ToolUrl api = urls.stream() + .filter(u -> u.getType() == ToolUrlType.API) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool has no API URL")); + return hasText(api.getPort()) ? api.getUrl() + PORT_SEPARATOR + api.getPort() : api.getUrl(); + } + + private static String resolveApiToken(IntegratedTool tool) { + ToolCredentials credentials = tool.getCredentials(); + ToolApiKey apiKey = credentials == null ? null : credentials.getApiKey(); + if (apiKey == null || !hasText(apiKey.getKey())) { + throw new IllegalStateException("Fleet MDM tool has no API token configured"); + } + return apiKey.getKey(); + } +} 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/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java new file mode 100644 index 0000000000..a22e070ec1 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java @@ -0,0 +1,65 @@ +package com.openframe.api.service.rmm.software; + +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 java.util.Comparator; +import java.util.List; +import java.util.Objects; + +final class FleetSoftwareMapper { + + private FleetSoftwareMapper() { + } + + 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/service/rmm/software/FleetVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java new file mode 100644 index 0000000000..8690774d8b --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java @@ -0,0 +1,51 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; + +import java.time.Instant; +import java.time.format.DateTimeParseException; + +final class FleetVulnerabilityMapper { + + private FleetVulnerabilityMapper() { + } + + static SoftwareVulnerabilityResponse toResponse(String cveId, Vulnerability enrichment, String affectedVersion) { + if (cveId == null) { + return null; + } + Double cvss = enrichment == null ? null : enrichment.getCvssScore(); + String published = enrichment == null ? null : enrichment.getCvePublished(); + return SoftwareVulnerabilityResponse.builder() + .cveId(cveId) + .severity(bucketSeverity(cvss)) + .cvssScore(cvss) + .affectedVersion(affectedVersion) + .publishedAt(parseInstant(published)) + .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/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..1d333edaed --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareActionDetailService.java @@ -0,0 +1,198 @@ +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; + +/** + * The device drill-down for one Software Action: every target device with its result. Rows are layered so the + * most concrete state wins — the schedule's assigned machines seed a SCHEDULED baseline (a not-yet-fired + * schedule has no leaves/sentinels), reconnect sentinels overlay it (EXPIRED → FAILED), and execution leaves + * overlay everything with the real outcome and output for "Show Result". Each device is enriched with its + * hostname and customer (organization) and can be filtered by status / customer / search. + */ +@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-loads each device's hostname and customer (organization) 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..c8610bd784 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java @@ -0,0 +1,243 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +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.UpdateSoftwareBundleInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.core.exception.NotFoundException; +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.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.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import com.openframe.data.service.TenantIdProvider; +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.Optional; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class SoftwareBundleService { + + private final SoftwareBundleRepository bundleRepository; + private final SoftwareBundleOnlineDispatchRepository onlineDispatchRepository; + private final SoftwareScheduleService softwareScheduleService; + 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 SoftwareBundleResponse create(CreateSoftwareBundleInput input, String createdBy) { + String tenantId = tenantIdProvider.getTenantId(); + Instant now = Instant.now(); + SoftwareBundle entity = SoftwareBundle.builder() + .tenantId(tenantId) + .action(input.getAction()) + .mode(input.getMode()) + .status(SoftwareBundleStatus.PENDING) + .machineIds(List.copyOf(input.getMachineIds())) + .packages(toDomainPackages(input.getPackages())) + .startAt(input.getStartAt()) + .createdBy(createdBy) + .createdAt(now) + .updatedAt(now) + .expireAt(now.plus(pendingTtl)) + .build(); + SoftwareBundle saved = bundleRepository.save(entity); + log.info("Created software bundle id={} action={} devices={} packages={} tenantId={}", + saved.getId(), saved.getAction(), saved.getMachineIds().size(), + saved.getPackages().size(), tenantId); + return toResponse(saved); + } + + public SoftwareBundleResponse update(UpdateSoftwareBundleInput input, String actor) { + SoftwareBundle entity = loadPendingOrThrow(input.getId()); + entity.setMode(input.getMode()); + entity.setMachineIds(List.copyOf(input.getMachineIds())); + entity.setPackages(toDomainPackages(input.getPackages())); + entity.setStartAt(input.getStartAt()); + Instant now = Instant.now(); + entity.setUpdatedAt(now); + entity.setExpireAt(now.plus(pendingTtl)); + SoftwareBundle saved = bundleRepository.save(entity); + log.debug("Updated software bundle id={} devices={} packages={} actor={}", + saved.getId(), saved.getMachineIds().size(), saved.getPackages().size(), actor); + return toResponse(saved); + } + + public boolean delete(String id, String actor) { + SoftwareBundle entity = loadPendingOrThrow(id); + 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 List list(SoftwareBundleStatus status) { + String tenantId = tenantIdProvider.getTenantId(); + List bundles = status == null + ? bundleRepository.findByTenantIdOrderByIdDesc(tenantId) + : bundleRepository.findByTenantIdAndStatusOrderByIdDesc(tenantId, status); + return bundles.stream().map(SoftwareBundleService::toResponse).toList(); + } + + public SoftwareBundleResponse run(String id, String actor) { + SoftwareBundle entity = loadPendingOrThrow(id); + if (entity.getPackages() == null || entity.getPackages().isEmpty()) { + throw new BadRequestException("Cannot run software bundle " + id + ": no packages selected"); + } + if (entity.getMachineIds() == null || entity.getMachineIds().isEmpty()) { + throw new BadRequestException("Cannot run software bundle " + id + ": no devices selected"); + } + + Instant now = Instant.now(); + if (entity.getMode() == SoftwareBundleMode.SCHEDULED) { + runScheduled(entity, actor); + } else { + armOnlineDispatch(entity, now); + } + + entity.setStatus(SoftwareBundleStatus.COMPLETED); + entity.setCompletedAt(now); + entity.setUpdatedAt(now); + entity.setExpireAt(null); // completed bundles are history — never reaped + bundleRepository.save(entity); + + log.info("Ran software bundle id={} mode={} action={} devices={} actor={}", + id, entity.getMode(), entity.getAction(), entity.getMachineIds().size(), actor); + return toResponse(entity); + } + + private void runScheduled(SoftwareBundle bundle, String actor) { + if (bundle.getStartAt() == null) { + throw new BadRequestException("Cannot run SCHEDULED software bundle " + bundle.getId() + + ": startAt is required"); + } + CreateSoftwareScheduleInput input = new CreateSoftwareScheduleInput(); + input.setName(scheduleName(bundle)); + input.setAction(bundle.getAction()); + input.setPackages(toSchedulePackages(bundle.getPackages())); + input.setTimeReference(ScheduleTimeReference.SERVER); + input.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + input.setReconnectWindowSeconds(scheduleReconnectWindowSeconds); + input.setStartAt(bundle.getStartAt()); + input.setMachineIds(bundle.getMachineIds()); + + SoftwareScheduleResponse schedule = softwareScheduleService.create(input, actor); + bundle.setScheduleId(schedule.getId()); + log.info("SCHEDULED software bundle id={} → created schedule id={} startAt={} reconnectWindowSeconds={}", + bundle.getId(), schedule.getId(), bundle.getStartAt(), scheduleReconnectWindowSeconds); + } + + 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()) + .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()) + .packages(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..20324c91ae --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -0,0 +1,296 @@ +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.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.service.rmm.fleet.FleetClientProvider; +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.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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 final FleetClientProvider fleet; + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + public Optional findById(String softwareId) { + return parseNumericId(softwareId) + .map(id -> fleet.call(client -> client.getSoftwareTitle(id), "get Fleet software title id=" + id)) + .map(FleetSoftwareMapper::toResponse); + } + + public PageResult listSoftware(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.call( + client -> client.listSoftwareTitles(request), + "list Fleet software titles"); + List items = response.getSoftwareTitles() == null + ? List.of() + : response.getSoftwareTitles().stream() + .map(FleetSoftwareMapper::toResponse) + .filter(Objects::nonNull) + .toList(); + 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); + } + + 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.call( + client -> client.getSoftwareTitle(parsed.get()), + "get Fleet software title id=" + 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); + } + + /** + * Devices that have a given software title installed — backs the software detail "Devices" tab. + * Fleet exposes the installed version only per software version, so we fan out one host + * query per version of the title, tag each returned host with that version, correlate the Fleet + * host to an OpenFrame {@link Machine}, and derive the status from the version vs the title's latest. + * Hosts not enrolled in OpenFrame (no matching Machine) are dropped. Paginated in memory. + */ + 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.call( + client -> client.getSoftwareTitle(titleId.get()), + "get Fleet software title id=" + 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.call( + client -> client.searchHosts(request), + "list Fleet hosts for software_version_id=" + version.getId()); + 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) { + } + + /** Per-title scan cap when computing filter facet counts (one Fleet page). */ + private static final int FILTERS_SCAN_LIMIT = 1000; + + /** + * Faceted filter-option counts for the software list — Source / Version-status / Severity dropdowns. + * Fleet has no facet endpoint, so we scan the (searched) software titles and tally each dimension. + * Bounded to {@link #FILTERS_SCAN_LIMIT} titles. + */ + public SoftwareFilters getSoftwareFilters(String search) { + List titles = listSoftware(search, 0, FILTERS_SCAN_LIMIT, null, null, null).items(); + 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.call( + client -> client.getVulnerability(cve), + "get Fleet vulnerability " + 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 "publishedAt", "published" -> Comparator.comparing( + SoftwareVulnerabilityResponse::getPublishedAt, + 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..6bc7f28007 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; @@ -152,22 +154,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 +189,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) { @@ -244,7 +269,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..6145458cff --- /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()) + .publishedAt(parseInstant(v.getCvePublished())) + .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..8f33236a13 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java @@ -0,0 +1,131 @@ +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.FleetClientProvider; +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.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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +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 final FleetClientProvider fleet; + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + 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.call( + client -> client.searchHosts(request), + "list Fleet hosts for vulnerability=" + cveId); + 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.call( + client -> client.getVulnerability(cveId), + "get Fleet vulnerability " + cveId); + return Optional.ofNullable(FleetGlobalVulnerabilityMapper.toDetail(detail)); + } + + public PageResult listVulnerabilities( + String search, int page, Integer perPage, + String orderKey, String orderDirection, + Boolean exploit, SoftwareCveSeverity minSeverity) { + VulnerabilityRequest request = VulnerabilityRequest.builder() + .page(page).perPage(perPage).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .exploit(exploit) + .build(); + VulnerabilitiesResponse response = fleet.call( + client -> client.listVulnerabilities(request), + "list Fleet vulnerabilities"); + List items = response.getVulnerabilities() == null + ? List.of() + : response.getVulnerabilities().stream() + .map(FleetGlobalVulnerabilityMapper::toListRow) + .filter(Objects::nonNull) + .filter(row -> passesMinSeverity(row, minSeverity)) + .toList(); + 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); + } + + 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/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/software/SoftwareActionDetailServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java new file mode 100644 index 0000000000..bdadd9b7a2 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareActionDetailServiceTest.java @@ -0,0 +1,176 @@ +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), // waiting → SCHEDULED + bundleSentinel("m-skipped", DeviceOnlineDispatchStatus.DISPATCHED)));// dispatched, no leaf → hidden + + 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"))); + + // No filter → all three, enriched with hostname + customer name. + 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"); + + // Status filter. + List completed = service.devices(EXEC, null, null, filter(List.of(SoftwareActionStatus.COMPLETED), null), null); + assertThat(completed).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "beta"); + + // Customer filter. + List org1 = service.devices(EXEC, null, null, filter(null, List.of("org-1")), null); + assertThat(org1).extracting(SoftwareActionDeviceResponse::getMachineId).containsExactlyInAnyOrder("alpha", "gamma"); + + // Search by hostname. + 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..b06e924c82 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java @@ -0,0 +1,300 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; +import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; +import com.openframe.core.exception.BadRequestException; +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.software.SoftwareAction; +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.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +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.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 TenantIdProvider tenantIdProvider; + + private SoftwareBundleService service; + + @BeforeEach + void setUp() { + service = new SoftwareBundleService(bundleRepository, onlineDispatchRepository, softwareScheduleService, tenantIdProvider); + ReflectionTestUtils.setField(service, "pendingTtl", Duration.ofHours(1)); + ReflectionTestUtils.setField(service, "scheduleReconnectWindowSeconds", RECONNECT_WINDOW); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + @Test + @DisplayName("create: born PENDING with a TTL anchor, mode/devices/packages persisted") + void create_bornPending() { + when(bundleRepository.save(any())).thenAnswer(inv -> withId(inv.getArgument(0))); + + SoftwareBundleResponse res = service.create(createInput(), USER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(captor.capture()); + SoftwareBundle saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.NOW); + assertThat(saved.getMachineIds()).containsExactly("m1"); + assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("slack"); + assertThat(saved.getExpireAt()).isNotNull(); + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + } + + @Test + @DisplayName("run NOW: arms one NEW device-online sentinel per assigned device and marks the bundle COMPLETED") + void runNow_armsSentinelsAndCompletes() { + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1", "m2"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(onlineDispatchRepository.findByTenantIdAndMachineIdAndBundleId(eq(TENANT), anyString(), eq(BUNDLE_ID))) + .thenReturn(Optional.empty()); + + SoftwareBundleResponse res = service.run(BUNDLE_ID, USER); + + ArgumentCaptor sentinels = ArgumentCaptor.forClass(SoftwareBundleOnlineDispatch.class); + verify(onlineDispatchRepository, times(2)).save(sentinels.capture()); + assertThat(sentinels.getAllValues()).extracting(SoftwareBundleOnlineDispatch::getMachineId) + .containsExactlyInAnyOrder("m1", "m2"); + assertThat(sentinels.getAllValues()).allSatisfy(s -> { + assertThat(s.getBundleId()).isEqualTo(BUNDLE_ID); + assertThat(s.getStatus()).isEqualTo(DeviceOnlineDispatchStatus.NEW); + }); + verifyNoInteractions(softwareScheduleService); + + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getValue().getExpireAt()).isNull(); + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + } + + @Test + @DisplayName("run SCHEDULED: creates a DATE_TIME SoftwareSchedule (SERVER, RETRY_ON_RECONNECT) and links it, no sentinels armed") + void runScheduled_createsScheduleWithRetry() { + Instant startAt = Instant.parse("2026-09-20T02:00:00Z"); + SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, startAt, List.of("m1", "m2"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(softwareScheduleService.create(any(), eq(USER))) + .thenReturn(SoftwareScheduleResponse.builder().id("sched-1").build()); + + SoftwareBundleResponse res = service.run(BUNDLE_ID, USER); + + ArgumentCaptor input = ArgumentCaptor.forClass(CreateSoftwareScheduleInput.class); + verify(softwareScheduleService).create(input.capture(), eq(USER)); + CreateSoftwareScheduleInput created = input.getValue(); + assertThat(created.getAction()).isEqualTo(SoftwareAction.INSTALL); + assertThat(created.getTimeReference()).isEqualTo(ScheduleTimeReference.SERVER); + assertThat(created.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(created.getReconnectWindowSeconds()).isEqualTo(RECONNECT_WINDOW); + assertThat(created.getStartAt()).isEqualTo(startAt); + assertThat(created.getRepeat()).isNull(); + assertThat(created.getMachineIds()).containsExactly("m1", "m2"); + assertThat(created.getPackages()).extracting("packageName").containsExactly("slack"); + assertThat(created.getName()).contains(BUNDLE_ID); // unique per tenant + + verifyNoInteractions(onlineDispatchRepository); + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getValue().getScheduleId()).isEqualTo("sched-1"); + assertThat(res.getScheduleId()).isEqualTo("sched-1"); + } + + @Test + @DisplayName("run SCHEDULED without startAt is rejected before creating a schedule") + void runScheduled_noStartAt_rejected() { + SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(softwareScheduleService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("run: an already-COMPLETED bundle cannot be re-run") + void run_completed_rejected() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("run: a bundle with no packages is rejected before arming") + void run_noPackages_rejected() { + SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + empty.setPackages(List.of()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("run: a bundle with no devices is rejected before arming") + void run_noDevices_rejected() { + SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of(), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("update: a PENDING bundle's mode/devices/packages/startAt are replaced and its TTL anchor refreshed") + void update_pending_replacesAndRefreshes() { + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(bundleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Instant startAt = Instant.parse("2026-09-20T02:00:00Z"); + UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); + input.setId(BUNDLE_ID); + input.setMode(SoftwareBundleMode.SCHEDULED); + input.setMachineIds(List.of("m1", "m2")); + input.setStartAt(startAt); + SoftwarePackageInput pkg = new SoftwarePackageInput(); + pkg.setPackageManager(PackageManagerType.BREW); + pkg.setPackageName("chrome"); + input.setPackages(List.of(pkg)); + + service.update(input, USER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(captor.capture()); + SoftwareBundle saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.SCHEDULED); + assertThat(saved.getMachineIds()).containsExactly("m1", "m2"); + assertThat(saved.getStartAt()).isEqualTo(startAt); + assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("chrome"); + assertThat(saved.getExpireAt()).isNotNull(); + } + + @Test + @DisplayName("update: a COMPLETED bundle cannot be edited") + void update_completed_rejected() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); + input.setId(BUNDLE_ID); + input.setMode(SoftwareBundleMode.NOW); + input.setMachineIds(List.of("m1")); + + assertThatThrownBy(() -> service.update(input, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).save(any()); + } + + @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 is protected (history is immutable)") + void delete_completed_rejected() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + assertThatThrownBy(() -> service.delete(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).delete(any()); + } + + private static CreateSoftwareBundleInput createInput() { + CreateSoftwareBundleInput input = new CreateSoftwareBundleInput(); + input.setAction(SoftwareAction.INSTALL); + input.setMode(SoftwareBundleMode.NOW); + input.setMachineIds(List.of("m1")); + SoftwarePackageInput pkg = new SoftwarePackageInput(); + pkg.setPackageManager(PackageManagerType.BREW); + pkg.setPackageName("slack"); + input.setPackages(List.of(pkg)); + return input; + } + + 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(); + } + + private static SoftwareBundle withId(SoftwareBundle b) { + if (b.getId() == null) { + b.setId(BUNDLE_ID); + } + return b; + } +} 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..361b0e2959 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java @@ -0,0 +1,104 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetClientProvider; +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.model.Host; +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 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 FleetClientProvider fleet; + @Mock private FleetHostMachineResolver hostMachineResolver; + @Mock private TenantIdProvider tenantIdProvider; + + private SoftwareInventoryService service; + + @BeforeEach + void setUp() { + service = new SoftwareInventoryService(fleet, hostMachineResolver, tenantIdProvider); + } + + @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 + + // call 1 = getSoftwareTitle, call 2 = searchHosts(software_version_id=10) + when(fleet.call(any(), any())).thenReturn(title, 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.call(any(), any())).thenReturn(response); + + SoftwareFilters filters = service.getSoftwareFilters(null); + + assertThat(filters.getSources()).isEmpty(); + assertThat(filters.getVersionStatuses()).isEmpty(); + assertThat(filters.getSeverities()).isEmpty(); + } + + 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..7a52288961 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java @@ -0,0 +1,185 @@ +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() { + // setup + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + v.setCvssScore(9.8); + v.setEpssProbability(0.42); + v.setCisaKnownExploit(Boolean.TRUE); + v.setCvePublished("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.getPublishedAt()); + 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_unparseablePublishedAt_leavesInstantNullInsteadOfCrashing() { + // setup — Fleet occasionally returns partial or malformed dates + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-00000"); + v.setCvePublished("not-a-date"); + + // execution + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + // verifications — parseInstant swallows the DateTimeParseException, leaves publishedAt null + assertNull(row.getPublishedAt()); + } + + + @Test + void toDetail_populatesDetailFieldsAndAffectedSoftware() { + // setup + 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)); + + // execution + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + // verifications + 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() { + // setup — 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)); + + // execution + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + // verifications + 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..40df69fef7 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java @@ -0,0 +1,228 @@ +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.FleetClientProvider; +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.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VulnerabilityInventoryServiceTest { + + @Mock private FleetClientProvider fleet; + @Mock private FleetMdmClient fleetClient; + @Mock private com.openframe.api.service.rmm.fleet.FleetHostMachineResolver hostMachineResolver; + @Mock private com.openframe.data.service.TenantIdProvider tenantIdProvider; + + @Captor private ArgumentCaptor> callCaptor; + + private VulnerabilityInventoryService service; + + @BeforeEach + void setUp() { + service = new VulnerabilityInventoryService(fleet, hostMachineResolver, tenantIdProvider); + } + + @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.call(any(), any())).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() { + // execution + verifications + assertTrue(service.findByCveId("").isEmpty()); + assertTrue(service.findByCveId(null).isEmpty()); + // no interaction with fleet — the guard fires before we dispatch + } + + @Test + void findByCveId_fleetReturnsNull_returnsEmpty() { + // setup — 404 from Fleet detail: the SDK returns null and we propagate as empty + when(fleet.call(any(), any())).thenReturn(null); + + // execution + Optional found = service.findByCveId("CVE-2024-00000"); + + // verifications + assertTrue(found.isEmpty()); + } + + @Test + void findByCveId_fleetReturnsDetail_mapsToDetailFlow() throws Exception { + // setup — verify we hit the detail (getVulnerability) not the list endpoint + Vulnerability detail = new Vulnerability(); + detail.setCve("CVE-2024-38063"); + detail.setCveDescription("Windows TCP/IP RCE"); + detail.setCvssScore(9.2); + when(fleet.call(any(), any())).thenAnswer(inv -> { + FleetClientProvider.FleetSdkCall call = inv.getArgument(0); + when(fleetClient.getVulnerability("CVE-2024-38063")).thenReturn(detail); + return call.execute(fleetClient); + }); + + // execution + Optional found = service.findByCveId("CVE-2024-38063"); + + // verifications + 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_buildsRequestFromFilters_wiresPagingFromMeta() throws Exception { + // setup + 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(fleetClient.listVulnerabilities(reqCaptor.capture())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + "chrome", 0, 20, "cvss_score", "desc", Boolean.TRUE, null); + + // verifications + 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()); + assertEquals(Boolean.TRUE, req.getExploit()); + + assertEquals(2, result.items().size()); + assertTrue(result.hasNext()); + assertFalse(result.hasPrevious()); + assertEquals(37, result.filteredCount()); + assertEquals(0, result.page()); + } + + @Test + void listVulnerabilities_minSeverityHigh_filtersRowsBelowFloor() throws Exception { + // setup — 4 CVEs, 3 below HIGH + 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(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.HIGH); + + // verifications + 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() throws Exception { + // setup + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-LOW", 1.2))); + response.setCount(1L); + when(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.NONE); + + // verifications — NONE floor keeps the row (defensive against strict-mode UI) + assertEquals(1, result.items().size()); + } + + @Test + void listVulnerabilities_nullVulnerabilitiesArray_emitsEmptyPageNotNpe() throws Exception { + // setup — Fleet returns {"vulnerabilities": null, "count": 0} on some pages + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(null); + response.setCount(0L); + when(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 5, 20, null, null, null, null); + + // verifications + assertTrue(result.items().isEmpty()); + assertFalse(result.hasNext()); + assertEquals(5, result.page()); // page-index preserved on empty responses + assertEquals(0, result.filteredCount()); + } + + private static Vulnerability cve(String id, Double cvss) { + Vulnerability v = new Vulnerability(); + v.setCve(id); + v.setCvssScore(cvss); + return v; + } + + private void stubCallThatUnwrapsSdkCallOn(FleetMdmClient client) { + when(fleet.call(any(), any())).thenAnswer(inv -> { + FleetClientProvider.FleetSdkCall call = inv.getArgument(0); + return call.execute(client); + }); + } +} 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/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..b21c8bd076 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java @@ -0,0 +1,67 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; +import com.openframe.api.service.rmm.software.SoftwareBundleService; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.security.authentication.AuthPrincipal; +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.List; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +@Validated +public class SoftwareBundleDataFetcher { + + private final SoftwareBundleService softwareBundleService; + + @DgsQuery + public SoftwareBundleResponse softwareBundle(@InputArgument String id) { + return softwareBundleService.findById(id).orElse(null); + } + + @DgsQuery + public List softwareBundles(@InputArgument SoftwareBundleStatus status) { + return softwareBundleService.list(status); + } + + @DgsMutation + public SoftwareBundleResponse createSoftwareBundle(@InputArgument @Valid CreateSoftwareBundleInput input) { + return softwareBundleService.create(input, getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse updateSoftwareBundle(@InputArgument @Valid UpdateSoftwareBundleInput input) { + return softwareBundleService.update(input, getCurrentUserId()); + } + + @DgsMutation + public boolean deleteSoftwareBundle(@InputArgument String id) { + return softwareBundleService.delete(id, getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse runSoftwareBundle(@InputArgument String id) { + return softwareBundleService.run(id, getCurrentUserId()); + } + + 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..121ce4afd4 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -0,0 +1,90 @@ +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; + +import java.util.Locale; +import java.util.Map; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareDataFetcher { + + private static final Map FLEET_ORDER_KEY = Map.of( + "name", "name", + "devicesCount", "hosts_count", + "hosts_count", "hosts_count"); + + 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; + 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 vulnerable = filter != null && filter.getMinSeverity() != null + && filter.getMinSeverity() != SoftwareCveSeverity.NONE + ? Boolean.TRUE : null; + return PageCursors.toConnection(softwareInventoryService.listSoftware( + search, page, perPage, orderKey, orderDirection, 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..13c2e9e69d --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java @@ -0,0 +1,71 @@ +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.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", + "publishedAt", "cve_published", + "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 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/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..baa92008e0 --- /dev/null +++ b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls @@ -0,0 +1,97 @@ +# Software bundle — the staging layer for immediate Install / Update Software. +# +# The frontend creates a bundle the moment the first device is assigned on the Install/Update screen, then +# edits it (devices/packages) as the user builds the request. Running the bundle dispatches every package to +# the matching-OS subset of its devices (brew → macOS, winget/choco → Windows) and flips it to COMPLETED. +# A PENDING bundle left unrun 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, SoftwareDispatchResult, PackageManagerType and BrewPackageType +# from software.graphqls. + +extend type Query { + """One bundle by id. Null when it does not exist (or was already reaped).""" + softwareBundle(id: ID!): SoftwareBundle + + """Bundles for the tenant, newest first. Optionally filtered by status.""" + softwareBundles(status: SoftwareBundleStatus): [SoftwareBundle!]! +} + +extend type Mutation { + """Create a staged bundle (status PENDING). Called when the first device is assigned; packages may be + empty at this point and added later via updateSoftwareBundle.""" + createSoftwareBundle(input: CreateSoftwareBundleInput!): SoftwareBundle! + + """Replace a PENDING bundle's devices and/or packages. Fails if the bundle is already COMPLETED.""" + updateSoftwareBundle(input: UpdateSoftwareBundleInput!): SoftwareBundle! + + """Discard a PENDING bundle. Fails if the bundle is already COMPLETED (history is immutable).""" + deleteSoftwareBundle(id: ID!): Boolean! + + """Run a PENDING bundle now (Device-Online): arm each assigned device so its OS-compatible packages run as + soon as it is online — immediately for online devices, on reconnect for offline ones — and mark the bundle + COMPLETED. Returns the COMPLETED bundle. Fails if already COMPLETED.""" + runSoftwareBundle(id: ID!): SoftwareBundle! +} + +"""A staged install/update operation: devices + packages, plus lifecycle state.""" +type SoftwareBundle { + id: ID! + "INSTALL or UPDATE — fixed at creation." + action: SoftwareAction! + "NOW (Device-Online) or SCHEDULED (fires a DATE_TIME schedule at startAt)." + mode: SoftwareBundleMode! + "PENDING while drafting, COMPLETED once run. Server-controlled; never settable." + status: SoftwareBundleStatus! + "Assigned device machineIds (1..n)." + machineIds: [String!]! + "Packages to act on. May be empty while PENDING." + packages: [SoftwareBundlePackage!]! + "For SCHEDULED bundles: when the run fires (absolute instant, SERVER time). Null for NOW." + startAt: Instant + "For SCHEDULED bundles: the schedule created on run. Null for NOW / while PENDING." + scheduleId: String + createdBy: String + createdAt: Instant + updatedAt: Instant + "When the bundle was run. Null while PENDING." + completedAt: Instant + "Execution ids from the run (one per dispatched package). Null/empty while PENDING." + executionIds: [String!] +} + +enum SoftwareBundleMode { + NOW + SCHEDULED +} + +"""One catalog package staged in a bundle.""" +type SoftwareBundlePackage { + packageManager: PackageManagerType! + packageName: String! + brewPackageType: BrewPackageType +} + +enum SoftwareBundleStatus { + PENDING + COMPLETED +} + +"""Create a bundle. machineIds must have at least one device; packages may be omitted while drafting. +startAt is required for SCHEDULED bundles (enforced at run) and ignored for NOW.""" +input CreateSoftwareBundleInput { + action: SoftwareAction! + mode: SoftwareBundleMode! + machineIds: [String!]! + packages: [SoftwarePackageInput!] + startAt: Instant +} + +"""Edit a PENDING bundle. action and status are immutable and cannot be set.""" +input UpdateSoftwareBundleInput { + id: ID! + mode: SoftwareBundleMode! + machineIds: [String!]! + packages: [SoftwarePackageInput!] + startAt: Instant +} 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..5c7b772598 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,304 @@ +# 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).""" + 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! + + """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 + publishedAt: 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 + + """CVE publication timestamp (ISO-8601).""" + publishedAt: 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 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 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..f113171b08 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), @@ -87,6 +107,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 +123,65 @@ 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); + + // brew dispatched to the macOS device; winget skipped entirely (no Windows device). + 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 +198,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