Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package com.openframe.api.service.device;

import com.openframe.api.exception.DeviceNotFoundException;
import com.openframe.core.exception.ConflictException;
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.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
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.
*
* <p>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.
*
* <p>Deliberately not {@code @Transactional}: no Mongo transaction manager is configured, so the
* annotation would promise atomicity it cannot give. The tag key is written before the assignment,
* so a failure in between leaves at worst an unused key.
*
* <p>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
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.
*
* <p>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)
*/
public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List<String> values) {
return writeTag(machineId, key, values, false);
}

/**
* Sets the device's values for {@code key} to exactly {@code values}, creating the tag key and
* the assignment on first use. This is how a single value is taken off a device: send the list
* without it. {@code null} or empty leaves the device carrying the key as a plain label; use
* {@link #removeTag} to detach the key altogether.
*
* <p>Only the device's assignment is replaced. The tag's predefined options stay additive — a
* value dropped here may still be in use on other devices.
*
* @return the tag, carrying this device's values (not the tag's full option list)
*/
public Tag setTagValues(@NotBlank String machineId, @NotBlank String key, List<String> values) {
return writeTag(machineId, key, values, true);
}

private Tag writeTag(String machineId, String key, List<String> values, boolean replace) {
TagValidation.validateKey(key);
TagValidation.validateValues(values, key);
requireMachine(machineId);

Tag tag = findOrCreateTag(key, values);
List<String> assignedValues = upsertAssignment(machineId, tag.getId(), values, replace);

log.info("{} tag '{}' on machine {}, values now {}", replace ? "Set" : "Assigned", 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
*/
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.
*
* <p>Keys are unique ignoring case, same as {@code TagService.createTag}: the Mongo index is
* case-sensitive, so without the check "Site" would quietly become a second key next to "site".
*/
private Tag findOrCreateTag(String key, List<String> values) {
Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
if (existing == null) {
if (tagRepository.existsByKeyIgnoreCaseAndEntityType(key, DEVICE)) {
throw new ConflictException("Tag with key '" + key + "' already exists");
}
try {
Tag created = tagRepository.save(Tag.builder()
.key(key)
.values(merge(null, values))
.entityType(DEVICE)
.createdAt(Instant.now())
.build());
log.info("Created DEVICE tag '{}' (id={})", key, created.getId());
return created;
} catch (DuplicateKeyException e) {
// Lost a first-use race to another request: the unique (tenant, key, entityType)
// index kept the key single, so carry on with the winner's tag.
existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
if (existing == null) {
throw e;
}
}
}
Comment on lines +141 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🟠 [warn/recommended] findOrCreateTag / upsertAssignment read-then-write pattern is not concurrency-safe against parallel assignTag calls for the same key

findOrCreateTag() does a findByKeyAndEntityType then conditionally save() without any uniqueness constraint enforcement or optimistic locking visible in this diff. Two concurrent assignDeviceTag calls for a brand-new key (e.g., two devices tagging the same new key simultaneously) can both see existing == null and each create a separate Tag document for the same key, resulting in duplicate tag keys in Mongo. Given this is a new device-facing mutation exposed to potentially concurrent client callers, a unique index on (key, entityType) with duplicate-key handling (as required elsewhere in the org's OPENFRAM-006-11 idiom for idempotent inserts) should be applied here too.

Evidence
    private Tag findOrCreateTag(String key, List<String> 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;
        }
🤖 Prompt for AI agents
In openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java around lines 117-128, address this code-review finding: findOrCreateTag / upsertAssignment read-then-write pattern is not concurrency-safe against parallel assignTag calls for the same key.
findOrCreateTag() does a findByKeyAndEntityType then conditionally save() without any uniqueness constraint enforcement or optimistic locking visible in this diff. Two concurrent assignDeviceTag calls for a brand-new key (e.g., two devices tagging the same new key simultaneously) can both see `existing == null` and each create a separate Tag document for the same key, resulting in duplicate tag keys in Mongo. Given this is a new device-facing mutation exposed to potentially concurrent client callers, a unique index on (key, entityType) with duplicate-key handling (as required elsewhere in the org's OPENFRAM-006-11 idiom for idempotent inserts) should be applied here too.
The flagged code:
```
    private Tag findOrCreateTag(String key, List<String> 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;
        }
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 35 — react 👍/👎 to teach the reviewer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half right. Duplicate keys can't happen: Tag has a unique (tenantId, key, entityType) index and TagAssignment a unique (tenantId, entityId, tagId, entityType) one, and auto-index-creation is on in the base configs. What did happen is that the request losing the race got a DuplicateKeyException surfaced as a database error. Fixed in ada5ab6: both creates now catch it, re-read the winner's document and continue through the normal merge path. Covered by three new cases in DeviceTagServiceTest.

Comment on lines +141 to +164

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🟠 [warn/recommended] findOrCreateTag has a TOCTOU race between the existsByKeyIgnoreCase check and save that can allow duplicate keys differing only by case

In findOrCreateTag, the code checks existsByKeyIgnoreCaseAndEntityType(key, DEVICE) and, if false, proceeds to save. Between the check and the save, another request could create a tag with a different case of the same key. The DuplicateKeyException catch block only guards against an exact-key race (the same case), because the underlying unique index is stated to be case-sensitive ('Keys are unique ignoring case ... the Mongo index is case-sensitive, so without the check "Site" would quietly become a second key next to "site"'). This means the case-insensitive uniqueness guarantee promised in the class doc is not actually race-safe: two concurrent requests with 'Site' and 'site' could both pass the exists check and both succeed at save (since the underlying index is case-sensitive), producing two case-variant keys — the exact failure mode the check claims to prevent.

Evidence
    private Tag findOrCreateTag(String key, List<String> values) {
        Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
        if (existing == null) {
            if (tagRepository.existsByKeyIgnoreCaseAndEntityType(key, DEVICE)) {
                throw new ConflictException("Tag with key '" + key + "' already exists");
            }
            try {
                Tag created = tagRepository.save(Tag.builder()
                        .key(key)
                        .values(merge(null, values))
                        .entityType(DEVICE)
                        .createdAt(Instant.now())
🤖 Prompt for AI agents
In openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java around lines 141-164, address this code-review finding: findOrCreateTag has a TOCTOU race between the existsByKeyIgnoreCase check and save that can allow duplicate keys differing only by case.
In findOrCreateTag, the code checks existsByKeyIgnoreCaseAndEntityType(key, DEVICE) and, if false, proceeds to save. Between the check and the save, another request could create a tag with a different case of the same key. The DuplicateKeyException catch block only guards against an exact-key race (the same case), because the underlying unique index is stated to be case-sensitive ('Keys are unique ignoring case ... the Mongo index is case-sensitive, so without the check "Site" would quietly become a second key next to "site"'). This means the case-insensitive uniqueness guarantee promised in the class doc is not actually race-safe: two concurrent requests with 'Site' and 'site' could both pass the exists check and both succeed at save (since the underlying index is case-sensitive), producing two case-variant keys — the exact failure mode the check claims to prevent.
The flagged code:
```
    private Tag findOrCreateTag(String key, List<String> values) {
        Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
        if (existing == null) {
            if (tagRepository.existsByKeyIgnoreCaseAndEntityType(key, DEVICE)) {
                throw new ConflictException("Tag with key '" + key + "' already exists");
            }
            try {
                Tag created = tagRepository.save(Tag.builder()
                        .key(key)
                        .values(merge(null, values))
                        .entityType(DEVICE)
                        .createdAt(Instant.now())
                        .build());
                log.info("Created DEVICE tag '{}' (id={})", key, created.getId());
                return created;
            } catch (DuplicateKeyException e) {
                // Lost a first-use race to another request: the unique (tenant, key, entityType)
                // index kept the key single, so carry on with the winner's tag.
                existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
                if (existing == null) {
                    throw e;
                }
            }
        }
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 35 — react 👍/👎 to teach the reviewer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and knowingly left: the ignore-case check is best-effort, exactly like TagService.createTag which has the same check-then-save shape. The exact-case race is closed by the unique index (hence the DuplicateKeyException handling); a Site/site race within the same few milliseconds is not, and the outcome would be a second key an admin can delete — no data loss. The real fix is a case-insensitive collation on tenant_key_entity_idx, which is an index migration across every tenant DB and would cover createTag/updateTag/registration too, so it belongs in its own change.


List<String> 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;
}
Comment on lines +141 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦩 🟠 [warn/recommended] assignDeviceTag has no upper bound on tag values / no duplicate protection against unbounded tag growth per device

assignTag merges new values into both the tag's global option list and the device's own assignment with no cap on collection size. Since values are user/agent supplied via a public GraphQL mutation, repeated calls with novel values will grow both tags.values and tag_assignments.values unbounded, inflating the Mongo documents and the Kafka payload republished by MachineTagEventAspect on every write. Consider enforcing a maximum values-per-tag/assignment limit similar to what TagValidation.validateValues might already do for a single call, but applied against the merged/cumulative size.

Evidence
    private Tag findOrCreateTag(String key, List<String> 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;
        }
🤖 Prompt for AI agents
In openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java around lines 117-138, address this code-review finding: assignDeviceTag has no upper bound on tag values / no duplicate protection against unbounded tag growth per device.
assignTag merges new values into both the tag's global option list and the device's own assignment with no cap on collection size. Since values are user/agent supplied via a public GraphQL mutation, repeated calls with novel values will grow both `tags.values` and `tag_assignments.values` unbounded, inflating the Mongo documents and the Kafka payload republished by MachineTagEventAspect on every write. Consider enforcing a maximum values-per-tag/assignment limit similar to what TagValidation.validateValues might already do for a single call, but applied against the merged/cumulative size.
The flagged code:
```
    private Tag findOrCreateTag(String key, List<String> 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<String> 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;
    }
```
Make the minimal change that resolves the finding; do not refactor unrelated code.

confidence: 30 — react 👍/👎 to teach the reviewer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this PR. Duplicates are already handled — values go through an insertion-ordered set, an unchanged list isn't saved at all, and each value is length/charset-validated by TagValidation. A count cap is a product limit rather than a bug: the key's option list is the tenant-wide union (a site key legitimately has hundreds), and the same unbounded path exists for agent registration, so a limit belongs in TagValidation for both callers. The mutation is dashboard-only behind auth, so leaving it for a follow-up if we decide on a number.


/**
* Writes {@code values} to the device's assignment, creating it if the device does not carry the
* tag yet: merged into the current values, or in place of them when {@code replace} is set.
* Saving through the repository is what triggers the Pinot republish, so an unchanged list is
* not saved at all.
*/
private List<String> upsertAssignment(String machineId, String tagId, List<String> values, boolean replace) {
Optional<TagAssignment> existing = tagAssignmentRepository
.findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE);

if (existing.isEmpty()) {
try {
TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder()
.entityId(machineId)
.tagId(tagId)
.entityType(DEVICE)
.values(merge(null, values))
.taggedAt(Instant.now())
.build());
return saved.getValues();
} catch (DuplicateKeyException e) {
// Same race on the unique (tenant, entity, tag, entityType) index: apply the values
// to the assignment the other request just created.
existing = tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE);
if (existing.isEmpty()) {
throw e;
}
}
}

TagAssignment assignment = existing.get();
List<String> updated = merge(replace ? null : assignment.getValues(), values);
if (!updated.equals(normalize(assignment.getValues()))) {
assignment.setValues(updated);
return tagAssignmentRepository.save(assignment).getValues();
}
return normalize(assignment.getValues());
}

/** Insertion-ordered union — existing values keep their order, new ones are appended. */
private static List<String> merge(List<String> current, List<String> added) {
LinkedHashSet<String> merged = new LinkedHashSet<>(normalize(current));
merged.addAll(normalize(added));
return new ArrayList<>(merged);
}

private static List<String> normalize(List<String> values) {
return values != null ? values : List.of();
}

private static int size(List<String> values) {
return values != null ? values.size() : 0;
}
}
Loading
Loading