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
@@ -0,0 +1,15 @@
package com.openframe.data.reactive.repository.tool;

import com.openframe.data.document.tool.ToolConnection;
import com.openframe.data.document.tool.ToolType;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;

@Repository
public interface ReactiveToolConnectionRepository extends ReactiveMongoRepository<ToolConnection, String> {

Mono<ToolConnection> findByMachineIdAndToolType(String machineId, ToolType toolType);

Mono<ToolConnection> findFirstByAgentToolIdOrderByConnectedAtDesc(String agentToolId);
}
12 changes: 12 additions & 0 deletions openframe-gateway-service-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -73,11 +74,20 @@ public Mono<ResponseEntity<String>> proxyApiRequest(
public Mono<ResponseEntity<String>> proxyAgentRequest(
@PathVariable String toolId,
ServerHttpRequest request,
@RequestBody(required = false) String body
@RequestBody(required = false) String body,
Authentication authentication
) {
String path = request.getPath().toString();
log.debug("Proxying agent request for tool: {}, path: {}", toolId, path);
return restProxyService.proxyAgentRequest(toolId, request, body);
String jwtMachineId = extractMachineId(authentication);
return restProxyService.proxyAgentRequest(toolId, request, body, jwtMachineId);
}

private String extractMachineId(Authentication authentication) {
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
return jwtAuth.getToken().getClaimAsString("machine_id");
}
return null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.openframe.gateway.service;

import com.openframe.data.reactive.repository.tool.ReactiveToolConnectionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Service
@RequiredArgsConstructor
@Slf4j
public class AgentDeviceAccessValidator {

private final ReactiveToolConnectionRepository toolConnectionRepository;

/**
* Validates that the JWT machine_id is the owner of the requested agentToolId
* via the tool_connections collection.
*/
public Mono<Boolean> canAccess(String jwtMachineId, String agentToolId) {
if (jwtMachineId == null || agentToolId == null || agentToolId.isBlank()) {
log.warn("Device access denied: missing jwtMachineId={} or agentToolId={}", jwtMachineId, agentToolId);
return Mono.just(false);
}
return toolConnectionRepository
.findFirstByAgentToolIdOrderByConnectedAtDesc(agentToolId)
.map(conn -> {
boolean allowed = jwtMachineId.equals(conn.getMachineId());
if (!allowed) {
log.warn("Device access denied: jwtMachineId={} tried to access agentToolId={} owned by machineId={}",
jwtMachineId, agentToolId, conn.getMachineId());
}
return allowed;
})
.defaultIfEmpty(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class RestProxyService {
private final ReactiveIntegratedToolRepository toolRepository;
private final ToolUpstreamResolverRegistry upstreamRegistry;
private final ToolApiKeyHeadersResolver apiKeyHeadersResolver;
private final AgentDeviceAccessValidator agentDeviceAccessValidator;

public Mono<ResponseEntity<String>> proxyApiRequest(String toolId, ServerHttpRequest request, String body) {
return toolRepository.findById(toolId)
Expand Down Expand Up @@ -69,39 +70,52 @@ private Map<String, String> buildApiRequestHeaders(IntegratedTool tool) {
return headers;
}

/*
* Currently if one device have valid open-frame machine JWT token, it can send
* API request for other device.
* TODO: implement device access validation after tool connection feature is
* implemented.
*
* Tactical RMM request format:
* - GET /{agentId}/**
* - POST /**
* {
* "agentId": "*",
* }
* }
*/
public Mono<ResponseEntity<String>> proxyAgentRequest(String toolId, ServerHttpRequest request, String body) {
return toolRepository.findById(toolId)
.flatMap(tool -> {
if (!tool.isEnabled()) {
ResponseEntity<String> response = ResponseEntity.badRequest()
.body("Tool " + tool.getName() + " is not enabled");
return Mono.just(response);
public Mono<ResponseEntity<String>> proxyAgentRequest(
String toolId, ServerHttpRequest request, String body, String jwtMachineId) {
String agentToolId = extractAgentToolId(toolId, request).orElse(null);
return agentDeviceAccessValidator.canAccess(jwtMachineId, agentToolId)
.flatMap(allowed -> {
if (!allowed) {
log.warn("Blocked agent request: machineId={} to agentToolId={} via tool={}",
jwtMachineId, agentToolId, toolId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<String>build());
}
return toolRepository.findById(toolId)
.flatMap(tool -> {
if (!tool.isEnabled()) {
ResponseEntity<String> response = ResponseEntity.badRequest()
.body("Tool " + tool.getName() + " is not enabled");
return Mono.just(response);
}

URI targetUri = upstreamRegistry.resolve(toolId)
.resolveRest(tool, request, "/tools/agent");
log.debug("Proxying agent request for tool: {}, url: {}", toolId, targetUri);

HttpMethod method = request.getMethod();
Map<String, String> headers = buildAgentRequestHeaders(request);

return proxy(tool, targetUri, method, headers, body);
})
.switchIfEmpty(Mono.just(
ResponseEntity.status(HttpStatus.NOT_FOUND).body("Tool not found: " + toolId)));
});
}

URI targetUri = upstreamRegistry.resolve(toolId).resolveRest(tool, request, "/tools/agent");
log.debug("Proxying agent request for tool: {}, url: {}", toolId, targetUri);

HttpMethod method = request.getMethod();
Map<String, String> headers = buildAgentRequestHeaders(request);

return proxy(tool, targetUri, method, headers, body);
})
.switchIfEmpty(
Mono.just(ResponseEntity.status(HttpStatus.NOT_FOUND).body("Tool not found: " + toolId)));
private java.util.Optional<String> extractAgentToolId(String toolId, ServerHttpRequest request) {
String path = request.getPath().value();
String prefix = "/tools/agent/" + toolId + "/";
if (!path.startsWith(prefix)) {
return java.util.Optional.empty();
}
String remaining = path.substring(prefix.length());
if (remaining.isBlank()) {
return java.util.Optional.empty();
}
int slashIdx = remaining.indexOf('/');
String agentToolId = slashIdx == -1 ? remaining : remaining.substring(0, slashIdx);
return agentToolId.isBlank() ? java.util.Optional.empty() : java.util.Optional.of(agentToolId);
}

private Map<String, String> buildAgentRequestHeaders(ServerHttpRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.openframe.gateway.service;

import com.openframe.data.document.tool.ToolConnection;
import com.openframe.data.reactive.repository.tool.ReactiveToolConnectionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class AgentDeviceAccessValidatorTest {

@Mock
private ReactiveToolConnectionRepository toolConnectionRepository;

private AgentDeviceAccessValidator validator;

private static final String MACHINE_ID = "machine-abc123";
private static final String AGENT_TOOL_ID = "tactical-agent-xyz";
private static final String OTHER_MACHINE_ID = "machine-other999";

@BeforeEach
void setUp() {
validator = new AgentDeviceAccessValidator(toolConnectionRepository);
}

@Nested
@DisplayName("canAccess — happy path")
class AllowedAccess {

@Test
@DisplayName("returns true when JWT machineId matches ToolConnection owner")
void allowsWhenMachineIdMatches() {
ToolConnection conn = toolConnection(MACHINE_ID, AGENT_TOOL_ID);
when(toolConnectionRepository.findFirstByAgentToolIdOrderByConnectedAtDesc(AGENT_TOOL_ID))
.thenReturn(Mono.just(conn));

StepVerifier.create(validator.canAccess(MACHINE_ID, AGENT_TOOL_ID))
.expectNext(true)
.verifyComplete();
}
}

@Nested
@DisplayName("canAccess — denied")
class DeniedAccess {

@Test
@DisplayName("returns false when JWT machineId does NOT match ToolConnection owner — cross-device attack")
void deniesWhenMachineIdMismatch() {
ToolConnection conn = toolConnection(OTHER_MACHINE_ID, AGENT_TOOL_ID);
when(toolConnectionRepository.findFirstByAgentToolIdOrderByConnectedAtDesc(AGENT_TOOL_ID))
.thenReturn(Mono.just(conn));

StepVerifier.create(validator.canAccess(MACHINE_ID, AGENT_TOOL_ID))
.expectNext(false)
.verifyComplete();
}

@Test
@DisplayName("returns false when no ToolConnection exists for agentToolId")
void deniesWhenNoConnectionFound() {
when(toolConnectionRepository.findFirstByAgentToolIdOrderByConnectedAtDesc(AGENT_TOOL_ID))
.thenReturn(Mono.empty());

StepVerifier.create(validator.canAccess(MACHINE_ID, AGENT_TOOL_ID))
.expectNext(false)
.verifyComplete();
}

@Test
@DisplayName("returns false when jwtMachineId is null — no machine_id claim in JWT")
void deniesWhenJwtMachineIdIsNull() {
StepVerifier.create(validator.canAccess(null, AGENT_TOOL_ID))
.expectNext(false)
.verifyComplete();
}

@Test
@DisplayName("returns false when agentToolId is null — no target in path")
void deniesWhenAgentToolIdIsNull() {
StepVerifier.create(validator.canAccess(MACHINE_ID, null))
.expectNext(false)
.verifyComplete();
}

@Test
@DisplayName("returns false when agentToolId is blank")
void deniesWhenAgentToolIdIsBlank() {
StepVerifier.create(validator.canAccess(MACHINE_ID, " "))
.expectNext(false)
.verifyComplete();
}
}

private ToolConnection toolConnection(String machineId, String agentToolId) {
ToolConnection conn = new ToolConnection();
conn.setMachineId(machineId);
conn.setAgentToolId(agentToolId);
return conn;
}
}
Loading