From 5a5bd076f75e1e62761135299f7422903e9b1e91 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 22 Jun 2026 13:02:03 -0600 Subject: [PATCH 1/4] Scope laboratory assay protocol lookups to the request container (#286) ## Rationale `GetImportMethodsAction` and four sibling actions in `LaboratoryController` looked up assay protocols by row id via `ExperimentService.getExpProtocol(int)`, an unscoped global primary-key lookup. Because these actions are guarded only by container-level permissions, a user with read access to any single folder could supply an arbitrary protocol row id and have the server operate on a protocol defined in a folder they cannot read. `GetImportMethodsAction` additionally echoed the protocol's name, container, and container path back in its response, allowing cross-container enumeration of assay design names and folder paths (an IDOR / information-disclosure issue). The unchecked lookup in `GetImportMethodsAction` could also NPE on a non-existent row id. ## Related Pull Requests None. ## Changes - Each protocol lookup now verifies the protocol is in scope for the request container via `AssayService.getAssayProtocols(getContainer())` before use, covering `GetImportMethodsAction`, `ProcessAssayDataAction`, `SaveTemplateAction`, `CreateTemplateAction`, and `GetAssayImportHeadersAction`. - The scope check is folded into each existing null guard, returning the same generic not-found message whether the row id is unknown or simply out of scope, so the response is not an existence oracle. - Legitimate same-container callers (e.g. `Laboratory.Utils.getAssayDetails` from the assay import/template panels) are unaffected, since they always pass an in-scope protocol. --- .../laboratory/LaboratoryController.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/laboratory/src/org/labkey/laboratory/LaboratoryController.java b/laboratory/src/org/labkey/laboratory/LaboratoryController.java index 643a9dc6..ccaa0dab 100644 --- a/laboratory/src/org/labkey/laboratory/LaboratoryController.java +++ b/laboratory/src/org/labkey/laboratory/LaboratoryController.java @@ -646,8 +646,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); } @@ -935,8 +936,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; @@ -1062,8 +1064,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); } @@ -1513,8 +1516,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); @@ -1877,8 +1881,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) { From ab2575ecd7f2b20701b307e0989e8156e416d021 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 22 Jun 2026 17:47:24 -0600 Subject: [PATCH 2/4] Escape HTML in LDKController to prevent XSS (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale Two spots in LDKController rendered untrusted content as raw HTML. The container-scoped-table inspection view is a correction to a previous security fix (#268): the HTMLView cleanup there wrapped the whole string in HtmlString.of, which escaped the literal
/

markup too — safe, but it broke the intended formatting. This escapes only the dynamic validation messages (which can contain arbitrary content from direct DB inserts that bypass the user schema) while preserving the markup. The invalid-redirect error message separately echoed the user-supplied URL via HtmlString.unsafe, so it is now escaped. ## Related Pull Requests - #268 ## Changes - Container-scoped-table inspection view: escape each validation message with PageFlowUtil.filter before joining with
, then wrap the assembled markup in HtmlString.unsafe — fixing the over-escaping introduced by #268 while keeping the output safe. - Invalid-redirect error message: switch the user-supplied URL from HtmlString.unsafe to HtmlString.of so it is escaped. --- LDK/src/org/labkey/ldk/LDKController.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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)); } } } From ab596194a960fc8d3b269636443cf7f49ca420d3 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 22 Jun 2026 13:59:02 -0500 Subject: [PATCH 3/4] LaboratoryController: Use java DOM API instead of StringBuilder --- .../laboratory/LaboratoryController.java | 91 ++++++++++--------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/laboratory/src/org/labkey/laboratory/LaboratoryController.java b/laboratory/src/org/labkey/laboratory/LaboratoryController.java index ccaa0dab..2efab977 100644 --- a/laboratory/src/org/labkey/laboratory/LaboratoryController.java +++ b/laboratory/src/org/labkey/laboratory/LaboratoryController.java @@ -79,6 +79,7 @@ import org.labkey.api.security.permissions.UpdatePermission; import org.labkey.api.util.ErrorRenderer; import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.HtmlString; import org.labkey.api.util.JsonUtil; import org.labkey.api.util.Pair; import org.labkey.api.util.URLHelper; @@ -106,6 +107,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 { @@ -126,13 +137,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); @@ -172,15 +183,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 @@ -231,17 +240,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) { @@ -307,25 +311,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 @@ -467,7 +469,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 @@ -506,20 +508,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?" + )); } @@ -578,7 +578,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 From c9c4f6ff8789a4fec081bff8b71b8377dd9e0598 Mon Sep 17 00:00:00 2001 From: bbimber Date: Mon, 29 Jun 2026 09:52:08 -0700 Subject: [PATCH 4/4] Use UpdateService instead of raw DbSchema layer for assay_template edits (#292) @labkey-martyp, this is another take on: https://github.com/LabKey/LabDevKitModules/pull/290. I think by using UpdateService instead of DbSchema directly we solve the container/permission issues. I added some additional validation over update permissions to fail faster as well. --------- Co-authored-by: Marty Pradere --- .../laboratory/assay/DefaultAssayParser.java | 55 +++++++++--- .../labkey/laboratory/assay/AssayHelper.java | 86 ++++++++++++++++--- 2 files changed, 117 insertions(+), 24 deletions(-) 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 bbcd2ac4..b06acf61 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, int runId) throws B { 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, int runId) throws B 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/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())