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..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,6 +61,8 @@ 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") 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..96cb9922 --- /dev/null +++ b/src/main/java/io/redlink/more/data/controller/TriggerProxyController.java @@ -0,0 +1,72 @@ +/* + * 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 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.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 java.util.List; +import java.util.Map; + +@RestController +public class TriggerProxyController { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerProxyController.class); + static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; + + private final StudyRepository studyRepository; + private final ExternalService externalService; + + public TriggerProxyController(StudyRepository studyRepository, ExternalService externalService) { + this.studyRepository = studyRepository; + this.externalService = externalService; + } + + @PostMapping(value = "/api/v1/trigger/external", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity triggerExternal( + @RequestHeader("More-Api-Token") String moreApiToken, + @RequestBody TriggerRequest request + ) { + LOG.info("Validating intervention token and writing pending participants to DB"); + + 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")); + } + + 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.INTERNAL_SERVER_ERROR).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..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; @@ -119,6 +123,21 @@ 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_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 = @@ -165,6 +184,37 @@ 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(); + } + } + + @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/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..1c5e452f 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,41 @@ 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] + + + 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: