diff --git a/src/main/java/org/ohdsi/webapi/security/model/ConceptSetPermissionSchema.java b/src/main/java/org/ohdsi/webapi/security/model/ConceptSetPermissionSchema.java index e5d9c72cdc..9ff3ac88b6 100644 --- a/src/main/java/org/ohdsi/webapi/security/model/ConceptSetPermissionSchema.java +++ b/src/main/java/org/ohdsi/webapi/security/model/ConceptSetPermissionSchema.java @@ -15,6 +15,7 @@ public class ConceptSetPermissionSchema extends EntityPermissionSchema { put("conceptset:%s:annotation:*:delete", "Delete Annotations of Concept Set with ID = %s"); put("conceptset:*:annotation:*:delete", "Delete Annotations of any Concept Set"); put("conceptset:%s:delete", "Delete Concept Set with ID = %s"); + put("conceptset:%s:snapshot:post", "Invoke Snapshot Action for Concept Set with ID = %s"); }}; private static Map readPermissions = new HashMap() {{ diff --git a/src/main/java/org/ohdsi/webapi/service/AbstractDaoService.java b/src/main/java/org/ohdsi/webapi/service/AbstractDaoService.java index bf7a4877e8..61a2c9675f 100644 --- a/src/main/java/org/ohdsi/webapi/service/AbstractDaoService.java +++ b/src/main/java/org/ohdsi/webapi/service/AbstractDaoService.java @@ -489,4 +489,9 @@ protected List listByTags(List getConceptSets() { } + /** + * Get the list of concept sets that were locked using the snapshot lock feature + * + * @summary Get all locked concept sets + * @return A list of locked concept sets + */ + @GET + @Path("/locked") + @Produces(MediaType.APPLICATION_JSON) + public Collection getLockedConceptSets() { + return conceptSetLockingService.getLockedConceptSets(defaultGlobalReadPermissions); + } + /** * Get the concept set items for a selected concept set ID. * @@ -564,6 +588,71 @@ public ConceptSetDTO updateConceptSet(@PathParam("id") final int id, ConceptSetD ConceptSet conceptSet = conversionService.convert(conceptSetDTO, ConceptSet.class); return conversionService.convert(updateConceptSet(updated, conceptSet), ConceptSetDTO.class); } + @Path("/{id}/snapshots") + @GET + @Produces(MediaType.APPLICATION_JSON) + public List listSnapshots(@PathParam("id") final int id) throws Exception { + return conceptSetLockingService.listSnapshotsByConceptSetId(id); + } + + @Path("/{id}/snapshot") + @GET + @Produces(MediaType.APPLICATION_JSON) + public ConceptSetSnapshotParameters getLastSnapshot(@PathParam("id") final int id) throws Exception { + return conceptSetLockingService.getLastSnapshotByConceptSetId(id); + } + + @POST + @Path("/{id}/snapshot") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response invokeSnapshotAction(@PathParam("id") final int id, ConceptSetSnapshotActionRequest snapshotActionRequest) { + try { + Supplier conceptSetExpressionSupplier = () -> getConceptSetExpression(id, snapshotActionRequest.getSourceKey()); + conceptSetLockingService.invokeSnapshotAction(id, snapshotActionRequest, conceptSetExpressionSupplier); + return Response.ok().entity("Snapshot action successfully invoked.").build(); + } catch (Exception e) { + log.error("Invoke snapshot action failed", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Invoke snapshot action failed: " + e.getMessage()) + .build(); + } + } + + @POST + @Path("/locked") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response checkIsLockedBatch(IsLockedBatchCheckRequest isLockedBatchCheckRequest) { + IsLockedBatchCheckResponse response = new IsLockedBatchCheckResponse(); + try { + List ids = isLockedBatchCheckRequest.getConceptSetIds(); + Map lockStatuses = conceptSetLockingService.areLocked(ids); + response.setLockStatus(lockStatuses); + return Response.ok(response).build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Error checking lock statuses: " + e.getMessage()) + .build(); + } + } + + @POST + @Path("/snapshot-items") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response getSnapshotItems(GetConceptSetSnapshotItemsRequest request) { + try { + List conceptSetItems = conceptSetLockingService.getConceptSetSnapshotItemsBySnapshotId(request.getSnapshotId(), request.getSnapshotItemType()); + GetConceptSetSnapshotItemsResponse response = new GetConceptSetSnapshotItemsResponse(); + response.setConceptSetItems(conceptSetItems); + return Response.ok(response).build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Error fetching snapshot items: " + e.getMessage()) + .build(); + } + } private ConceptSet updateConceptSet(ConceptSet dst, ConceptSet src) { diff --git a/src/main/java/org/ohdsi/webapi/service/dto/LockedConceptSetsResponse.java b/src/main/java/org/ohdsi/webapi/service/dto/LockedConceptSetsResponse.java new file mode 100644 index 0000000000..fc5ac8a8fe --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/dto/LockedConceptSetsResponse.java @@ -0,0 +1,30 @@ +package org.ohdsi.webapi.service.dto; + +import org.ohdsi.webapi.service.lock.dto.ConceptSetSnapshotParameters; + +public class LockedConceptSetsResponse { + private ConceptSetDTO conceptSet; + private ConceptSetSnapshotParameters snapshotParameters; + + public LockedConceptSetsResponse(ConceptSetDTO conceptSet, ConceptSetSnapshotParameters snapshotParameters) { + this.conceptSet = conceptSet; + this.snapshotParameters = snapshotParameters; + } + + + public ConceptSetSnapshotParameters getSnapshotParameters() { + return snapshotParameters; + } + + public void setSnapshotParameters(ConceptSetSnapshotParameters snapshotParameters) { + this.snapshotParameters = snapshotParameters; + } + + public ConceptSetDTO getConceptSet() { + return conceptSet; + } + + public void setConceptSet(ConceptSetDTO conceptSet) { + this.conceptSet = conceptSet; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/ConceptSetLockingService.java b/src/main/java/org/ohdsi/webapi/service/lock/ConceptSetLockingService.java new file mode 100644 index 0000000000..01902fd3bd --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/ConceptSetLockingService.java @@ -0,0 +1,355 @@ +package org.ohdsi.webapi.service.lock; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Iterables; +import org.apache.commons.lang3.BooleanUtils; +import org.ohdsi.circe.vocabulary.ConceptSetExpression; +import org.ohdsi.vocabulary.Concept; +import org.ohdsi.webapi.conceptset.ConceptSet; +import org.ohdsi.webapi.conceptset.ConceptSetComparison; +import org.ohdsi.webapi.security.PermissionService; +import org.ohdsi.webapi.service.AbstractDaoService; +import org.ohdsi.webapi.service.VocabularyService; +import org.ohdsi.webapi.service.dto.ConceptSetDTO; +import org.ohdsi.webapi.service.dto.LockedConceptSetsResponse; +import org.ohdsi.webapi.service.lock.dto.ConceptSetSnapshotActionRequest; +import org.ohdsi.webapi.service.lock.dto.ConceptSetSnapshotParameters; +import org.ohdsi.webapi.service.lock.dto.GetConceptSetSnapshotItemsRequest; +import org.ohdsi.webapi.service.lock.dto.SnapshotAction; +import org.ohdsi.webapi.source.Source; +import org.ohdsi.webapi.source.SourceDaimon; +import org.ohdsi.webapi.source.SourceRepository; +import org.ohdsi.webapi.util.CancelableJdbcTemplate; +import org.ohdsi.webapi.versioning.domain.ConceptSetVersion; +import org.ohdsi.webapi.versioning.domain.VersionBase; +import org.ohdsi.webapi.versioning.domain.VersionType; +import org.ohdsi.webapi.versioning.service.VersionService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionCallback; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +@Service +public class ConceptSetLockingService extends AbstractDaoService { + + private static final String LOCKED_CONCEPT_SET_TAG_NAME = "Locked Concept Set"; + + @Autowired + private ObjectMapper objectMapper; + @Autowired + private VocabularyService vocabularyService; + @Autowired + private SourceRepository sourceRepository; + @Autowired + private VersionService versionService; + @Autowired + private SnapshotHistorySourceProvider snapshotHistorySourceProvider; + @Autowired + private GenericConversionService conversionService; + @Autowired + private PermissionService permissionService; + + + @Transactional + public void invokeSnapshotAction(int conceptSetId, ConceptSetSnapshotActionRequest snapshotActionRequest, Supplier conceptSetExpressionSupplier) { + ConceptSet conceptSet = getConceptSetRepository().findById(conceptSetId); + if (conceptSet == null) { + throw new RuntimeException("Concept Set does not exist."); + } + + Source source = sourceRepository.findBySourceKey(snapshotActionRequest.getSourceKey()); + List versions = versionService.getVersions(VersionType.CONCEPT_SET, conceptSetId); + + Timestamp lockedDate = Timestamp.from(Instant.now()); + + String snapshotCreatedBy = security.getSubject(); + + String vocabularyBundleName = source.getSourceName(); + String vocabularyBundleSchema = source.getTableQualifier(SourceDaimon.DaimonType.Vocabulary); + String vocabularyBundleVersion = vocabularyService.getInfo(snapshotActionRequest.getSourceKey()).version; + String conceptSetVersion = Integer.toString(versions.stream() + .mapToInt(VersionBase::getVersion) + .max() + .orElse(1)); + + final Function>> callbackFunction = + jdbcTemplate -> (TransactionCallback>) transactionStatus -> { + try { + Long snapshotMetadataId = jdbcTemplate.queryForObject( + String.format("INSERT INTO %s.CONCEPT_SET_SNAPSHOT_METADATA " + + "(CONCEPT_SET_ID, ACTION, ACTION_DATE, CREATED_BY, MESSAGE, VOCABULARY_BUNDLE_NAME, " + + "VOCABULARY_BUNDLE_SCHEMA, VOCABULARY_BUNDLE_VERSION, CONCEPT_SET_VERSION) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING ID", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()), + new Object[]{conceptSetId, snapshotActionRequest.getAction().toString(), lockedDate, snapshotCreatedBy, snapshotActionRequest.getMessage(), vocabularyBundleName, + vocabularyBundleSchema, vocabularyBundleVersion, conceptSetVersion}, Long.class); + + if (snapshotActionRequest.isTakeSnapshot()) { + ConceptSetExpression conceptSetExpression = conceptSetExpressionSupplier.get(); + Arrays.stream(conceptSetExpression.items).forEach(conceptSetItem -> saveConceptSetExpressionItemSnapshot(jdbcTemplate, conceptSetItem, snapshotMetadataId)); + + vocabularyService.executeIncludedConceptLookup(snapshotActionRequest.getSourceKey(), conceptSetExpression) + .forEach(concept -> saveIncludedSnapshotItem(jdbcTemplate, concept, snapshotMetadataId, "INCLUDED_CONCEPTS_SNAPSHOTS")); + + vocabularyService.executeMappedLookup(snapshotActionRequest.getSourceKey(), conceptSetExpression) + .forEach(sourceCode -> saveIncludedSnapshotItem(jdbcTemplate, sourceCode, snapshotMetadataId, "INCLUDED_SOURCE_CODES_SNAPSHOTS")); + } + return null; + } catch (Exception ex) { + log.error("An error occurred during snapshot creation", ex); + throw ex; + } + }; + + executeInTransaction(snapshotHistorySourceProvider.getSnapshotHistorySource(), callbackFunction); + + updateLockedTag(conceptSet, snapshotActionRequest.getAction()); + } + + private void updateLockedTag(ConceptSet conceptSet, SnapshotAction snapshotAction) { + try { + getTagService().listInfo(LOCKED_CONCEPT_SET_TAG_NAME) + .stream() + .findFirst() + .ifPresent(lockedConceptSetTag -> { + if (snapshotAction == SnapshotAction.LOCK) { + assignTag(conceptSet, lockedConceptSetTag.getId()); + } else { + unassignTag(conceptSet, lockedConceptSetTag.getId()); + } + }); + } catch (Exception e) { + log.error("Unable to update the concept set Locked Tag", e); + } + } + + + private void saveConceptSetExpressionItemSnapshot(JdbcTemplate jdbcTemplate, ConceptSetExpression.ConceptSetItem conceptSetItem, Long snapshotMetadataId) { + jdbcTemplate.update(String.format("INSERT INTO %s.CONCEPT_SET_ITEM_SNAPSHOTS " + + "(SNAPSHOT_METADATA_ID, CONCEPT_ID, CONCEPT_NAME, DOMAIN_ID, VOCABULARY_ID, CONCEPT_CLASS_ID, STANDARD_CONCEPT, CONCEPT_CODE, VALID_START_DATE, VALID_END_DATE, INVALID_REASON, IS_EXCLUDED, INCLUDE_DESCENDANTS, INCLUDE_MAPPED) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()), + snapshotMetadataId, conceptSetItem.concept.conceptId, conceptSetItem.concept.conceptName, conceptSetItem.concept.domainId, conceptSetItem.concept.vocabularyId, + conceptSetItem.concept.conceptClassId, conceptSetItem.concept.standardConcept, conceptSetItem.concept.conceptCode, + ((Concept) (conceptSetItem.concept)).validStartDate, ((Concept) (conceptSetItem.concept)).validEndDate, conceptSetItem.concept.invalidReason, + BooleanUtils.toInteger(conceptSetItem.isExcluded), + BooleanUtils.toInteger(conceptSetItem.includeDescendants), + BooleanUtils.toInteger(conceptSetItem.includeMapped)); + } + + private void saveIncludedSnapshotItem(JdbcTemplate jdbcTemplate, Concept concept, Long snapshotMetadataId, String snapshotItemTableName) { + jdbcTemplate.update(String.format("INSERT INTO %s.%s (SNAPSHOT_METADATA_ID, CONCEPT_ID, CONCEPT_NAME, DOMAIN_ID, VOCABULARY_ID, CONCEPT_CLASS_ID, STANDARD_CONCEPT, CONCEPT_CODE, VALID_START_DATE, VALID_END_DATE, INVALID_REASON) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema(), snapshotItemTableName), + snapshotMetadataId, concept.conceptId, concept.conceptName, concept.domainId, concept.vocabularyId, + concept.conceptClassId, concept.standardConcept, concept.conceptCode, concept.validStartDate, concept.validEndDate, concept.invalidReason); + } + + @Transactional(readOnly = true) + public Map areLocked(List conceptSetIds) { + Map lockedStatusMap = new HashMap<>(); + CancelableJdbcTemplate jdbcTemplate = getSourceJdbcTemplate(snapshotHistorySourceProvider.getSnapshotHistorySource()); + for (Integer conceptSetId : conceptSetIds) { + String sql = String.format("SELECT ACTION FROM %s.CONCEPT_SET_SNAPSHOT_METADATA " + + "WHERE CONCEPT_SET_ID = ? " + + "ORDER BY ACTION_DATE DESC " + // Ordering by ACTION_DATE, only interested in the most recent record + "LIMIT 1", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + List lastAction = jdbcTemplate.query(sql, new Object[]{conceptSetId}, (rs, rowNum) -> rs.getString("ACTION")); + lockedStatusMap.put(conceptSetId, !lastAction.isEmpty() && SnapshotAction.LOCK.toString().equals(lastAction.get(0))); + } + return lockedStatusMap; + } + + @Transactional(readOnly = true) + public ConceptSetSnapshotParameters getLastSnapshotByConceptSetId(Integer conceptSetId) { + String sql = String.format("SELECT ID, ACTION, ACTION_DATE, CREATED_BY, VOCABULARY_BUNDLE_NAME, VOCABULARY_BUNDLE_SCHEMA, " + + "VOCABULARY_BUNDLE_VERSION, CONCEPT_SET_VERSION, MESSAGE FROM %s.CONCEPT_SET_SNAPSHOT_METADATA " + + "WHERE CONCEPT_SET_ID = ? ORDER BY ACTION_DATE DESC LIMIT 1", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + + CancelableJdbcTemplate jdbcTemplate = getSourceJdbcTemplate(snapshotHistorySourceProvider.getSnapshotHistorySource()); + return Iterables.getOnlyElement(jdbcTemplate.query(sql, new Object[]{conceptSetId}, (rs, rowNum) -> { + ConceptSetSnapshotParameters snapshot = new ConceptSetSnapshotParameters(); + snapshot.setSnapshotId(rs.getLong("ID")); + snapshot.setAction(SnapshotAction.valueOf(rs.getString("ACTION"))); + snapshot.setSnapshotDate(rs.getString("ACTION_DATE")); + snapshot.setUser(rs.getString("CREATED_BY")); + snapshot.setVocabularyBundleName(rs.getString("VOCABULARY_BUNDLE_NAME")); + snapshot.setVocabularyBundleSchema(rs.getString("VOCABULARY_BUNDLE_SCHEMA")); + snapshot.setVocabularyBundleVersion(rs.getString("VOCABULARY_BUNDLE_VERSION")); + snapshot.setConceptSetVersion(rs.getString("CONCEPT_SET_VERSION")); + snapshot.setMessage(rs.getString("MESSAGE")); + return snapshot; + })); + } + + @Transactional(readOnly = true) + public List listSnapshotsByConceptSetId(Integer conceptSetId) { + String sql = String.format("SELECT ID, ACTION, ACTION_DATE, CREATED_BY, VOCABULARY_BUNDLE_NAME, VOCABULARY_BUNDLE_SCHEMA, " + + "VOCABULARY_BUNDLE_VERSION, CONCEPT_SET_VERSION, MESSAGE FROM %s.CONCEPT_SET_SNAPSHOT_METADATA " + + "WHERE CONCEPT_SET_ID = ? ORDER BY ACTION_DATE DESC", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + + CancelableJdbcTemplate jdbcTemplate = getSourceJdbcTemplate(snapshotHistorySourceProvider.getSnapshotHistorySource()); + List snapshots = jdbcTemplate.query(sql, new Object[]{conceptSetId}, (rs, rowNum) -> toConceptSetSnapshotParameters(rs)); + enrichSnapshotParametersWithEmptyIndicator(snapshots, jdbcTemplate); + return snapshots; + } + private void enrichSnapshotParametersWithEmptyIndicator(List snapshots, CancelableJdbcTemplate jdbcTemplate){ + for (ConceptSetSnapshotParameters snapshot : snapshots) { + Long snapshotId = snapshot.getSnapshotId(); + if (isSnapshotEmpty(jdbcTemplate, snapshotId)) { + snapshot.setEmptySnapshot(true); + } + } + } + + private ConceptSetSnapshotParameters toConceptSetSnapshotParameters(ResultSet rs) throws SQLException { + ConceptSetSnapshotParameters snapshot = new ConceptSetSnapshotParameters(); + snapshot.setSnapshotId(rs.getLong("ID")); + snapshot.setAction(SnapshotAction.valueOf(rs.getString("ACTION"))); + snapshot.setSnapshotDate(rs.getString("ACTION_DATE")); + snapshot.setUser(rs.getString("CREATED_BY")); + snapshot.setVocabularyBundleName(rs.getString("VOCABULARY_BUNDLE_NAME")); + snapshot.setVocabularyBundleSchema(rs.getString("VOCABULARY_BUNDLE_SCHEMA")); + snapshot.setVocabularyBundleVersion(rs.getString("VOCABULARY_BUNDLE_VERSION")); + snapshot.setConceptSetVersion(rs.getString("CONCEPT_SET_VERSION")); + snapshot.setMessage(rs.getString("MESSAGE")); + return snapshot; + } + + + @Transactional(readOnly = true) + public List getConceptSetSnapshotItemsBySnapshotId(int snapshotId, GetConceptSetSnapshotItemsRequest.ItemType type) { + String tableName; + switch (type) { + case EXPRESSION_ITEMS: + tableName = String.format("%s.CONCEPT_SET_ITEM_SNAPSHOTS", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + break; + case CONCEPTS: + tableName = String.format("%s.INCLUDED_CONCEPTS_SNAPSHOTS", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + break; + case SOURCE_CODES: + tableName = String.format("%s.INCLUDED_SOURCE_CODES_SNAPSHOTS", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + break; + default: + throw new IllegalArgumentException("Invalid ItemType provided"); + } + String sql; + if (type == GetConceptSetSnapshotItemsRequest.ItemType.EXPRESSION_ITEMS) { + sql = "SELECT CONCEPT_ID, CONCEPT_NAME, DOMAIN_ID, VOCABULARY_ID, CONCEPT_CLASS_ID, " + + "STANDARD_CONCEPT, CONCEPT_CODE, VALID_START_DATE, VALID_END_DATE, " + + "INVALID_REASON, IS_EXCLUDED, INCLUDE_DESCENDANTS, INCLUDE_MAPPED " + + "FROM " + tableName + " WHERE SNAPSHOT_METADATA_ID = ?"; + } else { + sql = "SELECT CONCEPT_ID, CONCEPT_NAME, DOMAIN_ID, VOCABULARY_ID, CONCEPT_CLASS_ID, " + + "STANDARD_CONCEPT, CONCEPT_CODE, VALID_START_DATE, VALID_END_DATE, " + + "INVALID_REASON FROM " + tableName + " WHERE SNAPSHOT_METADATA_ID = ?"; + } + + CancelableJdbcTemplate jdbcTemplate = getSourceJdbcTemplate(snapshotHistorySourceProvider.getSnapshotHistorySource()); + return jdbcTemplate.query(sql, new Object[]{snapshotId}, (rs, rowNum) -> convertToConceptSetItem(rs, type)); + } + + /** + * Helper method to check if the snapshot metadata ID has records in associated tables. + * + * @param jdbcTemplate JDBC template for querying the database. + * @param snapshotMetadataId Metadata ID to check for records. + * @return true if all tables are empty, false if at least one table contains records. + */ + private boolean isSnapshotEmpty(CancelableJdbcTemplate jdbcTemplate, Long snapshotMetadataId) { + String itemSnapshotsQuery = String.format("SELECT COUNT(*) FROM %s.CONCEPT_SET_ITEM_SNAPSHOTS WHERE SNAPSHOT_METADATA_ID = ?", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + Integer itemSnapshotsCount = jdbcTemplate.queryForObject(itemSnapshotsQuery, new Object[]{snapshotMetadataId}, Integer.class); + + String includedConceptsQuery = String.format("SELECT COUNT(*) FROM %s.INCLUDED_CONCEPTS_SNAPSHOTS WHERE SNAPSHOT_METADATA_ID = ?", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + Integer includedConceptsCount = jdbcTemplate.queryForObject(includedConceptsQuery, new Object[]{snapshotMetadataId}, Integer.class); + + String includedSourceCodesQuery = String.format("SELECT COUNT(*) FROM %s.INCLUDED_SOURCE_CODES_SNAPSHOTS WHERE SNAPSHOT_METADATA_ID = ?", snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + Integer includedSourceCodesCount = jdbcTemplate.queryForObject(includedSourceCodesQuery, new Object[]{snapshotMetadataId}, Integer.class); + + return (itemSnapshotsCount == 0) && (includedConceptsCount == 0) && (includedSourceCodesCount == 0); + } + + private ConceptSetExpression.ConceptSetItem convertToConceptSetItem(ResultSet rs, GetConceptSetSnapshotItemsRequest.ItemType type) throws SQLException { + Concept concept = new Concept(); + concept.conceptId = rs.getLong("CONCEPT_ID"); + concept.conceptName = rs.getString("CONCEPT_NAME"); + concept.domainId = rs.getString("DOMAIN_ID"); + concept.vocabularyId = rs.getString("VOCABULARY_ID"); + concept.conceptClassId = rs.getString("CONCEPT_CLASS_ID"); + concept.standardConcept = rs.getString("STANDARD_CONCEPT"); + concept.conceptCode = rs.getString("CONCEPT_CODE"); + concept.validStartDate = rs.getTimestamp("VALID_START_DATE"); + concept.validEndDate = rs.getTimestamp("VALID_END_DATE"); + concept.invalidReason = rs.getString("INVALID_REASON"); + + ConceptSetExpression.ConceptSetItem item = new ConceptSetExpression.ConceptSetItem(); + item.concept = concept; + + if (type == GetConceptSetSnapshotItemsRequest.ItemType.EXPRESSION_ITEMS) { + item.isExcluded = rs.getBoolean("IS_EXCLUDED"); + item.includeDescendants = rs.getBoolean("INCLUDE_DESCENDANTS"); + item.includeMapped = rs.getBoolean("INCLUDE_MAPPED"); + } + + return item; + } + + private Map getLockedConceptSetIdsWithMetadata() { + String sql = String.format( + "SELECT cs.ID, cs.ACTION, cs.ACTION_DATE, cs.CREATED_BY, cs.VOCABULARY_BUNDLE_NAME, " + + "cs.VOCABULARY_BUNDLE_SCHEMA, cs.VOCABULARY_BUNDLE_VERSION, cs.CONCEPT_SET_VERSION, cs.MESSAGE, cs.CONCEPT_SET_ID " + + "FROM %s.CONCEPT_SET_SNAPSHOT_METADATA cs " + + "WHERE cs.CONCEPT_SET_ID IN (" + + " SELECT subcs.CONCEPT_SET_ID FROM %s.CONCEPT_SET_SNAPSHOT_METADATA subcs " + + " WHERE subcs.ACTION_DATE = (" + + " SELECT MAX(subsubcs.ACTION_DATE) FROM %s.CONCEPT_SET_SNAPSHOT_METADATA subsubcs " + + " WHERE subsubcs.CONCEPT_SET_ID = subcs.CONCEPT_SET_ID" + + " ) AND subcs.ACTION = 'LOCK' " + + ") AND cs.ACTION = 'LOCK' " + + "ORDER BY cs.ACTION_DATE DESC", + snapshotHistorySourceProvider.getSnapshotHistorySourceSchema(), + snapshotHistorySourceProvider.getSnapshotHistorySourceSchema(), + snapshotHistorySourceProvider.getSnapshotHistorySourceSchema()); + + CancelableJdbcTemplate jdbcTemplate = getSourceJdbcTemplate(snapshotHistorySourceProvider.getSnapshotHistorySource()); + + Map resultMap = jdbcTemplate.query(sql, rs -> { + HashMap map = new HashMap<>(); + while (rs.next()) { + Integer conceptSetId = rs.getInt("CONCEPT_SET_ID"); + ConceptSetSnapshotParameters snapshot = toConceptSetSnapshotParameters(rs); + map.put(conceptSetId, snapshot); + } + return map; + }); + return resultMap; + } + + public Collection getLockedConceptSets(boolean defaultGlobalReadPermissions) { + Map lockedConceptSetMetadataByConceptSetId = getLockedConceptSetIdsWithMetadata(); + return getTransactionTemplate().execute( + transactionStatus -> StreamSupport.stream(getConceptSetRepository().findAll(lockedConceptSetMetadataByConceptSetId.keySet()).spliterator(), false) + .filter(!defaultGlobalReadPermissions ? entity -> permissionService.hasReadAccess(entity) : entity -> true) + .map(conceptSet -> { + ConceptSetDTO dto = conversionService.convert(conceptSet, ConceptSetDTO.class); + permissionService.fillWriteAccess(conceptSet, dto); + permissionService.fillReadAccess(conceptSet, dto); + return dto; + }).map(dto -> new LockedConceptSetsResponse(dto, lockedConceptSetMetadataByConceptSetId.get(dto.getId()))) + .collect(Collectors.toList())); + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/SnapshotHistorySourceProvider.java b/src/main/java/org/ohdsi/webapi/service/lock/SnapshotHistorySourceProvider.java new file mode 100644 index 0000000000..71e8bd70c1 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/SnapshotHistorySourceProvider.java @@ -0,0 +1,33 @@ +package org.ohdsi.webapi.service.lock; + +import org.ohdsi.webapi.source.Source; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SnapshotHistorySourceProvider { + + @Value("${datasource.url}") + private String snapshotHistorySourceConnection; + @Value("${datasource.ohdsi.schema}") + private String snapshotHistorySourceSchema; + @Value("${datasource.dialect}") + private String snapshotHistorySourceDialect; + @Value("${datasource.username}") + private String snapshotHistorySourceUsername; + @Value("${datasource.password}") + private String snapshotHistorySourcePassword; + + public Source getSnapshotHistorySource() { + Source source = new Source(); + source.setSourceConnection(snapshotHistorySourceConnection); + source.setSourceDialect(snapshotHistorySourceDialect); + source.setUsername(snapshotHistorySourceUsername); + source.setPassword(snapshotHistorySourcePassword); + return source; + } + + public String getSnapshotHistorySourceSchema() { + return snapshotHistorySourceSchema; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotActionRequest.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotActionRequest.java new file mode 100644 index 0000000000..8435a4ea19 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotActionRequest.java @@ -0,0 +1,40 @@ +package org.ohdsi.webapi.service.lock.dto; + +public class ConceptSetSnapshotActionRequest { + private String sourceKey; + private SnapshotAction action; + private String message; + private boolean takeSnapshot; + + public String getSourceKey() { + return sourceKey; + } + + public void setSourceKey(String sourceKey) { + this.sourceKey = sourceKey; + } + + public SnapshotAction getAction() { + return action; + } + + public void setAction(SnapshotAction action) { + this.action = action; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public boolean isTakeSnapshot() { + return takeSnapshot; + } + + public void setTakeSnapshot(boolean takeSnapshot) { + this.takeSnapshot = takeSnapshot; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotParameters.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotParameters.java new file mode 100644 index 0000000000..59f80bd7af --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/ConceptSetSnapshotParameters.java @@ -0,0 +1,94 @@ +package org.ohdsi.webapi.service.lock.dto; + +public class ConceptSetSnapshotParameters { + private Long snapshotId; + private SnapshotAction action; + private String snapshotDate; + private String user; + private String vocabularyBundleName; + private String vocabularyBundleSchema; + private String vocabularyBundleVersion; + private String conceptSetVersion; + private String message; + private boolean emptySnapshot; + + public String getSnapshotDate() { + return snapshotDate; + } + + public void setSnapshotDate(String snapshotDate) { + this.snapshotDate = snapshotDate; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getVocabularyBundleName() { + return vocabularyBundleName; + } + + public void setVocabularyBundleName(String vocabularyBundleName) { + this.vocabularyBundleName = vocabularyBundleName; + } + + public String getVocabularyBundleSchema() { + return vocabularyBundleSchema; + } + + public void setVocabularyBundleSchema(String vocabularyBundleSchema) { + this.vocabularyBundleSchema = vocabularyBundleSchema; + } + + public String getVocabularyBundleVersion() { + return vocabularyBundleVersion; + } + + public void setVocabularyBundleVersion(String vocabularyBundleVersion) { + this.vocabularyBundleVersion = vocabularyBundleVersion; + } + + public String getConceptSetVersion() { + return conceptSetVersion; + } + + public void setConceptSetVersion(String conceptSetVersion) { + this.conceptSetVersion = conceptSetVersion; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public SnapshotAction getAction() { + return action; + } + + public void setAction(SnapshotAction action) { + this.action = action; + } + + public Long getSnapshotId() { + return snapshotId; + } + + public void setSnapshotId(Long snapshotId) { + this.snapshotId = snapshotId; + } + + public boolean isEmptySnapshot() { + return emptySnapshot; + } + + public void setEmptySnapshot(boolean emptySnapshot) { + this.emptySnapshot = emptySnapshot; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsRequest.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsRequest.java new file mode 100644 index 0000000000..f52da8b546 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsRequest.java @@ -0,0 +1,29 @@ +package org.ohdsi.webapi.service.lock.dto; + +public class GetConceptSetSnapshotItemsRequest { + private int snapshotId; + + private ItemType snapshotItemType; + + public int getSnapshotId() { + return snapshotId; + } + + public void setSnapshotId(int snapshotId) { + this.snapshotId = snapshotId; + } + + public ItemType getSnapshotItemType() { + return snapshotItemType; + } + + public void setSnapshotItemType(ItemType snapshotItemType) { + this.snapshotItemType = snapshotItemType; + } + + public enum ItemType { + EXPRESSION_ITEMS, + CONCEPTS, + SOURCE_CODES + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsResponse.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsResponse.java new file mode 100644 index 0000000000..8a0de41b82 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/GetConceptSetSnapshotItemsResponse.java @@ -0,0 +1,17 @@ +package org.ohdsi.webapi.service.lock.dto; + +import org.ohdsi.circe.vocabulary.ConceptSetExpression; + +import java.util.List; + +public class GetConceptSetSnapshotItemsResponse { + private List conceptSetItems; + + public List getConceptSetItems() { + return conceptSetItems; + } + + public void setConceptSetItems(List conceptSetItems) { + this.conceptSetItems = conceptSetItems; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckRequest.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckRequest.java new file mode 100644 index 0000000000..95a429f0be --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckRequest.java @@ -0,0 +1,16 @@ +package org.ohdsi.webapi.service.lock.dto; + +import java.util.List; + +public class IsLockedBatchCheckRequest { + + private List conceptSetIds; + + public List getConceptSetIds() { + return conceptSetIds; + } + + public void setConceptSetIds(List conceptSetIds) { + this.conceptSetIds = conceptSetIds; + } +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckResponse.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckResponse.java new file mode 100644 index 0000000000..61b97f1748 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/IsLockedBatchCheckResponse.java @@ -0,0 +1,21 @@ +package org.ohdsi.webapi.service.lock.dto; + +import java.util.HashMap; +import java.util.Map; + +public class IsLockedBatchCheckResponse { + private Map lockStatus; + + public IsLockedBatchCheckResponse() { + this.lockStatus = new HashMap<>(); + } + + public Map getLockStatus() { + return lockStatus; + } + + public void setLockStatus(Map lockStatus) { + this.lockStatus = lockStatus; + } + +} diff --git a/src/main/java/org/ohdsi/webapi/service/lock/dto/SnapshotAction.java b/src/main/java/org/ohdsi/webapi/service/lock/dto/SnapshotAction.java new file mode 100644 index 0000000000..9cc25abde2 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/service/lock/dto/SnapshotAction.java @@ -0,0 +1,7 @@ +package org.ohdsi.webapi.service.lock.dto; + +public enum SnapshotAction { + LOCK, + UNLOCK, + SNAPSHOT +} diff --git a/src/main/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilter.java b/src/main/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilter.java new file mode 100644 index 0000000000..d3b561d80a --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilter.java @@ -0,0 +1,48 @@ +package org.ohdsi.webapi.shiro.filters; + +import com.google.common.collect.Iterables; +import org.apache.commons.lang3.BooleanUtils; +import org.ohdsi.webapi.service.lock.ConceptSetLockingService; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ConceptSetLockWriteBlockingFilter extends UrlBasedAuthorizingFilter { + + private final boolean snapshotLockingDisabled; + private final ConceptSetLockingService conceptSetLockingService; + + public ConceptSetLockWriteBlockingFilter(boolean snapshotLockingDisabled, ConceptSetLockingService conceptSetLockingService) { + this.snapshotLockingDisabled = snapshotLockingDisabled; + this.conceptSetLockingService = conceptSetLockingService; + } + + private static final Pattern RESTRICTED_PATH_PATTERN = Pattern.compile("^conceptset:(\\d+):(items:put|annotation:put|put)$"); + + @Override + protected boolean isPermitted(String pathPermission) { + + if (snapshotLockingDisabled) { + return true; // If snapshot locking feature is disabled in the configuration - no restrictions for concept set saving + } + + return getConceptSetIdIfRestrictedPath(pathPermission) + .map(conceptSetId -> conceptSetLockingService.areLocked(Collections.singletonList(conceptSetId))) + .map(Map::values) + .map(Iterables::getOnlyElement) + .map(BooleanUtils::negate) + .orElse(Boolean.TRUE); + } + + private Optional getConceptSetIdIfRestrictedPath(String path) { + Matcher matcher = RESTRICTED_PATH_PATTERN.matcher(path); + if (matcher.matches()) { + Integer conceptSetId = Integer.parseInt(matcher.group(1)); + return Optional.of(conceptSetId); + } + return Optional.empty(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasGoogleSecurity.java b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasGoogleSecurity.java index c86a829a08..01838f3358 100644 --- a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasGoogleSecurity.java +++ b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasGoogleSecurity.java @@ -16,6 +16,7 @@ import static org.ohdsi.webapi.shiro.management.FilterTemplates.AUTHZ; import static org.ohdsi.webapi.shiro.management.FilterTemplates.CORS; +import static org.ohdsi.webapi.shiro.management.FilterTemplates.CONCEPTSET_LOCK; import static org.ohdsi.webapi.shiro.management.FilterTemplates.JWT_AUTHC; import static org.ohdsi.webapi.shiro.management.FilterTemplates.NO_CACHE; import static org.ohdsi.webapi.shiro.management.FilterTemplates.NO_SESSION_CREATION; @@ -47,6 +48,7 @@ protected FilterChainBuilder getFilterChainBuilder() { FilterChainBuilder filterChainBuilder = new FilterChainBuilder() .setRestFilters(SSL, NO_SESSION_CREATION, CORS, NO_CACHE) .setAuthcFilter(JWT_AUTHC) + .setCustomUrlFilter(CONCEPTSET_LOCK) .setAuthzFilter(AUTHZ); setupProtectedPaths(filterChainBuilder); diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java index 6b083b9e82..01fd7c08a1 100644 --- a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java +++ b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasRegularSecurity.java @@ -412,6 +412,7 @@ protected FilterChainBuilder getFilterChainBuilder() { FilterChainBuilder filterChainBuilder = new FilterChainBuilder() .setRestFilters(SSL, NO_SESSION_CREATION, CORS, NO_CACHE) .setAuthcFilter(authcFilters.toArray(new FilterTemplates[0])) + .setCustomUrlFilter(CONCEPTSET_LOCK) .setAuthzFilter(AUTHZ) // login/logout .addRestPath("/user/refresh", JWT_AUTHC, UPDATE_TOKEN, SEND_TOKEN_IN_HEADER) diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java index 47e086a572..e158d4e7d6 100644 --- a/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java +++ b/src/main/java/org/ohdsi/webapi/shiro/management/AtlasSecurity.java @@ -1,12 +1,5 @@ package org.ohdsi.webapi.shiro.management; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; -import javax.annotation.PostConstruct; -import javax.servlet.Filter; - import org.apache.shiro.SecurityUtils; import org.apache.shiro.UnavailableSecurityManagerException; import org.apache.shiro.authc.pam.ModularRealmAuthenticator; @@ -17,7 +10,9 @@ import org.ohdsi.webapi.cohortcharacterization.CcImportEvent; import org.ohdsi.webapi.security.model.EntityPermissionSchemaResolver; import org.ohdsi.webapi.security.model.EntityType; +import org.ohdsi.webapi.service.lock.ConceptSetLockingService; import org.ohdsi.webapi.shiro.PermissionManager; +import org.ohdsi.webapi.shiro.filters.ConceptSetLockWriteBlockingFilter; import org.ohdsi.webapi.shiro.filters.CorsFilter; import org.ohdsi.webapi.shiro.filters.ForceSessionCreationFilter; import org.ohdsi.webapi.shiro.filters.ResponseNoCacheFilter; @@ -30,8 +25,16 @@ import org.springframework.context.event.EventListener; import waffle.shiro.negotiate.NegotiateAuthenticationStrategy; +import javax.annotation.PostConstruct; +import javax.servlet.Filter; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + import static org.ohdsi.webapi.shiro.management.FilterTemplates.AUTHZ; import static org.ohdsi.webapi.shiro.management.FilterTemplates.CORS; +import static org.ohdsi.webapi.shiro.management.FilterTemplates.CONCEPTSET_LOCK; import static org.ohdsi.webapi.shiro.management.FilterTemplates.FORCE_SESSION_CREATION; import static org.ohdsi.webapi.shiro.management.FilterTemplates.JWT_AUTHC; import static org.ohdsi.webapi.shiro.management.FilterTemplates.NO_CACHE; @@ -62,6 +65,11 @@ public abstract class AtlasSecurity extends Security { @Autowired protected OidcConfCreator oidcConfCreator; + @Value("${snapshot.locking.disabled:false}") + private boolean snapshotLockingDisabled; + @Autowired + protected ConceptSetLockingService conceptSetLockingService; + @Value("${server.port}") private int sslPort; @@ -129,7 +137,8 @@ private void fillFilters() { filters.put(NO_SESSION_CREATION, new NoSessionCreationFilter()); filters.put(FORCE_SESSION_CREATION, new ForceSessionCreationFilter()); - filters.put(AUTHZ, new UrlBasedAuthorizingFilter()); + filters.put(CONCEPTSET_LOCK, new ConceptSetLockWriteBlockingFilter(snapshotLockingDisabled, conceptSetLockingService)); + filters.put(AUTHZ, new UrlBasedAuthorizingFilter()); filters.put(CORS, new CorsFilter()); filters.put(SSL, this.getSslFilter()); filters.put(NO_CACHE, this.getNoCacheFilter()); diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/FilterChainBuilder.java b/src/main/java/org/ohdsi/webapi/shiro/management/FilterChainBuilder.java index 6bb2c96a19..7ad8a9ee30 100644 --- a/src/main/java/org/ohdsi/webapi/shiro/management/FilterChainBuilder.java +++ b/src/main/java/org/ohdsi/webapi/shiro/management/FilterChainBuilder.java @@ -11,6 +11,7 @@ public class FilterChainBuilder { private String restFilters; private String authcFilter; private String authzFilter; + private String customUrlFilter; private String filtersBeforeOAuth; private String filtersAfterOAuth; @@ -39,6 +40,11 @@ public FilterChainBuilder setAuthzFilter(FilterTemplates... authzFilters) { return this; } + public FilterChainBuilder setCustomUrlFilter(FilterTemplates... customUrlFilters) { + this.customUrlFilter = convertArrayToString(customUrlFilters); + return this; + } + public FilterChainBuilder addRestPath(String path, String filters) { return this.addPath(path, this.restFilters + ", " + filters); } @@ -56,7 +62,7 @@ public FilterChainBuilder addOAuthPath(String path, FilterTemplates... oauthFilt } public FilterChainBuilder addProtectedRestPath(String path) { - return this.addRestPath(path, this.authcFilter + ", " + this.authzFilter); + return this.addRestPath(path, this.authcFilter + ", " + this.authzFilter + ", " + this.customUrlFilter); } public FilterChainBuilder addProtectedRestPath(String path, FilterTemplates... filters) { diff --git a/src/main/java/org/ohdsi/webapi/shiro/management/FilterTemplates.java b/src/main/java/org/ohdsi/webapi/shiro/management/FilterTemplates.java index cc3e578fde..b0198fb32d 100644 --- a/src/main/java/org/ohdsi/webapi/shiro/management/FilterTemplates.java +++ b/src/main/java/org/ohdsi/webapi/shiro/management/FilterTemplates.java @@ -20,6 +20,7 @@ public enum FilterTemplates { NO_SESSION_CREATION("noSessionCreation"), FORCE_SESSION_CREATION("forceSessionCreation"), AUTHZ("authz"), + CONCEPTSET_LOCK("cslock"), CORS("cors"), SSL("ssl"), NO_CACHE("noCache"), diff --git a/src/main/java/org/ohdsi/webapi/tag/TagService.java b/src/main/java/org/ohdsi/webapi/tag/TagService.java index 85c50dedb8..f3080d78d1 100644 --- a/src/main/java/org/ohdsi/webapi/tag/TagService.java +++ b/src/main/java/org/ohdsi/webapi/tag/TagService.java @@ -101,6 +101,10 @@ public List listInfo(String namePart) { return tagRepository.findAllTags(namePart); } + public List listByName(String name) { + return tagRepository.findByName(name); + } + public List listInfo() { return tagRepository.findAll(); } diff --git a/src/main/java/org/ohdsi/webapi/tag/repository/TagRepository.java b/src/main/java/org/ohdsi/webapi/tag/repository/TagRepository.java index b823a667e1..8e1b3ad28c 100644 --- a/src/main/java/org/ohdsi/webapi/tag/repository/TagRepository.java +++ b/src/main/java/org/ohdsi/webapi/tag/repository/TagRepository.java @@ -11,6 +11,8 @@ public interface TagRepository extends JpaRepository { @Query("SELECT t FROM Tag t WHERE LOWER(t.name) LIKE LOWER(CONCAT(?1, '%'))") List findAllTags(String namePart); + List findByName(String name); + List findByIdIn(List ids); @Query("SELECT ct.assetId.tagId as id, " + diff --git a/src/main/resources/db/migration/postgresql/V2.15.0.20250210100000__conceptset_snapshot_lock_permissions.sql b/src/main/resources/db/migration/postgresql/V2.15.0.20250210100000__conceptset_snapshot_lock_permissions.sql new file mode 100644 index 0000000000..0d7f6a3fcf --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2.15.0.20250210100000__conceptset_snapshot_lock_permissions.sql @@ -0,0 +1,38 @@ + + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT nextval('${ohdsiSchema}.sec_permission_id_seq'), 'conceptset:*:snapshot:post', 'Invoke Concept Set Snapshot Action'; + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT nextval('${ohdsiSchema}.sec_permission_id_seq'), 'conceptset:*:snapshots:get', 'List Concept Set Snapshots'; + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT nextval('${ohdsiSchema}.sec_permission_id_seq'), 'conceptset:*:snapshot:get', 'Get current Concept Set Snapshot'; + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT nextval('${ohdsiSchema}.sec_permission_id_seq'), 'conceptset:locked:post', 'Batch-Check Concept Set Locked Snapshots'; + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT nextval('${ohdsiSchema}.sec_permission_id_seq'), 'conceptset:snapshot-items:post', 'Fetch Concept Set Snapshot Items'; + + +INSERT INTO ${ohdsiSchema}.sec_role_permission(id, role_id, permission_id) +SELECT nextval('${ohdsiSchema}.sec_role_permission_sequence'), sr.id, sp.id +FROM ${ohdsiSchema}.sec_permission SP, ${ohdsiSchema}.sec_role sr +WHERE sp.value IN ( + 'conceptset:*:snapshot:post', + 'conceptset:*:snapshots:get', + 'conceptset:*:snapshot:get', + 'conceptset:locked:post', + 'conceptset:snapshot-items:post' + ) AND sr.name IN ('admin'); + +INSERT INTO ${ohdsiSchema}.sec_role_permission(id, role_id, permission_id) +SELECT nextval('${ohdsiSchema}.sec_role_permission_sequence'), sr.id, sp.id +FROM ${ohdsiSchema}.sec_permission SP, ${ohdsiSchema}.sec_role sr +WHERE sp.value IN ( + 'conceptset:*:snapshots:get', + 'conceptset:*:snapshot:get', + 'conceptset:locked:post', + 'conceptset:snapshot-items:post' + ) AND sr.name IN ('Atlas users'); diff --git a/src/main/resources/db/migration/postgresql/V2.15.0.20250220000001__webapi_snapshots_schema_init.sql b/src/main/resources/db/migration/postgresql/V2.15.0.20250220000001__webapi_snapshots_schema_init.sql new file mode 100644 index 0000000000..52a7ba65bc --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2.15.0.20250220000001__webapi_snapshots_schema_init.sql @@ -0,0 +1,133 @@ +CREATE SEQUENCE ${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA_SEQUENCE MAXVALUE 9223372036854775807 NO CYCLE; + +CREATE SEQUENCE ${ohdsiSchema}.CONCEPT_SET_ITEM_SNAPSHOTS_SEQUENCE + START WITH 1 + INCREMENT BY 1 + MAXVALUE 9223372036854775807 + NO CYCLE; + +CREATE SEQUENCE ${ohdsiSchema}.INCLUDED_CONCEPTS_SNAPSHOTS_SEQUENCE + START WITH 1 + INCREMENT BY 1 + MAXVALUE 9223372036854775807 + NO CYCLE; + +CREATE SEQUENCE ${ohdsiSchema}.INCLUDED_SOURCE_CODES_SNAPSHOTS_SEQUENCE + START WITH 1 + INCREMENT BY 1 + MAXVALUE 9223372036854775807 + NO CYCLE; + +CREATE TABLE ${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA ( + ID INTEGER NOT NULL PRIMARY KEY DEFAULT NEXTVAL('${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA_SEQUENCE'), + CONCEPT_SET_ID INTEGER NOT NULL, + ACTION VARCHAR(10), + LOCKED_DATE TIMESTAMP, + LOCKED_BY VARCHAR(200), + MESSAGE VARCHAR(2000), + VOCABULARY_BUNDLE_NAME VARCHAR(200), + VOCABULARY_BUNDLE_SCHEMA VARCHAR(200), + VOCABULARY_BUNDLE_VERSION VARCHAR(200), + CONCEPT_SET_VERSION VARCHAR(200) +); + +CREATE TABLE ${ohdsiSchema}.CONCEPT_SET_ITEM_SNAPSHOTS ( + ID INTEGER NOT NULL PRIMARY KEY DEFAULT NEXTVAL('${ohdsiSchema}.CONCEPT_SET_ITEM_SNAPSHOTS_SEQUENCE'), + SNAPSHOT_METADATA_ID INTEGER NOT NULL, + CONCEPT_ID INTEGER NOT NULL, + CONCEPT_NAME VARCHAR(255) NOT NULL, + DOMAIN_ID VARCHAR(20) NOT NULL, + VOCABULARY_ID VARCHAR(20) NOT NULL, + CONCEPT_CLASS_ID VARCHAR(20) NOT NULL, + STANDARD_CONCEPT VARCHAR(1), + CONCEPT_CODE VARCHAR(50) NOT NULL, + VALID_START_DATE DATE NOT NULL, + VALID_END_DATE DATE NOT NULL, + INVALID_REASON VARCHAR(1), + IS_EXCLUDED INTEGER NOT NULL, + INCLUDE_DESCENDANTS INTEGER NOT NULL, + INCLUDE_MAPPED INTEGER NOT NULL, + FOREIGN KEY (SNAPSHOT_METADATA_ID) REFERENCES ${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA(ID) +); + +CREATE TABLE ${ohdsiSchema}.INCLUDED_CONCEPTS_SNAPSHOTS ( + ID INTEGER NOT NULL PRIMARY KEY DEFAULT NEXTVAL('${ohdsiSchema}.INCLUDED_CONCEPTS_SNAPSHOTS_SEQUENCE'), + SNAPSHOT_METADATA_ID INTEGER NOT NULL, + CONCEPT_ID INTEGER NOT NULL, + CONCEPT_NAME VARCHAR(255) NOT NULL, + DOMAIN_ID VARCHAR(20) NOT NULL, + VOCABULARY_ID VARCHAR(20) NOT NULL, + CONCEPT_CLASS_ID VARCHAR(20) NOT NULL, + STANDARD_CONCEPT VARCHAR(1), + CONCEPT_CODE VARCHAR(50) NOT NULL, + VALID_START_DATE DATE NOT NULL, + VALID_END_DATE DATE NOT NULL, + INVALID_REASON VARCHAR(1), + FOREIGN KEY (SNAPSHOT_METADATA_ID) REFERENCES ${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA(ID) +); + +CREATE TABLE ${ohdsiSchema}.INCLUDED_SOURCE_CODES_SNAPSHOTS ( + ID INTEGER NOT NULL PRIMARY KEY DEFAULT NEXTVAL('${ohdsiSchema}.INCLUDED_SOURCE_CODES_SNAPSHOTS_SEQUENCE'), + SNAPSHOT_METADATA_ID INTEGER NOT NULL, + CONCEPT_ID INTEGER NOT NULL, + CONCEPT_NAME VARCHAR(255) NOT NULL, + DOMAIN_ID VARCHAR(20) NOT NULL, + VOCABULARY_ID VARCHAR(20) NOT NULL, + CONCEPT_CLASS_ID VARCHAR(20) NOT NULL, + STANDARD_CONCEPT VARCHAR(1), + CONCEPT_CODE VARCHAR(50) NOT NULL, + VALID_START_DATE DATE NOT NULL, + VALID_END_DATE DATE NOT NULL, + INVALID_REASON VARCHAR(1), + FOREIGN KEY (SNAPSHOT_METADATA_ID) REFERENCES ${ohdsiSchema}.CONCEPT_SET_SNAPSHOT_METADATA(ID) +); + +-- This script inserts a new permission of type "conceptset:%s:snapshot:post" and maps it to the existing roles if such permission does not exist +--for each concept set which has a "conceptset:%s:put" permission. This is made to allow making snapshot actions to old concept sets +--which were created before the snapshot/lock feature was deployed + +DROP TABLE IF EXISTS temp_migration; + +CREATE TEMP TABLE temp_migration ( + from_perm_id INT, + new_value CHARACTER VARYING(255) +); + +INSERT INTO temp_migration (from_perm_id, new_value) +SELECT + p.id AS from_perm_id, + 'conceptset:' || split_part(p.value, ':', 2) || ':snapshot:post' AS new_value +FROM + ${ohdsiSchema}.sec_permission p +WHERE + p.value ~ '^conceptset:[0-9]+:put$'; + +INSERT INTO ${ohdsiSchema}.sec_permission (id, value, description) +SELECT + nextval('${ohdsiSchema}.sec_permission_id_seq'), + tm.new_value AS value, + 'Permission to create snapshot for concept set' +FROM + temp_migration tm +LEFT JOIN + ${ohdsiSchema}.sec_permission sp ON tm.new_value = sp.value +WHERE + sp.id IS NULL; + +INSERT INTO ${ohdsiSchema}.sec_role_permission (id, role_id, permission_id) +SELECT + nextval('${ohdsiSchema}.sec_role_permission_sequence'), + srp.role_id, + sp.id AS permission_id +FROM + temp_migration tm +JOIN + ${ohdsiSchema}.sec_permission sp ON tm.new_value = sp.value +JOIN + ${ohdsiSchema}.sec_role_permission srp ON tm.from_perm_id = srp.permission_id +LEFT JOIN + ${ohdsiSchema}.sec_role_permission rp ON srp.role_id = rp.role_id AND sp.id = rp.permission_id +WHERE + rp.id IS NULL; + +DROP TABLE temp_migration; \ No newline at end of file diff --git a/src/test/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilterTest.java b/src/test/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilterTest.java new file mode 100644 index 0000000000..fecc81e19b --- /dev/null +++ b/src/test/java/org/ohdsi/webapi/shiro/filters/ConceptSetLockWriteBlockingFilterTest.java @@ -0,0 +1,46 @@ +package org.ohdsi.webapi.shiro.filters; + +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.mockito.Mockito; +import org.ohdsi.webapi.service.lock.ConceptSetLockingService; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Matchers.anyListOf; +import static org.mockito.Mockito.when; + + +public class ConceptSetLockWriteBlockingFilterTest { + + + @Test + public void shouldAllowAllIfSnapshotLockingIsDisabled() { + ConceptSetLockWriteBlockingFilter sut = new ConceptSetLockWriteBlockingFilter(true, null); + assertThat(sut.isPermitted("conceptset:1:put"), is(true)); + } + + @Test + public void shouldRejectIfLockedConceptSet() { + ConceptSetLockingService conceptSetLockingServiceMock = Mockito.mock(ConceptSetLockingService.class); + when(conceptSetLockingServiceMock.areLocked(anyListOf(Integer.class))).thenReturn(ImmutableMap.of(1, true)); + ConceptSetLockWriteBlockingFilter sut = new ConceptSetLockWriteBlockingFilter(false, conceptSetLockingServiceMock); + assertThat(sut.isPermitted("conceptset:1:put"), is(false)); + assertThat(sut.isPermitted("conceptset:1:annotation:put"), is(false)); + assertThat(sut.isPermitted("conceptset:1:items:put"), is(false)); + } + + @Test + public void shouldAllowNonWriteConceptSetPaths() { + ConceptSetLockingService conceptSetLockingServiceMock = Mockito.mock(ConceptSetLockingService.class); + when(conceptSetLockingServiceMock.areLocked(anyListOf(Integer.class))).thenReturn(ImmutableMap.of(1, true)); + ConceptSetLockWriteBlockingFilter sut = new ConceptSetLockWriteBlockingFilter(false, conceptSetLockingServiceMock); + assertThat(sut.isPermitted("conceptset:check:post"), is(true)); + assertThat(sut.isPermitted("conceptset:locked:post"), is(true)); + assertThat(sut.isPermitted("conceptset:1:get"), is(true)); + assertThat(sut.isPermitted("conceptset:1:annotation:get"), is(true)); + assertThat(sut.isPermitted("conceptset:1:items:get"), is(true)); + } + + +}