From 2c835d3db6a9c2660a068e3feb0ba86b660a02fb Mon Sep 17 00:00:00 2001 From: Gmasek Date: Wed, 4 Feb 2026 11:52:31 +0100 Subject: [PATCH 1/4] Init_basic working prototype for classes and factory routing not done no auth --- .../component/trigger/api/ApiTrigger.java | 65 +++++++++++++++++++ .../trigger/api/ApiTriggerFactory.java | 39 +++++++++++ .../studymanager/ApiTriggerController.java | 55 ++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTrigger.java create mode 100644 studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTriggerFactory.java create mode 100644 studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java diff --git a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTrigger.java b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTrigger.java new file mode 100644 index 00000000..c591aadc --- /dev/null +++ b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTrigger.java @@ -0,0 +1,65 @@ +package io.redlink.more.studymanager.component.trigger.api; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.redlink.more.studymanager.core.component.Trigger; +import io.redlink.more.studymanager.core.exception.ConfigurationValidationException; +import io.redlink.more.studymanager.core.io.TriggerResult; +import io.redlink.more.studymanager.core.properties.TriggerProperties; +import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK; +import io.redlink.more.studymanager.core.sdk.schedule.CronSchedule; +import io.redlink.more.studymanager.core.io.ActionParameter; +import io.redlink.more.studymanager.core.io.Parameters; + + +public class ApiTrigger extends Trigger { + + private static final Logger LOGGER = LoggerFactory.getLogger(ApiTrigger.class); + public static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; + + protected ApiTrigger(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException { + super(sdk, properties); + } + + @Override + public void activate() { + // Poll every 5 seconds for pending trigger requests + String schedule = sdk.addSchedule(new CronSchedule("*/5 * * * * ?")); + sdk.setValue("scheduleId", schedule); + } + + @Override + public void deactivate() { + sdk.getValue("scheduleId", String.class).ifPresent(sdk::removeSchedule); + } + + @Override + @SuppressWarnings("unchecked") + public TriggerResult execute(Parameters parameters) { + // Read pending participants from storage + Set pending = sdk.getValue(PENDING_PARTICIPANTS_KEY, HashSet.class) + .orElse(new HashSet<>()); + + if (pending.isEmpty()) { + return TriggerResult.NOOP; + } + + LOGGER.info("Execute API trigger on study {} - triggering for {} participant(s): {}", + sdk.getStudyId(), pending.size(), pending); + + // Build action parameters for all pending participants + Set actionParams = pending.stream() + .map(pid -> new ActionParameter(sdk.getStudyId(), pid)) + .collect(Collectors.toSet()); + + // Clear pending list after processing + sdk.removeValue(PENDING_PARTICIPANTS_KEY); + + return TriggerResult.withParams(actionParams); + } +} diff --git a/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTriggerFactory.java b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTriggerFactory.java new file mode 100644 index 00000000..320aded8 --- /dev/null +++ b/studymanager-intervention/src/main/java/io/redlink/more/studymanager/component/trigger/api/ApiTriggerFactory.java @@ -0,0 +1,39 @@ +package io.redlink.more.studymanager.component.trigger.api; + +import java.util.List; + +import io.redlink.more.studymanager.core.exception.ConfigurationValidationException; +import io.redlink.more.studymanager.core.factory.TriggerFactory; +import io.redlink.more.studymanager.core.properties.TriggerProperties; +import io.redlink.more.studymanager.core.properties.model.Value; +import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK; + +public class ApiTriggerFactory extends TriggerFactory{ + private static List properties = List.of( + + ); + @Override + public String getId(){ + return "api-trigger"; + } + + @Override + public String getTitle() { + return "Api trigger intervention"; + } + + @Override + public String getDescription() { + return "Intervention triggered by external api"; + } + + @Override + public List getProperties() { + return properties; + } + + @Override + public ApiTrigger create(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException { + return new ApiTrigger(sdk, properties); + } +} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java new file mode 100644 index 00000000..06365359 --- /dev/null +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java @@ -0,0 +1,55 @@ +/* + * 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, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.controller.studymanager; + +import io.redlink.more.studymanager.repository.NameValuePairRepository; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@RestController +@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) +public class ApiTriggerController { + + public static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; + + private final NameValuePairRepository nvpairs; + + public ApiTriggerController(NameValuePairRepository nvpairs) { + this.nvpairs = nvpairs; + } + + @PostMapping("/studies/{studyId}/interventions/{interventionId}/trigger") + public ResponseEntity triggerIntervention( + @PathVariable Long studyId, + @PathVariable Integer interventionId, + @RequestBody TriggerRequest request) { + + // Get existing pending participants (or empty set) + @SuppressWarnings("unchecked") + Set pending = nvpairs.getTriggerValue( + studyId, interventionId, PENDING_PARTICIPANTS_KEY, HashSet.class) + .orElse(new HashSet<>()); + + // Add new participant(s) + pending.addAll(request.participantIds()); + + // Store back + nvpairs.setTriggerValue(studyId, interventionId, PENDING_PARTICIPANTS_KEY, (Serializable) pending); + + return ResponseEntity.accepted().build(); + } + + public record TriggerRequest(List participantIds) {} +} From 58322c3baa7bf3a154d73db9d6b45c08abfdde4b Mon Sep 17 00:00:00 2001 From: Gmasek Date: Tue, 10 Feb 2026 13:36:45 +0100 Subject: [PATCH 2/4] First impl --- .../InterventionTokenRepository.java | 104 +++++++++++++++ .../service/InterventionTokenService.java | 121 ++++++++++++++++++ .../studymanager/service/StudyService.java | 6 +- .../V1_18_0__init_intervention_api_tokens.sql | 10 ++ .../WebSecurityConfiguration.java | 3 + .../studymanager/ApiTriggerController.java | 87 +++++++++++-- .../ConfigurationApiV1Controller.java | 22 +++- .../resources/openapi/StudyManagerAPI.yaml | 5 + 8 files changed, 342 insertions(+), 16 deletions(-) create mode 100644 studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionTokenRepository.java create mode 100644 studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionTokenService.java create mode 100644 studymanager-services/src/main/resources/db/migration/V1_18_0__init_intervention_api_tokens.sql diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionTokenRepository.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionTokenRepository.java new file mode 100644 index 00000000..7947ea31 --- /dev/null +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/repository/InterventionTokenRepository.java @@ -0,0 +1,104 @@ +/* + * 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, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.repository; + +import io.redlink.more.studymanager.model.EndpointToken; +import java.util.List; +import java.util.Optional; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +public class InterventionTokenRepository { + private static final String ADD_TOKEN = + "INSERT INTO intervention_api_tokens(study_id, intervention_id, token_id, token_label, token) " + + "VALUES (:study_id, :intervention_id, (SELECT COALESCE(MAX(token_id),0)+1 FROM intervention_api_tokens WHERE study_id = :study_id AND intervention_id = :intervention_id), :token_label, :token) " + + "RETURNING *"; + private static final String LIST_TOKENS = + "SELECT token_id, token_label, created " + + "FROM intervention_api_tokens " + + "WHERE study_id = ? AND intervention_id = ?"; + private static final String GET_TOKEN = + "SELECT token_id, token_label, created " + + "FROM intervention_api_tokens " + + "WHERE study_id = ? AND intervention_id = ? AND token_id = ?"; + private static final String GET_TOKEN_SECRET = + "SELECT token FROM intervention_api_tokens " + + "WHERE study_id = ? AND intervention_id = ? AND token_id = ?"; + private static final String DELETE_TOKEN = + "DELETE FROM intervention_api_tokens " + + "WHERE study_id = ? AND intervention_id = ? AND token_id = ?"; + private static final String DELETE_ALL_FOR_STUDY_ID = + "DELETE FROM intervention_api_tokens " + + "WHERE study_id = ?"; + + private final JdbcTemplate template; + private final NamedParameterJdbcTemplate namedTemplate; + + public InterventionTokenRepository(JdbcTemplate template) { + this.template = template; + this.namedTemplate = new NamedParameterJdbcTemplate(template); + } + + public Optional addToken(Long studyId, Integer interventionId, String tokenLabel, String encryptedSecret) { + try { + return Optional.ofNullable(namedTemplate.queryForObject(ADD_TOKEN, + new MapSqlParameterSource() + .addValue("token_label", tokenLabel) + .addValue("token", encryptedSecret) + .addValue("study_id", studyId) + .addValue("intervention_id", interventionId), + getHiddenTokenRowMapper())); + } catch (DuplicateKeyException e) { + return Optional.empty(); + } + } + + public List getAllTokens(Long studyId, Integer interventionId) { + return template.query(LIST_TOKENS, getHiddenTokenRowMapper(), studyId, interventionId); + } + + public Optional getToken(Long studyId, Integer interventionId, Integer tokenId) { + try { + return Optional.ofNullable(template.queryForObject(GET_TOKEN, getHiddenTokenRowMapper(), studyId, interventionId, tokenId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public Optional getTokenSecret(Long studyId, Integer interventionId, Integer tokenId) { + try { + return Optional.ofNullable(template.queryForObject(GET_TOKEN_SECRET, String.class, studyId, interventionId, tokenId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public void deleteToken(Long studyId, Integer interventionId, Integer tokenId) { + template.update(DELETE_TOKEN, studyId, interventionId, tokenId); + } + + public void clearForStudyId(long studyId) { + template.update(DELETE_ALL_FOR_STUDY_ID, studyId); + } + + private static RowMapper getHiddenTokenRowMapper() { + return (rs, rowNum) -> new EndpointToken( + rs.getInt("token_id"), + rs.getString("token_label"), + RepositoryUtils.readInstant(rs, "created"), + null + ); + } +} diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionTokenService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionTokenService.java new file mode 100644 index 00000000..ba58b8cc --- /dev/null +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/InterventionTokenService.java @@ -0,0 +1,121 @@ +/* + * 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, Österreichische Vereinigung zur + * Förderung der wissenschaftlichen Forschung). + * Licensed under the Elastic License 2.0. + */ +package io.redlink.more.studymanager.service; + +import io.redlink.more.studymanager.model.EndpointToken; +import io.redlink.more.studymanager.model.Study; +import io.redlink.more.studymanager.repository.InterventionTokenRepository; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class InterventionTokenService { + + private final StudyStateService studyStateService; + private final InterventionTokenRepository repository; + private final PasswordEncoder passwordEncoder; + + public InterventionTokenService(StudyStateService studyStateService, + InterventionTokenRepository repository, + PasswordEncoder passwordEncoder) { + this.studyStateService = studyStateService; + this.repository = repository; + this.passwordEncoder = passwordEncoder; + } + + public Optional addToken(Long studyId, Integer interventionId, String tokenLabel) { + studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED); + String secret = UUID.randomUUID().toString(); + + Optional newToken = repository.addToken(studyId, interventionId, + tokenLabel, + passwordEncoder.encode(secret) + ); + + return newToken.map(token -> + token.withToken( + String.format("%s.%s", + Base64.getEncoder().encodeToString( + String.format("%s-%s-%s", studyId, interventionId, token.tokenId()).getBytes(StandardCharsets.UTF_8)), + Base64.getEncoder().encodeToString( + secret.getBytes(StandardCharsets.UTF_8)) + ) + ) + ); + } + + public List getTokens(Long studyId, Integer interventionId) { + return repository.getAllTokens(studyId, interventionId); + } + + public Optional getToken(Long studyId, Integer interventionId, Integer tokenId) { + return repository.getToken(studyId, interventionId, tokenId); + } + + public void deleteToken(Long studyId, Integer interventionId, Integer tokenId) { + studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED); + repository.deleteToken(studyId, interventionId, tokenId); + } + + public void alignWithStudyState(Study study) { + if (study.getStudyState() == Study.Status.CLOSED) { + repository.clearForStudyId(study.getStudyId()); + } + } + + /** + * Validates an API token and returns the resolved study and intervention IDs. + * + * @param moreApiToken the token in format Base64(studyId-interventionId-tokenId).Base64(secret) + * @return resolved token info with studyId and interventionId + * @throws AccessDeniedException if the token is invalid + */ + public ResolvedToken validateToken(String moreApiToken) { + try { + String[] split = moreApiToken.split("\\."); + if (split.length != 2) { + throw new AccessDeniedException("Invalid token format"); + } + + String[] primaryKey = new String( + Base64.getDecoder().decode(split[0]), StandardCharsets.UTF_8 + ).split("-"); + if (primaryKey.length != 3) { + throw new AccessDeniedException("Invalid token format"); + } + + 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]), StandardCharsets.UTF_8 + ); + + Optional storedHash = repository.getTokenSecret(studyId, interventionId, tokenId); + if (storedHash.isEmpty() || !passwordEncoder.matches(secret, storedHash.get())) { + throw new AccessDeniedException("Invalid token"); + } + + return new ResolvedToken(studyId, interventionId); + } catch (AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new AccessDeniedException("Invalid token"); + } + } + + public record ResolvedToken(Long studyId, Integer interventionId) {} +} diff --git a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/StudyService.java b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/StudyService.java index d262dc5c..73f28aeb 100644 --- a/studymanager-services/src/main/java/io/redlink/more/studymanager/service/StudyService.java +++ b/studymanager-services/src/main/java/io/redlink/more/studymanager/service/StudyService.java @@ -58,6 +58,7 @@ public class StudyService { private final ParticipantService participantService; private final StudyStateService studyStateService; private final IntegrationService integrationService; + private final InterventionTokenService interventionTokenService; private final ElasticService elasticService; private final PushNotificationService pushNotificationService; @@ -66,7 +67,8 @@ public class StudyService { public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepository, UserRepository userRepo, StudyStateService studyStateService, InterventionService interventionService, ObservationService observationService, - ParticipantService participantService, IntegrationService integrationService, ElasticService elasticService, PushNotificationService pushNotificationService, StudyGroupRepository studyGroupRepository) { + ParticipantService participantService, IntegrationService integrationService, InterventionTokenService interventionTokenService, + ElasticService elasticService, PushNotificationService pushNotificationService, StudyGroupRepository studyGroupRepository) { this.studyRepository = studyRepository; this.aclRepository = aclRepository; this.userRepo = userRepo; @@ -75,6 +77,7 @@ public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepos this.observationService = observationService; this.participantService = participantService; this.integrationService = integrationService; + this.interventionTokenService = interventionTokenService; this.elasticService = elasticService; this.pushNotificationService = pushNotificationService; this.studyGroupRepository = studyGroupRepository; @@ -169,6 +172,7 @@ private void alignWithStudyState(Study s) { interventionService.alignInterventionsWithStudyState(s); observationService.alignObservationsWithStudyState(s); integrationService.alignIntegrationsWithStudyState(s); + interventionTokenService.alignWithStudyState(s); } public Map> getACL(Long studyId) { diff --git a/studymanager-services/src/main/resources/db/migration/V1_18_0__init_intervention_api_tokens.sql b/studymanager-services/src/main/resources/db/migration/V1_18_0__init_intervention_api_tokens.sql new file mode 100644 index 00000000..0d0b64d4 --- /dev/null +++ b/studymanager-services/src/main/resources/db/migration/V1_18_0__init_intervention_api_tokens.sql @@ -0,0 +1,10 @@ +CREATE TABLE intervention_api_tokens ( + study_id BIGINT NOT NULL, + intervention_id INT NOT NULL, + token_id SERIAL NOT NULL, + token_label VARCHAR NOT NULL, + token VARCHAR UNIQUE NOT NULL, + created TIMESTAMP NOT NULL DEFAULT now(), + PRIMARY KEY (study_id, intervention_id, token_id), + FOREIGN KEY (study_id, intervention_id) REFERENCES interventions(study_id, intervention_id) ON DELETE CASCADE +); diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java index cdf54107..47cdf20a 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java @@ -76,6 +76,7 @@ protected SecurityFilterChain filterChain(HttpSecurity http, // Basics http.csrf(csrf -> csrf .ignoringRequestMatchers("/kibana/**") + .ignoringRequestMatchers("/api/v1/trigger/external") .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ); http.cors(AbstractHttpConfigurer::disable); @@ -92,6 +93,8 @@ protected SecurityFilterChain filterChain(HttpSecurity http, // Study-Data-Export is authenticated internally using individual access-tokens .requestMatchers(HttpMethod.GET, "/api/v1/studies/*/export/studydata/*").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/studies/*/calendar.ics").permitAll() + // External trigger endpoint is authenticated via API token, not OAuth2 + .requestMatchers(HttpMethod.POST, "/api/v1/trigger/external").permitAll() .requestMatchers("/api/v1/**").authenticated() .requestMatchers("/kibana/**").authenticated() .requestMatchers("/login/init").authenticated() diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java index 06365359..4a3a1dfb 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java @@ -8,9 +8,17 @@ */ package io.redlink.more.studymanager.controller.studymanager; +import io.redlink.more.studymanager.api.v1.model.EndpointTokenDTO; +import io.redlink.more.studymanager.controller.RequiresStudyRole; +import io.redlink.more.studymanager.model.EndpointToken; +import io.redlink.more.studymanager.model.StudyRole; +import io.redlink.more.studymanager.model.transformer.EndpointTokenTransformer; import io.redlink.more.studymanager.repository.NameValuePairRepository; +import io.redlink.more.studymanager.service.InterventionTokenService; +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.io.Serializable; @@ -25,30 +33,93 @@ public class ApiTriggerController { public static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; private final NameValuePairRepository nvpairs; + private final InterventionTokenService interventionTokenService; - public ApiTriggerController(NameValuePairRepository nvpairs) { + public ApiTriggerController(NameValuePairRepository nvpairs, + InterventionTokenService interventionTokenService) { this.nvpairs = nvpairs; + this.interventionTokenService = interventionTokenService; } @PostMapping("/studies/{studyId}/interventions/{interventionId}/trigger") + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) public ResponseEntity triggerIntervention( @PathVariable Long studyId, @PathVariable Integer interventionId, @RequestBody TriggerRequest request) { - // Get existing pending participants (or empty set) - @SuppressWarnings("unchecked") + addPendingParticipants(studyId, interventionId, request.participantIds()); + return ResponseEntity.accepted().build(); + } + + // --- Token management endpoints (OAuth2-protected) --- + + @PostMapping("/studies/{studyId}/interventions/{interventionId}/tokens") + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + public ResponseEntity createToken( + @PathVariable Long studyId, + @PathVariable Integer interventionId, + @RequestBody EndpointTokenDTO endpointTokenDTO) { + + String tokenLabel = endpointTokenDTO.getTokenLabel(); + if (tokenLabel == null || tokenLabel.isBlank()) { + return ResponseEntity.badRequest().build(); + } + + return interventionTokenService.addToken(studyId, interventionId, tokenLabel) + .map(EndpointTokenTransformer::toEndpointTokenDTO) + .map(dto -> ResponseEntity.status(HttpStatus.CREATED).body(dto)) + .orElse(ResponseEntity.badRequest().build()); + } + + @GetMapping("/studies/{studyId}/interventions/{interventionId}/tokens") + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + public ResponseEntity> getTokens( + @PathVariable Long studyId, + @PathVariable Integer interventionId) { + + List tokens = interventionTokenService.getTokens(studyId, interventionId); + return ResponseEntity.ok(EndpointTokenTransformer.toEndpointTokensDTO(tokens)); + } + + @DeleteMapping("/studies/{studyId}/interventions/{interventionId}/tokens/{tokenId}") + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + public ResponseEntity deleteToken( + @PathVariable Long studyId, + @PathVariable Integer interventionId, + @PathVariable Integer tokenId) { + + interventionTokenService.deleteToken(studyId, interventionId, tokenId); + return ResponseEntity.noContent().build(); + } + + // --- External trigger endpoint (token-authenticated, no OAuth2) --- + + @PostMapping("/trigger/external") + public ResponseEntity triggerExternal( + @RequestHeader("More-Api-Token") String moreApiToken, + @RequestBody TriggerRequest request) { + + try { + InterventionTokenService.ResolvedToken resolved = interventionTokenService.validateToken(moreApiToken); + addPendingParticipants(resolved.studyId(), resolved.interventionId(), request.participantIds()); + return ResponseEntity.accepted().build(); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + // --- Shared helpers --- + + @SuppressWarnings("unchecked") + private void addPendingParticipants(Long studyId, Integer interventionId, List participantIds) { Set pending = nvpairs.getTriggerValue( studyId, interventionId, PENDING_PARTICIPANTS_KEY, HashSet.class) .orElse(new HashSet<>()); - // Add new participant(s) - pending.addAll(request.participantIds()); + pending.addAll(participantIds); - // Store back nvpairs.setTriggerValue(studyId, interventionId, PENDING_PARTICIPANTS_KEY, (Serializable) pending); - - return ResponseEntity.accepted().build(); } public record TriggerRequest(List participantIds) {} diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ConfigurationApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ConfigurationApiV1Controller.java index d26dbb1c..7214ce52 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ConfigurationApiV1Controller.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ConfigurationApiV1Controller.java @@ -13,6 +13,8 @@ import io.redlink.more.studymanager.api.v1.model.KeycloakSettingsDTO; import io.redlink.more.studymanager.api.v1.webservices.ConfigurationApi; import io.redlink.more.studymanager.properties.FrontendConfigurationProperties; +import io.redlink.more.studymanager.properties.GatewayProperties; +import java.net.URI; import java.time.Instant; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.http.MediaType; @@ -22,13 +24,15 @@ @RestController @RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) -@EnableConfigurationProperties(FrontendConfigurationProperties.class) +@EnableConfigurationProperties({FrontendConfigurationProperties.class, GatewayProperties.class}) public class ConfigurationApiV1Controller implements ConfigurationApi { private final FrontendConfigurationProperties uiConfig; + private final GatewayProperties gatewayProperties; - public ConfigurationApiV1Controller(FrontendConfigurationProperties uiConfig) { + public ConfigurationApiV1Controller(FrontendConfigurationProperties uiConfig, GatewayProperties gatewayProperties) { this.uiConfig = uiConfig; + this.gatewayProperties = gatewayProperties; } @Override @@ -46,18 +50,22 @@ public ResponseEntity getBuildInfo() { @Override public ResponseEntity getFrontendConfig() { return ResponseEntity.ok( - transform(uiConfig) + transform(uiConfig, gatewayProperties) ); } - private static FrontendConfigurationDTO transform(FrontendConfigurationProperties uiConfig) { - return new FrontendConfigurationDTO( + private static FrontendConfigurationDTO transform(FrontendConfigurationProperties uiConfig, GatewayProperties gatewayProperties) { + var dto = new FrontendConfigurationDTO( new KeycloakSettingsDTO( uiConfig.keycloak().server(), uiConfig.keycloak().realm(), uiConfig.keycloak().clientId() )) - .title(uiConfig.title()) - ; + .title(uiConfig.title()); + String baseUrl = gatewayProperties.baseUrl(); + if (baseUrl != null && !baseUrl.isBlank()) { + dto.gatewayUrl(URI.create(baseUrl)); + } + return dto; } } diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml index 8f1569bc..9efba5d3 100644 --- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml +++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml @@ -2349,6 +2349,11 @@ components: description: | alternative title for better identification (informational only) type: string + gatewayUrl: + description: | + base-url of the data-gateway for external integrations + type: string + format: uri auth: type: object title: KeycloakSettings From d265cd9ce627512bb5e21504907bb252772d1007 Mon Sep 17 00:00:00 2001 From: Gmasek Date: Wed, 11 Feb 2026 09:29:35 +0100 Subject: [PATCH 3/4] test fix. --- .../io/redlink/more/studymanager/service/StudyServiceTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java index 2a323c2b..946b523e 100644 --- a/studymanager-services/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java +++ b/studymanager-services/src/test/java/io/redlink/more/studymanager/service/StudyServiceTest.java @@ -77,6 +77,9 @@ class StudyServiceTest { @Mock StudyStateService studyStateService; + @Mock + InterventionTokenService interventionTokenService; + @InjectMocks StudyService studyService; From 938d7843a16f38448db632b40936cccbad1feb1c Mon Sep 17 00:00:00 2001 From: Gmasek Date: Thu, 26 Feb 2026 20:58:51 +0100 Subject: [PATCH 4/4] Trigger impl fixes --- .../WebSecurityConfiguration.java | 2 +- .../studymanager/ApiTriggerController.java | 33 ++-------- .../InterventionsApiV1Controller.java | 28 +++++++- .../main/resources/openapi/ExternalAPI.yaml | 12 ++++ .../resources/openapi/StudyManagerAPI.yaml | 65 +++++++++++++++++++ .../InterventionControllerTest.java | 4 ++ 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java index 47cdf20a..50dc7ff4 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/configuration/WebSecurityConfiguration.java @@ -93,7 +93,7 @@ protected SecurityFilterChain filterChain(HttpSecurity http, // Study-Data-Export is authenticated internally using individual access-tokens .requestMatchers(HttpMethod.GET, "/api/v1/studies/*/export/studydata/*").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/studies/*/calendar.ics").permitAll() - // External trigger endpoint is authenticated via API token, not OAuth2 + // External trigger endpoint (token validated by data-gateway) .requestMatchers(HttpMethod.POST, "/api/v1/trigger/external").permitAll() .requestMatchers("/api/v1/**").authenticated() .requestMatchers("/kibana/**").authenticated() diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java index 4a3a1dfb..fa808b31 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/ApiTriggerController.java @@ -9,6 +9,7 @@ package io.redlink.more.studymanager.controller.studymanager; import io.redlink.more.studymanager.api.v1.model.EndpointTokenDTO; +import io.redlink.more.studymanager.api.v1.model.ExternalTriggerRequestDTO; import io.redlink.more.studymanager.controller.RequiresStudyRole; import io.redlink.more.studymanager.model.EndpointToken; import io.redlink.more.studymanager.model.StudyRole; @@ -18,7 +19,6 @@ 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.io.Serializable; @@ -41,17 +41,6 @@ public ApiTriggerController(NameValuePairRepository nvpairs, this.interventionTokenService = interventionTokenService; } - @PostMapping("/studies/{studyId}/interventions/{interventionId}/trigger") - @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) - public ResponseEntity triggerIntervention( - @PathVariable Long studyId, - @PathVariable Integer interventionId, - @RequestBody TriggerRequest request) { - - addPendingParticipants(studyId, interventionId, request.participantIds()); - return ResponseEntity.accepted().build(); - } - // --- Token management endpoints (OAuth2-protected) --- @PostMapping("/studies/{studyId}/interventions/{interventionId}/tokens") @@ -93,23 +82,15 @@ public ResponseEntity deleteToken( return ResponseEntity.noContent().build(); } - // --- External trigger endpoint (token-authenticated, no OAuth2) --- + // --- External trigger endpoint (pre-validated by data-gateway) --- @PostMapping("/trigger/external") public ResponseEntity triggerExternal( - @RequestHeader("More-Api-Token") String moreApiToken, - @RequestBody TriggerRequest request) { - - try { - InterventionTokenService.ResolvedToken resolved = interventionTokenService.validateToken(moreApiToken); - addPendingParticipants(resolved.studyId(), resolved.interventionId(), request.participantIds()); - return ResponseEntity.accepted().build(); - } catch (AccessDeniedException e) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - } + @RequestBody ExternalTriggerRequestDTO request) { - // --- Shared helpers --- + addPendingParticipants(request.getStudyId(), request.getInterventionId(), request.getParticipantIds()); + return ResponseEntity.accepted().build(); + } @SuppressWarnings("unchecked") private void addPendingParticipants(Long studyId, Integer interventionId, List participantIds) { @@ -121,6 +102,4 @@ private void addPendingParticipants(Long studyId, Integer interventionId, List participantIds) {} } diff --git a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/InterventionsApiV1Controller.java b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/InterventionsApiV1Controller.java index 80eea98b..0ba0f54d 100644 --- a/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/InterventionsApiV1Controller.java +++ b/studymanager/src/main/java/io/redlink/more/studymanager/controller/studymanager/InterventionsApiV1Controller.java @@ -11,6 +11,7 @@ import io.redlink.more.studymanager.api.v1.model.ActionDTO; import io.redlink.more.studymanager.api.v1.model.InterventionDTO; import io.redlink.more.studymanager.api.v1.model.TriggerDTO; +import io.redlink.more.studymanager.api.v1.model.TriggerRequestDTO; import io.redlink.more.studymanager.api.v1.webservices.InterventionsApi; import io.redlink.more.studymanager.audit.Audited; import io.redlink.more.studymanager.controller.RequiresStudyRole; @@ -18,6 +19,7 @@ import io.redlink.more.studymanager.model.transformer.ActionTransformer; import io.redlink.more.studymanager.model.transformer.InterventionTransformer; import io.redlink.more.studymanager.model.transformer.TriggerTransformer; +import io.redlink.more.studymanager.repository.NameValuePairRepository; import io.redlink.more.studymanager.service.InterventionService; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -25,16 +27,23 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.io.Serializable; +import java.util.HashSet; import java.util.List; +import java.util.Set; @RestController @RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE) public class InterventionsApiV1Controller implements InterventionsApi { + public static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants"; + private final InterventionService service; + private final NameValuePairRepository nvpairs; - public InterventionsApiV1Controller(InterventionService service) { + public InterventionsApiV1Controller(InterventionService service, NameValuePairRepository nvpairs) { this.service = service; + this.nvpairs = nvpairs; } @@ -167,4 +176,21 @@ public ResponseEntity updateTrigger(Long studyId, Integer interventi ) ); } + + @Override + @RequiresStudyRole({StudyRole.STUDY_ADMIN, StudyRole.STUDY_OPERATOR}) + @Audited + public ResponseEntity triggerIntervention(Long studyId, Integer interventionId, TriggerRequestDTO triggerRequestDTO) { + addPendingParticipants(studyId, interventionId, triggerRequestDTO.getParticipantIds()); + return ResponseEntity.accepted().build(); + } + + @SuppressWarnings("unchecked") + private void addPendingParticipants(Long studyId, Integer interventionId, java.util.List participantIds) { + Set pending = nvpairs.getTriggerValue( + studyId, interventionId, PENDING_PARTICIPANTS_KEY, HashSet.class) + .orElse(new HashSet<>()); + pending.addAll(participantIds); + nvpairs.setTriggerValue(studyId, interventionId, PENDING_PARTICIPANTS_KEY, (Serializable) pending); + } } diff --git a/studymanager/src/main/resources/openapi/ExternalAPI.yaml b/studymanager/src/main/resources/openapi/ExternalAPI.yaml index d80a5d75..cc619c0f 100644 --- a/studymanager/src/main/resources/openapi/ExternalAPI.yaml +++ b/studymanager/src/main/resources/openapi/ExternalAPI.yaml @@ -32,6 +32,18 @@ paths: description: Internal Error components: + schemas: + TriggerRequest: + type: object + required: + - participantIds + properties: + participantIds: + type: array + items: + type: integer + description: List of participant IDs to trigger + example: [1, 2, 3] securitySchemes: ApiKeyAuth: type: apiKey diff --git a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml index 9efba5d3..06203d48 100644 --- a/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml +++ b/studymanager/src/main/resources/openapi/StudyManagerAPI.yaml @@ -953,6 +953,22 @@ paths: parameters: - $ref: '#/components/parameters/StudyId' - $ref: '#/components/parameters/InterventionId' + post: + tags: + - interventions + description: Trigger intervention for specific participants + operationId: triggerIntervention + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerRequest' + responses: + '202': + description: Trigger accepted + '400': + description: Bad request get: tags: - interventions @@ -1483,8 +1499,57 @@ paths: schema: $ref: '#/components/schemas/BuildInfo' + /trigger/external: + post: + tags: + - trigger + description: External trigger endpoint (pre-validated by data-gateway) + operationId: triggerExternal + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalTriggerRequest' + responses: + '202': + description: Trigger accepted + '400': + description: Bad request + components: schemas: + TriggerRequest: + type: object + required: + - participantIds + properties: + participantIds: + type: array + items: + type: integer + description: List of participant IDs to trigger + + ExternalTriggerRequest: + type: object + required: + - studyId + - interventionId + - participantIds + properties: + studyId: + type: integer + format: int64 + description: The study ID + interventionId: + type: integer + description: The intervention ID + participantIds: + type: array + items: + type: integer + description: List of participant IDs to trigger ComponentFactory: type: object properties: diff --git a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java index 3106313b..6ecf798e 100644 --- a/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java +++ b/studymanager/src/test/java/io/redlink/more/studymanager/controller/studymanager/InterventionControllerTest.java @@ -20,6 +20,7 @@ import io.redlink.more.studymanager.model.PlatformRole; import io.redlink.more.studymanager.model.Trigger; import io.redlink.more.studymanager.model.scheduler.Event; +import io.redlink.more.studymanager.repository.NameValuePairRepository; import io.redlink.more.studymanager.service.InterventionService; import io.redlink.more.studymanager.service.OAuth2AuthenticationService; import io.redlink.more.studymanager.utils.MapperUtils; @@ -55,6 +56,9 @@ class InterventionControllerTest { @MockitoBean InterventionService interventionService; + @MockitoBean + NameValuePairRepository nameValuePairRepository; + @MockitoBean OAuth2AuthenticationService oAuth2AuthenticationService;