feat(security): introduce AgentOwnershipGuard for agent-id JWT subject check

This commit is contained in:
hsiegeln
2026-04-29 09:42:10 +02:00
parent efdda29d99
commit 78e6d5c1d8
2 changed files with 98 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
package io.cameleer.server.app.security;
import io.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
/**
* Verifies that the authenticated JWT subject matches the {@code {id}} path
* variable on agent self-service endpoints. Required wherever an AGENT-role
* caller could otherwise act on another agent's identity.
*/
@Component
public class AgentOwnershipGuard {
public void requireAgentOwnership(String pathId, HttpServletRequest request) {
String subject = resolveAgentSubject(request);
if (subject == null || !subject.equals(pathId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Agent token does not own the requested agent id");
}
}
public String resolveAgentSubject(HttpServletRequest request) {
Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (attr instanceof JwtValidationResult result) {
return result.subject();
}
return null;
}
}

View File

@@ -0,0 +1,66 @@
package io.cameleer.server.app.security;
import io.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AgentOwnershipGuardTest {
private final AgentOwnershipGuard guard = new AgentOwnershipGuard();
@Test
void allowsWhenSubjectMatchesPathId() {
HttpServletRequest request = requestWith(jwt("agent-A"));
guard.requireAgentOwnership("agent-A", request); // does not throw
}
@Test
void rejectsWhenSubjectMismatchesPathId() {
HttpServletRequest request = requestWith(jwt("agent-A"));
assertThatThrownBy(() -> guard.requireAgentOwnership("agent-B", request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void rejectsWhenJwtAttributeMissing() {
HttpServletRequest request = new MockHttpServletRequest();
assertThatThrownBy(() -> guard.requireAgentOwnership("agent-A", request))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void rejectsWhenSubjectIsNull() {
HttpServletRequest request = requestWith(jwt(null));
assertThatThrownBy(() -> guard.requireAgentOwnership("agent-A", request))
.isInstanceOf(ResponseStatusException.class);
}
@Test
void exposesSubjectViaResolvedHelper() {
HttpServletRequest request = requestWith(jwt("agent-A"));
assertThat(guard.resolveAgentSubject(request)).isEqualTo("agent-A");
}
private JwtValidationResult jwt(String subject) {
return new JwtValidationResult(subject, "default-app", "default", List.of("AGENT"), Instant.now());
}
private HttpServletRequest requestWith(JwtValidationResult result) {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR, result);
return req;
}
}