diff --git a/LDK/src/org/labkey/ldk/LDKController.java b/LDK/src/org/labkey/ldk/LDKController.java index 6709803f..b57cdc7f 100644 --- a/LDK/src/org/labkey/ldk/LDKController.java +++ b/LDK/src/org/labkey/ldk/LDKController.java @@ -92,6 +92,7 @@ import java.util.Map; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; public class LDKController extends SpringActionController { @@ -439,9 +440,9 @@ public ModelAndView getView(Object form, BindException errors) throws Exception List messages = service.validateContainerScopedTables(false); String sb = "This page is designed to inspect all registered container scoped tables and report any tables with duplicate keys in the same container. This should be enforced by the user schema; however, direct DB inserts will bypass this check.

" + - StringUtils.join(messages, "
"); + messages.stream().map(PageFlowUtil::filter).collect(Collectors.joining("
")); - return new HtmlView(HtmlString.of(sb)); + return new HtmlView(HtmlString.unsafe(sb)); } @Override @@ -912,7 +913,7 @@ public ModelAndView getView(Object form, BindException errors) throws Exception } catch (URISyntaxException e) { - return new HtmlView(HtmlString.unsafe("Invalid redirect URL set: " + urlString)); + return new HtmlView(HtmlString.of("Invalid redirect URL set: " + urlString)); } } } diff --git a/laboratory/api-src/org/labkey/api/laboratory/assay/DefaultAssayParser.java b/laboratory/api-src/org/labkey/api/laboratory/assay/DefaultAssayParser.java index c7bdf324..4046640f 100644 --- a/laboratory/api-src/org/labkey/api/laboratory/assay/DefaultAssayParser.java +++ b/laboratory/api-src/org/labkey/api/laboratory/assay/DefaultAssayParser.java @@ -20,18 +20,19 @@ import org.apache.commons.beanutils.ConversionException; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.poi.openxml4j.exceptions.InvalidFormatException; import org.json.JSONArray; import org.json.JSONObject; +import org.labkey.api.assay.AssayProvider; +import org.labkey.api.assay.AssayService; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.ContainerType; import org.labkey.api.data.ConvertHelper; -import org.labkey.api.data.DbSchema; -import org.labkey.api.data.RuntimeSQLException; import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; import org.labkey.api.exp.PropertyDescriptor; @@ -44,14 +45,14 @@ import org.labkey.api.laboratory.LaboratoryService; import org.labkey.api.query.BatchValidationException; import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.UserSchema; import org.labkey.api.query.ValidationException; import org.labkey.api.reader.ColumnDescriptor; import org.labkey.api.reader.ExcelFactory; import org.labkey.api.reader.Readers; import org.labkey.api.reader.TabLoader; import org.labkey.api.security.User; -import org.labkey.api.assay.AssayProvider; -import org.labkey.api.assay.AssayService; import org.labkey.api.util.FileType; import org.labkey.api.util.JsonUtil; import org.labkey.api.util.Pair; @@ -466,10 +467,16 @@ protected void saveTemplate(ViewContext ctx, int templateId, long runId) throws { try { - //validate the template exists - TableInfo ti = DbSchema.get("laboratory").getTable("assay_run_templates"); + //validate the template exists. Note: this should always act against the current container, even if the container is a workbook (e.g., it should not allow cross-workbook actions) + UserSchema us = QueryService.get().getUserSchema(ctx.getUser(), ctx.getContainer(), "laboratory"); + if (us == null) + { + throw new IllegalStateException("The laboratory schema is not available in container: " + ctx.getContainer().getPath()); + } + + TableInfo ti = us.getTable("assay_run_templates"); TableSelector ts = new TableSelector(ti, new SimpleFilter(FieldKey.fromString("rowid"), templateId), null); - if (ts.getRowCount() == 0) + if (!ts.exists()) { throw new BatchValidationException(Collections.singletonList(new ValidationException("Unknown template: " + templateId)), null); } @@ -478,11 +485,15 @@ protected void saveTemplate(ViewContext ctx, int templateId, long runId) throws row.put("runid", runId); row.put("status", "Complete"); - Table.update(ctx.getUser(), ti, row, templateId); + ti.getUpdateService().updateRows(ctx.getUser(), ctx.getContainer(), Arrays.asList(row), Arrays.asList(Map.of("rowid", templateId)), null, null); + } + catch (BatchValidationException e) + { + throw e; } - catch (RuntimeSQLException e) + catch (Exception e) { - throw new BatchValidationException(Collections.singletonList(new ValidationException(e.getSQLException().getMessage())), null); + throw new BatchValidationException(Collections.singletonList(new ValidationException(e.getMessage())), null); } } @@ -584,8 +595,12 @@ protected Map> getTemplateRowMap(ImportContext conte if (templateId == null) return ret; - TableInfo ti = DbSchema.get("laboratory").getTable("assay_run_templates"); - + UserSchema us = QueryService.get().getUserSchema(context.getViewContext().getUser(), context.getViewContext().getContainer().getContainerFor(ContainerType.DataType.tabParent), "laboratory"); + if (us == null) + { + throw new IllegalStateException("Could not find the laboratory schema in container: " + context.getViewContext().getContainer().getContainerFor(ContainerType.DataType.tabParent).getPath()); + } + TableInfo ti = us.getTable("assay_run_templates"); TableSelector ts = new TableSelector(ti, new SimpleFilter(FieldKey.fromString("rowid"), templateId), null); Map[] maps = ts.getMapArray(); if (maps.length == 0) @@ -595,6 +610,18 @@ protected Map> getTemplateRowMap(ImportContext conte Map map = maps[0]; JSONObject templateJson = new JSONObject((String)map.get("json")); + + // This enforces that the request and existing record are from the same container, including for workbook/parents: + Container rowContainer = ContainerManager.getForId(String.valueOf(map.get("container"))); + if (rowContainer == null) + { + throw new IllegalStateException("Unable to determine the container for template: " + templateId); + } + else if (!rowContainer.equals(context.getViewContext().getContainer())) + { + throw new IllegalStateException("Template is from the wrong container: " + templateId); + } + JSONArray rows = templateJson.getJSONArray("ResultRows"); for (JSONObject row : JsonUtil.toJSONObjectList(rows)) { diff --git a/laboratory/src/org/labkey/laboratory/LaboratoryController.java b/laboratory/src/org/labkey/laboratory/LaboratoryController.java index 98ef9ea9..439e8dca 100644 --- a/laboratory/src/org/labkey/laboratory/LaboratoryController.java +++ b/laboratory/src/org/labkey/laboratory/LaboratoryController.java @@ -80,6 +80,7 @@ import org.labkey.api.util.ErrorRenderer; import org.labkey.api.util.ExceptionUtil; import org.labkey.api.util.FileUtil; +import org.labkey.api.util.HtmlString; import org.labkey.api.util.JsonUtil; import org.labkey.api.util.Pair; import org.labkey.api.util.URLHelper; @@ -107,6 +108,16 @@ import java.util.Map; import java.util.Set; +import static org.labkey.api.util.DOM.BR; +import static org.labkey.api.util.DOM.INPUT; +import static org.labkey.api.util.DOM.P; +import static org.labkey.api.util.DOM.TABLE; +import static org.labkey.api.util.DOM.TD; +import static org.labkey.api.util.DOM.TR; +import static org.labkey.api.util.DOM.at; +import static org.labkey.api.util.DOM.createHtmlFragment; +import static org.labkey.api.util.DOM.Attribute.style; + public class LaboratoryController extends SpringActionController { @@ -127,13 +138,13 @@ public ModelAndView getView(PlanExptRunForm form, BindException errors) throws E Integer assayId = form.getAssayId(); if (assayId == null) { - return new HtmlView("Error: must provide a rowId for the assay"); + return new HtmlView(HtmlString.of("Error: must provide a rowId for the assay")); } AssayDataProvider ad = LaboratoryService.get().getDataProviderForAssay(assayId); if (ad == null || !ad.supportsRunTemplates()) { - return new HtmlView("Error: this assay does not support requests"); + return new HtmlView(HtmlString.of("Error: this assay does not support requests")); } Module labModule = ModuleLoader.getInstance().getModule(LaboratoryModule.NAME); @@ -173,15 +184,13 @@ public void validateCommand(Object form, Errors errors) @Override public ModelAndView getConfirmView(Object form, BindException errors) throws Exception { - StringBuilder msg = new StringBuilder(); - msg.append("Certain assays can have performance improved by the addition of indexes, which can be suggested by modules. The following indexes are recommended for the assays installed on this server:

"); - List msgs = LaboratoryManager.get().createIndexes(getUser(), false, false); - msg.append(StringUtils.join(msgs, "
")); - msg.append("

Do you want to continue?"); - - return new HtmlView(msg.toString()); + return new HtmlView(createHtmlFragment( + "Certain assays can have performance improved by the addition of indexes, which can be suggested by modules. The following indexes are recommended for the assays installed on this server:", + P(msgs.stream().map(msg -> createHtmlFragment(msg, BR()))), + P("Do you want to continue?") + )); } @Override @@ -232,17 +241,12 @@ public ModelAndView getConfirmView(EnsureAssayFieldsForm form, BindException err { try { - StringBuilder sb = new StringBuilder(); - sb.append("This action will iterate all protocols for the assay " + form.getProviderName() + " and append any columns present in the definition, but lacking from that instance of the assay. The following changes will be made:

"); List messages = AssayHelper.ensureAssayFields(getUser(), form.getProviderName(), form.isRenameConflicts(), true); - for (String msg : messages) - { - sb.append(msg).append("

"); - } - - sb.append("
Do you want to continue?"); - - return new HtmlView(sb.toString()); + return new HtmlView(createHtmlFragment( + "This action will iterate all protocols for the assay " + form.getProviderName() + " and append any columns present in the definition, but lacking from that instance of the assay. The following changes will be made:", BR(), BR(), + messages.stream().map(msg -> createHtmlFragment(msg, BR(), BR())), + BR(), "Do you want to continue?" + )); } catch (ChangePropertyDescriptorException e) { @@ -308,25 +312,23 @@ public void validateCommand(SetTableIncrementForm form, Errors errors) @Override public ModelAndView getConfirmView(SetTableIncrementForm form, BindException errors) throws Exception { - StringBuilder sb = new StringBuilder(); - sb.append("This allows you to reset the current value for an auto-incrementing table

"); - sb.append(""); - String schema = form.getSchemaName() == null ? "" : form.getSchemaName(); - sb.append(""); - String query = form.getQueryName() == null ? "" : form.getQueryName(); - sb.append(""); ContainerIncrementingTable ti = getTable(schema, query, errors, false); - Integer value = null; + Integer currentId = null; if (ti != null) - value = ti.getCurrentId(getContainer()); - - sb.append(""); - sb.append("
Schema:
Query:
Value:

Do you want to continue?"); + currentId = ti.getCurrentId(getContainer()); - return new HtmlView(sb.toString()); + return new HtmlView(createHtmlFragment( + "This allows you to reset the current value for an auto-incrementing table", BR(), BR(), + TABLE(at(style, "border-collapse: collapse;"), + TR(TD("Schema:"), TD(INPUT(at().name("schema").value(schema)))), + TR(TD("Query:"), TD(INPUT(at().name("query").value(query)))), + TR(TD("Value:"), TD(INPUT(at().name("value").value(currentId == null ? "" : currentId.toString())))) + ), + BR(), "Do you want to continue?" + )); } @Override @@ -468,7 +470,7 @@ public void validateCommand(Object form, Errors errors) @Override public ModelAndView getConfirmView(Object form, BindException errors) throws Exception { - return new HtmlView("This action will iterate all workbooks in the current folder and create laboratory experiments for them as needed"); + return new HtmlView(HtmlString.of("This action will iterate all workbooks in the current folder and create laboratory experiments for them as needed")); } @Override @@ -507,20 +509,18 @@ public void validateCommand(SetTableIncrementForm form, Errors errors) @Override public ModelAndView getConfirmView(SetTableIncrementForm form, BindException errors) throws Exception { - StringBuilder sb = new StringBuilder(); - sb.append("This allows you to initialize the autoincrementing column for the provided schema/query

"); - sb.append("This is very rarely required and was created as a helper for admins with a good deal of knowledge about this module. Under most cases these columns will be automatically populated and you will not need to worry about this. If you are unsure about this page, please post on the LabKey help forums, which are read by the authors of this module.

"); - sb.append(""); - String schema = form.getSchemaName() == null ? "" : form.getSchemaName(); - sb.append(""); - String query = form.getQueryName() == null ? "" : form.getQueryName(); - sb.append(""); - sb.append("
Schema:
Query:

Do you want to continue?"); - - return new HtmlView(sb.toString()); + return new HtmlView(createHtmlFragment( + "This allows you to initialize the autoincrementing column for the provided schema/query", BR(), BR(), + "This is very rarely required and was created as a helper for admins with a good deal of knowledge about this module. Under most cases these columns will be automatically populated and you will not need to worry about this. If you are unsure about this page, please post on the LabKey help forums, which are read by the authors of this module.", BR(), BR(), + TABLE(at(style, "border-collapse: collapse;"), + TR(TD("Schema:"), TD(INPUT(at().name("schema").value(schema)))), + TR(TD("Query:"), TD(INPUT(at().name("query").value(query)))) + ), + BR(), "Do you want to continue?" + )); } @@ -579,7 +579,8 @@ public void validateCommand(Object form, Errors errors) @Override public ModelAndView getConfirmView(Object form, BindException errors) throws Exception { - return new HtmlView("This action will reset webparts and tabs for the current folder and all children to the default Laboratory FolderType, if these folders are either Laboratory Folders or Expt Workbooks"); + HtmlString message = HtmlString.of("This action will reset webparts and tabs for the current folder and all children to the default Laboratory FolderType, if these folders are either Laboratory Folders or Expt Workbooks"); + return new HtmlView(message); } @Override @@ -647,8 +648,9 @@ public String getResponse(ProcessAssayForm form, Map> throw new UploadException("No Assay Id Provided", HttpServletResponse.SC_BAD_REQUEST); } + // getExpProtocol() is unscoped, so verify the protocol is in scope for this container before using it. ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getLabkeyAssayId()); - if (protocol == null) + if (protocol == null || !AssayService.get().getAssayProtocols(getContainer()).contains(protocol)) { throw new UploadException("Unable to find assay protocol with Id: " + form.getLabkeyAssayId(), HttpServletResponse.SC_BAD_REQUEST); } @@ -936,8 +938,9 @@ public ApiResponse execute(SaveTemplateForm form, BindException errors) throws E { JSONObject json = new JSONObject(form.getJson()); + // getExpProtocol() is unscoped, so verify the protocol is in scope for this container before saving a template against it. ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getProtocolId()); - if (protocol == null) + if (protocol == null || !AssayService.get().getAssayProtocols(getContainer()).contains(protocol)) { errors.reject(ERROR_MSG, "Unknown assay: " + form.getProtocolId()); return null; @@ -1063,8 +1066,9 @@ public void export(ProcessAssayForm form, HttpServletResponse response, BindExce return; } + // getExpProtocol() is unscoped, so verify the protocol is in scope for this container before generating a template against it. ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getLabkeyAssayId()); - if (protocol == null) + if (protocol == null || !AssayService.get().getAssayProtocols(getContainer()).contains(protocol)) { throw new AbstractFileUploadAction.UploadException("Unable to find assay protocol with Id: " + form.getLabkeyAssayId(), HttpServletResponse.SC_BAD_REQUEST); } @@ -1514,8 +1518,9 @@ public ApiResponse execute(AssayImportHeadersForm form, BindException errors) return new ApiSimpleResponse(results); } + // getExpProtocol() is unscoped, so verify the protocol is in scope for this container before returning its import columns. ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getProtocol()); - if (protocol == null) + if (protocol == null || !AssayService.get().getAssayProtocols(getContainer()).contains(protocol)) { errors.reject(ERROR_MSG, "Protocol not found: " + form.getProtocol()); return new ApiSimpleResponse(results); @@ -1916,8 +1921,16 @@ public ApiResponse execute(ImportMethodsForm form, BindException errors) List protocols = new ArrayList<>(); if (form.getAssayId() != null) { - protocols.add(ExperimentService.get().getExpProtocol(form.getAssayId())); - ap = AssayService.get().getProvider(protocols.get(0)); + ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getAssayId()); + // getExpProtocol() is unscoped, so verify the protocol is in scope before echoing its metadata; otherwise a user + // could enumerate arbitrary row ids and harvest assay names and container paths from folders they cannot read. + if (protocol == null || !AssayService.get().getAssayProtocols(getContainer()).contains(protocol)) + { + errors.reject(ERROR_MSG, "Unknown assay: " + form.getAssayId()); + return null; + } + protocols.add(protocol); + ap = AssayService.get().getProvider(protocol); } else if (form.getAssayType() != null) { diff --git a/laboratory/src/org/labkey/laboratory/assay/AssayHelper.java b/laboratory/src/org/labkey/laboratory/assay/AssayHelper.java index ee4a9816..c94fa98d 100644 --- a/laboratory/src/org/labkey/laboratory/assay/AssayHelper.java +++ b/laboratory/src/org/labkey/laboratory/assay/AssayHelper.java @@ -32,9 +32,9 @@ import org.labkey.api.collections.CollectionUtils; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.ContainerType; +import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.TSVMapWriter; -import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; import org.labkey.api.exp.ChangePropertyDescriptorException; @@ -51,9 +51,14 @@ import org.labkey.api.laboratory.assay.AssayDataProvider; import org.labkey.api.laboratory.assay.AssayImportMethod; import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.UserSchema; import org.labkey.api.query.ValidationException; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.UpdatePermission; import org.labkey.api.util.FileUtil; +import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewContext; @@ -66,6 +71,7 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -125,27 +131,44 @@ public Map saveTemplate(User u, Container c, ExpProtocol protoco { validateTemplate(u, c, protocol, templateId, title, importMethod, json); - TableInfo ti = LaboratorySchema.getInstance().getSchema().getTable(LaboratorySchema.TABLE_ASSAY_RUN_TEMPLATES); + UserSchema us = QueryService.get().getUserSchema(u, c, "laboratory"); + if (us == null) + { + throw new IllegalStateException("Could not find the laboratory schema in container: " + c.getPath()); + } + + TableInfo ti = us.getTable(LaboratorySchema.TABLE_ASSAY_RUN_TEMPLATES); Map row = new HashMap<>(); row.put("assayId", protocol.getRowId()); row.put("title", title); row.put("importMethod", importMethod); row.put("json", json.toString()); - row.put("container", c.getId()); if (templateId == null) { - row = Table.insert(u, ti, row); + BatchValidationException bve = new BatchValidationException(); + List> rows = ti.getUpdateService().insertRows(u, c, Arrays.asList(row), bve, null, null); + if (bve.hasErrors()) + { + throw bve; + } + + return rows.get(0); } else { row.put("rowid", templateId); - row = Table.update(u, ti, row, templateId); + List> rows = ti.getUpdateService().updateRows(u, c, Arrays.asList(row), Arrays.asList(Map.of("rowid", templateId)), null, null); + return rows.get(0); } - - return row; } - catch (RuntimeSQLException e) + catch (BatchValidationException e) + { + // Expected validation failures (e.g. from validateTemplate() or insertRows()) should propagate to the + // client as-is, not be logged as server errors. + throw e; + } + catch (Exception e) { _log.error(e.getMessage(), e); errors.addRowError(new ValidationException(e.getMessage())); @@ -153,7 +176,7 @@ public Map saveTemplate(User u, Container c, ExpProtocol protoco } } - public void validateTemplate(User u, Container c, ExpProtocol protocol, Integer templateId, String title, String importMethod, JSONObject json) throws BatchValidationException + public void validateTemplate(User u, Container c, ExpProtocol protocol, @Nullable Integer templateId, String title, String importMethod, JSONObject json) throws BatchValidationException { BatchValidationException errors = new BatchValidationException(); @@ -178,6 +201,49 @@ public void validateTemplate(User u, Container c, ExpProtocol protocol, Integer throw errors; } + // Verify if this template exists and permissions. This expects any existing row to be present in the current container: + if (templateId != null) + { + // This queries the current container+workbooks to identify the existence of rows in other reasonable containers: + UserSchema us = QueryService.get().getUserSchema(u, c.getContainerFor(ContainerType.DataType.tabParent), "laboratory"); + TableInfo ti = us.getTable("assay_run_templates"); + TableSelector ts = new TableSelector(ti, PageFlowUtil.set("container"), new SimpleFilter(FieldKey.fromString("rowId"), templateId), null); + if (ts.exists()) + { + Container rowContainer = ContainerManager.getForId(ts.getObject(String.class)); + if (rowContainer == null) + { + errors.addRowError(new ValidationException("Unable to determine the container for template: " + templateId)); + throw errors; + } + + if (!rowContainer.hasPermission("AssayHelper.validateTemplate()", u, UpdatePermission.class)) + { + errors.addRowError(new ValidationException("The current user does not have permission to edit template: " + templateId)); + throw errors; + } + + if (!c.equals(rowContainer)) + { + errors.addRowError(new ValidationException("Template " + templateId + " is not from this folder")); + throw errors; + } + } + else + { + errors.addRowError(new ValidationException("Unable to find template with ID: " + templateId)); + throw errors; + } + } + else + { + if (!c.hasPermission("AssayHelper.validateTemplate()", u, UpdatePermission.class)) + { + errors.addRowError(new ValidationException("The current user does not have permission to creates templates in folder: " + c.getName())); + throw errors; + } + } + method.validateTemplate(u, c, protocol, templateId, title, json, errors); if (errors.hasErrors())