From 3c591cd9286837abc31e4eedb33cd2bb6b8f80ef Mon Sep 17 00:00:00 2001
From: Josh Eckels
Date: Mon, 15 Jun 2026 09:50:01 -0700
Subject: [PATCH 01/14] Use centralized XML parser config - backport (#7751)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### Rationale
Backport changes already in develop to help configure XML parsers
consistently
#### Changes
- Configure parsers for security
#### Tasks 📍
- [x] Claude Code Review
- ~Manual Testing~
- ~Test Automation~
---
api/src/org/labkey/api/util/XmlBeansUtil.java | 71 +++++++++++++++----
1 file changed, 57 insertions(+), 14 deletions(-)
diff --git a/api/src/org/labkey/api/util/XmlBeansUtil.java b/api/src/org/labkey/api/util/XmlBeansUtil.java
index 6f0eafdd61e..397e2eb7d47 100644
--- a/api/src/org/labkey/api/util/XmlBeansUtil.java
+++ b/api/src/org/labkey/api/util/XmlBeansUtil.java
@@ -28,6 +28,7 @@
import org.labkey.api.settings.LookAndFeelProperties;
import org.xml.sax.SAXException;
+import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;
@@ -121,36 +122,78 @@ public static void addComment(XmlTokenSource doc, String comment)
cursor.dispose();
}
- /** XML parsing factories preconfigured to prevent XML external entity references (XXE) */
+ /**
+ * XML parsing factories preconfigured to prevent XML external entity references (XXE).
+ * These are static and are unfortunately mutable. We could switch to a factory pattern to create
+ * freshly configured factories.
+ */
public static final SAXParserFactory SAX_PARSER_FACTORY;
+ public static final SAXParserFactory SAX_PARSER_FACTORY_ALLOWING_DOCTYPE;
public static final XMLInputFactory XML_INPUT_FACTORY;
public static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY;
+ public static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY_ALLOWING_DOCTYPE;
static
{
+ //noinspection XMLInputFactory
XML_INPUT_FACTORY = XMLInputFactory.newInstance();
XML_INPUT_FACTORY.setProperty(XMLInputFactory.SUPPORT_DTD, false);
XML_INPUT_FACTORY.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
- SAX_PARSER_FACTORY = SAXParserFactory.newInstance();
try
{
- SAX_PARSER_FACTORY.setNamespaceAware(true);
- SAX_PARSER_FACTORY.setFeature("http://xml.org/sax/features/validation", false);
- SAX_PARSER_FACTORY.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
- SAX_PARSER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
-
- DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance();
- DOCUMENT_BUILDER_FACTORY.setNamespaceAware(true);
- DOCUMENT_BUILDER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
- DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false);
- DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
- DOCUMENT_BUILDER_FACTORY.setXIncludeAware(false);
- DOCUMENT_BUILDER_FACTORY.setExpandEntityReferences(false);
+ SAX_PARSER_FACTORY = saxParserFactory(false);
+ SAX_PARSER_FACTORY_ALLOWING_DOCTYPE = saxParserFactory(true);
+
+ DOCUMENT_BUILDER_FACTORY = documentBuilderFactory(false);
+ // Use the ALLOWING_DOCTYPE variant when parsing XML that contains a declaration (e.g. NCBI's eSummary responses)
+ DOCUMENT_BUILDER_FACTORY_ALLOWING_DOCTYPE = documentBuilderFactory(true);
}
catch (ParserConfigurationException | SAXException e)
{
throw UnexpectedException.wrap(e);
}
}
+
+ private static SAXParserFactory saxParserFactory(boolean allowDocType) throws SAXException, ParserConfigurationException
+ {
+ //noinspection XMLInputFactory
+ SAXParserFactory result = SAXParserFactory.newInstance();
+ result.setNamespaceAware(true);
+ result.setFeature("http://xml.org/sax/features/validation", false);
+ result.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+
+ // Disable features that could lead to XXE or other vulnerabilities
+ // Keep in sync with ModuleArchive.nameFromModuleXML()
+ if (!allowDocType)
+ {
+ result.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ }
+ result.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ result.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ result.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ return result;
+ }
+
+ private static DocumentBuilderFactory documentBuilderFactory(boolean allowDocType) throws ParserConfigurationException
+ {
+ //noinspection XMLInputFactory
+ DocumentBuilderFactory result = DocumentBuilderFactory.newInstance();
+ result.setNamespaceAware(true);
+
+ // Disable features that could lead to XXE or other vulnerabilities.
+ // When allowDocType is true the DOCTYPE declaration is permitted. External entity
+ // resolution remains disabled, so XXE protection is still in effect.
+ if (!allowDocType)
+ {
+ result.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ }
+ result.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ result.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ result.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+ result.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ result.setXIncludeAware(false);
+ result.setExpandEntityReferences(false);
+ return result;
+ }
}
From b29668ab5a6d68572062ea8c9b265fceb5caab30 Mon Sep 17 00:00:00 2001
From: Karl Lum
Date: Mon, 15 Jun 2026 11:08:31 -0700
Subject: [PATCH 02/14] Container scoping improvements for NAb assay (#7747)
#### Rationale
Small refactors to help introduce better container scoping checks for
NAb actions.
#### Related Pull Requests
- https://github.com/LabKey/platform/pull/7747
- https://github.com/LabKey/commonAssays/pull/1022
- https://github.com/LabKey/testAutomation/pull/3042
---------
Co-authored-by: labkey-nicka
Co-authored-by: cnathe
---
.../org/labkey/api/exp/api/ExperimentService.java | 3 +++
api/src/org/labkey/api/view/ViewServlet.java | 6 ++++++
.../api/assay/nab/view/DilutionGraphAction.java | 3 ++-
.../api/assay/nab/view/GraphSelectedAction.java | 9 +++++++++
.../api/assay/nab/view/MultiGraphAction.java | 12 ++++++++++--
.../experiment/api/ExperimentServiceImpl.java | 15 ++++++++++++++-
6 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/api/src/org/labkey/api/exp/api/ExperimentService.java b/api/src/org/labkey/api/exp/api/ExperimentService.java
index eb195043dee..b744dcbf3ca 100644
--- a/api/src/org/labkey/api/exp/api/ExperimentService.java
+++ b/api/src/org/labkey/api/exp/api/ExperimentService.java
@@ -163,6 +163,9 @@ enum DataTypeForExclusion
@Nullable
ExpRun getExpRun(int rowId);
+ @Nullable
+ ExpRun getExpRun(int rowId, @Nullable Container container);
+
List extends ExpRun> getExpRuns(Collection rowIds);
@Nullable
diff --git a/api/src/org/labkey/api/view/ViewServlet.java b/api/src/org/labkey/api/view/ViewServlet.java
index 2f446760067..48273010c3b 100644
--- a/api/src/org/labkey/api/view/ViewServlet.java
+++ b/api/src/org/labkey/api/view/ViewServlet.java
@@ -506,6 +506,12 @@ public void setActionURL(ActionURL actionURL)
return _actionURL.getParameterMap();
}
+ @Override
+ public String getParameter(@NotNull String name)
+ {
+ return _actionURL.getParameter(name);
+ }
+
@Override
public @NotNull String @NotNull [] getParameterValues(@NotNull String name)
{
diff --git a/assay/api-src/org/labkey/api/assay/nab/view/DilutionGraphAction.java b/assay/api-src/org/labkey/api/assay/nab/view/DilutionGraphAction.java
index 43787be934c..194313e095a 100644
--- a/assay/api-src/org/labkey/api/assay/nab/view/DilutionGraphAction.java
+++ b/assay/api-src/org/labkey/api/assay/nab/view/DilutionGraphAction.java
@@ -44,7 +44,8 @@ public ModelAndView getView(GraphForm form, BindException errors) throws Excepti
{
if (form.getRowId() == -1)
throw new NotFoundException("Run ID not specified.");
- ExpRun run = ExperimentService.get().getExpRun(form.getRowId());
+ // GitHub Issue #1892: Resolve the run scoped to the current container
+ ExpRun run = ExperimentService.get().getExpRun(form.getRowId(), getContainer());
if (run == null)
throw new NotFoundException("Run " + form.getRowId() + " does not exist.");
diff --git a/assay/api-src/org/labkey/api/assay/nab/view/GraphSelectedAction.java b/assay/api-src/org/labkey/api/assay/nab/view/GraphSelectedAction.java
index 2db8d7c6c69..4c5e9a033e2 100644
--- a/assay/api-src/org/labkey/api/assay/nab/view/GraphSelectedAction.java
+++ b/assay/api-src/org/labkey/api/assay/nab/view/GraphSelectedAction.java
@@ -71,6 +71,8 @@ public ModelAndView getView(FormType form, BindException errors) throws Exceptio
objectIds[idx++] = Integer.parseInt(objectIdString);
}
+ // GitHub Issue #1892: (NAB-9) The object ids come straight from the request and getDilutionSummaries() resolves them to runs
+ verifyObjectIdsReadable(objectIds);
Set cutoffSet = new HashSet<>();
DilutionAssayProvider provider = (DilutionAssayProvider) AssayService.get().getProvider(_protocol);
Map summaries = provider.getDataHandler().getDilutionSummaries(getUser(), form.getFitTypeEnum(), objectIds);
@@ -92,6 +94,13 @@ public ModelAndView getView(FormType form, BindException errors) throws Exceptio
return new VBox(new AssayHeaderView(_protocol, provider, false, true, null), multiGraphView);
}
+ /**
+ * Verify that the current user may view each of the requested object ids before any run data is loaded.
+ */
+ protected void verifyObjectIdsReadable(int[] ids) throws Exception
+ {
+ }
+
protected abstract GraphSelectedBean createSelectionBean(ViewContext context, ExpProtocol protocol, int[] cutoffs,
int[] dataObjectIds, String caption, String title);
diff --git a/assay/api-src/org/labkey/api/assay/nab/view/MultiGraphAction.java b/assay/api-src/org/labkey/api/assay/nab/view/MultiGraphAction.java
index 09924804c8c..ef6bbb09afc 100644
--- a/assay/api-src/org/labkey/api/assay/nab/view/MultiGraphAction.java
+++ b/assay/api-src/org/labkey/api/assay/nab/view/MultiGraphAction.java
@@ -16,13 +16,13 @@
package org.labkey.api.assay.nab.view;
import org.labkey.api.action.SimpleViewAction;
+import org.labkey.api.assay.AssayService;
import org.labkey.api.assay.dilution.DilutionAssayProvider;
import org.labkey.api.assay.dilution.DilutionAssayRun;
import org.labkey.api.assay.dilution.DilutionSummary;
import org.labkey.api.assay.nab.NabGraph;
import org.labkey.api.exp.api.ExpProtocol;
import org.labkey.api.exp.api.ExperimentService;
-import org.labkey.api.assay.AssayService;
import org.labkey.api.view.NavTree;
import org.springframework.validation.BindException;
import org.springframework.web.servlet.ModelAndView;
@@ -35,12 +35,13 @@
* User: klum
* Date: 6/11/13
*/
-public class MultiGraphAction extends SimpleViewAction
+public abstract class MultiGraphAction extends SimpleViewAction
{
@Override
public ModelAndView getView(FormType form, BindException errors) throws Exception
{
int[] ids = form.getId();
+ verifyObjectIdsReadable(ids);
ExpProtocol protocol = ExperimentService.get().getExpProtocol(form.getProtocolId());
DilutionAssayProvider provider = (DilutionAssayProvider)AssayService.get().getProvider(protocol);
Map summaries = provider.getDataHandler().getDilutionSummaries(getUser(), form.getFitTypeEnum(), ids);
@@ -63,6 +64,13 @@ public ModelAndView getView(FormType form, BindException errors) throws Exceptio
return null;
}
+ /**
+ * Verify that the current user may view each of the requested object ids before any run data is loaded.
+ */
+ protected void verifyObjectIdsReadable(int[] ids) throws Exception
+ {
+ }
+
protected NabGraph.Config getGraphConfig(FormType form)
{
NabGraph.Config config = new NabGraph.Config();
diff --git a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java
index fe53e505d8f..a5ea0f69505 100644
--- a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java
+++ b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java
@@ -374,10 +374,23 @@ public void clearDataClassCache(@Nullable Container c)
@Override
public @Nullable ExpRunImpl getExpRun(int rowId)
+ {
+ return getExpRun(rowId, null);
+ }
+
+ @Override
+ public @Nullable ExpRunImpl getExpRun(int rowId, @Nullable Container container)
{
SimpleFilter filter = new SimpleFilter(FieldKey.fromParts(ExpRunTable.Column.RowId.name()), rowId);
ExperimentRun run = new TableSelector(getTinfoExperimentRun(), filter, null).getObject(ExperimentRun.class);
- return run == null ? null : new ExpRunImpl(run);
+ if (run == null)
+ return null;
+
+ // GitHub Issue #1892: if container provided, ensure the run belongs to the container
+ if (container != null && !run.getContainer().equals(container))
+ return null;
+
+ return new ExpRunImpl(run);
}
private List getExpRuns(SimpleFilter filter)
From 85ecead18eacb954137b8d50361f7c9e950aec5e Mon Sep 17 00:00:00 2001
From: Cory Nathe
Date: Tue, 16 Jun 2026 14:30:03 -0500
Subject: [PATCH 03/14] TreatmentManager container scoping (#7758)
#### Rationale
Update TreatmentManager getStudyProductsDoseAndRoute / getDoseAndRoute /
deleteStudyProduct filters to scope by container
---
.../studydesign/model/TreatmentManager.java | 141 +++++++++++++++++-
1 file changed, 138 insertions(+), 3 deletions(-)
diff --git a/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java b/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java
index 8bfae0273ed..1c441aef779 100644
--- a/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java
+++ b/studydesign/src/org/labkey/studydesign/model/TreatmentManager.java
@@ -54,6 +54,7 @@
import org.labkey.api.util.GUID;
import org.labkey.api.util.JunitUtil;
import org.labkey.api.util.TestContext;
+import org.labkey.api.view.NotFoundException;
import java.math.BigDecimal;
import java.util.ArrayList;
@@ -318,7 +319,10 @@ public void deleteStudyProduct(Container container, User user, int rowId)
deleteProductAntigens(container, user, rowId);
// delete the associated doses and routes for this product
- Table.delete(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), new SimpleFilter(FieldKey.fromParts("ProductId"), rowId));
+ // GitHub Kanban #1929: scope to the container in addition to ProductId
+ SimpleFilter doseAndRouteFilter = SimpleFilter.createContainerFilter(container);
+ doseAndRouteFilter.addCondition(FieldKey.fromParts("ProductId"), rowId);
+ Table.delete(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), doseAndRouteFilter);
// delete the associated treatment study product mappings (provision table)
SimpleFilter filter = SimpleFilter.createContainerFilter(container);
@@ -363,19 +367,31 @@ public DoseAndRoute saveStudyProductDoseAndRoute(Container container, User user,
if (doseAndRoute.isNew())
return Table.insert(user, StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), doseAndRoute);
else
+ {
+ // GitHub Kanban #1929: verify the existing row is in this container before updating
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addCondition(FieldKey.fromParts("RowId"), doseAndRoute.getRowId());
+ if (!new TableSelector(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), filter, null).exists())
+ throw new NotFoundException("No dose and route found for rowId: " + doseAndRoute.getRowId());
+
return Table.update(user, StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), doseAndRoute, doseAndRoute.getRowId());
+ }
}
public Collection getStudyProductsDoseAndRoute(Container container, User user, int productId)
{
- SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("ProductId"), productId);
+ // GitHub Kanban #1929: scope to the container in addition to ProductId
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addCondition(FieldKey.fromParts("ProductId"), productId);
return new TableSelector(StudyDesignSchema.getInstance().getTableInfoDoseAndRoute(), filter, null).getCollection(DoseAndRoute.class);
}
@Nullable
public DoseAndRoute getDoseAndRoute(Container container, String dose, String route, int productId)
{
- SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("ProductId"), productId);
+ // GitHub Kanban #1929: scope to the container in addition to ProductId
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addCondition(FieldKey.fromParts("ProductId"), productId);
if (dose != null)
filter.addCondition(FieldKey.fromParts("Dose"), dose);
else
@@ -989,6 +1005,125 @@ private void tearDown()
assertTrue(ContainerManager.delete(_junitStudy.getContainer(), _context.getUser()));
}
}
+
+ // GitHub Kanban #1929: getStudyProductsDoseAndRoute / getDoseAndRoute / deleteStudyProduct container scoping checks
+ @Test
+ public void testDoseAndRouteContainerScoping() throws Exception
+ {
+ TestContext context = TestContext.get();
+ User user = context.getUser();
+ Container containerA = null;
+ Container containerB = null;
+ try
+ {
+ containerA = createStudyContainer(context, GUID.makeHash());
+ containerB = createStudyContainer(context, GUID.makeHash());
+
+ int productIdA = insertStudyProduct(containerA, user, "Immunogen A", "Immunogen");
+ int productIdB = insertStudyProduct(containerB, user, "Immunogen B", "Immunogen");
+
+ _manager.saveStudyProductDoseAndRoute(containerA, user, new DoseAndRoute("Dose A", "Route A", productIdA, containerA));
+ _manager.saveStudyProductDoseAndRoute(containerB, user, new DoseAndRoute("Dose B", "Route B", productIdB, containerB));
+
+ // getStudyProductsDoseAndRoute: scoped by container, not ProductId alone
+ assertEquals("Dose/route should be returned within its own container", 1,
+ _manager.getStudyProductsDoseAndRoute(containerB, user, productIdB).size());
+ assertEquals("Dose/route must not be returned from another container", 0,
+ _manager.getStudyProductsDoseAndRoute(containerA, user, productIdB).size());
+
+ // getDoseAndRoute: same scoping
+ assertNotNull("getDoseAndRoute should find the row within its own container",
+ _manager.getDoseAndRoute(containerB, "Dose B", "Route B", productIdB));
+ assertNull("getDoseAndRoute must not find the row from another container",
+ _manager.getDoseAndRoute(containerA, "Dose B", "Route B", productIdB));
+
+ // deleteStudyProduct's dose/route delete is now container scoped; deleting the product in its own
+ // container removes its dose/route (the cross-container case is unreachable since ProductId is unique).
+ _manager.deleteStudyProduct(containerB, user, productIdB);
+ assertEquals("deleteStudyProduct should remove the dose/route within its own container", 0,
+ _manager.getStudyProductsDoseAndRoute(containerB, user, productIdB).size());
+ }
+ finally
+ {
+ if (null != containerB)
+ ContainerManager.delete(containerB, user);
+ if (null != containerA)
+ ContainerManager.delete(containerA, user);
+ }
+ }
+
+ // GitHub Kanban #1929: saveStudyProductDoseAndRoute rejects updating a dose/route row from another container
+ @Test
+ public void testCrossContainerDoseAndRouteUpdateDenied()
+ {
+ TestContext context = TestContext.get();
+ User user = context.getUser();
+ Container containerA = null;
+ Container containerB = null;
+ try
+ {
+ containerA = createStudyContainer(context, GUID.makeHash());
+ containerB = createStudyContainer(context, GUID.makeHash());
+
+ int productIdA = insertStudyProduct(containerA, user, "Immunogen A", "Immunogen");
+ int productIdB = insertStudyProduct(containerB, user, "Immunogen B", "Immunogen");
+
+ // a dose/route owned by container B
+ DoseAndRoute savedB = _manager.saveStudyProductDoseAndRoute(containerB, user, new DoseAndRoute("Dose B", "Route B", productIdB, containerB));
+ int rowIdB = savedB.getRowId();
+
+ // an editor in container A submits an update carrying container B's RowId
+ DoseAndRoute foreign = new DoseAndRoute("Rejected", "Rejected", productIdA, containerA);
+ foreign.setRowId(rowIdB);
+ try
+ {
+ _manager.saveStudyProductDoseAndRoute(containerA, user, foreign);
+ fail("Expected NotFoundException updating a dose/route row from another container");
+ }
+ catch (NotFoundException expected)
+ {
+ // expected
+ }
+
+ // container B's row must be untouched (neither overwritten nor repointed into container A)
+ assertNotNull("Cross-container update must not modify container B's dose/route",
+ _manager.getDoseAndRoute(containerB, "Dose B", "Route B", productIdB));
+ assertNull("Cross-container update must not have repointed the row into container A",
+ _manager.getDoseAndRoute(containerA, "Rejected", "Rejected", productIdA));
+
+ // positive control: updating the row from within its own container succeeds
+ DoseAndRoute updateB = new DoseAndRoute("Dose B2", "Route B2", productIdB, containerB);
+ updateB.setRowId(rowIdB);
+ _manager.saveStudyProductDoseAndRoute(containerB, user, updateB);
+ assertNotNull("In-container update should succeed",
+ _manager.getDoseAndRoute(containerB, "Dose B2", "Route B2", productIdB));
+ }
+ finally
+ {
+ if (null != containerB)
+ ContainerManager.delete(containerB, user);
+ if (null != containerA)
+ ContainerManager.delete(containerA, user);
+ }
+ }
+
+ private Container createStudyContainer(TestContext context, String name)
+ {
+ Container junit = JunitUtil.getTestContainer();
+ Container c = ContainerManager.createContainer(junit, name, context.getUser());
+ Set modules = new HashSet<>(c.getActiveModules());
+ modules.add(ModuleLoader.getInstance().getModule("studydesign"));
+ c.setActiveModules(modules);
+ StudyService.get().createStudy(c, context.getUser(), "Junit Study " + name, TimepointType.VISIT, true);
+ return c;
+ }
+
+ private int insertStudyProduct(Container c, User user, String label, String role)
+ {
+ UserSchema schema = QueryService.get().getUserSchema(user, c, StudyDesignQuerySchema.STUDY_SCHEMA_NAME);
+ TableInfo ti = ((FilteredTable) schema.getTable(StudyDesignQuerySchema.PRODUCT_TABLE_NAME)).getRealTable();
+ return Table.insert(user, ti, new ProductImpl(c, label, role)).getRowId();
+ }
}
@TestWhen(TestWhen.When.BVT)
From 5815b3537a86b013ea9e19ae556a55ec6581845e Mon Sep 17 00:00:00 2001
From: Adam Rauch
Date: Tue, 16 Jun 2026 14:15:46 -0700
Subject: [PATCH 04/14] Modernize specimen, report, and messages rendering
(#7746)
---
.../AnnouncementsController.java | 19 +-
.../announcements/announcementThread.jsp | 3 +-
.../model/AnnouncementFullModel.java | 23 ++
.../model/AnnouncementManager.java | 15 +-
.../model/AnnouncementModel.java | 17 --
.../org/labkey/api/action/ApiJsonWriter.java | 4 +-
.../labkey/api/action/ApiResponseWriter.java | 1 -
.../api/defaults/DefaultValuesAction.java | 12 ++
.../api/defaults/SetDefaultValuesAction.java | 46 ----
.../api/qc/AbstractManageQCStatesAction.java | 1 -
.../org/labkey/api/qc/DataStateManager.java | 7 +-
.../AbstractContainerScopingTest.java | 3 +-
core/src/org/labkey/core/CoreController.java | 201 ++++++++++++++----
core/src/org/labkey/core/CoreModule.java | 1 +
.../org/labkey/core/webdav/DavController.java | 23 +-
.../controllers/exp/ExperimentController.java | 15 +-
.../filecontent/FileContentController.java | 51 +++++
.../mothership/MothershipController.java | 42 ++--
.../labkey/mothership/MothershipManager.java | 7 +
.../mothership/query/MothershipSchema.java | 28 ++-
.../specimen/actions/SpecimenController.java | 12 +-
.../specimen/actions/SpecimenHeaderBean.java | 26 +--
.../specimen/view/manageRequirement.jsp | 2 +-
.../labkey/specimen/view/specimenHeader.jsp | 62 +++---
study/src/org/labkey/study/StudyModule.java | 3 +
.../reports/ReportsController.java | 147 +++++++++++--
.../labkey/study/dataset/DataStatesTest.java | 56 +++++
27 files changed, 582 insertions(+), 245 deletions(-)
create mode 100644 announcements/src/org/labkey/announcements/model/AnnouncementFullModel.java
create mode 100644 study/src/org/labkey/study/dataset/DataStatesTest.java
diff --git a/announcements/src/org/labkey/announcements/AnnouncementsController.java b/announcements/src/org/labkey/announcements/AnnouncementsController.java
index a52c4c4278e..d14ba2e4332 100644
--- a/announcements/src/org/labkey/announcements/AnnouncementsController.java
+++ b/announcements/src/org/labkey/announcements/AnnouncementsController.java
@@ -26,6 +26,7 @@
import org.jetbrains.annotations.Nullable;
import org.json.JSONObject;
import org.labkey.announcements.model.AnnouncementDigestProvider;
+import org.labkey.announcements.model.AnnouncementFullModel;
import org.labkey.announcements.model.AnnouncementManager;
import org.labkey.announcements.model.AnnouncementModel;
import org.labkey.announcements.model.DailyDigestEmailPrefsSelector;
@@ -107,11 +108,11 @@
import org.labkey.api.util.DateUtil;
import org.labkey.api.util.GUID;
import org.labkey.api.util.HtmlString;
+import org.labkey.api.util.OptionBuilder;
import org.labkey.api.util.PageFlowUtil;
import org.labkey.api.util.Pair;
-import org.labkey.api.util.URLHelper;
-import org.labkey.api.util.OptionBuilder;
import org.labkey.api.util.SelectBuilder;
+import org.labkey.api.util.URLHelper;
import org.labkey.api.view.ActionURL;
import org.labkey.api.view.AjaxCompletion;
import org.labkey.api.view.AlwaysAvailableWebPartFactory;
@@ -137,7 +138,6 @@
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;
-import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
@@ -218,7 +218,6 @@ public static ActionURL getBeginURL(Container c)
public static AnnouncementModel copyEditableProps(AnnouncementModel target, AnnouncementModel source, boolean isInsert)
{
- if (source.getApproved() != null) target.setApproved(source.getApproved());
if (source.getAssignedTo() != null) target.setAssignedTo(source.getAssignedTo());
if (source.getBody() != null) target.setBody(source.getBody());
if (source.getExpires() != null) target.setExpires(source.getExpires());
@@ -940,7 +939,7 @@ public BindException bindParameters(PropertyValues m) throws Exception
public ModelAndView getInsertUpdateView(AnnouncementForm form, boolean reshow, BindException errors)
{
Permissions perm = getPermissions();
- AnnouncementModel parent = null;
+ AnnouncementFullModel parent = null;
Container c = getContainer();
if (null != form.getParentId())
@@ -2308,7 +2307,7 @@ protected DataRegion getDataRegion(Permissions perm, Settings settings)
public static class ThreadViewBean
{
- public AnnouncementModel announcementModel;
+ public AnnouncementFullModel announcementModel;
public String message = "";
public Permissions perm = null;
public boolean isResponse = false;
@@ -2336,7 +2335,7 @@ public ThreadView(Container c, URLHelper currentURL, User user, String rowId, St
init(c, findThread(c, rowId, entityId), currentURL, getPermissions(c, user, getSettings(c)), false, false);
}
- public ThreadView(Container c, ActionURL url, AnnouncementModel ann, Permissions perm)
+ public ThreadView(Container c, ActionURL url, AnnouncementFullModel ann, Permissions perm)
{
this();
init(c, ann, url, perm, true, false);
@@ -2345,11 +2344,11 @@ public ThreadView(Container c, ActionURL url, AnnouncementModel ann, Permissions
public ThreadView(AnnouncementForm form, Container c, ActionURL url, Permissions perm, boolean print)
{
this();
- AnnouncementModel ann = findThread(c, form.get("rowId"), form.get("entityId"));
+ AnnouncementFullModel ann = findThread(c, form.get("rowId"), form.get("entityId"));
init(c, ann, url, perm, false, print);
}
- protected void init(Container c, AnnouncementModel ann, URLHelper currentURL, Permissions perm, boolean isResponse, boolean print)
+ protected void init(Container c, AnnouncementFullModel ann, URLHelper currentURL, Permissions perm, boolean isResponse, boolean print)
{
if (null == c || !perm.allowRead(ann))
{
@@ -2454,7 +2453,7 @@ public AnnouncementModel getAnnouncement()
}
- private static @Nullable AnnouncementModel findThread(Container c, String rowIdVal, String entityId)
+ private static @Nullable AnnouncementFullModel findThread(Container c, String rowIdVal, String entityId)
{
int rowId = 0;
if (rowIdVal != null)
diff --git a/announcements/src/org/labkey/announcements/announcementThread.jsp b/announcements/src/org/labkey/announcements/announcementThread.jsp
index 78ddd926c94..de02d2b148f 100644
--- a/announcements/src/org/labkey/announcements/announcementThread.jsp
+++ b/announcements/src/org/labkey/announcements/announcementThread.jsp
@@ -20,6 +20,7 @@
<%@ page import="org.labkey.announcements.AnnouncementsController.RespondAction" %>
<%@ page import="org.labkey.announcements.AnnouncementsController.ThreadView" %>
<%@ page import="org.labkey.announcements.AnnouncementsController.ThreadViewBean" %>
+<%@ page import="org.labkey.announcements.model.AnnouncementFullModel" %>
<%@ page import="org.labkey.announcements.model.AnnouncementManager" %>
<%@ page import="org.labkey.announcements.model.AnnouncementModel" %>
<%@ page import="org.labkey.announcements.model.DiscussionServiceImpl" %>
@@ -40,7 +41,7 @@
Container c = getContainer();
User user = getUser();
ThreadViewBean bean = me.getModelBean();
- AnnouncementModel announcementModel = bean.announcementModel;
+ AnnouncementFullModel announcementModel = bean.announcementModel;
DiscussionService.Settings settings = bean.settings;
if (null == announcementModel)
diff --git a/announcements/src/org/labkey/announcements/model/AnnouncementFullModel.java b/announcements/src/org/labkey/announcements/model/AnnouncementFullModel.java
new file mode 100644
index 00000000000..fb6c5e07ccd
--- /dev/null
+++ b/announcements/src/org/labkey/announcements/model/AnnouncementFullModel.java
@@ -0,0 +1,23 @@
+package org.labkey.announcements.model;
+
+import java.util.Date;
+
+public class AnnouncementFullModel extends AnnouncementModel
+{
+ private Date _approved = null;
+
+ public Date getApproved()
+ {
+ return _approved;
+ }
+
+ public void setApproved(Date approved)
+ {
+ _approved = approved;
+ }
+
+ public boolean isSpam()
+ {
+ return AnnouncementManager.SPAM_MAGIC_DATE.equals(getApproved());
+ }
+}
diff --git a/announcements/src/org/labkey/announcements/model/AnnouncementManager.java b/announcements/src/org/labkey/announcements/model/AnnouncementManager.java
index 7c50d985c2a..b1053019ba2 100644
--- a/announcements/src/org/labkey/announcements/model/AnnouncementManager.java
+++ b/announcements/src/org/labkey/announcements/model/AnnouncementManager.java
@@ -124,20 +124,20 @@ private AnnouncementManager()
{
}
- private static @Nullable AnnouncementModel getAnnouncement(@Nullable Container c, @NotNull SimpleFilter filter)
+ private static @Nullable AnnouncementFullModel getAnnouncement(@Nullable Container c, @NotNull SimpleFilter filter)
{
if (c != null)
filter.addCondition(FieldKey.fromParts("Container"), c);
- return new TableSelector(_comm.getTableInfoAnnouncements(), filter, null).getObject(AnnouncementModel.class);
+ return new TableSelector(_comm.getTableInfoAnnouncements(), filter, null).getObject(AnnouncementFullModel.class);
}
- public static @Nullable AnnouncementModel getAnnouncement(@Nullable Container c, int rowId)
+ public static @Nullable AnnouncementFullModel getAnnouncement(@Nullable Container c, int rowId)
{
return getAnnouncement(c, new SimpleFilter(FieldKey.fromParts("RowId"), rowId));
}
- public static @Nullable AnnouncementModel getAnnouncement(@Nullable Container c, String entityId)
+ public static @Nullable AnnouncementFullModel getAnnouncement(@Nullable Container c, String entityId)
{
try
{
@@ -531,7 +531,7 @@ private static AnnouncementModel validateModelWithSideEffects(AnnouncementModel
}
// Magic date value used to mark an announcement that a moderator has reviewed and marked as spam
- private static final Date SPAM_MAGIC_DATE = new Date(0);
+ static final Date SPAM_MAGIC_DATE = new Date(0);
// Standard filters for retrieving specific classes of messages (approved, spam, needs review)
public static final SimpleFilter IS_APPROVED_FILTER = new SimpleFilter(FieldKey.fromParts("Approved"), AnnouncementManager.SPAM_MAGIC_DATE, CompareType.GT);
@@ -543,11 +543,6 @@ public static void markAsSpam(Container c, AnnouncementModel ann)
updateApproved(c, ann, SPAM_MAGIC_DATE);
}
- public static boolean isSpam(AnnouncementModel ann)
- {
- return SPAM_MAGIC_DATE.equals(ann.getApproved());
- }
-
// Execute direct SQL (not Table.update())... I don't think we want to change Modified or ModifiedBy. Could consider adding column for Moderator, though.
// Returns true if an update was made, false if not (e.g., message was already reviewed).
private static boolean updateApproved(Container c, AnnouncementModel ann, Date date)
diff --git a/announcements/src/org/labkey/announcements/model/AnnouncementModel.java b/announcements/src/org/labkey/announcements/model/AnnouncementModel.java
index 3a1e2ad5960..46bcc85abdb 100644
--- a/announcements/src/org/labkey/announcements/model/AnnouncementModel.java
+++ b/announcements/src/org/labkey/announcements/model/AnnouncementModel.java
@@ -81,7 +81,6 @@ public class AnnouncementModel extends Entity implements Serializable
private Collection _responses = null;
private Set _authors;
- private Date _approved = null;
/**
* Standard constructor.
@@ -429,21 +428,5 @@ public AttachmentParent getAttachmentParent()
{
return new AnnouncementAttachmentParent(this);
}
-
- public Date getApproved()
- {
- return _approved;
- }
-
- public void setApproved(Date approved)
- {
- _approved = approved;
- }
-
- @JsonIgnore
- public boolean isSpam()
- {
- return AnnouncementManager.isSpam(this);
- }
}
diff --git a/api/src/org/labkey/api/action/ApiJsonWriter.java b/api/src/org/labkey/api/action/ApiJsonWriter.java
index f98a4e23ade..6ce20dbc676 100644
--- a/api/src/org/labkey/api/action/ApiJsonWriter.java
+++ b/api/src/org/labkey/api/action/ApiJsonWriter.java
@@ -475,7 +475,9 @@ public void testExceptionNotCommitted() throws IOException
var responseText = ((MockHttpServletResponse)writer.getResponse()).getContentAsString();
var json = new JSONObject(responseText);
assertEquals("throwing up", json.getString("exception"));
- assertTrue(json.has("stackTrace"));
+ assertFalse(json.getBoolean("success"));
+ assertEquals("java.lang.IllegalStateException", json.get("exceptionClass"));
+ assertFalse(json.has("stackTrace"));
assertFalse(json.has("schemaName"));
}
diff --git a/api/src/org/labkey/api/action/ApiResponseWriter.java b/api/src/org/labkey/api/action/ApiResponseWriter.java
index 3d202d3b298..f85716c02c2 100644
--- a/api/src/org/labkey/api/action/ApiResponseWriter.java
+++ b/api/src/org/labkey/api/action/ApiResponseWriter.java
@@ -446,7 +446,6 @@ public JSONObject toJSON(Throwable e)
JSONObject json = new JSONObject();
json.put("exception", e.getMessage() != null ? e.getMessage() : e.getClass().getName());
json.put("exceptionClass", e.getClass().getName());
- json.put("stackTrace", e.getStackTrace());
return json;
}
diff --git a/api/src/org/labkey/api/defaults/DefaultValuesAction.java b/api/src/org/labkey/api/defaults/DefaultValuesAction.java
index 0d1c34c01ad..46f13b18b85 100644
--- a/api/src/org/labkey/api/defaults/DefaultValuesAction.java
+++ b/api/src/org/labkey/api/defaults/DefaultValuesAction.java
@@ -18,8 +18,10 @@
import org.labkey.api.action.FormViewAction;
import org.labkey.api.exp.property.Domain;
import org.labkey.api.exp.property.PropertyService;
+import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.view.ActionURL;
import org.labkey.api.view.NotFoundException;
+import org.labkey.api.view.UnauthorizedException;
import org.springframework.validation.Errors;
public abstract class DefaultValuesAction extends FormViewAction
@@ -41,6 +43,16 @@ protected Domain getDomain(FormType domainIdForm)
{
throw new NotFoundException();
}
+ // Multi-container domains allow overriding default values outside the domain's container. The subclasses of
+ // this action ensure that the user has admin (or list/assay designer) permission in the current container
+ // (where writes will occur). We also want to ensure the user has read in the domain's container to prevent
+ // arbitrary reads of domain information. We're not checking that current container is in scope for this domain
+ // because there's no definitive way to do that. An admin creating default values outside a domain's scope is
+ // fairly harmless.
+ if (!domain.getContainer().hasPermission(getUser(), ReadPermission.class))
+ {
+ throw new UnauthorizedException();
+ }
return domain;
}
diff --git a/api/src/org/labkey/api/defaults/SetDefaultValuesAction.java b/api/src/org/labkey/api/defaults/SetDefaultValuesAction.java
index c6d6adbdc0c..79797906462 100644
--- a/api/src/org/labkey/api/defaults/SetDefaultValuesAction.java
+++ b/api/src/org/labkey/api/defaults/SetDefaultValuesAction.java
@@ -180,52 +180,6 @@ public void render(RenderContext ctx, HtmlWriter out)
return app;
}
).appendTo(out);
-// oldWriter.write("");
-
-
-// renderer.renderDetailsCaptionCell(ctx, out, "control-label lk-form-row-label");
-//
-// if (isFile)
-// TD().appendTo(out); // No input for file
-// else
-// renderer.renderInputCell(ctx, out);
-//
-// TD(
-// (Renderable) ret -> {
-// if (isFile)
-// {
-// out.write("Defaults cannot be set for file fields.");
-// }
-// else
-// {
-// DefaultValueType defaultType = ((DefaultableDisplayColumn) renderer).getDefaultValueType();
-// if (defaultType == null)
-// defaultType = DefaultValueType.FIXED_EDITABLE;
-// out.write(defaultType.getLabel());
-// out.write(PageFlowUtil.popupHelp(HtmlString.of(defaultType.getHelpText()), "Default Value Type: " + defaultType.getLabel()));
-// }
-// return ret;
-// }
-// ).appendTo(out);
-// oldWriter.write("| ");
-// if (isFile)
-// {
-// out.write("Defaults cannot be set for file fields.");
-// }
-// else
-// {
-// DefaultValueType defaultType = ((DefaultableDisplayColumn) renderer).getDefaultValueType();
-// if (defaultType == null)
-// defaultType = DefaultValueType.FIXED_EDITABLE;
-// out.write(defaultType.getLabel());
-// out.write(PageFlowUtil.popupHelp(HtmlString.of(defaultType.getHelpText()), "Default Value Type: " + defaultType.getLabel()));
-// }
-// oldWriter.write(" | ");
-
-// oldWriter.write("");
-// }
-// oldWriter.write("
");
-
ButtonBar bbar = getButtonBar(MODE_INSERT);
bbar.setStyle(ButtonBar.Style.separateButtons);
diff --git a/api/src/org/labkey/api/qc/AbstractManageQCStatesAction.java b/api/src/org/labkey/api/qc/AbstractManageQCStatesAction.java
index 0731902e111..ca68b7d4158 100644
--- a/api/src/org/labkey/api/qc/AbstractManageQCStatesAction.java
+++ b/api/src/org/labkey/api/qc/AbstractManageQCStatesAction.java
@@ -192,5 +192,4 @@ protected HtmlString getQcStateHtml(Container container, DataStateHandler qcStat
return qcStateHtml.getHtmlString();
}
-
}
diff --git a/api/src/org/labkey/api/qc/DataStateManager.java b/api/src/org/labkey/api/qc/DataStateManager.java
index 9de7b1ae387..d2cc4394cbe 100644
--- a/api/src/org/labkey/api/qc/DataStateManager.java
+++ b/api/src/org/labkey/api/qc/DataStateManager.java
@@ -15,6 +15,8 @@
*/
package org.labkey.api.qc;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.labkey.api.cache.Cache;
import org.labkey.api.cache.CacheManager;
@@ -25,6 +27,7 @@
import org.labkey.api.data.Table;
import org.labkey.api.data.TableSelector;
import org.labkey.api.security.User;
+import org.labkey.api.util.logging.LogHelper;
import java.util.ArrayList;
import java.util.Collections;
@@ -34,6 +37,7 @@
public class DataStateManager
{
+ private static final Logger LOG = LogHelper.getLogger(DataStateManager.class, "Data state persistence issues");
private static final DataStateManager _instance = new DataStateManager();
private static final Map> _DataStateHandlers = new HashMap<>();
private static final Cache DATA_STATE_DB_CACHE = CacheManager.getBlockingCache(CacheManager.UNLIMITED, CacheManager.DAY, "Data states",
@@ -124,7 +128,8 @@ public DataState insertState(User user, DataState state)
public DataState updateState(User user, DataState state)
{
DATA_STATE_DB_CACHE.remove(state.getContainer());
- return Table.update(user, CoreSchema.getInstance().getTableInfoDataStates(), state, state.getRowId());
+ SimpleFilter filter = SimpleFilter.createContainerFilter(state.getContainer());
+ return Table.update(user, CoreSchema.getInstance().getTableInfoDataStates(), state, state.getRowId(), filter, Level.WARN);
}
public boolean deleteState(DataState state)
diff --git a/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java
index bb5db4d34a6..fd0ea652544 100644
--- a/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java
+++ b/api/src/org/labkey/api/security/permissions/AbstractContainerScopingTest.java
@@ -15,6 +15,7 @@
*/
package org.labkey.api.security.permissions;
+import jakarta.servlet.http.HttpServletResponse;
import org.junit.After;
import org.junit.Assert;
import org.labkey.api.data.Container;
@@ -124,7 +125,7 @@ protected MockHttpServletResponse post(ActionURL url, User user) throws Exceptio
}
/** Assert that a dispatched response has the expected HTTP status code. */
- protected void assertStatus(int expected, MockHttpServletResponse response)
+ protected void assertStatus(int expected, HttpServletResponse response)
{
assertEquals("Unexpected HTTP status", expected, response.getStatus());
}
diff --git a/core/src/org/labkey/core/CoreController.java b/core/src/org/labkey/core/CoreController.java
index 9f70b100609..10d71253623 100644
--- a/core/src/org/labkey/core/CoreController.java
+++ b/core/src/org/labkey/core/CoreController.java
@@ -29,6 +29,7 @@
import org.jetbrains.annotations.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
+import org.junit.Assert;
import org.junit.Test;
import org.labkey.api.action.ApiResponse;
import org.labkey.api.action.ApiSimpleResponse;
@@ -107,10 +108,16 @@
import org.labkey.api.reports.LabKeyScriptEngineManager;
import org.labkey.api.security.AdminConsoleAction;
import org.labkey.api.security.IgnoresTermsOfUse;
+import org.labkey.api.security.MutableSecurityPolicy;
import org.labkey.api.security.RequiresLogin;
import org.labkey.api.security.RequiresNoPermission;
import org.labkey.api.security.RequiresPermission;
+import org.labkey.api.security.SecurityManager;
+import org.labkey.api.security.SecurityManager.NewUserStatus;
+import org.labkey.api.security.SecurityPolicyManager;
import org.labkey.api.security.User;
+import org.labkey.api.security.UserManager;
+import org.labkey.api.security.ValidEmail;
import org.labkey.api.security.permissions.AbstractActionPermissionTest;
import org.labkey.api.security.permissions.AdminOperationsPermission;
import org.labkey.api.security.permissions.AdminPermission;
@@ -119,6 +126,9 @@
import org.labkey.api.security.permissions.Permission;
import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.security.permissions.UpdatePermission;
+import org.labkey.api.security.roles.FolderAdminRole;
+import org.labkey.api.security.roles.ProjectAdminRole;
+import org.labkey.api.security.roles.ReaderRole;
import org.labkey.api.security.roles.RoleManager;
import org.labkey.api.services.ServiceRegistry;
import org.labkey.api.settings.AdminConsole;
@@ -135,6 +145,7 @@
import org.labkey.api.util.HtmlString;
import org.labkey.api.util.HtmlStringBuilder;
import org.labkey.api.util.JsonUtil;
+import org.labkey.api.util.JunitUtil;
import org.labkey.api.util.MimeMap;
import org.labkey.api.util.PageFlowUtil;
import org.labkey.api.util.PageFlowUtil.Content;
@@ -156,6 +167,7 @@
import org.labkey.api.view.RedirectException;
import org.labkey.api.view.UnauthorizedException;
import org.labkey.api.view.ViewContext;
+import org.labkey.api.view.ViewServlet;
import org.labkey.api.view.template.ClientDependency;
import org.labkey.api.view.template.WarningService;
import org.labkey.api.view.template.Warnings;
@@ -176,8 +188,10 @@
import org.labkey.core.workbook.MoveWorkbooksBean;
import org.labkey.core.workbook.WorkbookFolderType;
import org.labkey.folder.xml.FolderDocument;
+import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.validation.BindException;
import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
@@ -205,10 +219,6 @@
import static org.labkey.api.view.template.WarningService.SESSION_WARNINGS_BANNER_KEY;
-/**
- * User: jeckels
- * Date: Jan 4, 2007
- */
public class CoreController extends SpringActionController
{
private static final Map _customStylesheetCache = new ConcurrentHashMap<>();
@@ -863,36 +873,11 @@ public static class MoveContainerAction extends MutatingApiAction children = ContainerManager.getAllChildren(target, getUser()); // assumes read permission
if (children.contains(parent))
{
- errors.reject(ERROR_MSG, "The container '" + parentIdentifier + "' is not a valid parent folder.");
- return;
+ errors.reject(ERROR_MSG, "The container '" + json.get("parent") + "' is not a valid parent folder.");
+ }
+ }
+
+ private @Nullable Container validateContainer(JSONObject json, String key, String description, Errors errors)
+ {
+ String identifier = json.optString(key, null);
+
+ if (null == identifier)
+ {
+ errors.reject(ERROR_MSG, description + " container must be specified for move operation.");
+ return null;
+ }
+
+ Path path = Path.parse(identifier);
+ Container c = ContainerManager.getForPath(path);
+
+ if (null == c)
+ {
+ c = ContainerManager.getForId(identifier);
+ }
+
+ // Treat non-existent and bad permissions equivalently to not leak any info
+ if (null == c || !c.hasPermission(getUser(), AdminPermission.class))
+ {
+ throw new NotFoundException(description + " not found");
}
+
+ return c;
}
@Override
@@ -949,7 +956,7 @@ public ApiResponse execute(SimpleApiJsonForm form, BindException errors) throws
// Prepare aliases
JSONObject object = form.getJsonObject();
- Boolean addAlias = (Boolean) object.get("addAlias");
+ boolean addAlias = object.optBoolean("addAlias"); // optional, false by default
List aliasList = new ArrayList<>(ContainerManager.getAliasesForContainer(target));
aliasList.add(target.getPath());
@@ -2914,4 +2921,106 @@ public void setProvider(String provider)
}
+ public static class MoveContainerTestCase extends Assert
+ {
+ private static final String FOLDER_NAME = "MoveContainerFolder";
+ private static final String NEW_PARENT = "NewParent";
+ private static final ValidEmail TEST_EMAIL;
+
+ static
+ {
+ try
+ {
+ TEST_EMAIL = new ValidEmail("testMove@myDomain.com");
+ }
+ catch (ValidEmail.InvalidEmailException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Test
+ public void testMoveContainer() throws Exception
+ {
+ doCleanup();
+
+ User adminUser = TestContext.get().getUser();
+ Container junit = JunitUtil.getTestContainer();
+ Container folder = ContainerManager.createContainer(junit, FOLDER_NAME, adminUser);
+ Container newParent = ContainerManager.createContainer(junit, NEW_PARENT, adminUser);
+
+ NewUserStatus newUserStatus = SecurityManager.addUser(TEST_EMAIL, null);
+ User nonAdminUser = newUserStatus.getUser();
+ // Create and save a new, non-empty policy for the folder so it doesn't inherit permissions
+ MutableSecurityPolicy policy = new MutableSecurityPolicy(folder.getPolicy());
+ policy.addRoleAssignment(nonAdminUser, ReaderRole.class);
+ SecurityPolicyManager.savePolicyForTests(policy, adminUser);
+
+ // Admin permissions nowhere... should fail
+ moveFolder(nonAdminUser, folder, newParent, HttpServletResponse.SC_FORBIDDEN);
+
+ // Give nonAdminUser admin permission in just the folder being moved and try again (should fail)
+ policy = new MutableSecurityPolicy(folder.getPolicy());
+ policy.addRoleAssignment(nonAdminUser, FolderAdminRole.class);
+ SecurityPolicyManager.savePolicyForTests(policy, adminUser);
+ moveFolder(nonAdminUser, folder, newParent, HttpServletResponse.SC_FORBIDDEN);
+
+ // Give nonAdminUser admin permission in the new parent as well and try again (should still fail)
+ MutableSecurityPolicy newParentPolicy = new MutableSecurityPolicy(newParent);
+ newParentPolicy.addRoleAssignment(nonAdminUser, FolderAdminRole.class);
+ SecurityPolicyManager.savePolicyForTests(newParentPolicy, adminUser);
+ moveFolder(nonAdminUser, folder, newParent, HttpServletResponse.SC_FORBIDDEN);
+
+ // Give nonAdminUser admin permission in the folder's current parent and try again (should now succeed)
+ policy = new MutableSecurityPolicy(folder.getParent().getPolicy());
+ policy.addRoleAssignment(nonAdminUser, FolderAdminRole.class);
+ SecurityPolicyManager.savePolicyForTests(policy, adminUser);
+ moveFolder(nonAdminUser, folder, newParent, HttpServletResponse.SC_OK);
+ folder = ContainerManager.getForId(folder.getId()); // Refresh its path
+ assertNotNull(folder);
+ assertEquals(junit.getPath() + "/" + NEW_PARENT + "/" + FOLDER_NAME, folder.getPath());
+ // Should be able to move it back
+ moveFolder(nonAdminUser, folder, junit, HttpServletResponse.SC_OK);
+ folder = ContainerManager.getForId(folder.getId()); // Refresh its path
+ assertNotNull(folder);
+ assertEquals(junit.getPath() + "/" + FOLDER_NAME, folder.getPath());
+
+ // Happy path -- admin user should be able to move folder to new parent and back
+ moveFolder(adminUser, folder, newParent, HttpServletResponse.SC_OK);
+ folder = ContainerManager.getForId(folder.getId()); // Refresh its path
+ assertNotNull(folder);
+ assertEquals(junit.getPath() + "/" + NEW_PARENT + "/" + FOLDER_NAME, folder.getPath());
+ moveFolder(adminUser, folder, junit, HttpServletResponse.SC_OK);
+ folder = ContainerManager.getForId(folder.getId()); // Refresh its path
+ assertNotNull(folder);
+ assertEquals(junit.getPath() + "/" + FOLDER_NAME, folder.getPath());
+ }
+
+ private void moveFolder(User user, Container folder, Container newParent, int expectedResponseCode) throws Exception
+ {
+ JSONObject json = new JSONObject().put("container", folder.getId()).put("parent", newParent.getId());
+ HttpServletRequest request = ViewServlet.mockRequest(RequestMethod.POST.name(), new ActionURL(MoveContainerAction.class, folder), user, Map.of("Content-Type", "application/json"), json.toString());
+ MockHttpServletResponse response = ViewServlet.mockDispatch(request, null);
+ assertEquals("Unexpected response code", expectedResponseCode, response.getStatus());
+ }
+
+ private static void doCleanup() throws Exception
+ {
+ Container folder = ContainerManager.getForPath(JunitUtil.getTestContainer().getPath() + "/" + FOLDER_NAME);
+ if (folder != null)
+ {
+ ContainerManager.deleteAll(folder, TestContext.get().getUser());
+ }
+
+ Container newParent = ContainerManager.getForPath(JunitUtil.getTestContainer().getPath() + "/" + NEW_PARENT);
+ if (newParent != null)
+ {
+ ContainerManager.deleteAll(newParent, TestContext.get().getUser());
+ }
+
+ User u = UserManager.getUser(TEST_EMAIL);
+ if (u != null)
+ UserManager.deleteUser(u.getUserId());
+ }
+ }
}
diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java
index bd342ae5afa..3c862288961 100644
--- a/core/src/org/labkey/core/CoreModule.java
+++ b/core/src/org/labkey/core/CoreModule.java
@@ -1398,6 +1398,7 @@ public Set getIntegrationTests()
AllowListType.TestCase.class,
AttachmentServiceImpl.TestCase.class,
CoreController.TestCase.class,
+ CoreController.MoveContainerTestCase.class,
DataRegion.TestCase.class,
DavController.TestCase.class,
DavController.MoveActionContainerScopingTestCase.class,
diff --git a/core/src/org/labkey/core/webdav/DavController.java b/core/src/org/labkey/core/webdav/DavController.java
index 4b5590749db..4a71e7b134e 100644
--- a/core/src/org/labkey/core/webdav/DavController.java
+++ b/core/src/org/labkey/core/webdav/DavController.java
@@ -3538,17 +3538,24 @@ WebdavStatus doMethod() throws DavException, IOException
throw new DavException(WebdavStatus.SC_METHOD_NOT_ALLOWED);
}
+ WebdavResource resource = resolvePath();
+ if (null == resource)
+ {
+ throw new DavException(WebdavStatus.SC_NOT_FOUND);
+ }
+
+ if (!resource.canWrite(getUser(), true))
+ {
+ throw new DavException(WebdavStatus.SC_FORBIDDEN);
+ }
+
FileTime lastModified = getLastModified(getRequest().getInputStream());
if (lastModified != null)
{
try
{
- WebdavResource resource = resolvePath();
- if (resource != null)
- {
- resource.setLastModified(lastModified.toMillis());
- }
+ resource.setLastModified(lastModified.toMillis());
}
catch (ConversionException ignored) {}
}
@@ -3558,12 +3565,6 @@ WebdavStatus doMethod() throws DavException, IOException
assert track(writer);
try
{
- WebdavResource resource = resolvePath();
- if (resource == null)
- {
- throw new DavException(WebdavStatus.SC_NOT_FOUND);
- }
-
XMLResourceWriter resourceWriter = new XMLResourceWriter(writer);
resourceWriter.beginResponse(getResponse());
diff --git a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java
index cc2ffd6445f..33bfd5a917d 100644
--- a/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java
+++ b/experiment/src/org/labkey/experiment/controllers/exp/ExperimentController.java
@@ -2415,7 +2415,8 @@ public static class CheckDataFileAction extends MutatingApiAction
public void validateForm(DataFileForm form, Errors errors)
{
_data = form.lookupData();
- if (_data == null)
+ // Not using ensureCorrectContainer() because we don't redirect API actions
+ if (_data == null || !getContainer().equals(_data.getContainer()))
{
errors.reject(ERROR_MSG, "No ExpData found for id: " + form.getRowId());
}
@@ -8387,7 +8388,17 @@ public void testDataClassAttachmentContainerScoping() throws Exception
.addParameter("lsid", lsid)
.addParameter("name", attachmentName);
assertStatus(HttpServletResponse.SC_OK, get(ownUrl, admin));
+
+ ActionURL checkDataFileUrl = new ActionURL(CheckDataFileAction.class, folderB)
+ .addParameter("rowId", data.getRowId());
+ assertStatus(HttpServletResponse.SC_OK, post(checkDataFileUrl, admin));
+ assertStatus(HttpServletResponse.SC_FORBIDDEN, post(checkDataFileUrl, readerA)); // No perms
+ checkDataFileUrl.setContainer(folderA);
+ assertStatus(HttpServletResponse.SC_FORBIDDEN, post(checkDataFileUrl, readerA)); // Has read in folder A, but not admin
+ resp = post(checkDataFileUrl, admin); // Wrong container. Not found.
+ assertStatus(HttpServletResponse.SC_BAD_REQUEST, resp);
+ JSONObject json = new JSONObject(resp.getContentAsString());
+ assertEquals("No ExpData found for id: " + data.getRowId(), json.get("exception"));
}
}
-
}
diff --git a/filecontent/src/org/labkey/filecontent/FileContentController.java b/filecontent/src/org/labkey/filecontent/FileContentController.java
index 10e144ca207..3f01703bb95 100644
--- a/filecontent/src/org/labkey/filecontent/FileContentController.java
+++ b/filecontent/src/org/labkey/filecontent/FileContentController.java
@@ -16,10 +16,13 @@
package org.labkey.filecontent;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.json.JSONArray;
import org.json.JSONObject;
+import org.junit.Test;
import org.labkey.api.action.ApiJsonWriter;
import org.labkey.api.action.ApiResponse;
import org.labkey.api.action.ApiResponseWriter;
@@ -79,19 +82,27 @@
import org.labkey.api.query.UserSchema;
import org.labkey.api.query.ValidationError;
import org.labkey.api.reader.Readers;
+import org.labkey.api.security.MutableSecurityPolicy;
import org.labkey.api.security.RequiresLogin;
import org.labkey.api.security.RequiresNoPermission;
import org.labkey.api.security.RequiresPermission;
import org.labkey.api.security.RequiresSiteAdmin;
+import org.labkey.api.security.SecurityManager;
+import org.labkey.api.security.SecurityManager.NewUserStatus;
+import org.labkey.api.security.SecurityPolicyManager;
import org.labkey.api.security.User;
+import org.labkey.api.security.UserManager;
+import org.labkey.api.security.ValidEmail;
import org.labkey.api.security.permissions.AbstractActionPermissionTest;
import org.labkey.api.security.permissions.AdminOperationsPermission;
import org.labkey.api.security.permissions.AdminPermission;
import org.labkey.api.security.permissions.InsertPermission;
import org.labkey.api.security.permissions.ReadPermission;
+import org.labkey.api.security.roles.ReaderRole;
import org.labkey.api.util.DOM;
import org.labkey.api.util.FileUtil;
import org.labkey.api.util.HtmlString;
+import org.labkey.api.util.JunitUtil;
import org.labkey.api.util.LinkBuilder;
import org.labkey.api.util.MimeMap;
import org.labkey.api.util.NetworkDrive;
@@ -108,6 +119,7 @@
import org.labkey.api.view.Portal;
import org.labkey.api.view.UnauthorizedException;
import org.labkey.api.view.ViewContext;
+import org.labkey.api.view.ViewServlet;
import org.labkey.api.view.WebPartView;
import org.labkey.api.view.template.PageConfig;
import org.labkey.api.webdav.WebdavResource;
@@ -115,10 +127,13 @@
import org.labkey.api.writer.HtmlWriter;
import org.labkey.filecontent.message.FileEmailConfig;
import org.labkey.filecontent.message.ShortMessageDigest;
+import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.validation.BindException;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
+import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.mvc.Controller;
import java.io.BufferedReader;
import java.io.File;
@@ -720,6 +735,9 @@ public Set
-<%= link("Remove " + subjectNounSingle + "/Visit Filter", noFitlerUrl)%><%
+<%= link("Remove " + subjectNounSingle + "/Visit Filter", noFilterUrl)%><%
}
%>
diff --git a/study/src/org/labkey/study/StudyModule.java b/study/src/org/labkey/study/StudyModule.java
index 60bb3b9d968..322655a8487 100644
--- a/study/src/org/labkey/study/StudyModule.java
+++ b/study/src/org/labkey/study/StudyModule.java
@@ -134,6 +134,7 @@
import org.labkey.study.controllers.publish.PublishController;
import org.labkey.study.controllers.reports.ReportsController;
import org.labkey.study.controllers.security.SecurityController;
+import org.labkey.study.dataset.DataStatesTest;
import org.labkey.study.dataset.DatasetAuditProvider;
import org.labkey.study.dataset.DatasetNotificationInfoProvider;
import org.labkey.study.dataset.DatasetSnapshotProvider;
@@ -710,6 +711,7 @@ public Set
getIntegrationTests()
{
return Set.of(
DatasetDefinition.TestCleanupOrphanedDatasetDomains.class,
+ DataStatesTest.class,
ParticipantGroupManager.ParticipantGroupTestCase.class,
StudyImpl.ProtocolDocumentTestCase.class,
StudyManager.StudySnapshotTestCase.class,
@@ -737,6 +739,7 @@ public Set getUnitTests()
DatasetDataWriter.TestCase.class,
DefaultStudyDesignWriter.TestCase.class,
ParticipantIdImportHelper.ParticipantIdTest.class,
+ ReportsController.TestCase.class,
SequenceNumImportHelper.SequenceNumTest.class,
StudyImpl.DateMathTestCase.class
);
diff --git a/study/src/org/labkey/study/controllers/reports/ReportsController.java b/study/src/org/labkey/study/controllers/reports/ReportsController.java
index e09c6265b59..f2b1b06dae4 100644
--- a/study/src/org/labkey/study/controllers/reports/ReportsController.java
+++ b/study/src/org/labkey/study/controllers/reports/ReportsController.java
@@ -22,6 +22,7 @@
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
+import org.junit.Test;
import org.labkey.api.action.Action;
import org.labkey.api.action.ActionType;
import org.labkey.api.action.ApiResponse;
@@ -49,9 +50,9 @@
import org.labkey.api.reports.report.view.ReportUtil;
import org.labkey.api.reports.report.view.ScriptReportBean;
import org.labkey.api.security.RequiresLogin;
-import org.labkey.api.security.RequiresNoPermission;
import org.labkey.api.security.RequiresPermission;
import org.labkey.api.security.User;
+import org.labkey.api.security.permissions.AbstractActionPermissionTest;
import org.labkey.api.security.permissions.AdminPermission;
import org.labkey.api.security.permissions.InsertPermission;
import org.labkey.api.security.permissions.ReadPermission;
@@ -63,6 +64,7 @@
import org.labkey.api.study.reports.CrosstabReportDescriptor;
import org.labkey.api.util.ImageUtil;
import org.labkey.api.util.PageFlowUtil;
+import org.labkey.api.util.TestContext;
import org.labkey.api.util.URLHelper;
import org.labkey.api.util.UniqueID;
import org.labkey.api.view.ActionURL;
@@ -533,7 +535,7 @@ public void addNavTrail(NavTree root)
}
}
- @RequiresNoPermission
+ @RequiresPermission(ReadPermission.class)
public class CreateCrosstabReportAction extends SimpleViewAction