From 292127fd4de09f337acb6d9bc2c5a6077ec3c460 Mon Sep 17 00:00:00 2001 From: Gmasek Date: Tue, 10 Feb 2026 13:33:48 +0100 Subject: [PATCH 1/3] First impl --- .../data/configuration/SecurityConfig.java | 3 + .../controller/TriggerProxyController.java | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/main/java/io/redlink/more/data/controller/TriggerProxyController.java diff --git a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java index cfca17c5..aba5cbc1 100644 --- a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java +++ b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java @@ -65,6 +65,9 @@ public SecurityFilterChain filterChain(HttpSecurity http, .permitAll(); req.requestMatchers("/api/v1/calendar/studies/*/calendar.ics") .permitAll(); + // External trigger proxy (token auth handled by studymanager) + req.requestMatchers("/api/v1/trigger/external") + .permitAll(); // all other apis require credentials req.requestMatchers("/api/v1/**") .authenticated(); diff --git a/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java new file mode 100644 index 00000000..ac80db82 --- /dev/null +++ b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java @@ -0,0 +1,63 @@ +/* + * Copyright LBI-DHP and/or licensed to LBI-DHP under one or more + * contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute + * for Digital Health and Prevention -- A research institute of the + * Ludwig Boltzmann Gesellschaft, Oesterreichische Vereinigung zur + * Foerderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.data.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@RestController +public class TriggerProxyController { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerProxyController.class); + + private final RestTemplate restTemplate; + private final String studymanagerUrl; + + public TriggerProxyController( + @Value("${more.studymanager.url:http://localhost:8080}") String studymanagerUrl + ) { + this.restTemplate = new RestTemplate(); + this.studymanagerUrl = studymanagerUrl; + } + + @PostMapping(value = "/api/v1/trigger/external", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity triggerExternal( + @RequestHeader("More-Api-Token") String moreApiToken, + @RequestBody String body + ) { + LOG.info("Proxying external trigger request to studymanager"); + + try { + var headers = new org.springframework.http.HttpHeaders(); + headers.set("More-Api-Token", moreApiToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + var request = new org.springframework.http.HttpEntity<>(body, headers); + restTemplate.postForEntity( + studymanagerUrl + "/api/v1/trigger/external", + request, + Void.class + ); + return ResponseEntity.accepted().build(); + } catch (HttpClientErrorException.Forbidden e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (Exception e) { + LOG.error("Error proxying trigger request", e); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + } + } +} From 8826b7cacbc5c6b922549a6969737f331b573f20 Mon Sep 17 00:00:00 2001 From: Gmasek Date: Thu, 26 Feb 2026 20:58:16 +0100 Subject: [PATCH 2/3] Trigger auth done in gateway --- .../data/configuration/SecurityConfig.java | 5 +- .../controller/TriggerProxyController.java | 48 +++++++++--- .../data/model/ResolvedInterventionToken.java | 8 ++ .../more/data/repository/StudyRepository.java | 26 +++++++ .../more/data/service/ExternalService.java | 28 +++++++ src/main/resources/openapi/ExternalAPI.yaml | 73 +++++++++++++++++++ 6 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 src/main/java/io/redlink/more/data/model/ResolvedInterventionToken.java diff --git a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java index aba5cbc1..dc48a3b0 100644 --- a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java +++ b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java @@ -61,13 +61,12 @@ public SecurityFilterChain filterChain(HttpSecurity http, //External Data Gateway req.requestMatchers("/api/v1/external/bulk") .permitAll(); + req.requestMatchers("/api/v1/trigger/external") + .permitAll(); req.requestMatchers("/api/v1/external/participants") .permitAll(); req.requestMatchers("/api/v1/calendar/studies/*/calendar.ics") .permitAll(); - // External trigger proxy (token auth handled by studymanager) - req.requestMatchers("/api/v1/trigger/external") - .permitAll(); // all other apis require credentials req.requestMatchers("/api/v1/**") .authenticated(); diff --git a/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java index ac80db82..89428821 100644 --- a/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java +++ b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java @@ -8,17 +8,22 @@ */ package io.redlink.more.data.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.redlink.more.data.model.ResolvedInterventionToken; +import io.redlink.more.data.service.ExternalService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.*; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; +import java.util.List; +import java.util.Map; + @RestController public class TriggerProxyController { @@ -26,38 +31,59 @@ public class TriggerProxyController { private final RestTemplate restTemplate; private final String studymanagerUrl; + private final ExternalService externalService; + private final ObjectMapper objectMapper; public TriggerProxyController( - @Value("${more.studymanager.url:http://localhost:8080}") String studymanagerUrl + @Value("${more.studymanager.url:http://localhost:8080}") String studymanagerUrl, + ExternalService externalService, + ObjectMapper objectMapper ) { this.restTemplate = new RestTemplate(); this.studymanagerUrl = studymanagerUrl; + this.externalService = externalService; + this.objectMapper = objectMapper; } @PostMapping(value = "/api/v1/trigger/external", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity triggerExternal( + public ResponseEntity triggerExternal( @RequestHeader("More-Api-Token") String moreApiToken, - @RequestBody String body + @RequestBody TriggerRequest request ) { - LOG.info("Proxying external trigger request to studymanager"); + LOG.info("Validating intervention token and proxying trigger request to studymanager"); try { + ResolvedInterventionToken resolved = externalService.validateInterventionToken(moreApiToken); + + if (!resolved.studyActive()) { + LOG.warn("Trigger rejected: study {} is not active", resolved.studyId()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "Study not active")); + } + + var proxyBody = objectMapper.writeValueAsString(Map.of( + "studyId", resolved.studyId(), + "interventionId", resolved.interventionId(), + "participantIds", request.participantIds() + )); + var headers = new org.springframework.http.HttpHeaders(); - headers.set("More-Api-Token", moreApiToken); headers.setContentType(MediaType.APPLICATION_JSON); - var request = new org.springframework.http.HttpEntity<>(body, headers); + var httpRequest = new org.springframework.http.HttpEntity<>(proxyBody, headers); restTemplate.postForEntity( studymanagerUrl + "/api/v1/trigger/external", - request, + httpRequest, Void.class ); return ResponseEntity.accepted().build(); - } catch (HttpClientErrorException.Forbidden e) { + } catch (AccessDeniedException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } catch (Exception e) { - LOG.error("Error proxying trigger request", e); + LOG.error("Error processing external trigger request", e); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); } } + + public record TriggerRequest(List participantIds) {} } diff --git a/src/main/java/io/redlink/more/data/model/ResolvedInterventionToken.java b/src/main/java/io/redlink/more/data/model/ResolvedInterventionToken.java new file mode 100644 index 00000000..91e7c3db --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/ResolvedInterventionToken.java @@ -0,0 +1,8 @@ +package io.redlink.more.data.model; + + +public record ResolvedInterventionToken( + Long studyId, + Integer interventionId, + boolean studyActive +) {} \ No newline at end of file diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index 49425c1a..c4931d43 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -119,6 +119,15 @@ INNER JOIN studies s ON (t.study_id = s.study_id) WHERE s.study_id = ? AND o.observation_id = ? AND t.token_id = ? """; + private static final String GET_INTERVENTION_TOKEN_SECRET = """ + SELECT t.study_id, t.intervention_id, t.token, + s.status IN ('active', 'preview') AS study_active + FROM intervention_api_tokens t + INNER JOIN interventions i ON (t.study_id = i.study_id AND t.intervention_id = i.intervention_id) + INNER JOIN studies s ON (t.study_id = s.study_id) + WHERE t.study_id = ? AND t.intervention_id = ? AND t.token_id = ? + """; + private static final String GET_OBSERVATION_SCHEDULE = "SELECT schedule FROM observations WHERE study_id = ? AND observation_id = ?"; private static final String GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT = @@ -165,6 +174,23 @@ public Optional getApiRoutingInfo(Long studyId, Integer observat } } + public record InterventionTokenInfo(Long studyId, Integer interventionId, boolean studyActive, String secret) {} + + public Optional getInterventionTokenInfo(Long studyId, Integer interventionId, Integer tokenId) { + try (var stream = jdbcTemplate.queryForStream( + GET_INTERVENTION_TOKEN_SECRET, + (rs, rowNum) -> new InterventionTokenInfo( + rs.getLong("study_id"), + rs.getInt("intervention_id"), + rs.getBoolean("study_active"), + rs.getString("token") + ), + studyId, interventionId, tokenId + )) { + return stream.findFirst(); + } + } + public Optional getObservationSchedule(Long studyId, Integer observationId) { try { return Optional.ofNullable(jdbcTemplate.queryForObject( diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index 5cb2d831..c16506f5 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -17,6 +17,7 @@ import io.redlink.more.data.model.ApiRoutingInfo; import io.redlink.more.data.model.Participant; import io.redlink.more.data.model.RoutingInfo; +import io.redlink.more.data.model.ResolvedInterventionToken; import io.redlink.more.data.model.scheduler.Event; import io.redlink.more.data.model.scheduler.Interval; import io.redlink.more.data.model.scheduler.RelativeEvent; @@ -114,6 +115,33 @@ public void assertTimestampsInBulk(Long studyId, Integer observationId, Integer } } + + public ResolvedInterventionToken validateInterventionToken(String moreApiToken) { + try { + String[] split = moreApiToken.split("\\."); + String[] primaryKey = new String(Base64.getDecoder().decode(split[0])).split("-"); + + Long studyId = Long.valueOf(primaryKey[0]); + Integer interventionId = Integer.valueOf(primaryKey[1]); + Integer tokenId = Integer.valueOf(primaryKey[2]); + String secret = new String(Base64.getDecoder().decode(split[1])); + + final Optional tokenInfo = + repository.getInterventionTokenInfo(studyId, interventionId, tokenId) + .stream().filter(info -> + passwordEncoder.matches(secret, info.secret())) + .findFirst(); + if (tokenInfo.isEmpty()) { + throw new AccessDeniedException("Invalid token"); + } + return new ResolvedInterventionToken(tokenInfo.get().studyId(), tokenInfo.get().interventionId(), tokenInfo.get().studyActive()); + } catch (AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new AccessDeniedException("Invalid token"); + } + } + public List listParticipants(Long studyId, OptionalInt studyGroupId) { return repository.listParticipants(studyId, studyGroupId); } diff --git a/src/main/resources/openapi/ExternalAPI.yaml b/src/main/resources/openapi/ExternalAPI.yaml index b063dfa2..40237f31 100644 --- a/src/main/resources/openapi/ExternalAPI.yaml +++ b/src/main/resources/openapi/ExternalAPI.yaml @@ -76,6 +76,66 @@ paths: schema: type: string example: "An unexpected error occurred." + + /trigger/external: + post: + operationId: triggerExternal + description: Trigger observation for specific participiants + tags: + - External trigger + parameters: + - $ref: '#/components/parameters/ExternalApiToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerRequest' + responses: + '202': + description: Trigger sent . + content: + application/json: + schema: + type: string + example: "Trigger sent to the backend." + '403': + description: Token is invalid. + content: + application/json: + schema: + type: string + example: "Invalid token!" + '409': + description: Conflict (e.g., study or participant is not active). + content: + application/json: + schema: + type: string + example: "Study or participant is not active" + '422': + description: Unprocessable Entity (e.g., malformed participant ID ). + content: + application/json: + examples: + malformedId: + summary: Malformed participant ID + value: "Malformed participant id!" + + '404': + description: Not Found (e.g., interval for observation not found). + content: + application/json: + schema: + type: string + example: "TimeFrame not found." + '400': + description: Bad Request (Any unhandled exception). + content: + application/json: + schema: + type: string + example: "An unexpected error occurred." /external/participants: get: @@ -162,6 +222,19 @@ components: - studyGroup - start + TriggerRequest: + type: object + required: + - participantIds + properties: + participantIds: + type: array + items: + type: integer + description: List of participant IDs to trigger + example: [1, 2, 3] + + ParticipantStatus: type: string enum: From 335b5f0718fd2bce148f8ddaa970f88640f2a380 Mon Sep 17 00:00:00 2001 From: Gmasek Date: Mon, 4 May 2026 10:55:28 +0200 Subject: [PATCH 3/3] Pre package --- .../controller/TriggerProxyController.java | 43 ++++++------------- .../more/data/repository/StudyRepository.java | 24 +++++++++++ src/main/resources/openapi/ExternalAPI.yaml | 22 ++++++++++ 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java index 89428821..96cb9922 100644 --- a/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java +++ b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java @@ -8,18 +8,16 @@ */ package io.redlink.more.data.controller; -import com.fasterxml.jackson.databind.ObjectMapper; import io.redlink.more.data.model.ResolvedInterventionToken; +import io.redlink.more.data.repository.StudyRepository; import io.redlink.more.data.service.ExternalService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.*; -import org.springframework.web.client.RestTemplate; import java.util.List; import java.util.Map; @@ -28,21 +26,14 @@ public class TriggerProxyController { private static final Logger LOG = LoggerFactory.getLogger(TriggerProxyController.class); + static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; - private final RestTemplate restTemplate; - private final String studymanagerUrl; + private final StudyRepository studyRepository; private final ExternalService externalService; - private final ObjectMapper objectMapper; - public TriggerProxyController( - @Value("${more.studymanager.url:http://localhost:8080}") String studymanagerUrl, - ExternalService externalService, - ObjectMapper objectMapper - ) { - this.restTemplate = new RestTemplate(); - this.studymanagerUrl = studymanagerUrl; + public TriggerProxyController(StudyRepository studyRepository, ExternalService externalService) { + this.studyRepository = studyRepository; this.externalService = externalService; - this.objectMapper = objectMapper; } @PostMapping(value = "/api/v1/trigger/external", produces = MediaType.APPLICATION_JSON_VALUE) @@ -50,7 +41,7 @@ public ResponseEntity triggerExternal( @RequestHeader("More-Api-Token") String moreApiToken, @RequestBody TriggerRequest request ) { - LOG.info("Validating intervention token and proxying trigger request to studymanager"); + LOG.info("Validating intervention token and writing pending participants to DB"); try { ResolvedInterventionToken resolved = externalService.validateInterventionToken(moreApiToken); @@ -61,27 +52,19 @@ public ResponseEntity triggerExternal( .body(Map.of("error", "Study not active")); } - var proxyBody = objectMapper.writeValueAsString(Map.of( - "studyId", resolved.studyId(), - "interventionId", resolved.interventionId(), - "participantIds", request.participantIds() - )); - - var headers = new org.springframework.http.HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - var httpRequest = new org.springframework.http.HttpEntity<>(proxyBody, headers); - restTemplate.postForEntity( - studymanagerUrl + "/api/v1/trigger/external", - httpRequest, - Void.class + studyRepository.addPendingTriggerParticipants( + resolved.studyId(), + resolved.interventionId(), + PENDING_PARTICIPANTS_KEY, + request.participantIds() ); + return ResponseEntity.accepted().build(); } catch (AccessDeniedException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } catch (Exception e) { LOG.error("Error processing external trigger request", e); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index c4931d43..a9ee0dd8 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -16,14 +16,18 @@ import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.SerializationUtils; +import java.io.Serializable; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.OptionalInt; +import java.util.Set; import java.util.function.Supplier; import static io.redlink.more.data.repository.DbUtils.toInstant; @@ -128,6 +132,12 @@ INNER JOIN studies s ON (t.study_id = s.study_id) WHERE t.study_id = ? AND t.intervention_id = ? AND t.token_id = ? """; + private static final String GET_PENDING_TRIGGER_PARTICIPANTS = + "SELECT value FROM nvpairs_triggers WHERE study_id = ? AND intervention_id = ? AND name = ? LIMIT 1 FOR UPDATE"; + private static final String UPSERT_PENDING_TRIGGER_PARTICIPANTS = + "INSERT INTO nvpairs_triggers(study_id, intervention_id, name, value) VALUES (?,?,?,?) " + + "ON CONFLICT(study_id, intervention_id, name) DO UPDATE SET value = EXCLUDED.value"; + private static final String GET_OBSERVATION_SCHEDULE = "SELECT schedule FROM observations WHERE study_id = ? AND observation_id = ?"; private static final String GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT = @@ -191,6 +201,20 @@ public Optional getInterventionTokenInfo(Long studyId, In } } + @Transactional + @SuppressWarnings("unchecked") + public void addPendingTriggerParticipants(Long studyId, Integer interventionId, String key, List participantIds) { + Set pending; + try { + byte[] raw = jdbcTemplate.queryForObject(GET_PENDING_TRIGGER_PARTICIPANTS, byte[].class, studyId, interventionId, key); + pending = raw != null ? (HashSet) SerializationUtils.deserialize(raw) : new HashSet<>(); + } catch (EmptyResultDataAccessException e) { + pending = new HashSet<>(); + } + pending.addAll(participantIds); + jdbcTemplate.update(UPSERT_PENDING_TRIGGER_PARTICIPANTS, studyId, interventionId, key, SerializationUtils.serialize((Serializable) pending)); + } + public Optional getObservationSchedule(Long studyId, Integer observationId) { try { return Optional.ofNullable(jdbcTemplate.queryForObject( diff --git a/src/main/resources/openapi/ExternalAPI.yaml b/src/main/resources/openapi/ExternalAPI.yaml index 40237f31..1c5e452f 100644 --- a/src/main/resources/openapi/ExternalAPI.yaml +++ b/src/main/resources/openapi/ExternalAPI.yaml @@ -235,6 +235,28 @@ components: example: [1, 2, 3] + ExternalTriggerRequest: + type: object + required: + - studyId + - interventionId + - participantIds + properties: + studyId: + type: integer + format: int64 + description: The study ID extracted from the token + interventionId: + type: integer + format: int32 + description: The intervention ID extracted from the token + participantIds: + type: array + items: + type: integer + description: List of participant IDs to trigger + example: [1, 2, 3] + ParticipantStatus: type: string enum: