-
Notifications
You must be signed in to change notification settings - Fork 2
Develop api based intervention trigger #463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TriggerProperties> { | ||
|
|
||
| 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<Integer> 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<ActionParameter> 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiTrigger,TriggerProperties>{ | ||
| private static List<Value> 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<Value> getProperties() { | ||
| return properties; | ||
| } | ||
|
|
||
| @Override | ||
| public ApiTrigger create(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException { | ||
| return new ApiTrigger(sdk, properties); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "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<EndpointToken> 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<EndpointToken> getAllTokens(Long studyId, Integer interventionId) { | ||
| return template.query(LIST_TOKENS, getHiddenTokenRowMapper(), studyId, interventionId); | ||
| } | ||
|
|
||
| public Optional<EndpointToken> 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<String> 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<EndpointToken> getHiddenTokenRowMapper() { | ||
| return (rs, rowNum) -> new EndpointToken( | ||
| rs.getInt("token_id"), | ||
| rs.getString("token_label"), | ||
| RepositoryUtils.readInstant(rs, "created"), | ||
| null | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EndpointToken> addToken(Long studyId, Integer interventionId, String tokenLabel) { | ||
| studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED); | ||
| String secret = UUID.randomUUID().toString(); | ||
|
|
||
| Optional<EndpointToken> 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<EndpointToken> getTokens(Long studyId, Integer interventionId) { | ||
| return repository.getAllTokens(studyId, interventionId); | ||
| } | ||
|
|
||
| public Optional<EndpointToken> 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<String> 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) {} | ||
| } |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have also DB changes incoming. So this will for sure create problems with the DB migration. As we do have already instances with data that are based on those migrations it would be great if this one could be done afterwards. I will provide a fitting name for the migration file
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then when your db changes are pushed and merged revisit this so its not causing any issues. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You need first to filter
pidand check if the parsedpidisIf not -> remove the participant from pending and update the DB
Alternatively but even better: You can check this already in the Gateway before writing and return a BAD_REQUEST if invalid participantIds are included. Than you can just ignore invalid participants at this point