From d9f147a6ebbc0ab4fdb1475ee8d7d68c84c54039 Mon Sep 17 00:00:00 2001 From: aliaska-varieva Date: Thu, 10 Sep 2026 01:50:47 +0200 Subject: [PATCH] feat(devices): add and remove tags on a device at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device tags could only be attached during agent registration, and could never be detached: the repository method and its Pinot-resync aspect existed, but only the ticket and knowledge-base services called them. DeviceTagService mirrors RegistrationTagAssignmentService so a tag applied from the UI behaves exactly like one sent in the registration payload — the tag key is created on first use and new values are merged into both the device's assignment and the key's predefined options, never replacing them. Removal drops the assignment and keeps the key, which stays available to other devices and in the filter dropdowns. Both writes go through repository methods that MachineTagEventAspect intercepts, so the Pinot tags/tagKeyValues columns backing the device filter facets stay in sync. That constraint is easy to break from a bulk variant later, so it is spelled out on the service. removeDeviceTag decodes the Relay global id the way updateTag and deleteTag do, since that is the form the clients hold. CU-86ak6uwjn Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Fv6BHWQQPGjLheZcX38J9 --- .../api/service/device/DeviceTagService.java | 182 ++++++++++++++++++ .../api/datafetcher/DeviceDataFetcher.java | 18 ++ .../src/main/resources/schema/device.graphqls | 9 + 3 files changed, 209 insertions(+) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java 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! }