diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java new file mode 100644 index 000000000..5433c34ca --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java @@ -0,0 +1,182 @@ +package com.openframe.api.service.device; + +import com.openframe.api.exception.DeviceNotFoundException; +import com.openframe.data.document.tag.Tag; +import com.openframe.data.document.tag.TagAssignment; +import com.openframe.data.document.tag.TagValidation; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; +import com.openframe.data.repository.tag.TagRepository; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; + +import static com.openframe.data.document.tag.TagEntityType.DEVICE; + +/** + * Attaches and detaches DEVICE tags at runtime — the counterpart to + * {@code RegistrationTagAssignmentService}, which does the same thing from the agent-registration + * payload. Both write only Mongo ({@code tags} + {@code tag_assignments}); the Pinot facet columns + * are refreshed by {@code MachineTagEventAspect}, which intercepts the repository calls made here + * and republishes the machine's full tag list to Kafka. + * + *

Every write therefore has to go through an intercepted repository method — {@code save} or + * {@code deleteByEntityIdAndTagIdAndEntityType}. Bypassing them (e.g. {@code deleteAll}) would + * leave the device's Pinot row carrying tags it no longer has. + * + *

Not reused from the client-core service because {@code openframe-client-core} does not depend + * on {@code openframe-api-lib}, and pulling the logic down into a shared module would drag the + * machine/tag repositories along with it. + */ +@Service +@Slf4j +@Validated +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DeviceTagService { + + private final TagRepository tagRepository; + private final TagAssignmentRepository tagAssignmentRepository; + private final MachineRepository machineRepository; + + /** + * Tags a device with {@code key}, creating the tag key on first use. + * + *

Additive in both directions: new {@code values} are merged into the device's existing + * assignment rather than replacing it, and into the tag's list of predefined options. Calling + * it twice with the same key and values is a no-op beyond the Pinot republish. + * + * @return the tag, carrying this device's values (not the tag's full option list) + */ + @Transactional + public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List values) { + TagValidation.validateKey(key); + TagValidation.validateValues(values, key); + requireMachine(machineId); + + Tag tag = findOrCreateTag(key, values); + List assignedValues = upsertAssignment(machineId, tag.getId(), values); + + log.info("Assigned tag '{}' to machine {} with values {}", key, machineId, assignedValues); + return Tag.builder() + .id(tag.getId()) + .key(tag.getKey()) + .description(tag.getDescription()) + .color(tag.getColor()) + .values(assignedValues) + .entityType(tag.getEntityType()) + .createdAt(tag.getCreatedAt()) + .build(); + } + + /** + * Detaches a tag from a device. The tag key itself survives — it stays available for other + * devices and in the filter dropdowns; use {@code TagService.deleteTag} to drop the key + * everywhere. + * + * @return {@code true} if the device had the tag, {@code false} if there was nothing to remove + */ + @Transactional + public boolean removeTag(@NotBlank String machineId, @NotBlank String tagId) { + requireMachine(machineId); + + boolean assigned = tagAssignmentRepository + .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE) + .isPresent(); + if (!assigned) { + log.info("Tag {} is not assigned to machine {}, nothing to remove", tagId, machineId); + return false; + } + + // Aspect-intercepted: publishes the machine's remaining tags before the delete proceeds. + tagAssignmentRepository.deleteByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); + log.info("Removed tag {} from machine {}", tagId, machineId); + return true; + } + + private void requireMachine(String machineId) { + if (machineRepository.findByMachineId(machineId).isEmpty()) { + throw new DeviceNotFoundException("Device not found: " + machineId); + } + } + + /** + * Finds the DEVICE tag for {@code key}, or creates it. On an existing tag any previously unseen + * values are appended to its predefined options, so a value typed on one device becomes a + * suggestion for the next. + */ + private Tag findOrCreateTag(String key, List values) { + Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE); + if (existing == null) { + Tag created = tagRepository.save(Tag.builder() + .key(key) + .values(normalize(values)) + .entityType(DEVICE) + .createdAt(Instant.now()) + .build()); + log.info("Created DEVICE tag '{}' (id={})", key, created.getId()); + return created; + } + + List merged = merge(existing.getValues(), values); + if (merged.size() != size(existing.getValues())) { + existing.setValues(merged); + // Aspect-intercepted: refreshes every device already carrying this tag. + existing = tagRepository.save(existing); + log.info("Appended values {} to existing tag '{}'", values, key); + } + return existing; + } + + /** + * Merges {@code values} into the device's assignment, creating it if the device does not carry + * the tag yet. Saving through the repository is what triggers the Pinot republish. + */ + private List upsertAssignment(String machineId, String tagId, List values) { + Optional existing = tagAssignmentRepository + .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); + + if (existing.isEmpty()) { + TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder() + .entityId(machineId) + .tagId(tagId) + .entityType(DEVICE) + .values(normalize(values)) + .taggedAt(Instant.now()) + .build()); + return saved.getValues(); + } + + TagAssignment assignment = existing.get(); + List merged = merge(assignment.getValues(), values); + if (merged.size() != size(assignment.getValues())) { + assignment.setValues(merged); + return tagAssignmentRepository.save(assignment).getValues(); + } + return assignment.getValues(); + } + + /** Insertion-ordered union — existing values keep their order, new ones are appended. */ + private static List merge(List current, List added) { + LinkedHashSet merged = new LinkedHashSet<>(normalize(current)); + merged.addAll(normalize(added)); + return new ArrayList<>(merged); + } + + private static List normalize(List values) { + return values != null ? values : List.of(); + } + + private static int size(List values) { + return values != null ? values.size() : 0; + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java index 2b5f08a93..84d16645c 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java @@ -19,6 +19,7 @@ import com.openframe.api.mapper.GraphQLDeviceMapper; import com.openframe.api.service.device.DeviceFilterService; import com.openframe.api.service.device.DeviceService; +import com.openframe.api.service.device.DeviceTagService; import com.openframe.api.service.FleetVulnerabilityStatusService; import com.openframe.api.service.TagService; import com.openframe.data.document.device.Machine; @@ -53,6 +54,7 @@ public class DeviceDataFetcher { private final DeviceService deviceService; private final DeviceFilterService deviceFilterService; + private final DeviceTagService deviceTagService; private final TagService tagService; private final FleetVulnerabilityStatusService fleetVulnerabilityStatusService; private final GraphQLDeviceMapper mapper; @@ -131,6 +133,22 @@ public Machine updateDeviceNickname(@InputArgument @NotBlank String machineId, return deviceService.updateNickname(machineId, nickname); } + @DgsMutation + public Tag assignDeviceTag(@InputArgument @NotBlank String machineId, + @InputArgument @NotBlank String key, + @InputArgument List values) { + log.debug("Assigning tag '{}' to machineId: {}", key, machineId); + return deviceTagService.assignTag(machineId, key, values); + } + + @DgsMutation + public boolean removeDeviceTag(@InputArgument @NotBlank String machineId, + @InputArgument @NotBlank String tagId) { + String rawTagId = RELAY.fromGlobalId(tagId).getId(); + log.debug("Removing tag {} (rawId: {}) from machineId: {}", tagId, rawTagId, machineId); + return deviceTagService.removeTag(machineId, rawTagId); + } + @DgsData(parentType = "Machine", field = "id") public String machineNodeId(DgsDataFetchingEnvironment dfe) { Machine machine = dfe.getSource(); diff --git a/openframe-api-service-core/src/main/resources/schema/device.graphqls b/openframe-api-service-core/src/main/resources/schema/device.graphqls index b02e91312..caaa4b64e 100644 --- a/openframe-api-service-core/src/main/resources/schema/device.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/device.graphqls @@ -146,4 +146,13 @@ type InstalledAgent implements Node { extend type Mutation { updateDeviceNickname(machineId: String!, nickname: String): Machine! + + # Tag a device, creating the tag key on first use. Values are merged into whatever the + # device already carries for that key, so this only ever adds. Returns the tag with + # THIS device's values. + assignDeviceTag(machineId: String!, key: String!, values: [String!]): Tag! + + # Detach a tag from a device. The tag key itself is kept (use deleteTag to drop it + # everywhere). False when the device did not carry the tag. + removeDeviceTag(machineId: String!, tagId: ID!): Boolean! }