Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,34 @@
* {@code CommandDispatcher}, which duplicated the {@code @JsonSubTypes} registration on
* {@link CommandPayload} with nothing keeping the two in step. Adding a command kind is
* now: new record + new {@code @Type} entry + a constant here (which forces a risk
* classification) + agent-side handler. {@code CommandKindTest} fails if the wire strings
* here and the Jackson subtypes ever diverge.
* classification and names the payload record) + agent-side handler.
* {@code CommandKindTest} fails if the wire strings, the payload types and the Jackson
* subtypes ever diverge.
* <p>
* The dispatcher never deserializes the payload into a {@link CommandPayload}; it stores
* the raw JSON and hands it to the agent so classification has to hang off the wire
* string rather than the payload type.
* Each constant carries the {@link CommandPayload} record that defines its payload shape.
* {@code CommandPayloadCodec} uses that mapping to deserialize and Bean-Validate the
* incoming JSON before anything is stored or forwarded, so a bound declared on a payload
* component is enforced on the wire without any further wiring.
*/
public enum CommandKind {

CHROME_RELOAD("chrome.reload", CommandRisk.BENIGN),
CONFIG_REFRESH("config.refresh", CommandRisk.BENIGN),
CHROME_RELOAD("chrome.reload", CommandRisk.BENIGN, ChromeReloadPayload.class),
CONFIG_REFRESH("config.refresh", CommandRisk.BENIGN, ConfigRefreshPayload.class),

KIOSK_RESTART("kiosk.restart", CommandRisk.DISRUPTIVE),
SYSTEM_REBOOT("system.reboot", CommandRisk.DISRUPTIVE),
KIOSK_RESTART("kiosk.restart", CommandRisk.DISRUPTIVE, KioskRestartPayload.class),
SYSTEM_REBOOT("system.reboot", CommandRisk.DISRUPTIVE, SystemRebootPayload.class),

CHROME_SCREENSHOT("chrome.screenshot", CommandRisk.INSPECT),
LOGS_TAIL("logs.tail", CommandRisk.INSPECT);
CHROME_SCREENSHOT("chrome.screenshot", CommandRisk.INSPECT, ChromeScreenshotPayload.class),
LOGS_TAIL("logs.tail", CommandRisk.INSPECT, LogsTailPayload.class);

private final String wireName;
private final CommandRisk risk;
private final Class<? extends CommandPayload> payloadType;

CommandKind(String wireName, CommandRisk risk) {
CommandKind(String wireName, CommandRisk risk, Class<? extends CommandPayload> payloadType) {
this.wireName = wireName;
this.risk = risk;
this.payloadType = payloadType;
}

/** The dotted discriminator sent over the wire, e.g. {@code chrome.reload}. */
Expand All @@ -47,6 +51,11 @@ public CommandRisk getRisk() {
return risk;
}

/** The record defining this kind's payload shape; the target type for validated deserialization. */
public Class<? extends CommandPayload> getPayloadType() {
return payloadType;
}

public Permission getRequiredPermission() {
return risk.getRequiredPermission();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@
* <p>
* The discriminator is {@code kind} (e.g. {@code "chrome.reload"}). Stored as JSON in
* {@code device_commands.payload_json} and re-parsed when delivering to the agent.
* <p>
* <b>This hierarchy is the single source of truth for payload shape on the issue path.</b>
* {@code CommandPayloadCodec} resolves the record from the request's {@code kind} via
* {@link CommandKind#getPayloadType()}, deserializes into it while rejecting unknown
* properties, runs Jakarta Bean Validation, and re-serializes the validated object as
* what gets persisted and pushed to the agent. Nothing an operator typed reaches the
* device verbatim, so a constraint annotation added to a component here is enforced on
* the wire with no further wiring. Declare bounds as constraint annotations rather than
* as compact-constructor checks: the codec reports the former as a clean 400.
* <p>
* The closed {@code @JsonSubTypes} registry below is a deliberate anti-gadget defence.
* Never replace it with {@code Id.CLASS}/{@code MINIMAL_CLASS} or enable default typing.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package com.ibrasoft.lensbridge.model.minbar.board.commands;

/** Tail the last {@code lines} entries from the agent + kiosk journald units. Capped at 500. */
public record LogsTailPayload(Integer lines) implements CommandPayload {
public LogsTailPayload {
if (lines != null && (lines < 1 || lines > 500)) {
throw new IllegalArgumentException("lines must be between 1 and 500");
}
}
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;

/**
* Tail the last {@code lines} entries from the agent + kiosk journald units.
* <p>
* The bound lives on the component as a constraint annotation and nowhere else.
* {@code CommandPayloadCodec} runs Bean Validation on every issued payload, so this is
* what the device actually receives — see {@link CommandPayload}. Null means "agent
* default"; the agent picks its own line count.
*/
public record LogsTailPayload(
@Min(value = 1, message = "lines must be between 1 and 500")
@Max(value = 500, message = "lines must be between 1 and 500")
Integer lines
) implements CommandPayload {
@Override public String kind() { return "logs.tail"; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.ibrasoft.lensbridge.model.minbar.board.DeviceCommand;
import com.ibrasoft.lensbridge.model.minbar.board.DeviceCommandStatus;
import com.ibrasoft.lensbridge.model.minbar.board.commands.CommandKind;
import com.ibrasoft.lensbridge.model.minbar.board.commands.CommandPayload;
import com.ibrasoft.lensbridge.repository.sql.DeviceCommandRepository;
import com.ibrasoft.lensbridge.repository.sql.DeviceRepository;
import com.ibrasoft.lensbridge.service.agent.events.DeviceEventPublisher;
Expand Down Expand Up @@ -62,6 +63,7 @@ public class CommandDispatcher {
private final AgentSessionRegistry registry;
private final ObjectMapper objectMapper;
private final DeviceEventPublisher events;
private final CommandPayloadCodec payloadCodec;

@Transactional
public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandRequest request) {
Expand All @@ -73,10 +75,19 @@ public CommandIssuedResponse issue(UUID deviceId, String issuedBy, IssueCommandR
throw new ResponseStatusException(HttpStatus.CONFLICT, "Device is revoked");
}

JsonNode payload = request.payload() == null ? NullNode.getInstance() : request.payload();
// Bind the wire JSON to the sealed CommandPayload subtype for this kind, validate it, and
// store what came back rather than what was sent. The raw JsonNode used to be stringified
// verbatim, which meant LogsTailPayload's 1..500 bound -- and every other constraint in
// that hierarchy -- was never evaluated on the only path that issues commands: the
// records existed but nothing deserialized into them. Every bound was therefore delegated
// to agent code that does not live in this repository. Re-serializing the validated
// object also drops anything the client tacked on beyond the record's components.
String payloadJson;
try {
CommandPayload payload = payloadCodec.parse(kind, request.payload());
payloadJson = objectMapper.writeValueAsString(payload);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid payload");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package com.ibrasoft.lensbridge.service.agent;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ibrasoft.lensbridge.model.minbar.board.commands.CommandKind;
import com.ibrasoft.lensbridge.model.minbar.board.commands.CommandPayload;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

import java.util.Comparator;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Turns the raw {@code payload} JSON of an issue request into a validated
* {@link CommandPayload}, and back into the JSON that is persisted and pushed to the device.
* <p>
* This exists because the issue path used to stringify whatever JSON the operator sent and
* hand it to the kiosk agent verbatim: the sealed {@code CommandPayload} hierarchy — bounds
* and all — was never on that path, so its constraints only <em>looked</em> authoritative.
* Everything a payload can express now has to survive four gates before it leaves the server:
* <ol>
* <li>the payload must be a JSON object (or absent);</li>
* <li>any {@code kind} inside the payload must agree with the request's {@code kind} —
* an operator cannot smuggle a screenshot payload into a reload command;</li>
* <li>it must deserialize into the record {@link CommandKind#getPayloadType()} names,
* with unknown properties rejected rather than silently forwarded;</li>
* <li>it must pass Jakarta Bean Validation.</li>
* </ol>
* Only the re-serialized, validated object is stored, so the device never sees a field the
* server did not understand.
* <p>
* The discriminator is stripped on the way out: the frame already carries {@code kind} at
* top level, and keeping the stored payload free of it preserves the shape the agent has
* always been handed.
*/
@Component
@Slf4j
public class CommandPayloadCodec {

/** Discriminator property configured on {@link CommandPayload}'s {@code @JsonTypeInfo}. */
private static final String KIND_PROPERTY = "kind";

private final ObjectMapper objectMapper;
private final ObjectReader strictReader;
private final Validator validator;

public CommandPayloadCodec(ObjectMapper objectMapper, Validator validator) {
this.objectMapper = objectMapper;
this.validator = validator;
// A dedicated reader: the shared mapper has FAIL_ON_UNKNOWN_PROPERTIES off, which is
// the right default for lenient DTOs and exactly wrong for a payload bound for a device.
this.strictReader = objectMapper
.reader()
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.forType(CommandPayload.class);
}

/**
* Parses and validates {@code raw} as the payload for {@code kind}.
*
* @throws ResponseStatusException 400 with a message safe to hand back to the caller
*/
public CommandPayload parse(CommandKind kind, JsonNode raw) {
ObjectNode body = asObject(kind, raw);
checkDiscriminator(kind, body);
body.put(KIND_PROPERTY, kind.getWireName());

CommandPayload payload;
try {
payload = strictReader.readValue(body);
} catch (Exception e) {
log.warn("Rejected {} payload: {}", kind.getWireName(), e.getMessage());
throw badRequest("Invalid payload for command " + kind.getWireName() + ": " + rootCauseMessage(e));
}

// Belt and braces: the Jackson registry and CommandKind are pinned together by
// CommandKindTest, so a mismatch here means one of them was edited without the other.
if (!kind.getPayloadType().isInstance(payload)) {
log.error("Payload for {} deserialized to {}, expected {}",
kind.getWireName(), payload.getClass().getSimpleName(), kind.getPayloadType().getSimpleName());
throw badRequest("Payload does not match command kind " + kind.getWireName());
}

Set<ConstraintViolation<CommandPayload>> violations = validator.validate(payload);
if (!violations.isEmpty()) {
String details = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.collect(Collectors.joining("; "));
log.warn("Rejected {} payload: {}", kind.getWireName(), details);
throw badRequest("Invalid payload for command " + kind.getWireName() + ": " + details);
}
return payload;
}

/**
* Serializes a validated payload for storage and delivery, without the {@code kind}
* discriminator (the command row and the outgoing frame both carry it already).
*/
public String toStoredJson(CommandPayload payload) {
ObjectNode node = objectMapper.valueToTree(payload);
node.remove(KIND_PROPERTY);
return node.toString();
}

/** Convenience for the issue path: validate, then render what gets stored. */
public String validateAndSerialize(CommandKind kind, JsonNode raw) {
return toStoredJson(parse(kind, raw));
}

private ObjectNode asObject(CommandKind kind, JsonNode raw) {
if (raw == null || raw.isNull() || raw.isMissingNode()) {
return objectMapper.createObjectNode();
}
if (!raw.isObject()) {
throw badRequest("Payload for command " + kind.getWireName() + " must be a JSON object");
}
return ((ObjectNode) raw).deepCopy();
}

private void checkDiscriminator(CommandKind kind, ObjectNode body) {
JsonNode declared = body.get(KIND_PROPERTY);
if (declared == null || declared.isNull()) {
return;
}
if (!declared.isTextual() || !kind.getWireName().equals(declared.asText())) {
log.warn("Rejected command {}: payload declares kind {}", kind.getWireName(), declared);
throw badRequest("Payload kind does not match command kind " + kind.getWireName());
}
}

/**
* Jackson nests the useful part of a parse failure inside wrapper exceptions and appends
* the parser location, which leaks nothing sensitive but is noise to an API caller.
*/
private static String rootCauseMessage(Exception e) {
Throwable cause = e;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
String message = cause.getMessage();
if (message == null || message.isBlank()) {
return "unreadable payload";
}
int at = message.indexOf("\n at [");
if (at >= 0) {
message = message.substring(0, at);
}
return message.strip();
}

private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}

/** Kept for readability of the violation ordering above. */
@SuppressWarnings("unused")
private static final Comparator<String> NATURAL = Comparator.naturalOrder();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.ibrasoft.lensbridge.service.agent;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ibrasoft.lensbridge.dto.board.agent.CommandAckFrame;
Expand Down Expand Up @@ -45,7 +46,9 @@ void setUp() {
registry = new AgentSessionRegistry();
mapper = new ObjectMapper();
events = mock(DeviceEventPublisher.class);
dispatcher = new CommandDispatcher(commandRepo, deviceRepo, registry, mapper, events);
CommandPayloadCodec codec = new CommandPayloadCodec(
mapper, Validation.buildDefaultValidatorFactory().getValidator());
dispatcher = new CommandDispatcher(commandRepo, deviceRepo, registry, mapper, events, codec);

when(commandRepo.save(any())).thenAnswer(inv -> {
DeviceCommand c = inv.getArgument(0);
Expand Down
Loading
Loading